Shots in this scene
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.
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.
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.
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
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
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
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.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
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 pausedThree global rules that switch animation off, each tried on a fresh transition and keyframe animation. The events are what a waiting test would see.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
duration 0s opacity 1, events: animationend
none opacity 1, events: none
delay 0s too opacity 1, events: animationendA 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.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
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: trueBreak 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.
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";.
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.pnghere), so generate them where your CI runs, usually a container. - ▸
To test code that runs when an animation ends (a
finishedpromise, a listener), callanimation.finish()and awaitanimation.finished, so the browser really ends it.
Remember this
- 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
What to test. End states: the toast is gone, the drawer is open and
aria-expandedsays 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
Fake timers for logic. Vitest's
vi.useFakeTimers()replacessetTimeout,requestAnimationFrame,performance.nowandDate(checked: afteradvanceTimersByTime(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
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,AnimationandmatchMediaareundefined: code that calls them throwsTypeError: el.animate is not a function. In happy-dom 21.0.4 they exist and seekingcurrentTimeworks, but the computed style never changes, CSS animations and transitions create noAnimationandanimationendnever fires. They check logic (timers, state, attributes), not that a toast fades. Ourtoast.jsremoves a toast at once whengetAnimations()is empty, so its test finishes in one tick. - 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 areAnimationobjects 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, andtoHaveScreenshot()takes a picture. Itsanimations: "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 withA snapshot doesn't exist ... writing actual.; commit the file it wrote (named for the platform) and the next run compares. - 6
Turning animations off. A global rule such as
* { animation-duration: 0s !important }speeds flows up, but the second example shows its trap. With0sthe animation still firesanimationend, but a transition never starts, so notransitionendfires. Withanimation: none !importantneither 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
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 therequestAnimationFrameloop keeps ticking. After every test assert nothing is left:document.getAnimations()empty,gsap.globalTimeline.getChildren()empty, and under fake timersvi.getTimerCount()0. The course'sanim.alive()helper is the same idea.
Explain it without notes
Why is await sleep(300) a bad way to test an animation?
What can happy-dom check about an animation, and what can it not?
How do you test the reduced-motion version of a toast?
Practice
Write a Vitest test that a toast with duration: 1000 is still present at 999 ms and gone at 1,000 ms.
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
currentTimeYou 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