ScenarioTracer
Ready-to-use ExUnit and JSON-backed scenario tracing built on top of ExTracer. ScenarioTracer wires together AST scanning, runtime trace collection, and report generation into a single Mix task abstraction — so you can extract living documentation from your existing test suite with minimal configuration.
Features
- ExUnit formatter that captures test outcomes and durations at runtime
- JSON file-based trace collector and trace store (no external services required)
MixTaskbehaviour for building project-specific trace extraction pipelines- Built-in support for ExUnit and StreamData test blocks
-
Produces
ExTracer.Reportstructs ready for further processing or export
Installation
def deps do
[
{:scenario_tracer, "~> 0.1"},
{:jason, "~> 1.4"}
]
endQuick Start
1. Record runtime traces
Add ScenarioTracer.ExUnitFormatter to your ExUnit configuration so test outcomes are captured to disk:
# test/test_helper.exs
ExUnit.start()
ExUnit.configure(
formatters: [ExUnit.CLIFormatter, ScenarioTracer.ExUnitFormatter],
# Pass trace_dir via ExUnit opts or application config
trace_dir: Path.join(File.cwd!(), "traces")
)Or start the formatter manually with options:
{:ok, _pid} = ScenarioTracer.ExUnitFormatter.start_link(trace_dir: "traces/")
After mix test, a JSON file per test suite will appear in traces/.
2. Define a Mix task
Implement ScenarioTracer.MixTask to describe how your project's sources should be wired:
defmodule Mix.Tasks.Scenarios.Extract do
use Mix.Task
@behaviour ScenarioTracer.MixTask
@impl ScenarioTracer.MixTask
def project_root, do: File.cwd!()
@impl ScenarioTracer.MixTask
def adapters, do: [MyApp.Tracer.ResourceAdapter]
@impl ScenarioTracer.MixTask
def frameworks, do: [ScenarioTracer.TestFrameworks.ExUnit]
@impl ScenarioTracer.MixTask
def trace_dir(root), do: Path.join(root, "traces")
@impl ScenarioTracer.MixTask
def node_source(root) do
# Return a list of maps describing your application nodes
MyApp.Tracer.NodeBuilder.build(root)
end
@impl ScenarioTracer.MixTask
def lookup_builder(root, nodes, runtime) do
%ExTracer.Lookup{
by_id: Map.new(nodes, &{&1.id, &1}),
aliases: MyApp.Tracer.AliasMap.build(root),
code: MyApp.Tracer.CodeIndex.build(nodes),
runtime: runtime
}
end
@impl Mix.Task
def run(_args) do
report = ScenarioTracer.MixTask.run(__MODULE__)
IO.puts("Extracted #{length(report.scenarios)} scenarios")
IO.puts("Coverage: #{report.coverage.coverage_pct}%")
end
endRun with:
mix scenarios.extractBehaviours
ScenarioTracer.MixTask
| Callback | Returns | Purpose |
|---|---|---|
project_root/0 | String.t() | Absolute path to project root |
adapters/0 | [module()] | ExTracer.Adapter implementations |
frameworks/0 | [module()] | ExTracer.TestFramework implementations |
trace_dir/1 | String.t() | Directory containing JSON trace files |
node_source/1 | [map()] | Application node descriptors |
lookup_builder/3 | ExTracer.Lookup.t() | Builds the cross-reference index |
ScenarioTracer.ExUnitFormatter
A GenServer that hooks into ExUnit's event stream. Handles:
:suite_started— initializes trace collection state:test_started— records test start time:test_finished— records outcome, duration, and any failure message
ScenarioTracer.TraceCollector.JsonFile
Implements ExTracer.TraceCollector. Writes one JSON file per test module to trace_dir. Each file contains an array of event maps:
[
{
"scenario_id": "myapp-accounts-user-creates-a-user",
"test_name": "creates a user with valid params",
"outcome": "passed",
"duration_ms": 42,
"captured_at": "2026-05-06T10:00:00Z"
}
]ScenarioTracer.TraceStore.JsonFile
Implements ExTracer.TraceStore. Loads all JSON files from trace_dir and indexes them by scenario ID. Provides fuzzy test-name matching to handle minor formatting differences between compile-time and runtime names.
Built-in Test Frameworks
| Module | Recognized macros |
|---|---|
ScenarioTracer.TestFrameworks.ExUnit | test/2, test/3 |
ScenarioTracer.TestFrameworks.StreamData | property/2, property/3 |
License
MIT