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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
storage.synccarries 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.localaccepts 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
- Run the fixture suite and confirm one passing test per fixture file.
- Change a migration step incorrectly and confirm the corresponding fixture test fails with the validator’s error.
- 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.
- Edit
schema.json 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.
Related
- Defaulting and versioning an options schema — the parser and migrations under test.
- Running data migrations on onInstalled — where migrations run for users.
- Exporting and importing extension settings — another reader of the same contract.
- Unit and integration testing — the parent guide.