Testing

Compile a real scene, resolve frames, assert on resolved actors — and never hand-build a fixture.

Because at(frame) is pure and needs no renderer, testing a scene is just calling it and asserting. No canvas, no browser, no video.

The core rule

Test through the runtime, not through fixtures. Compile a real scene with createScene(...), resolve frames with compiled.at(frame), and assert on the resolved actors.

Never hand-build ResolvedActor or FrameState literals

A hand-written resolved object encodes today's runtime shape and rots silently every time resolution changes. This class of fixture drift has repeatedly broken suites across renderers, desktop, scene3d and primitives — the test keeps passing while describing a runtime that no longer exists.

Actor tests

Spawn the actor in a scene generator, run its commands, resolve, assert.

import { createScene } from "@motionactor/core";
import { LineChart } from "../lineChart/actor";

it("animates trace progress over time", () => {
  const compiled = createScene(function* (ctx) {
    const chart = ctx.spawn(LineChart, {
      content: { points: [{ x: 0, y: 12, label: "9 AM" }] },
    });
    yield* chart.traceIn(0.4);
  });

  const tracing = compiled.at(6).findActor(LineChart);
  expect(tracing?.signals.reveal.traceProgress).toBeGreaterThan(0);
  expect(tracing?.signals.reveal.traceProgress).toBeLessThan(1);
});

Conventions

  • Durations are authored in seconds; at(frame) takes integer frames at the scene's fps (default 30). A 0.4 second tween has settled by frame 12.
  • Look up actors by blueprint — findActor(B), findActors(B), getActor(id, B). Do not probe signals through casts.
  • compiled.totalDuration is the length in frames. Render output normally samples 0 through totalDuration - 1; resolving totalDuration itself is useful for an endpoint assertion, but actors that have exited may already be absent.
  • Pick mid-animation frames. Asserting only the endpoints passes for a tween that teleports. Frame 6 of a 12-frame tween is the assertion that can actually fail.

Feeding channels

Write through the resolved handle, then re-resolve:

compiled.at(0).findActor(WeatherApp)?.channels.forecast.write({
  ...DEFAULT_WEATHER_FORECAST_SNAPSHOT,
  city: "Orleans",
});

const resolved = compiled.at(10).findActor(WeatherApp);
expect(resolved?.signals.forecast.city).toBe("Orleans");

Publications land in the runtime store, so a fresh at(...) after the write sees the value. Channel values surface on resolved signals, so assertions read them exactly like ordinary state.

View tests

Views are thin readers, so a view test reuses the same compile-and-resolve flow and renders the view with the resolved actor:

import { renderToStaticMarkup } from "react-dom/server.node";

const state = compiled.at(frame);
const markup = renderToStaticMarkup(<CardView actor={state.findActor(Card)!} />);

What is worth testing

Tests that can fail for a mistake nobody chose:

  • Timing — a mid-animation frame, not just the endpoints.
  • Seeking — resolve backwards and assert the same answer as forwards.
  • Channel precedence — a per-call override beating a publication.
  • Child identity — the same child instance across frames.
  • The bug you just fixed.

Not worth it: asserting an actor has the fields you just wrote, or that a view renders the prop you passed it.

The checked example

The Remotion example is verified in CI without rendering:

pnpm typecheck:examples
pnpm test:examples

The behavior check covers midpoint timing, child identity, channel overrides and backward seeking. These checks do not bundle or render a video, verify Tailwind output, or validate published tarballs in a fresh consumer — those are separate release checks.

On this page