Writing Playwright Tests for Motion States

Part of Motion Testing & Automation in Accessible Motion Architecture.

The problem

Testing an animated component looks straightforward until the first waitForTimeout(300) appears. It passes locally, fails one run in twenty on CI, gets bumped to 500, passes for a month, and then fails again when someone changes a duration. Meanwhile the assertion itself — usually a screenshot — tells you that something changed without telling you what.

The result is a suite that is simultaneously flaky and uninformative, which is the worst combination: it costs time to maintain and it does not build confidence.

Root cause: tests that guess at time instead of observing it

An animation has a well-defined lifecycle with observable end points, and a timeout ignores all of them. animation.finished settles precisely when the effect completes, playState reports where it is, and getAnimations() enumerates everything currently affecting the document.

Building on those turns a timing problem into a data problem. Instead of waiting long enough and hoping, the test waits for the exact event; instead of comparing pixels, it asserts over structured facts about the effects. When it fails, the failure names the animation, its target, its duration and its keyframes.

There is one more structural decision that determines whether the suite ages well: what the test observes. Asserting on class names couples the test to styling; asserting on screenshots couples it to rendering. Asserting on a state attribute — the same attribute the state machine already uses — couples it to behaviour, which is what you actually want to keep stable.

What to assert on, and how each choice agesState attributes and effect data survive refactors; class names and pixels do not.What to assert on, and how each choice agesSurvives a CSS refactor?Failure message qualityClass namesNoPoor — names a classScreenshotsNoPoor — shows a diffComputedstylesPartlyGood — names a propertyStateattributesYesGood — names a stategetAnimations()dataYesBest — names the effect
State attributes and effect data survive refactors; class names and pixels do not.

Step-by-step resolution

Building the testHelpers first; the tests themselves then read like a description of the component.Building the test1Add a settle() helper that awaits every animation's finished promise.No timeouts anywhere in the suite2Add an effects() helper that returns timing and keyframes for each runningeffect.Assertions read structured data3Assert the state attribute transitions in the order the component defines.Behaviour, not styling4Assert the property list never includes a layout-tier property.Compositing is protected by the suite5Add an interruption test that reverses mid-flight and re-settles.The state machine cannot end up half-open
Helpers first; the tests themselves then read like a description of the component.

Production code pattern

// motion-states.spec.js — driving one component through its whole lifecycle.
import { test, expect } from '@playwright/test';

const LAYOUT_TIER = /\b(width|height|top|left|right|bottom|margin|padding)\b/;

async function settle(page) {
  await page.evaluate(() =>
    Promise.allSettled(document.getAnimations().map((a) => a.finished))
  );
}

async function effects(page) {
  return page.evaluate(() =>
    document.getAnimations().map((a) => ({
      target: a.effect?.target?.dataset?.testid ?? '(unknown)',
      state: a.playState,
      properties: (a.effect?.getKeyframes?.() ?? [])
        .flatMap((frame) => Object.keys(frame))
        .filter((key) => !['offset', 'computedOffset', 'easing', 'composite'].includes(key)),
    }))
  );
}

test.describe('disclosure panel', () => {
  test.beforeEach(async ({ page }) => {
    await page.emulateMedia({ reducedMotion: 'no-preference' });
    await page.goto('/components/disclosure/');
  });

  test('moves through its states in order', async ({ page }) => {
    const panel = page.getByTestId('panel');
    await expect(panel).toHaveAttribute('data-state', 'closed');

    await page.getByTestId('toggle').click();
    // The opening state exists only while the animation runs.
    await expect(panel).toHaveAttribute('data-state', 'opening');

    await settle(page);
    await expect(panel).toHaveAttribute('data-state', 'open');
  });

  test('only animates compositor-safe properties', async ({ page }) => {
    await page.getByTestId('toggle').click();
    const animated = (await effects(page)).flatMap((e) => e.properties);
    const offenders = animated.filter((property) => LAYOUT_TIER.test(property));
    expect(offenders, `layout-tier properties animated: ${offenders}`).toEqual([]);
  });

  test('survives an interruption', async ({ page }) => {
    const panel = page.getByTestId('panel');
    await page.getByTestId('toggle').click();
    await page.waitForFunction(() =>
      document.querySelector('[data-testid="panel"]').dataset.state === 'opening'
    );
    await page.getByTestId('toggle').click();   // reverse mid-flight
    await settle(page);
    // Whatever happened in between, the component must land in a valid state.
    await expect(panel).toHaveAttribute('data-state', 'closed');
  });

  test('screenshot is stable with animations frozen', async ({ page }) => {
    await page.getByTestId('toggle').click();
    await settle(page);
    await expect(page.getByTestId('panel')).toHaveScreenshot('panel-open.png', {
      animations: 'disabled',   // fast-forwards finite effects, stops loops
    });
  });
});

