Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 18.4Advanced ~23 min · shot 4 of 6

Testing Animations

In one line

Test what the motion does, not how long it takes: the end state, the reduced-motion version, what happens on an interruption, and that nothing keeps running. Seek an animation to a time instead of waiting, run timer logic under fake timers, and use a real browser for what a simulated DOM cannot animate.

0/6 · 0%

Think of it like this

A film editor checks a scene by dragging the playhead to frame 120. They do not sit through the movie. A test should jump to the moment it wants to look at, not wait for it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

fake timers
A test tool that replaces the clock functions with ones the test moves by hand, so time-based code runs instantly.
seek
Jump an animation to a chosen time by setting its currentTime.
visual snapshot
A saved picture of the page or an element that a later run is compared against.
leak
Work (a loop, a tween, a listener) that keeps running after the thing it served was removed.

Step by step

01Install the tools

Add Vitest and happy-dom for logic tests and Playwright for the browser, pinned: npm install -D vitest@5.0.5 happy-dom@21.0.4 @playwright/test@1.63.0, then npx playwright install chromium (it downloads Chromium once). In a clean folder on 2026-10-23 (Node 24.14.1) npm printed added 47 packages, and audited 48 packages in 19s and found 0 vulnerabilities.

02See what the DOM simulators have

Run a one-test probe under each environment. It prints which animation APIs exist and what they do.

terminal
$ npx vitest run tests/probe.test.js --environment jsdom
npx vitest run tests/probe.test.js --environment happy-dom
── expected output ──
jsdom 30.1.3
Element.prototype.animate: undefined
Element.prototype.getAnimations: undefined
Animation: undefined
matchMedia: undefined
 
happy-dom 21.0.4
animate() -> Animation | playState: running | getAnimations().length: 1
after currentTime=100: currentTime 100 | computed opacity: ""
CSS keyframes: getAnimations().length: 0 | computed animation-name: ""
CSS transition: getAnimations().length: 0
animationend fired after 400 ms: false
Condensed from the printed lines of a probe file we wrote for this lesson. jsdom 30 asks for Node 24.15 or newer and printed an engine warning on 24.14.1; it ran.

03Run the logic tests, then the browser tests

tests/motion.test.js checks tokens, the toast's timing under fake timers, the drawer's state and the reduced-motion setting in under a second. tests/launch.spec.js opens the page in Chromium, pauses the toast's animation and reads its opacity at 0, 110 and 220 ms in about 400 ms. Topic 18.6 has the full runs.

Run the logic tests, then the browser testsdiagram
Rendering diagram…

04Add the leak check

Put expect(document.getAnimations()).toHaveLength(0) in an afterEach that runs after each component is unmounted, and the same for your library's global timeline.

Try it yourself

  1. 1

    Seek to your own times

    In "Seek, do not wait" change [0, 55, 110, 165, 220] to [0, 22, 44]. We got 0, 0.161 and 0.308: ease-out is already above a straight line's 0.1 at 22 ms.

  2. 2

    Compare the fills

    The last two lines read 500 ms, past the end: fill: "both" holds the last frame (opacity 1), fill: "none" falls back to the box's own style (0.2). A test of the end state must say which it expects (Topic 4.3).

Code & diagrams

Seek, do not wait

A 220 ms ease-out fade is paused and moved to five times. The box's own opacity is 0.2, so the last line shows what an animation with no fill leaves behind.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

fill both, at   0 ms: opacity 0
fill both, at  55 ms: opacity 0.378
fill both, at 110 ms: opacity 0.685
fill both, at 165 ms: opacity 0.907
fill both, at 220 ms: opacity 1
fill both, at 500 ms (past the end): opacity 1, playState paused
fill none, at 500 ms (past the end): opacity 0.2, playState paused
What a kill switch does to events

Three global rules that switch animation off, each tried on a fresh transition and keyframe animation. The events are what a waiting test would see.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

duration 0s   opacity 1, events: animationend
none          opacity 1, events: none
delay 0s too  opacity 1, events: animationend
Find the leak

A CSS animation, a Web Animation, a GSAP tween and a requestAnimationFrame loop are started, then the element is removed. The counts show which of the four stopped.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

while mounted: 2 browser animations, 1 GSAP tween
after removal: 0 browser animations, 1 GSAP tween still running
rAF loop kept ticking after removal: true

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Wait for the real time in the logic test

Write the toast test again in its own file, tests/slow.test.js, without fake timers: call showToast("Added", { duration: 2600 }), then await new Promise((r) => setTimeout(r, 2650)); and expect no .toast left.

terminal
$ npx vitest run tests/slow.test.js --reporter=verbose
── what you'll see ──
✓ tests/slow.test.js > a toast leaves after 2600 ms (real wait) 2669ms
Tests 1 passed (1)
Duration 3.31s

