evoq-testkit

Domain (command-side) test framework for evoq aggregates.

Why this exists

Testing an evoq aggregate well means proving two different things:

  1. Logic — given a prior history, a command produces exactly the right events (and no others), or is correctly rejected, and leaves the aggregate in the right state.
  2. Persistence — that same command, dispatched for real, actually lands its events in the store (with a valid stream id) and the projection folds them.

evoq ships evoq_test_assertions for one-shot decision checks. evoq-testkit adds what was missing:

It lives in its own repo (not in evoq) because Layer B depends on mem-evoq, which already depends on evoq — a separate package keeps the graph a clean DAG.

Installation

%% rebar.config — add to your TEST profile
{profiles, [
    {test, [
        {deps, [{evoq_testkit, "~> 0.1"}]}
    ]}
]}.

Layer A — pure aggregate spec

Tuple-list form (table-drivable):

evoq_aggregate_spec:run(my_aggregate, <<"agg-...">>, [
  {open_account, #{id => Id, owner => <<"alice">>},
       evoq_aggregate_spec:expect([<<"account_opened">>]),
       fun(S) -> my_state:is_open(S) end},
  {deposit, #{id => Id, amount => 100},
       evoq_aggregate_spec:expect([<<"funds_deposited">>]),
       fun(S) -> my_state:balance(S) =:= 100 end},
  {withdraw, #{id => Id, amount => 999},
       evoq_aggregate_spec:expect_error(insufficient_funds),
       evoq_aggregate_spec:unchanged()}
]).

Builder form (readable for long scenarios):

S0 = evoq_aggregate_spec:new(my_aggregate, Id),
S1 = evoq_aggregate_spec:emits(
       evoq_aggregate_spec:exec(S0, open_account, #{owner => <<"alice">>}),
       [<<"account_opened">>]),
S2 = evoq_aggregate_spec:state(S1, fun my_state:is_open/1),
ok = evoq_aggregate_spec:done(S2).

The builder is deliberately strict: running a command without asserting it before the next command raises — every command must be checked.

Layer B — persistence (coming)

evoq_cmd_case:with_mem_store(fun(StoreId) ->
    ok = evoq_cmd_case:dispatch_all(my_aggregate, Id, Scenario, StoreId),
    evoq_cmd_case:assert_stream(StoreId, my_aggregate:stream_id(Id),
        [<<"account_opened">>, <<"funds_deposited">>])
end).

License

Apache-2.0.