JSDOM ships a real MutationObserver, so test the actual behaviour rather than mocking it: mutate, then await a microtask (or call your code's takeRecords()-based flush) before asserting, and be careful with fake timers, which do not advance microtasks the way you might expect.
Problem / Scenario Context
A team has a form component that watches its own DOM with a MutationObserver and re-validates whenever fields are added or removed. Their unit test adds a field and immediately asserts that the error summary updated — and fails. Someone wraps the assertion in setTimeout and it passes locally but flakes in CI. Someone else mocks MutationObserver entirely, and the tests pass while a real bug (the observer watching the wrong element) ships to production.
Unlike IntersectionObserver and ResizeObserver, which JSDOM does not implement (see mocking IntersectionObserver in Jest and Vitest), MutationObserver works in JSDOM. The challenge is timing, not availability. The Testing Observers in JSDOM & Real Browsers topic covers the landscape.
Mechanics Explanation
In both browsers and JSDOM, mutation records are delivered at the next microtask checkpoint, not synchronously. A test that mutates and asserts in the same synchronous block runs its assertion before the callback — as explained in MutationObserver microtask timing.
So an assertion must come after the checkpoint. Three ways to get there:
await Promise.resolve()(orawait null) — ends the current synchronous run; queued microtasks, including the mutation observer's, run before theawaitresumes... if the observer's microtask was queued before the promise's. The mutation queued it first, so it runs first.await new Promise(r => setTimeout(r))— waits for a full task, which also drains microtasks. Works, but slower, and interacts badly with fake timers.- Flush synchronously with
takeRecords()— if the code exposes a flush method (as in using takeRecords before disconnect), the test can call it and assert immediately.
Fake timers (vi.useFakeTimers(), jest.useFakeTimers()) replace setTimeout and friends. Depending on configuration they may also fake queueMicrotask and process.nextTick, but they do not intercept the engine's native microtask queue used by MutationObserver. The setTimeout workaround then never resolves unless timers are advanced — the source of the CI flake.
Comparison Table: Ways to Wait for Delivery
| Technique | Works with real timers | Works with fake timers | Speed | Notes |
|---|---|---|---|---|
| Assert synchronously | no | no | — | callback has not run |
await Promise.resolve() |
yes | yes | fastest | enough for a single delivery |
await new Promise(r => setTimeout(r)) |
yes | only if timers advanced | slower | common flake source |
await vi.waitFor(() => …) / waitFor |
yes | partially | polls | good for async chains |
Component flush() using takeRecords() |
yes | yes | synchronous | requires a hook in the code |
Minimal Reproducible Example
import { expect, test } from 'vitest';
import { mountForm } from './form';
test('re-validates when a field is added', () => {
const form = mountForm(document.body);
const input = document.createElement('input');
input.required = true;
form.el.append(input);
expect(form.el.querySelector('.errors')!.textContent).toContain('1 field required'); // fails
});
Production-Safe Solution
// form.ts — expose a flush for tests (and for teardown correctness).
export function mountForm(root: HTMLElement) {
const el = Object.assign(document.createElement('form'), { innerHTML: '<div class="errors"></div>' });
root.append(el);
const validate = (): void => {
const missing = [...el.querySelectorAll<HTMLInputElement>('input[required]')].filter((i) => !i.value).length;
el.querySelector('.errors')!.textContent = missing ? `${missing} field required` : '';
};
const mo = new MutationObserver(() => validate());
mo.observe(el, { childList: true, subtree: true });
return {
el,
flush(): void { if (mo.takeRecords().length) validate(); },
destroy(): void { this.flush(); mo.disconnect(); el.remove(); },
};
}
// form.test.ts
import { afterEach, expect, test, vi } from 'vitest';
import { mountForm } from './form';
let form: ReturnType<typeof mountForm>;
afterEach(() => { form?.destroy(); vi.useRealTimers(); });
test('re-validates after the microtask checkpoint', async () => {
form = mountForm(document.body);
form.el.append(Object.assign(document.createElement('input'), { required: true }));
await Promise.resolve(); // let the observer deliver
expect(form.el.querySelector('.errors')!.textContent).toBe('1 field required');
});
test('re-validates synchronously via flush', () => {
form = mountForm(document.body);
form.el.append(Object.assign(document.createElement('input'), { required: true }));
form.flush(); // takeRecords-based
expect(form.el.querySelector('.errors')!.textContent).toBe('1 field required');
});
test('works under fake timers too', async () => {
vi.useFakeTimers();
form = mountForm(document.body);
form.el.append(Object.assign(document.createElement('input'), { required: true }));
await Promise.resolve(); // native microtasks are not faked
expect(form.el.querySelector('.errors')!.textContent).toBe('1 field required');
});
The real observer runs in every test, so a bug like observing the wrong element is caught. await Promise.resolve() is enough for one delivery; for code whose callback itself awaits something, use waitFor from the testing library to poll the expected state.
When JSDOM Is Not Enough
JSDOM's MutationObserver is faithful for record delivery, but the code around it often is not testable there:
- Layout-dependent callbacks. If the callback measures elements (
getBoundingClientRect,offsetHeight), JSDOM returns zeros. Test those paths in Playwright or Vitest browser mode. - Interaction with rendering observers. Code that registers new elements with an
IntersectionObserverfrom aMutationObservercallback needs a real browser to test the combined behaviour — JSDOM would need a mockedIntersectionObserver, which tests only half. - Performance characteristics. Record counts per framework render and callback cost are only meaningful in a real engine.
A pragmatic split: unit-test what the callback does with records in JSDOM; test that the whole feature works in one or two browser tests that exercise real rendering. Debugging flaky observer tests in CI covers the browser side.
Verification Steps
- Run the suite with and without fake timers and confirm identical results.
- Introduce a deliberate bug (observe the wrong element) and confirm a test fails — proof you are not over-mocking.
- Run tests in random order to catch shared observers leaking between tests.
- Check teardown disconnects observers so later tests do not receive records from earlier ones.
- Keep one browser test for the feature end to end.
Common Mistakes to Avoid
- Mocking
MutationObserverin JSDOM. It works natively; mocks hide real bugs. setTimeoutwaits under fake timers. They never fire unless advanced.- Asserting synchronously after a mutation. Delivery is at the microtask checkpoint.
- Not disconnecting between tests. Records from one test's DOM changes can run another test's callback.
FAQ
Does JSDOM support MutationObserver?
Yes. JSDOM implements MutationObserver with microtask delivery, record batching, takeRecords and the standard options. It does not implement IntersectionObserver or ResizeObserver.
Why does await Promise.resolve() work?
The mutation queued the observer's microtask before the test created its promise, and microtasks run in order. By the time the await resumes, the observer's callback has run.
Do fake timers break MutationObserver?
They do not break delivery, which uses the engine's native microtask queue. They break waits built on setTimeout, which only fire when the fake clock is advanced.
How do I test that my callback ignores its own writes?
Count callback invocations with a spy, perform one external mutation, await a microtask, and assert the count is one. If the self-write guard is missing, the count grows.
Why do records from one test show up in another?
Because an observer created in the first test is still connected to DOM that the second test mutates — typically document.body. Disconnect every observer in teardown and remove the test's DOM, or create each test's DOM in a fresh container.
Can I use Testing Library's waitFor with MutationObserver code?
Yes, and it is the right tool when the callback starts further asynchronous work such as a fetch. For purely synchronous callbacks it is slower than a single awaited microtask but equally correct.
Does happy-dom behave the same way as JSDOM here?
happy-dom also implements MutationObserver with microtask delivery, so the same waiting techniques apply. Small differences in edge cases exist between the two, which is another reason to keep one real-browser test for important features.
Should the flush method exist only for tests?
No. A takeRecords-based flush is also what correct teardown needs, so it earns its place in production code, and tests simply reuse it.
Related
- Using takeRecords Before Disconnect — the flush pattern
- Simulating Viewport Intersection in Unit Tests — the IntersectionObserver counterpart
- Visual Regression Testing for Lazy-Loaded Content — browser-level checks