Testing Alarms and Timers Deterministically
Unit-test chrome.alarms logic without waiting: a fake alarm registry, fake timers for debounce and backoff, and assertions that catch the minimum-period clamp.
Table of Contents
- Root cause: two clocks, and neither is under the test’s control
- Step 1. Build a fake alarm registry you can fire by hand
- Step 2. Install it alongside the rest of the chrome double
- Step 3. Test the schedule, not the call
- Step 4. Use fake timers for the in-memory side
- Step 5. Test the restart path, which is where scheduling actually breaks
- Cross-browser variation
- Verification
- FAQ
- Related
A test that calls chrome.alarms.create({ periodInMinutes: 1 }) and then waits is not a test, it is a delay. A test that mocks chrome.alarms.create and asserts it was called is barely better — it proves you called an API, not that the schedule is correct, that a restart re-creates it, or that the browser’s minimum-period clamp has not silently turned your thirty-second poll into a one-minute one. This page is part of Unit & Integration Testing for MV3 Extensions and builds a fake that makes scheduling logic testable in milliseconds.
Root cause: two clocks, and neither is under the test’s control
Extension scheduling runs on two mechanisms with different owners. chrome.alarms is owned by the browser: it survives worker eviction, it clamps short periods, and it fires whenever the browser decides. setTimeout is owned by the JavaScript runtime: it is precise, and it dies with the worker. A test that cannot advance both independently cannot test the code that uses both.
Step 1. Build a fake alarm registry you can fire by hand
The real API is small enough to reimplement faithfully in thirty lines, which is far more useful than a spy.
1// test/fake-alarms.js
2export function createFakeAlarms() {
3 const alarms = new Map();
4 const listeners = new Set();
5
6 return {
7 api: {
8 create: (name, info) => {
9 // Mirror the browser: a period below one minute is clamped in a packed extension.
10 const periodInMinutes = info.periodInMinutes
11 ? Math.max(info.periodInMinutes, 1)
12 : undefined;
13 alarms.set(name, { name, periodInMinutes, scheduledTime: Date.now() + (info.delayInMinutes ?? 0) * 60_000 });
14 },
15 get: async (name) => alarms.get(name) ?? undefined,
16 getAll: async () => [...alarms.values()],
17 clear: async (name) => alarms.delete(name),
18 clearAll: async () => { const had = alarms.size > 0; alarms.clear(); return had; },
19 onAlarm: {
20 addListener: (fn) => listeners.add(fn),
21 removeListener: (fn) => listeners.delete(fn),
22 },
23 },
24 // Test-only controls.
25 fire: async (name) => {
26 const alarm = alarms.get(name);
27 if (!alarm) throw new Error(`no alarm named ${name}`);
28 for (const fn of listeners) await fn(alarm);
29 },
30 names: () => [...alarms.keys()],
31 periodOf: (name) => alarms.get(name)?.periodInMinutes,
32 };
33}
Execution context: Node, under Jest or Vitest — no browser, no jsdom requirement, because none of this touches the DOM. Reproducing the one-minute clamp in the fake is the point of writing it rather than using a generic mock: a test that asserts periodOf("poll") === 1 after asking for 0.5 documents real browser behaviour and fails if someone “fixes” the code to request thirty seconds again. Chrome and Firefox both clamp packed extensions to one minute; Safari applies additional coalescing on top, which is a runtime characteristic rather than something a unit test should model.
Step 2. Install it alongside the rest of the chrome double
1// test/setup.js
2import { createFakeAlarms } from "./fake-alarms.js";
3import { createFakeStorage } from "./fake-storage.js";
4
5export function installChrome() {
6 const alarms = createFakeAlarms();
7 const storage = createFakeStorage();
8
9 globalThis.chrome = {
10 alarms: alarms.api,
11 storage: storage.api,
12 runtime: { onInstalled: { addListener: () => {} }, onStartup: { addListener: () => {} } },
13 };
14
15 return { alarms, storage };
16}
Execution context: Test setup file, imported by each suite. Assigning to globalThis.chrome matches how the API is reached in the worker, so the module under test needs no dependency injection and ships unchanged. Returning the control handles separately from the API keeps the seam obvious: production code sees only chrome.alarms, tests see alarms.fire. The storage double this composes with is covered in mocking Chrome APIs in Jest.
Step 3. Test the schedule, not the call
1// sync.test.js
2import { installChrome } from "./test/setup.js";
3import { scheduleSync, registerHandlers } from "../src/sync.js";
4
5test("sync alarm is clamped and writes a timestamp when it fires", async () => {
6 const { alarms, storage } = installChrome();
7 registerHandlers();
8
9 await scheduleSync({ everyMinutes: 0.5 });
10
11 expect(alarms.names()).toEqual(["sync"]);
12 expect(alarms.periodOf("sync")).toBe(1); // the browser's floor, not our request
13
14 await alarms.fire("sync");
15
16 expect(await storage.get("lastSyncAt")).toEqual(expect.any(Number));
17});
18
19test("re-scheduling does not create a duplicate alarm", async () => {
20 const { alarms } = installChrome();
21 await scheduleSync({ everyMinutes: 5 });
22 await scheduleSync({ everyMinutes: 5 });
23 expect(alarms.names()).toEqual(["sync"]); // create() replaces by name
24});
Execution context: Jest or Vitest in Node. The second test encodes behaviour people get wrong in both directions: alarms.create with an existing name replaces rather than adding, so a guard against duplicates is unnecessary — but a guard against recreating one on every event is very necessary, because replacing resets the period and an alarm re-created every thirty seconds never fires at all. That failure mode is described in alarms that don’t fire after browser restart.
Step 4. Use fake timers for the in-memory side
Debounce, backoff and keep-alive loops use setTimeout, which fake timers control exactly.
1// backoff.test.js
2import { retryWithBackoff } from "../src/backoff.js";
3
4beforeEach(() => { jest.useFakeTimers(); });
5afterEach(() => { jest.useRealTimers(); });
6
7test("backs off 1s, 2s, 4s and then gives up", async () => {
8 const attempt = jest.fn()
9 .mockRejectedValueOnce(new Error("503"))
10 .mockRejectedValueOnce(new Error("503"))
11 .mockRejectedValueOnce(new Error("503"))
12 .mockResolvedValue("ok");
13
14 const promise = retryWithBackoff(attempt, { tries: 3 });
15
16 await jest.advanceTimersByTimeAsync(1000);
17 await jest.advanceTimersByTimeAsync(2000);
18 await jest.advanceTimersByTimeAsync(4000);
19
20 await expect(promise).rejects.toThrow("503");
21 expect(attempt).toHaveBeenCalledTimes(3);
22});
Execution context: Node with Jest’s modern fake timers. advanceTimersByTimeAsync rather than the synchronous variant is what lets awaited promises between the timers resolve — with the synchronous version the timer fires but the continuation never runs and the test hangs. Vitest exposes the same behaviour through vi.advanceTimersByTimeAsync. Keep this separate from the alarm tests: setTimeout state does not survive worker eviction, so anything scheduled with it is by definition short-lived.
Step 5. Test the restart path, which is where scheduling actually breaks
Alarms survive eviction; the listener registration does not. A worker that registers onAlarm inside an await misses the event entirely.
1// startup.test.js
2test("handlers are registered synchronously at module evaluation", async () => {
3 const { alarms } = installChrome();
4 const added = [];
5 chrome.alarms.onAlarm.addListener = (fn) => added.push(fn);
6
7 await import("../src/sw.js"); // evaluate the worker entry point
8
9 expect(added).toHaveLength(1); // registered during evaluation, not later
10 expect(alarms.names()).not.toContain("sync"); // creating alarms is NOT evaluation work
11});
Execution context: Node. Asserting that the listener exists immediately after the module is evaluated is the closest a unit test gets to the real failure — the browser dispatches the alarm on the turn after the worker script finishes, so a listener added inside a promise chain is too late. Asserting the absence of alarm creation at evaluation is the complementary check: creating alarms on every cold start resets their periods, which is the same never-fires bug from the other direction.
Cross-browser variation
- Chrome/Edge: packed extensions clamp
periodInMinutesto a minimum of 1; unpacked builds allow 30 seconds, which is why a schedule that works in development can stop working after upload. - Firefox: the same one-minute floor for packed add-ons.
browser.alarmsreturns native Promises, so a shared fake should return promises fromget,getAllandclearas the one above does. - Safari: honours the API but coalesces firings for power management, so real intervals drift by tens of seconds. No unit test should encode a tolerance for this — assert ordering and effects, never timing.
- All engines: alarms persist across worker eviction and browser restart, but not across an extension update in every case. Re-assert the schedule from
onInstalledandonStartupboth.
Verification
- Run the suite and confirm it completes in well under a second — any test that takes seconds is still waiting on something real.
- Change the production code to request
periodInMinutes: 0.5without the clamp and confirm the clamp assertion still passes, proving the fake models the browser rather than the code. - Move the
onAlarmregistration inside anawaitin the worker entry point and confirm the registration test fails. - Delete the
advanceTimersByTimeAsynccalls and confirm the backoff test times out rather than passing silently.
FAQ
Should I test against the real chrome.alarms in an end-to-end run?
Yes, once — a single smoke test that an alarm fires at all. Everything about which alarms exist, with what periods, and what they do belongs in unit tests.
Why not use a generic auto-mocking library for chrome?
Auto-mocks return undefined and record calls. They cannot model the clamp, the replace-by-name behaviour, or persistence, which is where the real bugs are.
Do I need jsdom for these tests?
No. Alarms and timers are worker-side concerns with no DOM. Running them in the Node environment is faster and avoids jsdom’s gaps.
How do I test something that uses both an alarm and a debounce?
Fire the alarm through the registry, then advance fake timers. The two controls are independent, which is exactly why they are separate.
Related
- Mocking Chrome APIs in Jest — the storage and messaging doubles this composes with.
- Alarms that don’t fire after browser restart — the production bug these tests are designed to catch.
- Minimum alarm period and throttling — where the clamp comes from.
- Unit & Integration Testing for MV3 Extensions — the guide this page belongs to.