Mocking matchMedia for Responsive Component Tests

Components that adapt to the viewport or to user preferences — a navigation that collapses below a breakpoint, an animation that respects prefers-reduced-motion, a theme that follows prefers-color-scheme — almost always consult window.matchMedia. jsdom does not implement it, so the first render throws window.matchMedia is not a function, and the usual fix is a one-line stub that returns matches: false for everything. That makes the tests run and ensures every one of them sees the same viewport and the same preferences. This guide replaces that stub with a controllable implementation: tests set the active media conditions, change them mid-test, and assert that listeners fire — so the desktop and mobile layouts, and the reduced-motion path, are all genuinely covered. It sits under DOM and browser API mocking.

Root Cause Analysis

The one-line stub solves the crash and creates a coverage hole shaped exactly like the feature. A responsive navigation has two behaviours; a stub fixed at matches: false tests one of them. A reduced-motion check has two paths; the stub tests the one where the user has not asked for reduced motion, which is precisely the path accessibility audits care least about. Nothing fails, and half the component ships untested.

The second gap is change. Real media queries change at runtime — the user rotates a phone, resizes a window, switches the operating system to dark mode — and well-written components subscribe to those changes through addEventListener('change', …). A static stub never fires a change event, so the subscription logic, including its cleanup on unmount, is never exercised. Leaked listeners are a common bug precisely because nothing tests them.

Third, many stubs implement the API incorrectly — returning a plain object without addEventListener, or only the deprecated addListener — so components written against the modern API break in tests while working in browsers, and developers work around the stub rather than fixing it.

What a fixed matchMedia stub leaves untested A stub returning matches false tests the desktop layout, the full-motion path and the light theme, while the mobile layout, the reduced-motion path, the dark theme and all runtime change handling go untested. tested by the stub desktop layout full-motion animations light theme never tested mobile layout reduced-motion path dark theme every runtime change
The stub silences the error and quietly chooses one side of every responsive branch.

Reproducible Setup

A component that switches layout at a breakpoint and respects reduced motion, written against the standard API.

// src/hooks/use-media-query.ts
import { useSyncExternalStore } from 'react';

export function useMediaQuery(query: string) {
  return useSyncExternalStore(
    (onChange) => {
      const mql = window.matchMedia(query);
      mql.addEventListener('change', onChange);
      return () => mql.removeEventListener('change', onChange);
    },
    () => window.matchMedia(query).matches,
    () => false,
  );
}
// src/components/SiteNav.tsx
export function SiteNav() {
  const isMobile = useMediaQuery('(max-width: 767px)');
  const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
  return isMobile
    ? <MobileMenu animate={!reduceMotion} />
    : <nav aria-label="Main"><DesktopLinks /></nav>;
}

Implementation

Step 1 — Implement a controllable matchMedia. It evaluates each query against a set of current conditions and notifies listeners when those conditions change.

// test/match-media.ts
type Conditions = { width: number; reducedMotion: boolean; colorScheme: 'light' | 'dark' };

const listeners = new Map<string, Set<(e: MediaQueryListEvent) => void>>();
let current: Conditions = { width: 1280, reducedMotion: false, colorScheme: 'light' };

function evaluate(query: string, c: Conditions): boolean {
  const max = query.match(/max-width:\s*(\d+)px/);
  const min = query.match(/min-width:\s*(\d+)px/);
  if (max && c.width > Number(max[1])) return false;
  if (min && c.width < Number(min[1])) return false;
  if (query.includes('prefers-reduced-motion: reduce')) return c.reducedMotion;
  if (query.includes('prefers-color-scheme: dark')) return c.colorScheme === 'dark';
  return Boolean(max || min);
}

export function installMatchMedia() {
  window.matchMedia = (query: string) => {
    const set = listeners.get(query) ?? new Set();
    listeners.set(query, set);
    return {
      media: query,
      get matches() { return evaluate(query, current); },
      onchange: null,
      addEventListener: (_: 'change', fn: (e: MediaQueryListEvent) => void) => set.add(fn),
      removeEventListener: (_: 'change', fn: (e: MediaQueryListEvent) => void) => set.delete(fn),
      addListener: (fn: (e: MediaQueryListEvent) => void) => set.add(fn),       // deprecated, still used
      removeListener: (fn: (e: MediaQueryListEvent) => void) => set.delete(fn),
      dispatchEvent: () => true,
    } as unknown as MediaQueryList;
  };
}

export function setMedia(next: Partial<Conditions>) {
  const before = current;
  current = { ...current, ...next };
  for (const [query, set] of listeners) {
    const was = evaluate(query, before), now = evaluate(query, current);
    if (was !== now) set.forEach((fn) => fn({ matches: now, media: query } as MediaQueryListEvent));
  }
}

export const listenerCount = () => [...listeners.values()].reduce((n, s) => n + s.size, 0);
export const resetMedia = () => { current = { width: 1280, reducedMotion: false, colorScheme: 'light' }; listeners.clear(); };

The query evaluator is deliberately small. It understands the handful of features real components use — width ranges and the two common preference queries — and it is easy to extend when a component starts using orientation or pointer queries. Resist the temptation to implement the whole media-query grammar; a test helper that grows into a parser becomes code that needs its own tests, and the narrow version covers the cases that actually appear in the codebase.

Notifying only when a query’s result actually flips mirrors browser behaviour, and it matters for assertions about re-rendering. A browser does not fire change for every resize, only when the query crosses its threshold, and a test helper that fired on every call would make components appear to re-render far more than they do.

