Test a behaviour

A test proves one behaviour. Name that behaviour before you write the test. Then pick the shape below that fits it.ΒΆ

Each shape returns a fixed, independent answer. A test that reruns the code under test cannot catch its mistakes. Read tests that earn their place22 first.ΒΆ

1. A domain rule: a property

A rule that holds for many inputs is an it.prop over Effect Arbitrary inputs. Use it.effect.prop when the check returns an Effect.ΒΆ

The core suite generates names and checks their length after trimming. The assertion states the accepted length without calling the validator:ΒΆ

packages/core/test/join-identity.test.ts Β· f1e24f7e
it.effect.prop(
  "names trim before the nonempty 100-character bound",
  { input: Arbitrary.schema(Schema.String) },
  ({ input }) =>
    Effect.gen(function* names() {
      const trimmed = input.trim();

      const exit = yield* Schema.decodeEffect(ApplicantName)(input).pipe(
        Effect.exit
      );

      expect(Exit.isSuccess(exit)).toBe(
        trimmed.length > 0 && trimmed.length <= 100
      );

      if (Exit.isSuccess(exit)) {
        expect(exit.value).toBe(trimmed);
      }
    })
);

Read property-based testing23.ΒΆ

2. Anything with history: a model

Sessions, retries, and lifecycles depend on earlier steps. Generate command sequences and check the real system against a small model after each step.ΒΆ

The inspection lifecycle replays generated histories against the real actor:ΒΆ

packages/core/test/inspect-machine.model.test.ts Β· 1d4d3252
  it.effect.prop(
    "real actor preserves generated input, typed outcomes and terminal immutability",
    { generated: commands, scenario: Scenario },
    ({ generated, scenario }) => replayActor(scenario, generated),
    { arbitrary: { runs: 200 } }
  );

Copy skills/add-a-lifecycle-machine/templates/lifecycle.model.test.md for a new machine. Read model-based testing15.ΒΆ

3. UI behaviour: a Foldkit Scene

A Scene runs update, the view, and subscriptions together. Input and time are data:ΒΆ

A race is a plain assertion. Resolve a stale timer after the state has moved on, then assert that nothing broke.ΒΆ

Assert on what the view shows: roles, text, and data attributes. Do not assert on Model fields.ΒΆ

The reader’s copy button resolves the copy and then its reset wait:ΒΆ

apps/web/test/reader.scene.test.ts Β· 1d4d3252
it("a copy confirms with text the reader can see and hear, then resets after the wait", () => {
  scene(
    { update, view },
    given(clipboardReady),
    click(copyButton),
    Command.expectExact(CopyReaderText({ id, text: promptText })),
    Command.resolve(CopyReaderText, Message.SucceededCopyReaderText({ id })),
    expect(role("status")).toHaveText("Copied βœ“"),
    expect(copyButton).toContainText("Copied βœ“"),
    Command.expectExact(WaitBeforeCopyReset({ id })),
    Command.resolve(
      WaitBeforeCopyReset,
      Message.CompletedWaitBeforeCopyReset({ id })
    ),
    expect(role("status")).toHaveText(""),
    expect(copyButton).toContainText(label),
    Command.expectNone()
  );
});

Read UI behaviour is a scene7 for the Foldkit21 UI Toast race.ΒΆ

4. A seam: one example

An example test fits one interface between parts, such as a service over the real filesystem. Keep one, and supply services through a Layer:ΒΆ

packages/core/test/file-inspector.test.ts Β· 1d4d3252
it.layer(TestLayer)("FileInspector", (test) => {
  test.effect("reads a file through the Effect filesystem", () =>
    Effect.gen(function* readsThroughFileSystem() {
      const file = yield* writeTempFile("notes.txt", "one two\nthree\n");
      const inspector = yield* FileInspector;

      const stats = yield* inspector.inspect(file);

      expect(stats).toMatchObject({ bytes: 14, lines: 2, words: 3 });
    })
  );

5. Always: plant a bug

  1. Break the behaviour the test protects.ΒΆ
  2. Run the test. It must fail.ΒΆ
  3. Restore the code. The test must pass.ΒΆ

Reject these tests:ΒΆ

Do not rewrite an expected result to match broken code.ΒΆ


Last changed 2026-10-26 in 701becd. Source on GitHub. Change log.