Current section

Files

Jump to
evoq src evoq_test_assertions.erl
Raw

src/evoq_test_assertions.erl

%% @doc Test assertion helpers for evoq.
%%
%% Provides macros and functions for testing CQRS/ES applications.
%%
%% == Usage ==
%%
%% ```
%% -include_lib("evoq/include/evoq_test.hrl").
%%
%% my_test() ->
%% Command = #evoq_command{...},
%% ?assert_command_succeeds(Command),
%% ?assert_events_produced([<<"OrderCreated">>]).
%% '''
%%
%% @author rgfaber
-module(evoq_test_assertions).
-include("evoq.hrl").
%% Command assertions
-export([assert_command_succeeds/1, assert_command_succeeds/2]).
-export([assert_command_fails/1, assert_command_fails/2]).
-export([assert_command_fails_with/2]).
%% Event assertions
-export([assert_events_produced/2]).
-export([assert_event_produced/2]).
-export([assert_no_events_produced/1]).
%% State assertions
-export([assert_aggregate_state/3]).
-export([get_aggregate_state/2]).
%% Read model assertions
-export([assert_read_model_contains/3]).
-export([assert_read_model_empty/1]).
%% Process manager assertions
-export([assert_commands_dispatched/2]).
-export([assert_compensation_triggered/2]).
%% Telemetry assertions
-export([collect_telemetry/2]).
-export([assert_telemetry_emitted/2]).
%%====================================================================
%% Command Assertions
%%====================================================================
%% @doc Assert that a command dispatch succeeds.
-spec assert_command_succeeds(#evoq_command{}) -> {ok, non_neg_integer(), [map()]}.
assert_command_succeeds(Command) ->
assert_command_succeeds(Command, #{}).
%% @doc Assert that a command dispatch succeeds with options.
-spec assert_command_succeeds(#evoq_command{}, map()) -> {ok, non_neg_integer(), [map()]}.
assert_command_succeeds(Command, Opts) ->
case evoq_router:dispatch(Command, Opts) of
{ok, _Version, _Events} = Result ->
Result;
{error, Reason} ->
error({command_should_succeed, Command, Reason})
end.
%% @doc Assert that a command dispatch fails.
-spec assert_command_fails(#evoq_command{}) -> {error, term()}.
assert_command_fails(Command) ->
assert_command_fails(Command, #{}).
%% @doc Assert that a command dispatch fails with options.
-spec assert_command_fails(#evoq_command{}, map()) -> {error, term()}.
assert_command_fails(Command, Opts) ->
case evoq_router:dispatch(Command, Opts) of
{error, _Reason} = Result ->
Result;
{ok, Version, Events} ->
error({command_should_fail, Command, {ok, Version, Events}})
end.
%% @doc Assert that a command fails with a specific error.
-spec assert_command_fails_with(#evoq_command{}, term()) -> ok.
assert_command_fails_with(Command, ExpectedError) ->
case evoq_router:dispatch(Command) of
{error, ExpectedError} ->
ok;
{error, ActualError} ->
error({wrong_error, expected, ExpectedError, got, ActualError});
{ok, _, _} ->
error({command_should_fail, Command})
end.
%%====================================================================
%% Event Assertions
%%====================================================================
%% @doc Assert that specific event types were produced.
-spec assert_events_produced([binary()], [map()]) -> ok.
assert_events_produced(ExpectedTypes, Events) ->
ActualTypes = [maps:get(event_type, E, undefined) || E <- Events],
case lists:sort(ExpectedTypes) =:= lists:sort(ActualTypes) of
true ->
ok;
false ->
error({events_mismatch, expected, ExpectedTypes, got, ActualTypes})
end.
%% @doc Assert that a specific event type was produced.
-spec assert_event_produced(binary(), [map()]) -> map().
assert_event_produced(EventType, Events) ->
case lists:filter(fun(E) ->
maps:get(event_type, E, undefined) =:= EventType
end, Events) of
[Event | _] ->
Event;
[] ->
error({event_not_produced, EventType, Events})
end.
%% @doc Assert that no events were produced.
-spec assert_no_events_produced([map()]) -> ok.
assert_no_events_produced([]) ->
ok;
assert_no_events_produced(Events) ->
error({unexpected_events, Events}).
%%====================================================================
%% State Assertions
%%====================================================================
%% @doc Assert aggregate state matches expected.
-spec assert_aggregate_state(atom(), binary(), fun((term()) -> boolean())) -> ok.
assert_aggregate_state(AggregateType, AggregateId, Predicate) ->
case get_aggregate_state(AggregateType, AggregateId) of
{ok, State} ->
case Predicate(State) of
true ->
ok;
false ->
error({state_predicate_failed, State})
end;
{error, Reason} ->
error({could_not_get_state, Reason})
end.
%% @doc Get aggregate state for testing.
-spec get_aggregate_state(atom(), binary()) -> {ok, term()} | {error, term()}.
get_aggregate_state(_AggregateType, AggregateId) ->
case evoq_aggregate_registry:lookup(AggregateId) of
{ok, Pid} ->
evoq_aggregate:get_state(Pid);
{error, not_found} ->
{error, aggregate_not_running}
end.
%%====================================================================
%% Read Model Assertions
%%====================================================================
%% @doc Assert read model contains expected value.
-spec assert_read_model_contains(evoq_read_model:read_model(), term(), term()) -> ok.
assert_read_model_contains(ReadModel, Key, ExpectedValue) ->
case evoq_read_model:get(Key, ReadModel) of
{ok, ExpectedValue} ->
ok;
{ok, ActualValue} ->
error({read_model_value_mismatch, Key, expected, ExpectedValue, got, ActualValue});
{error, not_found} ->
error({read_model_key_not_found, Key})
end.
%% @doc Assert read model is empty.
-spec assert_read_model_empty(evoq_read_model:read_model()) -> ok.
assert_read_model_empty(ReadModel) ->
case evoq_read_model:list(all, ReadModel) of
{ok, []} ->
ok;
{ok, Items} ->
error({read_model_not_empty, Items});
{error, not_implemented} ->
%% Can't verify, assume ok
ok
end.
%%====================================================================
%% Process Manager Assertions
%%====================================================================
%% @doc Assert that commands were dispatched by a PM.
%% Note: This requires capturing commands during test execution.
-spec assert_commands_dispatched([atom()], [term()]) -> ok.
assert_commands_dispatched(ExpectedTypes, DispatchedCommands) ->
ActualTypes = [C#evoq_command.command_type || C <- DispatchedCommands],
case lists:sort(ExpectedTypes) =:= lists:sort(ActualTypes) of
true ->
ok;
false ->
error({commands_mismatch, expected, ExpectedTypes, got, ActualTypes})
end.
%% @doc Assert compensation was triggered.
-spec assert_compensation_triggered(atom(), [#evoq_command{}]) -> ok.
assert_compensation_triggered(ExpectedType, CompensatingCommands) ->
case lists:any(fun(C) ->
C#evoq_command.command_type =:= ExpectedType
end, CompensatingCommands) of
true ->
ok;
false ->
Types = [C#evoq_command.command_type || C <- CompensatingCommands],
error({compensation_not_found, ExpectedType, got, Types})
end.
%%====================================================================
%% Telemetry Assertions
%%====================================================================
%% @doc Collect telemetry events during a function execution.
-spec collect_telemetry([atom()], fun(() -> term())) -> {term(), [map()]}.
collect_telemetry(EventName, Fun) ->
Self = self(),
HandlerId = make_ref(),
Handler = fun(Name, Measurements, Metadata, _Config) ->
Self ! {telemetry, HandlerId, Name, Measurements, Metadata}
end,
ok = telemetry:attach(HandlerId, EventName, Handler, #{}),
try
Result = Fun(),
Events = collect_telemetry_messages(HandlerId, []),
_ = telemetry:detach(HandlerId),
{Result, Events}
catch
Class:Reason:Stack ->
_ = telemetry:detach(HandlerId),
erlang:raise(Class, Reason, Stack)
end.
%% @doc Assert that a telemetry event was emitted.
-spec assert_telemetry_emitted([atom()], [map()]) -> ok.
assert_telemetry_emitted(EventName, CollectedEvents) ->
case lists:any(fun(#{name := Name}) ->
Name =:= EventName
end, CollectedEvents) of
true ->
ok;
false ->
error({telemetry_not_emitted, EventName, CollectedEvents})
end.
%%====================================================================
%% Internal functions
%%====================================================================
%% @private
collect_telemetry_messages(HandlerId, Acc) ->
receive
{telemetry, HandlerId, Name, Measurements, Metadata} ->
Event = #{
name => Name,
measurements => Measurements,
metadata => Metadata
},
collect_telemetry_messages(HandlerId, [Event | Acc])
after 0 ->
lists:reverse(Acc)
end.