Break #2

Read a stylesheet with ?raw under Vitest

In tests/motion.test.js replace the readFileSync(...) line with import css from "../src/styles/tokens.css?raw";.

terminal
$ npm test
── what you'll see ──
× tests/motion.test.js > tokens > the JavaScript mirror reads the CSS
AssertionError: expected '' to be '220ms'

Myth vs fact

Myth

If a unit test passes in jsdom, the animation works.

Fact

jsdom and happy-dom compute no frames. They test your logic, which is valuable, but only a real browser shows that a toast fades or that animationend fires.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Screenshots compare pixels, and fonts and anti-aliasing differ between operating systems. Baselines are saved per platform (toast-win32.png here), so generate them where your CI runs, usually a container.

  • ▸

    To test code that runs when an animation ends (a finished promise, a listener), call animation.finish() and await animation.finished, so the browser really ends it.

Remember this

  1. 1

    The problem this solves: the usual animation test is await sleep(300) and then a check. It is slow (every test waits), flaky (a busy build machine misses the window) and weak (it shows time passed, not that the animation ended right). A flaky test is soon ignored, and an ignored test protects nothing.

  2. 2

    What to test. End states: the toast is gone, the drawer is open and aria-expanded says so. Reduced motion: the calm version runs and the state still changes (Topic 7.2). Interruption: a second click mid-way does not make the element jump (Topics 3.4, 9.4). Cleanup: nothing keeps running after a component is removed. Do not test the pixels of an easing at 137 ms: that is the browser's job.

  3. 3

    Fake timers for logic. Vitest's vi.useFakeTimers() replaces setTimeout, requestAnimationFrame, performance.now and Date (checked: after advanceTimersByTime(100) both clocks had moved 100 ms). The toast test advances 2,599 ms, checks the toast is there, advances 1 ms and checks it is gone. All 8 tests ran in 850 ms; the same toast test with a real wait took 2.67 s alone. Fake timers move JavaScript clocks only; they do not run a CSS animation.

  4. 4

    Why jsdom and happy-dom do not run animations. They imitate the DOM in JavaScript with no rendering engine, so nothing computes a frame. We measured both. In jsdom 30.1.3, element.animate, getAnimations, Animation and matchMedia are undefined: code that calls them throws TypeError: el.animate is not a function. In happy-dom 21.0.4 they exist and seeking currentTime works, but the computed style never changes, CSS animations and transitions create no Animation and animationend never fires. They check logic (timers, state, attributes), not that a toast fades. Our toast.js removes a toast at once when getAnimations() is empty, so its test finishes in one tick.

  5. 5

    Seek in a real browser. An animation can be paused and moved: animation.pause(), animation.currentTime = 110, then read the computed style. CSS animations and transitions are Animation objects too (we seeked both to 100 ms of 200 and read 0.5). Playwright runs your test in a real browser: test.use({ reducedMotion: "reduce" }) makes (prefers-reduced-motion: reduce) true, and toHaveScreenshot() takes a picture. Its animations: "disabled" option (the default) fast-forwards finite animations to their end and cancels infinite ones, so the picture does not depend on timing. The first run has no baseline and fails with A snapshot doesn't exist ... writing actual.; commit the file it wrote (named for the platform) and the next run compares.

  6. 6

    Turning animations off. A global rule such as * { animation-duration: 0s !important } speeds flows up, but the second example shows its trap. With 0s the animation still fires animationend, but a transition never starts, so no transitionend fires. With animation: none !important neither event fires, and code waiting for one hangs. Prefer seeking for animation tests; keep the kill switch for end-to-end flows where motion is not the subject.

  7. 7

    Leaks. The third example removes a component with four kinds of animation. The CSS animation and the Web Animation vanish from document.getAnimations(), but the GSAP tween is still running and the requestAnimationFrame loop keeps ticking. After every test assert nothing is left: document.getAnimations() empty, gsap.globalTimeline.getChildren() empty, and under fake timers vi.getTimerCount() 0. The course's anim.alive() helper is the same idea.

Explain it without notes

01

Why is await sleep(300) a bad way to test an animation?

02

What can happy-dom check about an animation, and what can it not?

03

How do you test the reduced-motion version of a toast?

Practice

01

Write a Vitest test that a toast with duration: 1000 is still present at 999 ms and gone at 1,000 ms.

02

Why does the boundary pair (999, then 1) catch more than one advance of 1,000?

Done when you can

  • You can list the four things worth testing in motion code

  • You can test timer logic with fake timers and seek a real animation with currentTime

  • You can say what jsdom and happy-dom can and cannot check about animations

  • You can set up reduced motion and a fixed-state snapshot in Playwright