DEV Community

Cover image for How I test CSS animations with Playwright and getAnimations()
Dmytro Shyshko
Dmytro Shyshko

Posted on AI-assisted

How I test CSS animations with Playwright and getAnimations()

I build nudge-icons, a set of animated line icons for React and Vue. Every icon has its own small motion: the bell rings, the arrow sinks into the tray, the menu folds into a cross. All of it is plain CSS, with no animation library.

Testing these animations turned out to be harder than testing the icons themselves. jsdom doesn't run CSS animations, and visual snapshots only capture a single frame. I needed to check whether animations start, how many times they repeat, and whether anything is left on the icon after they finish. So the tests run in a real browser, in real time, and ask the browser what is moving.

One playground story, driven by args

Storybook already renders every icon for visual review. For motion I added one more story, Testing/Playground, that renders a single live icon from args:

function Playground({ name, variant, animate, active, size }: PlaygroundProps) {
  const icon = iconByName(name);
  const Icon = icon.variants.find((v) => v.variant === variant)?.Icon;
  if (!Icon) throw new Error(`${name} has no ${variant} variant`);
  return (
    <Icon
      data-testid="icon"
      size={size}
      animate={toAnimate(animate)}
      {...(icon.toggle ? { active } : {})}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Playwright opens it with args in the URL, so each test starts from a known state:

await page.goto(`/iframe.html?id=testing-playground--live&args=name:bell;animate:once`);
Enter fullscreen mode Exit fullscreen mode

Some behaviour only shows when props change on a mounted component, like a menu turning into a cross and back. Reloading the page would remount the icon and skip that change. Instead, the test changes args in place through the Storybook channel, the same way the Controls panel does:

await page.evaluate((updatedArgs) => {
  window.__STORYBOOK_ADDONS_CHANNEL__.emit("updateStoryArgs", {
    storyId: "testing-playground--live",
    updatedArgs,
  });
}, { active: true });
Enter fullscreen mode Exit fullscreen mode

The component re-renders like it would in an app. The channel global is internal to Storybook, so this helper is the one place to fix if it ever changes.

Reading what moves

The Web Animations API lists the CSS animations and transitions on an element and its children. One helper turns the running ones into something a test can compare:

export const running = (page: Page) =>
  page.getByTestId("icon").evaluate((svg) =>
    svg
      .getAnimations({ subtree: true })
      .filter((a) => a.playState === "running")
      .map((a) => ({
        kind: a instanceof CSSTransition ? "transition" : "animation",
        part: a.effect?.target?.getAttribute("data-part") ?? null,
        iterations: Number(a.effect?.getComputedTiming().iterations),
      })),
  );
Enter fullscreen mode Exit fullscreen mode

CSSTransition versus CSSAnimation tells the two mechanisms apart: hover motion uses keyframe animations, the second state uses transitions. Each moving piece of an icon has a data-part name, so a test can say which part moved. iterations tells once (1), a loop (Infinity) and animate={3} apart without waiting for them to finish.

test("loop repeats without end", async ({ page }) => {
  await open(page, { name: "bell", animate: "loop" });
  const moving = await running(page);
  expect(moving.length).toBeGreaterThan(0);
  expect(moving.every((a) => a.iterations === Infinity)).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

What the tests check

  • Hover on every component: all 121, each icon in each variant. The test hovers the icon, waits until something runs, moves the pointer away and waits until nothing runs.
  • once, loop, a number of runs and false, on a few chosen icons rather than on every component.
  • The second state on and off for every component that has one.
  • The same checks with reducedMotion: "reduce", where nothing may move and the second state must switch at once.

Most tests poll until animations stop. I still use fixed waits in four places: to check that a loop is still running after 2.5 seconds, and to check that nothing starts within 300 ms when motion is turned off. I tried fake time first, but in my setup it did not advance reverse transitions, which are half of what a second state is. With real time the whole suite takes about 30 seconds.

Breaking it on purpose

I wanted to check whether the tests could catch actual regressions, so I deliberately reintroduced two bugs and ran the suite against each broken build.

First, I removed the prefers-reduced-motion rule. Three tests failed.

Second, I brought back a bug I had fixed earlier. The animations used animation-fill-mode: both, and the icons looked slightly blurred in Chrome after they finished moving. The suite passed.

It passed because every motion in the set ends exactly where it started. With fill: both, the finished animation keeps holding its last frame, but the last frame equals the resting drawing, so every computed style the tests compared was identical.

I had measured the blur on screenshots, as the share of half-grey pixels along the stroke edges:

State of the icon Half-grey edge pixels
Static 0.090
Transform animation running 0.145–0.158
Animation finished, fill: both 0.116
Animation finished, fill: backwards 0.090

In my Chrome screenshots, SVG strokes stayed slightly blurred while a finished transform animation remained attached. With fill: backwards, the animation is released when it ends and the edges go back to the static value.

So the tests needed to check for an animation that is still attached, not for a style value. That is one more helper and one more assertion:

const attached = (page: Page) =>
  page.getByTestId("icon").evaluate((svg) => svg.getAnimations({ subtree: true }).length);

await settled(page);
// No animation left holding its last frame, so the lines are drawn crisp again.
expect(await attached(page)).toBe(0);
Enter fullscreen mode Exit fullscreen mode

getAnimations() also returns finished animations that keep their effect through the fill mode, which is why this check sees what the style comparison missed. It does not prove that rendering is sharp in every case; it checks the cause of this particular regression.

The new assertion failed with fill: both and passed after restoring fill: backwards. The original tests had missed the regression.

Chromatic and Playwright

Every icon also has a story with all its variants and states at five sizes, standing still. Chromatic snapshots those on every pull request and catches a moved point or a changed stroke. The playground story is excluded from snapshots (parameters: { chromatic: { disableSnapshot: true } }), because a frame of live motion is not a stable baseline. Motion is checked by Playwright against the built Storybook, in CI, in Chromium.

The code is on GitHub: the playground story, the helpers and the tests. The icons are at nudge-icons.github.io/nudge-icons.

Top comments (0)