evoq_aggregate_spec (evoq_testkit v0.1.0)
View SourceSequence-driven CMD (aggregate) test spec — Layer A (pure).
Inject a sequence of commands into an aggregate and, after EACH command, assert the four things that matter for a command-side domain:
- the aggregate emitted the EXPECTED events,
- the aggregate emitted NO UNEXPECTED events,
- the aggregate did NOT fail (unless the step expects an error),
- the aggregate is in the CORRECT state.
(1)+(2) are a single check: the emitted event-type list is compared EXACTLY (in order) to the expected list, so an extra event is a mismatch — "no unexpected events" comes for free.
This layer is PURE: it drives Mod:init/1, Mod:execute/2 and Mod:apply/2 directly with no event store, no processes. State threads through the sequence by folding each command's emitted events via apply/2, so command N runs against the state left by commands 1..N-1 — true sequence semantics. This mirrors exactly what the runtime does: evoq_aggregate calls Module:execute(AggState, Command#evoq_command.payload) then folds events with Module:apply/2. We pass the command type to the handler the same way the dispatch path does — as command_type inside the payload map.
Two equivalent forms
Tuple-list (table-drivable):
evoq_aggregate_spec:run(vehicle_aggregate, <<"veh-...">>, [
{commission_vehicle, #{vehicle_id => Id, ...},
expect([<<"vehicle_commissioned">>]),
fun(S) -> vehicle_state:is_commissioned(S) end},
{pick_up_passenger, #{vehicle_id => Id},
expect_error(vehicle_not_dispatched),
unchanged()}
]).Builder (readable for long scenarios — thread the spec):
S0 = evoq_aggregate_spec:new(vehicle_aggregate, Id),
S1 = evoq_aggregate_spec:emits(
evoq_aggregate_spec:exec(S0, commission_vehicle, #{...}),
[<<"vehicle_commissioned">>]),
S2 = evoq_aggregate_spec:state(S1, fun vehicle_state:is_commissioned/1),
ok = evoq_aggregate_spec:done(S2).Both raise erlang:error/1 on the first failed assertion, so they slot straight into eunit/common_test.
NOTE: the persistence half (does the command actually persist through evoq_dispatcher against a real store, with a valid stream id?) is Layer B — see evoq_cmd_case. The four pure assertions here CANNOT catch a persistence/stream-id bug.
Summary
Functions
Terminal: assert there is no un-asserted command left, return ok.
Assert the last command emitted EXACTLY these event types (in order), then fold those events into state. Covers assertions (1), (2) and (3).
Assert the last command emitted no events at all.
Run one command against the current state. Does NOT thread state yet — the following emits/2 (or fails_with/2) consumes the result. Running two commands without an assertion between them is an error: every command must be asserted (the whole point of the spec).
Convenience constructor for the tuple form's expectation slot.
Convenience constructor: this step is expected to be rejected.
Assert the last command FAILED with this exact reason (a legitimate precondition rejection). State is left unchanged.
Seed state by folding prior events (the "given" of given/when/then), as if they had already been applied. Uses Mod:apply/2.
A fresh spec: initialises aggregate state via Mod:init/1.
Run a whole scenario against a fresh aggregate. Each step asserts events (exact), no-error-vs-expected-error, and the state predicate. Returns ok or raises on the first failure.
Assert a predicate over the CURRENT (post-fold) aggregate state — assertion (4). Use the state module's public accessors, not record internals (test behaviour, not implementation).
The "don't assert state" marker for the tuple form's last slot.
Types
Functions
-spec done(spec()) -> ok.
Terminal: assert there is no un-asserted command left, return ok.
Assert the last command emitted EXACTLY these event types (in order), then fold those events into state. Covers assertions (1), (2) and (3).
Assert the last command emitted no events at all.
Run one command against the current state. Does NOT thread state yet — the following emits/2 (or fails_with/2) consumes the result. Running two commands without an assertion between them is an error: every command must be asserted (the whole point of the spec).
-spec expect([binary()]) -> expectation().
Convenience constructor for the tuple form's expectation slot.
-spec expect_error(term()) -> expectation().
Convenience constructor: this step is expected to be rejected.
Assert the last command FAILED with this exact reason (a legitimate precondition rejection). State is left unchanged.
Seed state by folding prior events (the "given" of given/when/then), as if they had already been applied. Uses Mod:apply/2.
A fresh spec: initialises aggregate state via Mod:init/1.
Run a whole scenario against a fresh aggregate. Each step asserts events (exact), no-error-vs-expected-error, and the state predicate. Returns ok or raises on the first failure.
Assert a predicate over the CURRENT (post-fold) aggregate state — assertion (4). Use the state module's public accessors, not record internals (test behaviour, not implementation).
-spec unchanged() -> unchanged.
The "don't assert state" marker for the tuple form's last slot.