Scriba.Testing (Scriba v0.2.0)

Copy Markdown View Source

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
end

Both 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.

Runs events through the projection and commits the results.

Functions

handle(projection, event_data, opts \\ [])

@spec handle(module(), term(), keyword()) :: term()

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.

project(projection, events, opts \\ [])

@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 to scriba_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 (default 1); 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.