dream_test/runner
Test runner for dream_test.
This module provides a pipe-friendly builder API for running suites and
collecting dream_test/types.TestResult values.
When should I use this?
- Always: the runner is how you execute suites built with
dream_test/unit,dream_test/unit_context, ordream_test/gherkin/feature.
What does the runner do?
- Runs groups sequentially, tests in parallel (bounded by
max_concurrency) - Sandboxes tests and hooks (timeouts + crash isolation)
- Optionally drives an event-based reporter
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/parallel
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
pub fn tests() {
describe("Example", [
it("works", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("math should work")
}),
])
}
pub fn main() {
let db_config =
parallel.ParallelConfig(max_concurrency: 1, default_timeout_ms: 60_000)
runner.new([])
|> runner.add_suites([tests()])
|> runner.add_suites_with_config(db_config, [db_tests()])
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Types
Output sinks used by runner.run().
Reporters write to out. Runner-internal errors (not test failures) may be
written to error as well.
pub type Output {
Output(out: fn(String) -> Nil, error: fn(String) -> Nil)
}
Constructors
-
Output(out: fn(String) -> Nil, error: fn(String) -> Nil)
Builder for configuring and running suites.
You typically construct one with runner.new(...) and then pipe through
configuration helpers like runner.progress_reporter, runner.results_reporters,
runner.max_concurrency, etc.
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
import gleam/io
pub fn tests() {
describe("Example", [
it("works", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("math should work")
}),
])
}
pub fn main() {
runner.new([tests()])
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
pub opaque type RunBuilder(ctx)
Lightweight information about a test, used for filtering what runs.
See dream_test/types.TestInfo for fields and details.
pub type TestInfo =
types.TestInfo
Values
pub fn add_suites(
builder builder: RunBuilder(ctx),
suites suites: List(types.Root(ctx)),
) -> RunBuilder(ctx)
Append suites to the run, using the builder’s current execution config.
This is useful when you want to build up your suite list incrementally (especially when some suites need a different execution config).
Suites added with add_suites will run using the runner’s current execution
config (configured via max_concurrency / default_timeout_ms).
Example
runner.new([])
|> runner.add_suites([unit_suite()])
|> runner.add_suites([integration_suite()])
|> runner.run()
pub fn add_suites_with_config(
builder builder: RunBuilder(ctx),
config config: parallel.ParallelConfig,
suites suites: List(types.Root(ctx)),
) -> RunBuilder(ctx)
Append suites to the run, using an explicit execution config override.
This lets you run suites with different concurrency/timeout policies in a single runner invocation (for example: DB suites sequential, unit suites parallel).
The override applies only to the suites added by this call. Reporting, output, filtering, and exit behavior remain global runner settings.
Example
import dream_test/parallel
let db_config =
parallel.ParallelConfig(max_concurrency: 1, default_timeout_ms: 60_000)
runner.new([])
|> runner.add_suites([unit_suite()])
|> runner.add_suites_with_config(db_config, [db_suite()])
|> runner.max_concurrency(50)
|> runner.default_timeout_ms(5_000)
|> runner.run()
pub fn after_all_suites(
builder builder: RunBuilder(ctx),
hook hook: fn(List(types.SuiteInfo)) -> Result(Nil, String),
) -> RunBuilder(ctx)
Register a runner-level hook that runs after all suites.
The hook receives the full list of SuiteInfo after filtering.
If it returns Error("..."), a synthetic failure result is appended.
pub fn after_each_suite(
builder builder: RunBuilder(ctx),
hook hook: fn(types.SuiteInfo) -> Result(Nil, String),
) -> RunBuilder(ctx)
Register a runner-level hook that runs after each suite.
The hook receives types.SuiteInfo for the suite that just ran.
If it returns Error("..."), a synthetic failure result is appended.
pub fn after_each_test(
builder builder: RunBuilder(ctx),
hook hook: fn(types.TestInfo, ctx) -> Result(Nil, String),
) -> RunBuilder(ctx)
Register a runner-level hook that runs after each test.
The hook receives types.TestInfo and the current context value.
If it returns Error("..."), the test is marked Failed.
pub fn before_all_suites(
builder builder: RunBuilder(ctx),
hook hook: fn(List(types.SuiteInfo)) -> Result(Nil, String),
) -> RunBuilder(ctx)
Register a runner-level hook that runs before all suites.
The hook receives the full list of SuiteInfo after filtering.
If it returns Error("..."), all selected tests are marked Failed
and the run short-circuits.
pub fn before_each_suite(
builder builder: RunBuilder(ctx),
hook hook: fn(types.SuiteInfo) -> Result(Nil, String),
) -> RunBuilder(ctx)
Register a runner-level hook that runs before each suite.
The hook receives types.SuiteInfo for the suite about to run.
If it returns Error("..."), all tests in that suite are marked Failed
and the suite is skipped.
pub fn before_each_test(
builder builder: RunBuilder(ctx),
hook hook: fn(types.TestInfo, ctx) -> Result(ctx, String),
) -> RunBuilder(ctx)
Register a runner-level hook that runs before each test.
The hook receives types.TestInfo and the current context value.
If it returns Error("..."), the test is marked SetupFailed
and the test body does not run.
Example
import dream_test/runner
import dream_test/types.{type TestInfo}
fn log_test(info: TestInfo, ctx: Nil) {
io.println("starting " <> string.join(info.full_name, " > "))
Ok(ctx)
}
runner.new([suite])
|> runner.before_each_test(log_test)
pub fn default_timeout_ms(
builder builder: RunBuilder(ctx),
timeout_ms timeout_ms: Int,
) -> RunBuilder(ctx)
Set the default timeout (milliseconds) applied to tests without an explicit timeout.
Tests that exceed the timeout are killed and reported as TimedOut.
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
import gleam/io
pub fn tests() {
describe("Runner config demo", [
it("runs with custom config", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("Math works")
}),
])
}
pub fn main() {
runner.new([tests()])
|> runner.max_concurrency(8)
|> runner.default_timeout_ms(10_000)
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Parameters
builder: the runner builder you’re configuringtimeout_ms: timeout in milliseconds applied to tests without an explicit timeout
Returns
The updated RunBuilder(ctx).
pub fn exit_on_failure(
builder builder: RunBuilder(ctx),
) -> RunBuilder(ctx)
Exit the BEAM with a non-zero code if any tests fail.
Useful for CI pipelines.
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
import gleam/io
pub fn tests() {
describe("Example", [
it("works", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("math should work")
}),
])
}
pub fn main() {
runner.new([tests()])
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Parameters
builder: the runner builder you’re configuring
Returns
The updated RunBuilder(ctx).
pub fn filter_tests(
builder builder: RunBuilder(ctx),
predicate predicate: fn(types.TestInfo) -> Bool,
) -> RunBuilder(ctx)
Filter which tests are executed.
The predicate receives TestInfo (name, full name, effective tags, kind).
Tags include inherited group tags.
Groups with no selected tests in their entire subtree are skipped entirely, including hooks.
Example
import dream_test/matchers.{be_equal, or_fail_with, should, succeed}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner.{type TestInfo}
import dream_test/unit.{describe, it, with_tags}
import gleam/io
import gleam/list
pub fn tests() {
describe("Filtering tests", [
it("smoke", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("math should work")
})
|> with_tags(["smoke"]),
it("slow", fn() { Ok(succeed()) })
|> with_tags(["slow"]),
])
}
pub fn only_smoke(info: TestInfo) -> Bool {
list.contains(info.tags, "smoke")
}
pub fn main() {
runner.new([tests()])
|> runner.filter_tests(only_smoke)
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Parameters
builder: the runner builder you’re configuringpredicate: function that decides whether a test should run
Returns
The updated RunBuilder(ctx).
pub fn has_failures(
results results: List(types.TestResult),
) -> Bool
Return True if the list contains any failing statuses.
This treats Failed, SetupFailed, and TimedOut as failures.
Example
import dream_test/matchers.{be_equal, or_fail_with, should, succeed}
import dream_test/runner
import dream_test/unit.{describe, it}
pub fn tests() {
describe("has_failures", [
it("passes", fn() { Ok(succeed()) }),
])
}
fn failing_suite() {
describe("failing suite", [
it("fails", fn() {
1
|> should
|> be_equal(2)
|> or_fail_with("intentional failure for has_failures example")
}),
])
}
pub fn main() {
let results = runner.new([failing_suite()]) |> runner.run()
results
|> runner.has_failures()
|> should
|> be_equal(True)
|> or_fail_with("expected failures to be present")
}
Parameters
results: list ofTestResultvalues returned byrunner.run
Returns
True when any result has status Failed, SetupFailed, or TimedOut.
pub fn max_concurrency(
builder builder: RunBuilder(ctx),
max max: Int,
) -> RunBuilder(ctx)
Set the maximum number of concurrently running tests.
1gives fully sequential test execution.- Higher values increase parallelism.
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
import gleam/io
pub fn tests() {
describe("Sequential tests", [
it("first test", fn() {
// When tests share external resources, run them sequentially
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("Math works")
}),
it("second test", fn() {
2 + 2
|> should
|> be_equal(4)
|> or_fail_with("Math still works")
}),
])
}
pub fn main() {
// Sequential execution for tests with shared state
runner.new([tests()])
|> runner.max_concurrency(1)
|> runner.default_timeout_ms(30_000)
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Parameters
builder: the runner builder you’re configuringmax: maximum number of concurrently running tests (use1for fully sequential)
Returns
The updated RunBuilder(ctx).
pub fn new(
suites suites: List(types.Root(ctx)),
) -> RunBuilder(ctx)
Create a new runner builder for a list of suites.
The type parameter ctx is the suite context type. For dream_test/unit
suites this is Nil. For dream_test/unit_context suites it is your custom
context type.
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
import gleam/io
pub fn tests() {
describe("Example", [
it("works", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("math should work")
}),
])
}
pub fn main() {
runner.new([tests()])
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Parameters
suites: the test suites you want to run (often just[tests()])
Returns
A RunBuilder(ctx) you can pipe through configuration helpers and finally
runner.run().
pub fn output(
builder builder: RunBuilder(ctx),
output output: Output,
) -> RunBuilder(ctx)
Configure output sinks for runner.run().
This is how you route reporter output (stdout vs stderr, capturing output in tests, etc).
Example
import dream_test/runner
import gleam/io
pub fn main() {
runner.new([tests()])
|> runner.output(runner.Output(out: io.print, error: io.eprint))
|> runner.run()
}
pub fn progress_reporter(
builder builder: RunBuilder(ctx),
reporter reporter: progress.ProgressReporter,
) -> RunBuilder(ctx)
Attach a progress reporter (live output during the run).
This reporter is driven by TestFinished events in completion order.
It is intended for a single in-place progress bar UI.
pub fn results_reporters(
builder builder: RunBuilder(ctx),
reporters reporters: List(types.ResultsReporter),
) -> RunBuilder(ctx)
Attach results reporters (printed at the end, in the order provided).
Results reporters receive the full traversal-ordered results list from the
RunFinished event, so their output is deterministic under parallel execution.
pub fn run(
builder builder: RunBuilder(ctx),
) -> List(types.TestResult)
Run all suites and return a list of TestResult.
If a progress reporter is attached, the runner will emit progress output during the run. Results reporters print at the end of the run.
Example
import dream_test/matchers.{be_equal, or_fail_with, should}
import dream_test/reporters/bdd
import dream_test/reporters/progress
import dream_test/runner
import dream_test/unit.{describe, it}
pub fn tests() {
describe("Example", [
it("works", fn() {
1 + 1
|> should
|> be_equal(2)
|> or_fail_with("math should work")
}),
])
}
pub fn main() {
runner.new([tests()])
|> runner.progress_reporter(progress.new())
|> runner.results_reporters([bdd.new()])
|> runner.exit_on_failure()
|> runner.run()
}
Parameters
builder: the fully configured runner builder
Returns
A list of TestResult values, in deterministic order.
pub fn silent(
builder builder: RunBuilder(ctx),
) -> RunBuilder(ctx)
Disable all reporter output (still returns results from runner.run()).