Contract Testing a Storage Schema

Treat what an MV3 extension writes to chrome.storage as a contract — schema definitions, fixtures from every released version, round-trip and migration tests, and catching incompatible writes in CI.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Extension storage is read by code you have not written yet. The data a user’s browser holds today will be opened by next year’s release, by an older build after a rollback, by the Firefox version of the extension on a synced device, and by an import from a settings file. Every one of those readers depends on an implicit agreement about the shape of the data — and implicit agreements break silently. Writing the agreement down as a schema, and testing it, turns a class of data-loss bugs into failing tests. This guide is part of unit and integration testing.

Who depends on the stored shape

Readers of one stored settings objectA settings object written by the current version is read by future versions, older versions after a rollback, other-engine builds via sync, and settings imports, all of which depend on its shape.v2.4 writes settingsschema 4v3.0 reads itfuture, after updatev2.3 reads itafter a rollbackand elsewhereFirefox build reads itvia storage.syncImport reads a file of itexported last yearAll must agreeor data is lost
The writer is one version; the readers are many — which is what makes the shape a contract.

Step-by-step

1. Write the schema down

 1// src/data/schema.js
 2export const SCHEMA_VERSION = 4;
 3
 4export const SettingsSchema = {
 5  type: "object",
 6  additionalProperties: true,               // tolerate unknown keys from newer versions
 7  properties: {
 8    theme: { enum: ["light", "dark", "auto"] },
 9    syncHour: { type: "integer", minimum: 0, maximum: 23 },
10    enabledOrigins: { type: "array", items: { type: "string", pattern: "^https?://" }, maxItems: 500 },
11    badge: { enum: ["count", "dot", "none"] },
12  },
13};

Execution context: a shared module with no runtime dependencies. A JSON-Schema-shaped object can be validated with a small library such as Ajv in tests, and doubles as documentation. additionalProperties: true is deliberate: a reader must tolerate keys written by a newer version, or a rollback destroys them.

2. Validate every write in tests

 1import Ajv from "ajv";
 2import { SettingsSchema } from "../src/data/schema.js";
 3import { setSetting, readSettings } from "../src/data/settings.js";
 4
 5const validate = new Ajv({ allErrors: true }).compile(SettingsSchema);
 6
 7test("every write leaves storage valid", async () => {
 8  await setSetting("theme", "dark");
 9  await setSetting("syncHour", 9);
10  await setSetting("enabledOrigins", ["https://example.com"]);
11  const { settings } = await chrome.storage.sync.get("settings");
12  expect(validate(settings), JSON.stringify(validate.errors)).toBe(true);
13});

Execution context: Vitest or Jest with the storage fake from using Vitest with webextension mocks. Validating what is actually stored — not what the code intended to store — catches the write that sneaks a string into a numeric field.

3. Keep a fixture from every released version

1test/fixtures/storage/
2  v1.8.0.json    { "settings": { "darkMode": true }, "settingsSchema": 1 }
3  v2.1.0.json    { "settings": { "theme": "dark", "syncHour": "7" }, "settingsSchema": 2 }
4  v2.3.0.json    { "settings": { "theme": "dark", "syncHour": 7, "showBadge": false }, "settingsSchema": 3 }
5  v2.4.0.json    { "settings": { "theme": "dark", "syncHour": 7, "badge": "none" }, "settingsSchema": 4 }

Execution context: the repository. Capture each fixture from a real profile when the version ships — copy(JSON.stringify(await chrome.storage.sync.get(null))) from the worker console — rather than writing it by hand from memory. The capture step is part of the release checklist in testing an update before you publish it.

4. Test that every old version migrates to a valid current shape

 1import { readdirSync, readFileSync } from "node:fs";
 2import { migrateSettings } from "../src/data/migrate.js";
 3
 4for (const file of readdirSync("test/fixtures/storage")) {
 5  test(`migrates ${file} to a valid schema ${SCHEMA_VERSION}`, () => {
 6    const { settings, settingsSchema = 1 } = JSON.parse(readFileSync(`test/fixtures/storage/${file}`, "utf8"));
 7    const out = migrateSettings(settings, settingsSchema);
 8    expect(validate(out), JSON.stringify(validate.errors)).toBe(true);
 9    expect(migrateSettings(out, SCHEMA_VERSION)).toEqual(out);          // idempotent
10  });
11}

Execution context: Node, one test per fixture. Adding a new fixture automatically adds a test. The idempotence assertion guards against migrations that run twice after an interrupted update — the property required in defaulting and versioning an options schema.

