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:ΒΆ
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:ΒΆ
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:ΒΆ
- Input is a step:
pointerDown,click,type, orSubscription.emit.ΒΆ - Time is a Command. Resolve it with
Command.resolve(WaitX, CompletedWaitX(...)).ΒΆ - Do not use fake timers, a clock, or a DOM.ΒΆ
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:ΒΆ
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:ΒΆ
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
- Break the behaviour the test protects.ΒΆ
- Run the test. It must fail.ΒΆ
- Restore the code. The test must pass.ΒΆ
Reject these tests:ΒΆ
- A tautological test computes its expected answer with the code under test.ΒΆ
- A change-detector test fails on any edit and proves no rule.ΒΆ
- A regression test for a bug the suite already covers adds nothing.ΒΆ
Do not rewrite an expected result to match broken code.ΒΆ
Last changed 2026-10-26 in 701becd. Source on GitHub. Change log.