Rendering Impact: none in production. Note the third test in particular: interrupting mid-flight is the case that hand testing almost never covers and that users hit constantly.

The compositor assertion deserves emphasis. It does not measure performance — it asserts a property list, which is the thing that determines whether the animation can be composited. That makes it a cheap, deterministic proxy for a property that would otherwise need a performance trace, and it fails loudly the moment someone animates a height.

Making a component testableSmall authoring changes that make the suite short and durable.Making a component testable1Give each meaningful element a stable test id, separate from stylinghooks.Tests stop breaking on class renames2Expose the lifecycle as a data-state attribute with named values.Assertions describe behaviour3Keep transitional states in the attribute, not only in a class.The interruption test has something to observe4Avoid animating anything the compositor cannot handle.The property assertion stays green
Small authoring changes that make the suite short and durable.

Verification checklist

Constraints and trade-offs

  • getAnimations() returns an unordered list, so match effects by target or name rather than by index.
  • Effects created after the assertion runs are missed; drive the interaction and then read, never the other way around.
  • Freezing animations for screenshots fast-forwards them, so a mid-animation visual state cannot be captured that way — seek with currentTime instead.
  • Test ids add markup, which some teams dislike; the alternative is coupling tests to styling, which costs more over time.
  • A looping animation makes settle() hang, so exclude infinite effects from the helper or assert on them separately.

Frequently asked questions

Should I assert on class names or on computed styles?

On neither, ideally. Assert on a state attribute for logic and on computed styles only where the visual result is the contract. Class names are an implementation detail, and asserting on them makes a refactor look like a regression.

How do I test an animation that loops forever?

Do not await its finished promise — it never settles. Assert on its timing instead: that iterations is Infinity where intended, and that it is absent entirely under reduced motion.

Can Playwright test that an animation is on the compositor?

Not directly, but it can assert the property list, which is what determines compositing. Read the keyframes from getAnimations() and fail if any of them touch a layout or paint property.


Testing a scroll-driven animation

Scroll-linked motion needs a different driving mechanism, because there is no click to await and no finished promise that settles at a meaningful moment — progress is a function of position, so the test has to control position.

page.mouse.wheel() scrolls the way a user does, which is the right level for an integration test; page.evaluate(() => window.scrollTo(...)) jumps, which is fine when you only care about the end state. After either, read the animation’s currentTime and effect.getComputedTiming().progress rather than taking a screenshot: progress is a number between 0 and 1 that says exactly where the animation is.

test('reveal tracks scroll position', async ({ page }) => {
  await page.goto('/components/reveal/');
  await page.mouse.wheel(0, 600);
  const progress = await page.evaluate(() => {
    const el = document.querySelector('[data-testid="reveal"]');
    const [animation] = el.getAnimations();
    return animation?.effect.getComputedTiming().progress ?? null;
  });
  expect(progress).toBeGreaterThan(0);
  expect(progress).toBeLessThan(1);
});

Two cautions specific to scroll. Wheel events are asynchronous, so a waitForFunction on the progress value is more reliable than reading immediately. And a timeline that fails to resolve reports null progress rather than throwing, which is exactly the regression worth catching — assert that the value is a number before asserting anything about its magnitude.

Running the suite against a component library

A design system’s components are tested in isolation more often than in a page, and motion tests adapt to that well — with two adjustments.

The first is the environment. A component rendered in a story or a fixture usually lacks the design tokens, the reset and the reduced-motion overrides that the real application provides. A motion assertion in that environment tests the component against a stylesheet nobody ships, which is worse than no test. Mount the fixture with the same global stylesheet the application loads, or the suite will pass while production fails.

The second is the interaction surface. In a page, a test drives real controls; in a fixture, the component is often mounted in a fixed state with no way to transition into it. Motion is a transition between states, so a fixture that only renders the end state has nothing to observe. Expose a control, or drive the state attribute directly from the test and let the transition run.

With both in place the suite has a useful property: a component that regresses fails in the library, before it reaches an application, and the failure names the component rather than the page it happened to break.