Test a feature without React
A reducer that lives behind a component is tested through the component: render it, click, await the effect, read the DOM. Every assertion pays for a renderer, and a request race is only reachable through timing.
A feature is a value, so its tests are plain function calls. Three tools cover three kinds of claim:
feature.reducefor one transition: from this state, this action gives this state and asks for this command. Nothing runs.feature.runfor a sequence: with this layer, these actions end in this state, emitted these actions and these outputs. Commands run.createRecorderfor what a mount reported: transitions, commands, defects. A component must be mounted.
The feature under test
import { Action, Command, define, Next, Task } from "@wych/react";
import { Context, Effect, Layer, Schema } from "effect";
import { expect, test } from "vitest";
const Item = Schema.Struct({ id: Schema.String, price: Schema.Number });
class Payments extends Context.Service<
Payments,
{ readonly charge: (total: number) => Effect.Effect<string, Error> }
>()("Payments") {}
const actions = Action({
Added: { id: Schema.String, price: Schema.Number },
Submitted: {},
});
const Ordered = Action.output("Ordered", { total: Schema.Number });
const charge = Task("Charge", {
success: Schema.String,
run: (total: number) =>
Effect.gen(function* () {
const api = yield* Payments;
return yield* api.charge(total);
}),
});
const total = (items: ReadonlyArray<{ readonly price: number }>) =>
items.reduce((sum, item) => sum + item.price, 0);
const cart = define({
props: Schema.Struct({}),
state: Schema.Struct({ items: Schema.Array(Item) }),
tasks: { charge },
actions,
outputs: Ordered,
}).create({
initialState: () => ({ items: [] }),
reducer: {
Added: (item, { draft }) => {
draft.items.push(item);
return draft;
},
Submitted: (_payload, { state, tasks }) => tasks.charge.start(total(state.items)),
ChargeResolved: (_receipt, { state }) => [
state,
Command.output(Ordered, { total: total(state.items) }),
],
},
render: () => null,
});The payment provider is a service, so each test picks its own Payments layer. tasks: { charge } gives the task the charge state field, so initialState leaves it out and tasks.charge.start writes Pending into it. The fold writes the receipt into charge before ChargeResolved runs; the handler announces the order beside that write. A rejection needs no handler. render returns null: nothing on this page mounts the feature, and the view is a separate concern.
One step with reduce
reduce takes an action and a snapshot, and returns a Next. Next.state and Next.command read the two halves, so a test never destructures a tuple.
const empty = { items: [], charge: Task.idle } as const;
test("Added appends and issues no command", () => {
const next = cart.reduce(actions.Added.make({ id: "a", price: 10 }), {
state: empty,
props: {},
hooks: {},
});
expect(Next.state(next).items).toEqual([{ id: "a", price: 10 }]);
expect(Next.command(next)).toBeUndefined();
// => Next.command of a bare state is undefined
});Pick reduce when the claim is about one transition. You supply the snapshot, so any state is one object literal away, and there is no layer to build. The charge field is part of State, so a hand-built state includes charge: Task.idle. A handler's draft is finished before reduce returns the Next, so Next.state(next) is always a plain value, never the proxy.
reduce runs nothing. A handler that returns a command hands you the command as data, so a test can assert that work was requested without running it.
test("Submitted writes Pending and issues a command", () => {
const next = cart.reduce(actions.Submitted.make(), {
state: { items: [{ id: "a", price: 10 }], charge: Task.idle },
props: {},
hooks: {},
});
expect(Next.state(next).charge).toEqual({ _tag: "Pending" });
expect(Next.command(next)).toBeDefined();
});A claim about what the command does once it runs is out of reach here. That claim belongs to run.
A sequence with run
run builds the initial state from the props, folds the actions you seed, runs every command against the layer, and folds what those commands dispatch. It resolves once nothing is queued and nothing is in flight.
const paid = Layer.succeed(Payments)({
charge: (amount) => Effect.succeed(`receipt-${amount}`),
});
test("a paid cart resolves the task and announces the order", async () => {
const { state, emitted, outputs } = await Effect.runPromise(
cart.run([actions.Added.make({ id: "a", price: 10 }), actions.Submitted.make()], {
props: {},
hooks: {},
layer: paid,
}),
);
expect(state.charge).toEqual({ _tag: "Resolved", value: "receipt-10" });
expect(emitted).toEqual([{ _tag: "ChargeResolved", value: "receipt-10" }]);
expect(outputs).toEqual([{ _tag: "Ordered", total: 10 }]);
});Pick run when the claim spans a command and its result. The three results answer three questions:
state: what the fold accumulated.emitted: what the commands dispatched back in. Seeded actions are absent.outputs: what left through the outbound channel. Outputs are collected and never folded.
After each seeded action is reduced and its command started, run yields once, so that command's fiber reaches its first suspension before the next action is reduced. Seeded actions therefore behave like dispatches separated by an event-loop turn, and a race between two of them is testable.
const slow = Layer.succeed(Payments)({
charge: (amount) => Effect.delay(Effect.succeed(`receipt-${amount}`), "10 millis"),
});
test("a second Submitted supersedes the charge in flight", async () => {
const { emitted, outputs } = await Effect.runPromise(
cart.run(
[
actions.Added.make({ id: "a", price: 10 }),
actions.Submitted.make(),
actions.Added.make({ id: "b", price: 5 }),
actions.Submitted.make(),
],
{ props: {}, hooks: {}, layer: slow },
),
);
expect(emitted).toEqual([{ _tag: "ChargeResolved", value: "receipt-15" }]);
expect(outputs).toEqual([{ _tag: "Ordered", total: 15 }]);
});The first charge is asleep in Effect.delay when the second Submitted folds. Task runs in "latest" mode by default, so tasks.charge.start restarts the group and interrupts that fiber. An interrupted task dispatches nothing, which is why emitted holds one ChargeResolved.
One claim stays out of run's reach: a command that never completes keeps run from resolving, because run resolves at command quiescence. A long-lived source belongs in a subscription instead, declared through the subscriptions hook: run resolves once command work settles regardless of which subscriptions are still running, and the result's subscriptions field lists the keys still declared at that point. See subscribe to a stream for the recipe.
Assert on a dying command
A command can die: a bug in the effect, or a layer that fails to build. run records every death in defects, in order, as { from, error, handled }. from is the tag of the action whose command died, and handled says whether the feature's Error handler folded it.
const dying = Command.effect(() => Effect.die(new Error("card reader offline")));
const Scanned = Action("Scanned");
const scanner = define({
props: Schema.Struct({}),
state: Schema.Struct({ status: Schema.String }),
actions: [Scanned],
}).create({
initialState: () => ({ status: "idle" }),
reducer: {
Scanned: (_action, { state }) => [state, dying],
Error: (_action, { draft }) => {
draft.status = "failed";
return draft;
},
},
render: () => null,
});
test("a dying command recovers through Error and is recorded as a defect", async () => {
const { state, defects } = await Effect.runPromise(
scanner.run([Scanned.make()], { props: {}, hooks: {}, layer: Layer.empty }),
);
expect(state).toEqual({ status: "failed" });
expect(defects).toEqual([{ from: "Scanned", error: expect.any(Error), handled: true }]);
});"Given a failing command, this feature recovers" is a state assertion; "this command failed" is a defects assertion. run stays total either way, so the test never needs await expect(...).rejects.
Route a typed failure through Task's onError instead, which turns it into a Rejected action: that never reaches defects, because it never leaves the reducer as a death. Reserve defects for the bugs onError was not written to catch.
Supply a test layer
The layer is a plain argument, so the failure path is a second layer over the same feature.
const declined = Layer.succeed(Payments)({
charge: () => Effect.fail(new Error("card declined")),
});
test("a declined charge rejects the task and announces nothing", async () => {
const { state, outputs } = await Effect.runPromise(
cart.run([actions.Added.make({ id: "a", price: 10 }), actions.Submitted.make()], {
props: {},
hooks: {},
layer: declined,
}),
);
expect(state.charge).toEqual({ _tag: "Rejected", error: "card declined" });
expect(outputs).toEqual([]);
});charge declares no failure, so its onError is the default, Task.errorMessage, which maps the Cause to its message. onError covers typed failures and defects, so a bug inside the effect lands in the field. The Error lifecycle handler never sees it.
For a feature with no services, pass Layer.empty.
Assert on the devtools stream
createRecorder is an in-memory sink. Install it with devtoolsLayer(recorder.sink) and read recorder.events after the work.
import { createRecorder, createRuntime, devtoolsLayer } from "@wych/react";
const recorder = createRecorder();
const { component } = createRuntime(Layer.mergeAll(paid, devtoolsLayer(recorder.sink)));
const Cart = component(cart, { name: "Cart" });
test("the recorder starts empty", () => {
expect(recorder.events).toEqual([]);
recorder.clear();
});Pick the recorder when the claim is about what a mount reported: the order of transitions, the commands issued, a defect a command raised. Events come from a mounted component. feature.reduce and feature.run report nothing to devtools, so mount <Cart onOrdered={() => {}} /> with your React test renderer before reading the stream. The event shapes are in the devtools reference.
test("transitions are filterable by tag", () => {
const transitions = recorder.events.filter((event) => event._tag === "Transition");
expect(transitions).toEqual([]);
});Emission is synchronous, so everything a mount was going to report is in events by the time its effect settles.