Ani
Guides

Testing

Test animations deterministically with a manual frame source and isolated clocks.

Animations depend on time and requestAnimationFrame. Replace both with a clock you control and tests become exact and fast.

A manual clock

@freestylejs/ani-core/testing ships createManualClock: a real clock (createClock) fed by a hand-cranked frame source and time source. It follows the production code path with deterministic time. It is not part of the root entry, so it never reaches your bundle.

import { createManualClock } from "@freestylejs/ani-core/testing";

const manual = createManualClock(); // time starts at 1000 ms
MemberDescription
clockThe Clock to pass to timelines and createStates.
nowCurrent time in ms.
pendingQueued frame requests (0 or 1 for a healthy clock).
frame(ms = 16)Advance time by ms and run one frame.
frames(count, ms = 16)Run count frames.
advance(ms)Move time forward without delivering a frame.
drop()Lose the pending frame request, as a browser may.
setHidden(hidden)Flip the injected visibility source and fire visibilitychange. Throws TypeError with visibility: false.

Options: lagThreshold, maxDeltaTime, timeScale, onError (all forwarded to createClock), visibility (default true, the manual clock injects its own source) and reducedMotion (default false).

MemberDescription
setReducedMotion(on)Flip the injected reduced-motion preference. It is read at the start of each play().

Time is in milliseconds on the manual clock (now, frame(ms)) because it feeds the frame source; timelines still receive their dt in seconds.

Test a timeline

import * as a from "@freestylejs/ani-core";
import { createManualClock } from "@freestylejs/ani-core/testing";
import { expect, it } from "vitest";

it("reaches the target and leaves the clock", () => {
  const manual = createManualClock();
  const timeline = a.rafTimeline(
    a.ani({ to: { x: 100 }, duration: 1, timing: a.timing.linear() }),
    { clock: manual.clock }
  );

  timeline.play({ from: { x: 0 } });
  manual.frames(5, 100); // dt = 0.1 s each
  expect(timeline.value).toEqual({ x: 50 });
  expect(timeline.status).toBe("PLAYING");

  manual.frames(10, 100);
  expect(timeline.status).toBe("ENDED");
  expect(manual.clock.size).toBe(0); // no leaked subscriber
  expect(manual.pending).toBe(0);    // no leaked frame request
});
  • Frames up to the clock's lagThreshold (0.5 s) advance by their full duration. A longer frame(ms) simulates a suspension and advances by one ordinary frame; pass lagThreshold: Infinity to drive arbitrary jumps in wall-clock time.
  • Pass clock to createStates, and to timelines created by your code, so nothing touches the shared clock.

Reset the shared clock

If your code uses the default clock (defaultClock), isolate tests with resetDefaultClock(). It disposes the shared clock and restores its defaults (timing options, timeScale 1, not paused). The instance stays the same, so references to defaultClock remain valid.

import { resetDefaultClock } from "@freestylejs/ani-core/testing";
import { afterEach } from "vitest";

afterEach(() => {
  resetDefaultClock();
});

The same call is useful to tear down the shared clock on hot module reload.

Test for leaks

  • After unmounting views, clock.size (or defaultClock.size) must be 0.
  • Count onUpdate subscriptions by wrapping timeline.onUpdate and counting subscribe minus unsubscribe calls.
  • Repeat mount and unmount many times (hundreds) and assert the counts do not grow.

Test bindings

  • Pass your manual clock to the timelines and to useAniStates / createAniStates (clock option).
  • Run in jsdom. Wrap React updates in act.
  • Assert on element.style after calling manual.frame().
  • For server rendering, run in the node environment and render. See SSR.

Test reduced motion

The manual clock starts with the preference off, so existing timings hold. Turn it on to test the 'jump' policy. See Reduced motion.

import * as a from "@freestylejs/ani-core";
import { createManualClock } from "@freestylejs/ani-core/testing";
import { expect, it } from "vitest";

it("completes at once when reduced motion is on", async () => {
  const manual = createManualClock({ reducedMotion: true });
  const timeline = a.rafTimeline(a.ani({ to: { x: 1 }, duration: 1 }), {
    clock: manual.clock,
  });

  await expect(timeline.play({ from: { x: 0 } })).resolves.toBe("ended");
  expect(manual.clock.size).toBe(0);
});

Test errors

Callback errors are reported asynchronously. In a test, give the clock an onError to capture errors thrown by subscribers, and stub queueMicrotask to capture errors thrown by onUpdate callbacks:

import { createManualClock } from "@freestylejs/ani-core/testing";

const errors: unknown[] = [];
const manual = createManualClock({ onError: (error) => errors.push(error) });