Snabbkaffex

Snabbkaffex is a thin Elixir wrapper around the Erlang snabbkaffe library. It adds no testing engine of its own — all the collecting, matching, fault injection, and checking is done by snabbkaffe. Snabbkaffex only translates snabbkaffe's Erlang preprocessor macros into Elixir macros/functions and smooths a few rough edges for ExUnit. All credit for the underlying work goes to the snabbkaffe authors (kafka4beam/snabbkaffe).

An Elixir interface to the Erlang snabbkaffe trace-based testing library.

Instead of sprinkling Process.sleep/1 and polling through your tests, you instrument your code with trace points, run it, and then make assertions against the trace which is the ordered list of events that were emitted. This decouples tests from implementation timing and makes concurrent/asynchronous code straightforward to test deterministically.

snabbkaffe ships its instrumentation as Erlang preprocessor macros (-include_lib(...)), which aren't usable from Elixir. Snabbkaffex provides the Elixir macro/function counterparts, plus a few ergonomic adjustments for Elixir and ExUnit:

Installation

Where snabbkaffex belongs in deps depends on where your trace points live, because there are two separate needs:

In general you want to use it like this:

def deps do
[
{:snabbkaffex, "~> 0.1.0"}
]
end

Quick start

Instrument some code with use Snabbkaffex, only: :trace, which imports just the trace-point macros (tp/2, tp/3, tp_span/3, tp_span/4):

defmodule MyApp.Worker do
use Snabbkaffex, only: :trace
def run(input) do
tp(:worker_started, %{input: input})
result = input * 2
tp(:worker_finished, %{input: input, result: result})
result
end
end

Then test it. use Snabbkaffex (no options) brings in the full API. The trace collector is a global singleton, so a test module that runs traces must be async: false:

defmodule MyApp.WorkerTest do
use ExUnit.Case, async: false
use Snabbkaffex
test "run/1 emits start and finish events" do
check_trace(
# 1. Run the code under test; its return value becomes the result.
fn -> MyApp.Worker.run(21) end,
# 2. Assert on the result and the collected trace.
fn result, trace ->
assert result == 42
assert [%{input: 21}] = of_kind(trace, :worker_started)
assert [%{input: 21, result: 42}] = of_kind(trace, :worker_finished)
end
)
end
end

(use Snabbkaffex emits a compile-time warning if the module is async: true, since concurrent tests would clobber each other's traces through the shared collector.)

Event shape

A trace point tp(:worker_started, %{input: 21}) is collected as a map:

%{:"$kind" => :worker_started, :input => 21, :"~meta" => %{...}}

The event kind lives under the :"$kind" key and metadata (line, file, mfa, timestamp, ...) under :"~meta". Prefer of_kind/2 to filter by kind so you rarely need to spell :"$kind" out.

Trace points

tp(:kind, %{a: 1}) # debug event
tp(:kind, %{a: 1}, level: :warning) # explicit severity
tp(:kind, %{a: expensive()}, ignore_side_effects: true)
tp_span :work, %{id: id} do # start + {:complete, ret} events
do_work(id)
end

Behaviour outside :test (controlled by the options):

FormNon-test behaviour
tp(kind, data)evaluates data (side effects preserved), discards it → :ok
tp(kind, data, level: :error)degrades to a :logger call, so it stays in production logs
tp(kind, data, ignore_side_effects: true)pure :ok; data is not evaluated at all

:level and ignore_side_effects: true are mutually exclusive. Passing both raises ArgumentError at compile time. They pull in opposite directions: :level keeps the event in production logs, while ignore_side_effects: true drops it to a bare :ok outside :test. Pick one.

tp_span accepts the same options alongside its do block. With ignore_side_effects: true, outside :test the span reduces to just the block.

Running and checking a trace

check_trace/2 runs a function, collects the trace, and passes it to a check function of arity 1 (trace) or 2 (result, trace). The check passes unless it raises and its return value is ignored. check_trace/3 takes a leading snabbkaffe run config (%{timeout: 100}) or an integer statistics bucket.

check_trace(
%{timeout: 100},
fn -> MyApp.Worker.run(7) end,
fn trace -> assert [%{input: 7}] = of_kind(trace, :worker_started) end
)

If an assertion inside the check (or an exception inside the run function) fires, Snabbkaffex re-raises it verbatim, so ExUnit shows the real error.

Querying the trace

All of these take the trace first, so they pipe:

trace
|> of_kind(:worker_started) # keep events of a kind (or list of kinds)
|> projection(:input) # extract a field (or list of fields → tuples)
find_pairs(trace, %{:"$kind" => :req}, %{:"$kind" => :resp})
causality(trace, %{:"$kind" => :req}, %{:"$kind" => :resp}) # assert cause precedes effect
strict_causality(trace, %{:"$kind" => :req}, %{:"$kind" => :resp}) # + forbid unmatched causes
# Pattern-matching guards can tie fields together across the two events:
causality(
trace,
%{:"$kind" => :req, id: i1},
%{:"$kind" => :resp, id: i2},
i1 == i2
)

Other helpers: split_trace_at/2, splitl_trace/2, splitr_trace/2, of_domain/2, of_node/2, erase_timestamps/1, pair_max_depth/1, and the assertions unique/1, increasing/1, strictly_increasing/1, projection_complete/3, projection_is_subset/3, give_or_take/3.

Waiting for asynchronous events

When work happens in another process, block on the event instead of sleeping:

check_trace(
fn ->
MyApp.ping_after(20, ref)
assert {:ok, %{ref: ^ref}} = block_until(%{:"$kind" => :pong, ref: ^ref}, 1000)
end,
fn trace -> assert [%{ref: ^ref}] = of_kind(trace, :pong) end
)

Fault injection and scheduling

Backed by snabbkaffe_nemesis:

# Crash processes as they hit a trace point. With no strategy option it crashes
# on every match; pass one of recover_after:/random_crash:/scenario: to shape it.
ref = inject_crash(%{:"$kind" => :write}, reason: :disk_full)
inject_crash(%{:"$kind" => :flaky}, recover_after: 1)
inject_crash(%{:"$kind" => :flaky}, random_crash: 0.1)
inject_crash(%{:"$kind" => :wave}, scenario: periodic_crash(10, 0.5, 0.0))
fix_crash(ref) # remove it again
# Hold back :delayed events until a :continue event has fired ("delay X until Y").
force_ordering(delay: %{:"$kind" => :delayed}, until: %{:"$kind" => :continue})
# ... optional `count:` (wait for N until-events) and `when:` (a guard tying the
# two patterns together by a shared field).

Examples

The examples/ directory has runnable, self-contained scripts. Each one uses Mix.install/2, so you can run it straight from a checkout with no project setup:

elixir examples/get_or_compute_race.exs

Multi-node

forward_trace/1 makes a remote node's trace points land in this collector. unforward_trace/1 reverts it (useful before a deliberate Node.disconnect/1, so forwarding messages or RPCs don't auto-reconnect the node).

Documentation

Every function and macro is documented; see the Snabbkaffex moduledoc and h Snabbkaffex.<name> in IEx. For the underlying model and semantics, the snabbkaffe README is the canonical reference.

Credits

All the real work belongs to the snabbkaffe authors. Snabbkaffex is just an Elixir-friendly veneer over their library.