Test a projection's handle/2 clauses without starting a pipeline.
A projection is an ordinary module, so its clauses can be called directly —
but doing that by hand means building the meta map yourself, and it stops
short of the part worth asserting: the read-model rows. These helpers run
the handler the way the engine does and commit the result through the same
target, so a test can assert on rows.
test "a deposit increases the balance" do
Scriba.Testing.project(MyApp.Projections.Balances, [
%AccountOpened{account_id: "acc-1"},
%Deposited{account_id: "acc-1", amount_cents: 500}
])
assert Repo.get(Balance, "acc-1").balance_cents == 500
endBoth helpers work inside Ecto.Adapters.SQL.Sandbox; nothing here starts a
process, so no ownership needs to be shared.
What this exercises, and what it does not
It runs the real handler with the real meta map, applies the results
through the projection's configured target, and commits in one transaction —
the same Scriba.Target.apply_batch/6 the engine calls, so read-model
writes, Ecto.Multi merging and cursor advances behave as in production.
It does not simulate the surrounding pipeline: no retries, no dead-letter rows, no source-side dedup, no partitioning across processors, no telemetry. Handler failures are reported back to the caller instead of being routed, so a test can assert on them directly. For the routing itself — what gets dead-lettered, what halts, what replays — see the engine's own suite; reproducing those decisions here would be a second implementation of them, and the copy would be the one that drifts.
Summary
Functions
Calls one handle/2 clause and returns its result.
No database, no target — useful for asserting the shape a handler returns,
including :skip for event types the projection ignores.
assert :skip = Scriba.Testing.handle(MyProjection, %SomeOtherEvent{})
assert {:insert, %Balance{account_id: "acc-1"}} =
Scriba.Testing.handle(MyProjection, %AccountOpened{account_id: "acc-1"})opts override the meta map the handler receives: :id, :stream_id,
:position, :type, :metadata, :occurred_at. The defaults are the same
shape the engine builds, so a handler reading meta.position works here too.
A raising handler is returned as {:exception, exception, stacktrace},
which is what the engine tags it as — it is not re-raised, so a test can
assert that a handler fails on input it should reject.
@spec project(module(), [term()], keyword()) :: Scriba.Testing.Result.t()
Runs events through the projection and commits the results.
Returns a Scriba.Testing.Result. Events are applied in the order given, in a
single transaction, exactly as one batch would be.
%Scriba.Testing.Result{committed: 2, failed: []} =
Scriba.Testing.project(MyProjection, [event_a, event_b])Each element is either the event struct, or {event_struct, opts} where
opts sets that event's meta — most usefully :stream_id, since events
on different streams get independent cursors.
Scriba.Testing.project(MyProjection, [
{%Deposited{}, stream_id: "acc-1"},
{%Deposited{}, stream_id: "acc-2"}
])Options
:repo— overrides the repo from the projection's target config. Useful when the test repo differs from the production one.:name,:version— the projection identity written toscriba_positions. Default to the projection's own.:stream_id— default stream for events that do not set one (default"scriba-test").:start_position— position of the first event (default1); subsequent events increment from there.
Raises if the target rejects the batch, since that is a failed commit rather than a handler outcome the caller can assert on.