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() (or await null) — ends the current synchronous run; queued microtasks, including the mutation observer's, run before the await resumes... 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.

Why the Synchronous Assertion FailsA timeline of one test. The test appends a field and immediately asserts, while the mutation record is still queued. The microtask checkpoint arrives after the synchronous block, and only then does the observer callback re-validate. Awaiting a resolved promise moves the assertion after the checkpoint.One test bodytest (sync)append fieldassert: failsmicrotasksMO callback: validatetest (after await)assert: passes0ticks2ticks4ticks6ticks8ticks10ticks

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

TypeScript
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

TypeScript
// 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(); },
  };
}
TypeScript
// 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.

How Should This Test Wait?A decision chain. If the code exposes a flush method built on takeRecords, call it and assert synchronously. Otherwise, if the callback does all its work synchronously, await a resolved promise once. Otherwise, if the callback starts further asynchronous work, use waitFor to poll the expected state. Otherwise, for layout-dependent behaviour, move the test to a real browser.Does the code expose a takeRecords flush?Call flush(), assert synchronouslyyesnoIs the callback's work synchronous?await Promise.resolve() onceyesnoDoes the callback start more async work?waitFor the expected stateyesnoDepends on layout or real rendering: use a browser test.

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 IntersectionObserver from a MutationObserver callback needs a real browser to test the combined behaviour — JSDOM would need a mocked IntersectionObserver, 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.

What to Test WhereTwo columns. In JSDOM, test that records trigger the right logic, filtering by attributeFilter and childList, idempotence and teardown with takeRecords. In a real browser, test layout-dependent callbacks, combinations with IntersectionObserver or ResizeObserver, and the cost of callbacks during framework renders.JSDOM (fast, many tests)Records trigger the right logicOption filtering: childList, attributeFilterIdempotence and self-write guardsTeardown flushes with takeRecordsReal browser (few tests)Callbacks that measure layoutCombined with IO or RO registrationCost during large framework renders

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 MutationObserver in JSDOM. It works natively; mocks hide real bugs.
  • setTimeout waits 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.


↑ Back to Testing Observers in JSDOM & Real Browsers