Testing
Fixture tests an aggregate in isolation: given these events, when this command runs,
what happens? It runs commands through the real service on an in-memory store, so payload
validation and event encoding are tested too.
It reads like the CQRSFixture of moleculer-cqrs. The chain is an Effect, which runs
when you yield it or return it from it.effect:
import { assert, it } from "@effect/vitest"import { Fixture } from "effect-cqrs"
it.effect("creates a todo", () => Fixture.make(Todo) .givenEvents() .when("createTodo", { title: "Buy Milk" }) .expectEvent(TodoEvent.cases.TodoCreated.make({ title: "Buy Milk", createdAt: DateTime.makeUnsafe(0) })) .inspectState((state) => assert.strictEqual(state.title, "Buy Milk")))
it.effect("runs commands one after another", () => Fixture.make(Todo) .givenEvents([created]) .when("deleteTodo", {}) .expectEvent("TodoDeleted") .when("createTodo", { title: "Buy Eggs" }) .expectEvents(["TodoCreated"]))
it.effect("rejects deleting twice", () => Effect.gen(function*() { const error = yield* Fixture.make(Todo) .givenEvents([created, deleted]) .whenFails("deleteTodo", {})
assert.strictEqual(error._tag, "TodoAlreadyDeleted") }))The chain
Section titled “The chain”| Step | What it does |
|---|---|
givenEvents(events?, aggregateId?) |
The history the commands run against, empty by default. It can include old events |
when(command, payload) |
Runs a command, typed against the aggregate. A command that fails stops the chain with its error |
expectEvent(event) |
The last command emitted exactly this event |
expectEvents([...]) |
The last command emitted exactly these events, in order. [] means it recorded nothing |
inspectState(check) |
Passes the state to a callback that throws to fail the test, so any assertion library works |
whenFails(command, payload) |
Runs a command that should fail, and returns its error. It dies if the command succeeds |
An expected event is either a whole event, compared with the aggregate’s schema, or only
its tag. A mismatch dies with ExpectationFailed, which carries what was expected and
what happened, and Vitest shows the difference.
Yielding the chain gives { events, state, version, fixture } for the last command, if
you’d rather write plain assertions.
Old events
Section titled “Old events”givenEvents also takes events in an older version of their schema, built with
Aggregate.historical. They are stored as code from that time would have stored them,
so the history goes through the upcasting a real store
does:
it.effect("upcasts todos created before due dates", () => Fixture.make(Todo) .givenEvents([Aggregate.historical(Todo, "TodoCreated", 1, { title: "Buy Milk", createdAt })]) .inspectState((state) => assert.strictEqual(state.dueDate, null)))The value is checked against that version’s schema, at compile time and when it runs, so a
test can’t seed a shape that never existed. For JSON taken from a database,
new Aggregate.Historical({ type, schemaVersion, payload }) skips the check.
Invariants
Section titled “Invariants”checkInvariants runs random sequences of commands, with payloads generated from their
schemas by Effect’s Arbitrary, and checks a predicate on the state after every command
that succeeds. Commands that fail are part of the game. A violation dies with
ExpectationFailed, holding the shortest sequence it found that breaks the predicate.
it.effect("never overdraws", () => Fixture.make(Account) .givenEvents([opened]) .checkInvariants({ invariant: (state) => state.balance >= 0, commands: { closeAccount: false, // left out deposit: Arbitrary.schema(SmallAmount).pipe(Arbitrary.map((amount) => ({ amount }))) }, maxCommands: 20, // the longest sequence, 20 by default check: { runs: 200, seed: 1 } // passed to Arbitrary.checkEffect }))Each run starts from the given events on a fresh store.
it.effect runs on a test clock stopped at zero, so timestamps need no mocking: an event
created with DateTime.now has DateTime.makeUnsafe(0) until TestClock.adjust moves
time.