Unit Testing Animation Logic with Fake Timers

Part of Motion Testing & Automation in Accessible Motion Architecture.

The problem

A drawer component has real logic: it sequences an exit animation before removing an element, cancels the sequence if the drawer reopens, skips the animation under reduced motion, and cleans up listeners. The only tests are end-to-end, each waiting real seconds for animations, and they are the flakiest part of the suite. When a bug appears — the drawer occasionally removes itself after being reopened — reproducing it takes minutes per attempt.

Most of that behaviour is ordinary logic that happens to be about animation, and it can be tested in milliseconds.

Root cause analysis: what is logic and what is rendering

Animation code contains three separable parts.

Decisions — whether to animate at all, which duration to use, what happens when the trigger fires again mid-sequence — are pure logic. They depend on the reduced-motion preference and on state, not on pixels.

Orchestration — waiting for an animation to finish, then removing an element — depends on timers and promises, both of which can be faked.

Rendering — whether the motion is smooth, whether the element is visible, whether the layout is correct — genuinely needs a browser.

Testing environments such as jsdom implement the DOM but not layout or animation: element.animate may be missing, getAnimations() returns nothing, and getBoundingClientRect() returns zeros. That is not a limitation to work around with elaborate mocks; it is a signal that layout-dependent code belongs in browser tests, while the decisions and orchestration around it should be written so they can be tested without layout.

Fake timers replace setTimeout, setInterval and often requestAnimationFrame with a controllable clock, so a 300ms sequence is tested in microseconds and the ordering of concurrent timers is deterministic.

Where each behaviour should be testedUnit tests for decisions and sequencing; browser tests for pixels.Where each behaviour should be testedUnit testBrowser testReduced-motionbranch chosenYesSpot checkExit sequence thenremovalYesNoReopen cancelsremovalYesNoAnimation is smoothNoYesElement ends in theright placeNoYes
Unit tests for decisions and sequencing; browser tests for pixels.

Step-by-step resolution

Making animation logic testableInject the environment; assert the outcome.Making animation logic testable1Move decisions into functions that take the preference as an argument or read an injectedmatcher.No hidden globals2Install fake timers in the test setup.Instant, deterministic time3Mock matchMedia to return a controllable list.Both branches testable4Stub element.animate to return a controllable finished promise.Sequencing testable in jsdom5Assert on state, DOM structure and calls.Stable expectations6Keep smoothness and layout in end-to-end tests.
Inject the environment; assert the outcome.

Production code pattern

// drawer.js — logic separated from rendering.
export function createDrawer(el, { media = matchMedia('(prefers-reduced-motion: reduce)') } = {}) {
  let closing = null;

  async function close() {
    if (media.matches) { el.remove(); return 'instant'; }
    el.dataset.state = 'closing';
    closing = Promise.allSettled(el.getAnimations().map((a) => a.finished));
    await closing;
    if (el.dataset.state !== 'closing') return 'cancelled';   // reopened while animating
    el.remove();
    return 'removed';
  }

  function open() {
    el.dataset.state = 'open';
    closing = null;
  }

  return { open, close };
}
// drawer.test.js — vitest-style; the same shape works in Jest.
import { beforeEach, afterEach, expect, test, vi } from 'vitest';
import { createDrawer } from './drawer.js';

function fakeMedia(matches) { return { matches, addEventListener() {}, removeEventListener() {} }; }

function fakeAnimated(el, ms) {
  el.getAnimations = () => [{ finished: new Promise((r) => setTimeout(r, ms)) }];
  return el;
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test('removes the element after the exit animation', async () => {
  const el = fakeAnimated(document.body.appendChild(document.createElement('div')), 300);
  const drawer = createDrawer(el, { media: fakeMedia(false) });

  const result = drawer.close();
  expect(el.isConnected).toBe(true);            // still present during the animation
  await vi.advanceTimersByTimeAsync(300);
  expect(await result).toBe('removed');
  expect(el.isConnected).toBe(false);
});

test('reopening during the exit cancels the removal', async () => {
  const el = fakeAnimated(document.body.appendChild(document.createElement('div')), 300);
  const drawer = createDrawer(el, { media: fakeMedia(false) });

  const result = drawer.close();
  await vi.advanceTimersByTimeAsync(100);
  drawer.open();                                 // user reopens mid-animation
  await vi.advanceTimersByTimeAsync(300);
  expect(await result).toBe('cancelled');
  expect(el.isConnected).toBe(true);
});

test('reduced motion removes the element immediately', async () => {
  const el = document.body.appendChild(document.createElement('div'));
  const drawer = createDrawer(el, { media: fakeMedia(true) });
  expect(await drawer.close()).toBe('instant');
  expect(el.isConnected).toBe(false);
});

Rendering Impact: none; these tests never render. Their value is catching sequencing and cancellation bugs — the ones that cause elements to vanish or linger — in milliseconds rather than seconds.

Injecting the media query list rather than reading matchMedia inside the module is what makes both branches testable without global patching. The same approach works for injecting a clock or a scheduler.

Suite runtime for the same drawer behavioursThree behaviours: exit-then-remove, reopen-cancels, reduced motion.Suite runtime for the same drawer behavioursEnd-to-end with real waits6.4 sBrowser tests with clock control1.9 sUnit tests with fake timers0.04 s
Three behaviours: exit-then-remove, reopen-cancels, reduced motion.

What still needs a browser

Fake timers cannot tell you whether an animation looked right. Four things belong in browser tests: that the animation actually runs — getAnimations() returning the expected effects, as in writing Playwright tests for motion states; that the reduced-motion branch produces no motion, not merely a different code path; that the element ends in the right position; and that nothing is visually broken, which is screenshot territory.

The division of labour is what keeps the suite fast: dozens of cheap unit tests for the logic that changes often, and a handful of browser tests for the rendering that rarely changes but matters when it breaks.

Verification checklist

Constraints and trade-offs

  • Stubbing getAnimations risks testing the stub rather than the code; keep stubs minimal.
  • jsdom has no layout, so any code that measures must be tested in a browser.
  • Fake timers can hide real ordering problems that only appear with genuine frame timing.
  • Injecting dependencies adds a little ceremony to component APIs.
  • Over-mocking the animation API produces tests that pass while the feature is broken.

Frequently asked questions

Can I unit test CSS animations?

Not their appearance. You can test the logic around them — which branch runs, what happens on completion or cancellation — with fake timers and stubs.

How do I test reduced-motion behaviour in unit tests?

Inject a fake media query list with matches set to true or false, rather than reading matchMedia inside the module.

Does jsdom support element.animate?

Not fully. Provide a minimal stub returning a controllable finished promise, or keep animation-dependent code in browser tests.

What should stay in end-to-end tests?

Whether the animation runs at all, whether reduced motion removes it, final positions, and visual appearance.