5. Test that older readers survive newer data

 1test("current reader tolerates unknown future keys", () => {
 2  const future = { theme: "dark", syncHour: 7, enabledOrigins: [], badge: "count", newFeatureFlag: true };
 3  const parsed = parseSettings(future);
 4  expect(parsed.theme).toBe("dark");
 5});
 6
 7test("current writer preserves unknown keys it did not touch", async () => {
 8  await chrome.storage.sync.set({ settings: { theme: "dark", newFeatureFlag: true } });
 9  await setSetting("syncHour", 8);
10  const { settings } = await chrome.storage.sync.get("settings");
11  expect(settings.newFeatureFlag).toBe(true);
12});

Execution context: Vitest with the storage fake. The second test is the one that matters for rollbacks and cross-version sync: a writer that rebuilds the object from its own known keys silently deletes whatever a newer version added.

6. Fail CI when the schema changes without a fixture

1# CI: if schema.js changed, a new fixture must exist for the current version
2if git diff --name-only origin/main... | grep -q "src/data/schema.js"; then
3  VERSION=$(jq -r .version package.json)
4  test -f "test/fixtures/storage/v$VERSION.json" || { echo "schema changed: add fixture v$VERSION.json"; exit 1; }
5fi

Execution context: your shell in CI. It is a blunt rule and an effective one: a schema change without a captured fixture is a change nobody has tested the migration for.

What each contract test protectsWrite validation, forward migration, idempotence, unknown-key tolerance and unknown-key preservation mapped to the real-world scenario each protects.TestProtects againstScenarioWrites validateWrong types storedEvery releaseOld fixtures migrateBroken upgradeAuto-updateMigration idempotentDouble migrationInterrupted updateReader tolerates new keysCrash on unknown dataRollback, syncWriter preserves new keysSilent deletionRollback, sync
The last two rows are the ones most extensions lack — and the ones rollbacks and cross-engine sync depend on.

Beyond settings: every persisted shape is a contract

Settings are the obvious case, but the same reasoning applies to anything the extension persists and reads back: the cached article index in IndexedDB, the pending-sync queue in storage.session, dynamic declarativeNetRequest rules generated from user input, the export file format, and the message protocol between contexts that may run different versions during an update.

Each benefits from the same three pieces — a written shape, fixtures from released versions, and a test that old data still reads — scaled to how long the data lives. A storage.session queue cleared on every browser restart needs little more than validation. An IndexedDB cache that can survive for months needs the full treatment. The message protocol needs a one-version skew test, because an old popup can briefly talk to a new worker, as described in versioning message schemas across updates.

How long persisted data typically livesTypical lifetime of different persisted extension data, from session-scoped queues to synced settings, indicating how much contract testing each needs.storage.session queue1 days (typic…Cached API responses7 days (typic…IndexedDB article index180 days (typ…Synced settings900 days (typ…read by many versions
The longer data lives, the more versions will read it — and the more the contract matters.

Cross-browser variation

  • Chrome / Edge: storage.sync carries settings between devices that may run different extension versions, so forward tolerance is a live requirement, not a theoretical one.
  • Firefox: the same, plus storage.local accepts structured-clone types that Chrome does not — Date, Map — so a shared schema should stick to JSON-compatible types to stay portable.
  • Safari: slower sync convergence means an old and a new schema can coexist on one device for minutes. Tolerant readers matter most here.
  • All three: JSON-compatible, versioned, forward-tolerant data is the portable contract. Anything else ties the data to one engine.

Verification

  1. Run the fixture suite and confirm one passing test per fixture file.
  2. Change a migration step incorrectly and confirm the corresponding fixture test fails with the validator’s error.
  3. Confirm unknown-key preservation — the most commonly missing behaviour:
1await chrome.storage.sync.set({ settings: { theme: "dark", futureKey: 1 } });
2await setSetting("theme", "light");
3(await chrome.storage.sync.get("settings")).settings.futureKey;
4// 1

Execution context: the test suite, or a real extension’s worker console. undefined here means a rollback would silently lose data.

  1. Edit schema.js on a branch without adding a fixture and confirm CI fails.

FAQ

Is a TypeScript type enough?

A type describes what your code expects; it does not validate what is actually on disk, and it cannot express “tolerate unknown keys”. Use both — the type for the compiler, the schema for the data.

How many old fixtures should I keep?

All of them, at least for data that syncs. They are small, and a user returning after a year will present exactly one of them.

Should validation run in production?

Parsing with defaults should — every read goes through parseSettings. Full schema validation with error reporting is best kept to tests and development builds.

Can the schema generate the TypeScript type, or the other way round?

Yes — tools such as json-schema-to-typescript generate types from a JSON Schema, and others go the opposite way. Picking one source of truth and generating the other keeps them from drifting apart, which is the same argument as generating the manifest per target.

Other Testing, Debugging & Performance Optimization Resources