Step 2 — Install it globally and reset between tests. Every test starts from the same default viewport and preferences.

// vitest.setup.ts
import { beforeEach } from 'vitest';
import { installMatchMedia, resetMedia } from './test/match-media';

installMatchMedia();
beforeEach(resetMedia);

Step 3 — Test both sides of each breakpoint. Set the conditions before rendering to test the initial layout.

// src/components/SiteNav.test.tsx
import { render, screen } from '@testing-library/react';
import { test, expect } from 'vitest';
import { setMedia } from '../../test/match-media';
import { SiteNav } from './SiteNav';

test('shows inline links on a wide viewport', () => {
  setMedia({ width: 1280 });
  render(<SiteNav />);
  expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument();
});

test('shows the menu button on a narrow viewport', () => {
  setMedia({ width: 390 });
  render(<SiteNav />);
  expect(screen.getByRole('button', { name: 'Open menu' })).toBeInTheDocument();
});

Step 4 — Test the reduced-motion path explicitly. This is the branch a fixed stub always skips, and it is the one accessibility depends on.

test('does not animate the menu when the user prefers reduced motion', async () => {
  setMedia({ width: 390, reducedMotion: true });
  render(<SiteNav />);
  await userEvent.click(screen.getByRole('button', { name: 'Open menu' }));
  expect(screen.getByRole('dialog')).toHaveAttribute('data-animated', 'false');
});
Driving a media change mid-test The test renders at desktop width, calls setMedia to narrow the viewport, the controllable implementation notifies only the listeners whose query result changed, and the component re-renders into its mobile layout. render at 1280px desktop links setMedia(390) inside act() change event only if result flipped re-render menu button this path — subscription, notification, cleanup — is invisible to a static stub
Notifying only on a genuine flip matches browser behaviour and keeps re-render assertions precise.

Step 5 — Test a change at runtime, and the cleanup. Resize mid-test inside act, assert the new layout, then unmount and confirm no listeners remain.

import { act } from 'react';
import { listenerCount } from '../../test/match-media';

test('switches layout when the viewport narrows, and cleans up on unmount', () => {
  const { unmount } = render(<SiteNav />);
  expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument();

  act(() => setMedia({ width: 600 }));
  expect(screen.getByRole('button', { name: 'Open menu' })).toBeInTheDocument();

  unmount();
  expect(listenerCount()).toBe(0);
});

Step 6 — Match the breakpoints to your design tokens. Import the same breakpoint constants the components use, so a token change does not silently leave tests probing the wrong width.

One pattern worth adopting across the suite is a small table of named viewports — phone, tablet, desktop — shared by every responsive test, rather than scattered pixel values. It keeps the tests readable, and when the design system adds or moves a breakpoint, one table changes and every test probes the new boundaries automatically.

Verification

Confirm both branches are exercised by running coverage on the navigation component; the mobile and reduced-motion branches should now be covered, where before they were not.

npx vitest run src/components/SiteNav.test.tsx --coverage --coverage.include=src/components/SiteNav.tsx
# SiteNav.tsx  | 100 | 100 | 100 | 100

Then confirm the listener check has teeth by removing the cleanup from the hook. The unmount assertion should fail with a non-zero listener count — exactly the leak it exists to catch.

jsdom with a controllable matchMedia versus a real browser The controllable implementation is ideal for testing which branch a component takes and how it reacts to changes, while real CSS media query rendering and layout at a breakpoint need a real browser via Playwright. jsdom + controllable which branch renders change handling, cleanup real browser CSS media queries in stylesheets actual layout at a breakpoint
JavaScript branching belongs in jsdom; CSS layout belongs in a browser.

Troubleshooting

Symptom: window.matchMedia is not a function persists. Diagnosis: the component reads it at module import time, before the setup file runs. Fix: make sure the setup file is listed in setupFiles, which runs before test files import anything; avoid reading matchMedia at module scope in components.

Symptom: the layout does not change after setMedia. Diagnosis: the update happened outside act, so React did not flush the re-render before the assertion. Fix: wrap mid-test changes in act, as in Step 5.

Symptom: a third-party component crashes on addListener. Diagnosis: it uses the deprecated API. Fix: keep addListener and removeListener in the implementation, as Step 1 does; libraries still rely on them.

Symptom: CSS-only responsive behaviour is not reflected. Diagnosis: jsdom does not compute layout or apply media queries in stylesheets. Fix: test JavaScript-driven responsiveness here, and use a Playwright test with a real viewport for layout that depends on CSS.

FAQ

Is there a library that does this?

Several — jest-matchmedia-mock and mq-polyfill among them — and they are reasonable choices. The implementation above is short enough to own, supports the modern and deprecated APIs, and notifies only on genuine changes, which some libraries do not. Whichever you use, make sure it lets tests change conditions and observe listeners.

Should I set the viewport width on window.innerWidth too?

If components read innerWidth directly, yes — keep the two consistent in setMedia. Better still, route all responsiveness through matchMedia, which is both the modern approach and the easier one to control in tests.

How does this relate to dark-mode tests?

The same controllable condition drives prefers-color-scheme, so a component that follows the operating system theme can be tested in both states. For screenshot tests of each theme, a real browser is still needed, as covered in reducing flaky screenshots with deterministic rendering.

Does this work with Vitest browser mode?

Browser mode uses a real browser, which implements matchMedia natively; control the viewport through the provider’s API instead. The jsdom approach here is for the far more common case of fast component tests in a simulated DOM.