---
title: Testing
description: Compile a real scene, resolve frames and assert on what they hold, with no renderer, browser or hand-built fixture.
---

[`at(frame)`](/docs/runtime/compiled-scene) needs no renderer, so a test compiles a scene the way
an app does and reads frames from it. These tests use the `Toast` from
[Creating actors](/docs/guides/creating-actors).

```ts twoslash title="toast.test.ts"
// @filename: toast.ts
import {
  derived,
  LayoutActor,
  layoutGroup,
  signal,
  type Duration,
} from "@motionactor/core";

type ToastEvents = {
  dismissed: { message: string };
};

export class Toast extends LayoutActor<ToastEvents> {
  layout = layoutGroup({ width: 360, height: 64 });
  message = signal<string>();
  tone = signal<"info" | "error">("info");
  remaining = signal(1);

  barWidth = derived(() => this.remaining() * this.localLayout().width);

  *show({ hold }: { hold: Duration }) {
    yield* this.appearance.opacity.tween(1, 0.2);
    yield* this.remaining.tween(0, hold);
    yield* this.appearance.opacity.tween(0, 0.2);
    yield* this.emit.dismissed({ message: this.message() });
  }
}
// @filename: toast.test.ts
// ---cut---
import { createScene } from "@motionactor/core";
import { afterAll, expect, it } from "vitest";
import { Toast } from "./toast";

const compiled = createScene(function* (ctx) {
  const toast = ctx.spawn(Toast, {
    message: "Saved",
    appearance: { opacity: 0 },
  });
  yield* toast.show({ hold: 2 });
  return { toast };
}).build({ viewport: { width: 1920, height: 1080 }, fps: 30 });
// The file's tests share one build; it releases its cells after the last.
afterAll(() => compiled[Symbol.dispose]());

it("shrinks the bar during the countdown", () => {
  expect(compiled.at(36).scene.toast?.barWidth).toBe(180);
  expect(compiled.at(66).scene.toast?.barWidth).toBe(0);
});

it("fades out after the countdown", () => {
  expect(compiled.at(66).scene.toast?.appearance.opacity).toBe(1);
  expect(compiled.at(69).scene.toast?.appearance.opacity).toBe(0.5);
});

it("announces the dismissal once it has faded", () => {
  const announced = compiled.events.flatMap((entry) =>
    entry.kind === "actor-event" ? [entry.event] : [],
  );
  expect(announced.map(({ eventName, frame }) => [eventName, frame]))
    .toEqual([["dismissed", 72]]);
});
```

## Frames and seconds

Scenes are authored in seconds and `at` takes whole frames, at the `fps` the test built the
scene at. Built at 30 fps, the toast's 0.2 second fade is 6 frames and its 2 second countdown runs
from frame 6 to frame 66.

Assert inside a motion as well as at its ends. A tween that jumped straight to its target
would pass a test of frames 6 and 66; frame 36 is the one that catches it.

`compiled.totalDuration` is the length in frames. A render draws frames 0 to
`totalDuration - 1`. Resolving `totalDuration` itself is useful for a final value, but an
actor that has exited by then is absent from the frame.

## Reading actors

Return the actors a test needs from the scene, as the toast is here, and read them from
`at(frame).scene`. For an actor the scene does not return, look it up by class with
`findActor(Toast)`, `findActors(Toast)` or `getActor(id, Toast)`; the result is typed from
the class. [Compiled scene](/docs/runtime/compiled-scene#looking-up-actors) lists the lookups.

Build every frame through the runtime. A hand-written resolved actor encodes the runtime's
shape on the day it was written, and the test keeps passing after that shape changes.

## Values from the host

A [channel](/docs/reactivity/channel) reads what a host published. A test publishes its own value
through the resolved actor's handle. Compile the scene inside the test with `using`, so the
publication goes with the build when the test ends:

```ts twoslash title="meter.test.ts"
import { channel, createScene, derived, LayoutActor, signal } from "@motionactor/core";
import { expect, it } from "vitest";

class Meter extends LayoutActor {
  level = signal(0.5);
  trackWidth = channel({ default: 400 });
  fillWidth = derived(() => this.level() * this.trackWidth());
}

it("fills half of a measured track", () => {
  using compiled = createScene(function* (ctx) {
    const meter = ctx.spawn(Meter);
    yield* ctx.wait(1);
    return { meter };
  }).build({ viewport: { width: 1920, height: 1080 }, fps: 30 });

  compiled.at(0).scene.meter?.channels.trackWidth.write(800);
  expect(compiled.at(0).scene.meter?.fillWidth).toBe(400);
});
```

A publication stays for every later read of that build until
`actor.channels.<name>.unpublish()` removes it. A scene compiled at module scope is shared by
every test in the file, and so is what one of them publishes.

## Drawing

A renderer test renders a frame to markup and checks what the renderer wrote:

```tsx twoslash title="toast-renderer.test.tsx"
// @jsx: react-jsx
// @filename: toast.ts
import {
  derived,
  LayoutActor,
  layoutGroup,
  signal,
  type Duration,
} from "@motionactor/core";

type ToastEvents = {
  dismissed: { message: string };
};

export class Toast extends LayoutActor<ToastEvents> {
  layout = layoutGroup({ width: 360, height: 64 });
  message = signal<string>();
  tone = signal<"info" | "error">("info");
  remaining = signal(1);

  barWidth = derived(() => this.remaining() * this.localLayout().width);

  *show({ hold }: { hold: Duration }) {
    yield* this.appearance.opacity.tween(1, 0.2);
    yield* this.remaining.tween(0, hold);
    yield* this.appearance.opacity.tween(0, 0.2);
    yield* this.emit.dismissed({ message: this.message() });
  }
}
// @filename: toast-renderer.tsx
import { dom } from "@motionactor/dom/react";
import { Toast } from "./toast";

export const toastRenderer = dom.render(Toast, ({ actor }) => (
  <div role="status" className="toast" data-tone={actor.signals.tone}>
    {actor.signals.message}
    <i className="toast-bar" style={{ width: actor.signals.barWidth }} />
  </div>
));
// @filename: toast-renderer.test.tsx
// ---cut---
import { createScene } from "@motionactor/core";
import { dom } from "@motionactor/dom/react";
import { createRenderRuntime, SceneRenderer } from "@motionactor/renderers/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterAll, expect, it } from "vitest";
import { Toast } from "./toast";
import { toastRenderer } from "./toast-renderer";

const runtime = createRenderRuntime({
  backends: [dom],
  renderers: [toastRenderer],
});

const compiled = createScene(function* (ctx) {
  const toast = ctx.spawn(Toast, { message: "Saved" });
  yield* toast.show({ hold: 2 });
}).build({ viewport: { width: 1920, height: 1080 }, fps: 30 });
afterAll(() => compiled[Symbol.dispose]());

it("draws the bar at its width for the frame", () => {
  const html = renderToStaticMarkup(
    <SceneRenderer
      compiled={compiled}
      frame={36}
      width={1280}
      height={720}
      runtime={runtime}
    />,
  );
  expect(html).toContain('<i class="toast-bar" style="width:180px">');
});
```

Keep these few. The values a renderer draws are better tested on the actor, as above, where a
wrong number fails without parsing markup.

## What to test

Test what can break without anyone choosing it:

- timing, at a frame inside the motion,
- seeking: resolve a late frame, then an early one, and get the same answer as before,
- a child staying the same actor across frames,
- the bug you just fixed.

A test that an actor has the fields you just declared, or that a renderer shows the prop you
passed it, cannot fail for a reason you did not write.
