Testing an Update Before You Publish It
Rehearse an MV3 auto-update locally — load the old version, apply the new one over it, and verify migrations, orphaned content scripts and open tabs survive the swap.
Table of Contents
An update is tested as a fresh install almost every time, which means the one code path that runs for every existing user — onInstalled with reason: "update" — is the one least exercised. The failures it produces are the worst kind: they only affect people who already had the extension, they appear hours after publishing as the rollout reaches them, and the previous version is no longer installable to compare against. This guide is part of extension updates and data migration.
What an update actually changes underneath a running browser
There is one more casualty that is easy to forget: content scripts already injected into open tabs keep running, but their chrome.runtime connection to the old extension is severed. Any call they make afterwards throws “Extension context invalidated” — the failure covered in fixing extension context invalidated after an update.
Step-by-step: rehearsing the update
1. Keep a loadable copy of the released version
1# Tag every release, and keep its built artifact.
2git tag -a v2.3.0 -m "released 2026-09-10"
3mkdir -p releases/v2.3.0 && cp -r dist/* releases/v2.3.0/
Execution context: your shell, as part of the release process. Rebuilding an old tag is not equivalent: dependency drift means the artifact you rebuild today is not the one users are running. Keep the bytes.
2. Load the old version and use it like a user
Load releases/v2.3.0 unpacked, then actually exercise it — change settings, let a sync run, create whatever records the extension stores. A migration test against an empty profile proves nothing.
1// Capture the resulting state so a second rehearsal starts identically.
2copy(JSON.stringify({
3 local: await chrome.storage.local.get(null),
4 sync: await chrome.storage.sync.get(null),
5}, null, 2));
Execution context: the old version’s service worker console. copy() is a DevTools helper that puts the value on the clipboard; save it as a fixture so the rehearsal is repeatable and so it can become a regression test later.
3. Apply the new version over the top
Unpacked extensions update in place if the directory changes, so the most faithful rehearsal is to load the old build from a directory, then replace its contents with the new build and press Reload.
1rm -rf /tmp/ext-under-test/* && cp -r dist/* /tmp/ext-under-test/
Execution context: your shell, with /tmp/ext-under-test already loaded unpacked in the browser. Pressing Reload after this fires onInstalled with reason: "update" and previousVersion set, which is precisely the path you need to exercise.
A caveat worth knowing: a manual reload is close to but not identical to a store auto-update. It does not exercise the download and signature check, and it happens while you are watching rather than in the background. For migration logic it is faithful enough; for rollout behaviour it is not.
4. Assert the migration ran and was correct
1chrome.runtime.onInstalled.addListener(async ({ reason, previousVersion }) => {
2 if (reason !== "update") return;
3 console.info("[update] from", previousVersion, "to", chrome.runtime.getManifest().version);
4 const before = await chrome.storage.local.get(null);
5 await runMigrations();
6 const after = await chrome.storage.local.get(null);
7 console.table({ keysBefore: Object.keys(before).length, keysAfter: Object.keys(after).length });
8});
Execution context: the service worker, registered at the top level — an await before this listener means the update event is missed entirely, which is the silent failure described in messages sent while the worker is starting.
5. Check the surfaces that were open
Before reloading, leave a popup-equivalent page, an options page and a content-scripted tab open. After the update:
- The options page should show an “extension updated, reload this page” state rather than throwing on its next storage call.
- The content-scripted tab should either recover or degrade quietly; it must not spam the console.
- The worker should have rebuilt alarms and registrations.
1// In the service worker, after migrations
2console.table({
3 alarms: (await chrome.alarms.getAll()).map((a) => a.name),
4 scripts: (await chrome.scripting.getRegisteredContentScripts()).map((s) => s.id),
5});
6// { alarms: ["daily-sync", "cache-trim"], scripts: ["user-sites"] }
Execution context: the service worker console after the reload. An empty array here is the most common post-update bug and it is completely invisible to the user until a scheduled job fails to run.
6. Rehearse the downgrade too
Users can end up on an older version — a rollback, a managed deployment, a sideloaded copy. Load the old build over the new one and confirm it does not crash on data written by the newer schema.
1// Defensive read: unknown schema versions are tolerated, not fatal.
2const { schema = 1 } = await chrome.storage.local.get("schema");
3if (schema > SCHEMA) {
4 console.warn("[migrate] data written by a newer version; running read-only");
5 return { readOnly: true };
6}
Execution context: the service worker. Refusing to start is worse than degrading: a user on a rolled-back version with an unusable extension will uninstall it.
Turning the rehearsal into a test
A manual rehearsal catches the bug once. Encoding it as a test catches it on every release, and the mechanics are less awkward than they look because the migration logic should not need a browser at all.
Split the migration into a pure function over a storage snapshot and a thin driver that reads and writes.
1// src/migrate/index.js
2export function migrate(state) {
3 let s = { ...state };
4 if ((s.schema ?? 1) < 2) s = { ...s, sites: (s.sites ?? []).map(normaliseSite), schema: 2 };
5 if (s.schema < 3) s = { ...s, settings: { ...DEFAULTS, ...s.settings }, schema: 3 };
6 if (s.schema < 4) { delete s.legacyCache; s.schema = 4; }
7 return s;
8}
Execution context: a module importable by the worker and by a Node test alike — it touches no chrome.* API, which is what makes it testable. The driver in the worker reads storage.local.get(null), calls migrate, and writes the difference back.
The test then runs the real fixtures captured in step 2:
1import { migrate } from "../src/migrate/index.js";
2import v230 from "./fixtures/v2.3.0.json" with { type: "json" };
3
4const out = migrate(v230.local);
5assert.equal(out.schema, 4);
6assert.equal(out.sites.length, v230.local.sites.length); // nothing lost
7assert.equal(out.legacyCache, undefined);
8assert.deepEqual(migrate(out), out); // idempotent
Execution context: Node, under your test runner. The last assertion is the one that pays for itself: a migration that is not idempotent will eventually run twice, because an interrupted update can re-fire onInstalled.
Keep one fixture per released version. The file is small, it documents the shape that version wrote, and a chain test — apply every migration from the oldest fixture forward — is then a single loop. This is also what makes the end-to-end rehearsal shorter: by the time you load the old build in a browser, the data transformation is already proven, and you are only checking the browser-side rebuild of alarms, registrations and surfaces.
Cross-browser variation
- Chrome / Edge:
onInstalledfires withreason: "update"andpreviousVersion. Reloading an unpacked extension after changing its files reproduces this faithfully.chrome.runtime.onUpdateAvailablelets you defer a store update, covered in controlling when an update is applied. - Firefox:
browser.runtime.onInstalledbehaves the same.web-ext runreloads on file change, which makes the rehearsal loop faster but also makes it easy to reload without an intervening state change — set up the fixture deliberately. - Safari: updates arrive through the containing app, so the rehearsal means installing one app build over another. The extension-side events match Chrome’s.
- All three: the update event fires once per update, and nothing replays it. A migration that throws halfway leaves the profile in a mixed state with no second chance — which is why migrations should be resumable and idempotent.
Verification
- Run the rehearsal end to end and confirm the console shows exactly one migration line, with the expected
previousVersion. - Re-run the reload without changing the version and confirm the migration does not run again.
- Confirm the fixture profile’s data is intact and in the new shape:
1(await chrome.storage.local.get(null)).schema; // 4
Execution context: the service worker console after the update. Compare the full object against your saved fixture to see exactly what the migration touched.
- Check
chrome://extensions → Errorsis empty after the swap.
FAQ
Can I test the real auto-update path?
Partly. Publishing to an unlisted or trusted-tester channel exercises the download, signature check and rollout mechanics. It is worth doing for a risky release; it is too slow to be the routine loop.
How do I test a migration from three versions back?
Keep the artifacts. Load v2.1.0, build the fixture, then load the current build — a migration chain should be written so each step is independent, and skipping intermediate versions is the normal case rather than the exception.
Does the store roll updates out gradually?
Chrome rolls updates out over hours rather than instantly, and you can set a percentage. That is a mitigation for a bad release, not a substitute for rehearsal — see rolling back a bad extension release.
Related
- Running data migrations on onInstalled — writing the migration this rehearses.
- Fixing extension context invalidated after an update — the orphaned-tab failure.
- Rolling back a bad extension release — what to do when the rehearsal missed something.
- Extension updates and data migration — the parent guide.