dream_test/unit
Unit test DSL for dream_test.
This module provides a BDD-style syntax for defining tests: describe,
it, and lifecycle hooks (before_all, before_each, after_each,
after_all). Tests are organized hierarchically and converted to
runnable test cases or suites.
Quick Start
import dream_test/unit.{describe, it, to_test_cases}
import dream_test/assertions/should.{should, equal, or_fail_with}
import dream_test/runner.{run_all}
import dream_test/reporter/bdd.{report}
import gleam/io
pub fn main() {
tests()
|> to_test_cases("my_module_test")
|> run_all()
|> report(io.print)
}
pub fn tests() {
describe("Calculator", [
describe("add", [
it("adds positive numbers", fn() {
add(2, 3)
|> should()
|> equal(5)
|> or_fail_with("2 + 3 should equal 5")
}),
it("handles zero", fn() {
add(0, 5)
|> should()
|> equal(5)
|> or_fail_with("0 + 5 should equal 5")
}),
]),
])
}
Output
Calculator
add
✓ adds positive numbers
✓ handles zero
Summary: 2 run, 0 failed, 2 passed
Lifecycle Hooks
Setup and teardown logic for tests:
import dream_test/unit.{describe, it, before_each, after_each, to_test_cases}
import dream_test/types.{AssertionOk}
describe("Database", [
before_each(fn() {
reset_database()
AssertionOk
}),
it("creates users", fn() { ... }),
it("queries users", fn() { ... }),
after_each(fn() {
rollback()
AssertionOk
}),
])
| Hook | Runs | Requires |
|---|---|---|
before_all | Once before all tests in group | to_test_suite |
before_each | Before each test | Either mode |
after_each | After each test (always) | Either mode |
after_all | Once after all tests in group | to_test_suite |
Two Execution Modes
Flat mode — faster, simpler, no before_all/after_all:
tests() |> to_test_cases("my_test") |> run_all()
Suite mode — supports all hooks, preserves group structure:
tests() |> to_test_suite("my_test") |> run_suite()
Nesting
You can nest describe blocks as deeply as needed. Each level adds to
the test’s full_name, which the reporter uses for grouping output.
Lifecycle hooks are inherited by nested groups.
describe("User", [
before_each(fn() { create_user(); AssertionOk }),
describe("authentication", [
describe("with valid credentials", [
it("returns the user", fn() { ... }),
it("sets the session", fn() { ... }),
]),
describe("with invalid credentials", [
it("returns an error", fn() { ... }),
]),
]),
])
Types
A node in the test tree.
This type represents either a single test (ItTest), a group of tests
(DescribeGroup), or a lifecycle hook. You typically don’t construct
these directly—use it, describe, and the hook functions instead.
Variants
ItTest(name, tags, run)- A single test with a name, tags, and body functionDescribeGroup(name, children)- A group of tests under a shared nameBeforeAll(setup)- Runs once before all tests in the groupBeforeEach(setup)- Runs before each test in the groupAfterEach(teardown)- Runs after each test in the groupAfterAll(teardown)- Runs once after all tests in the group
pub type UnitTest {
ItTest(
name: String,
tags: List(String),
run: fn() -> types.AssertionResult,
)
DescribeGroup(name: String, children: List(UnitTest))
BeforeAll(setup: fn() -> types.AssertionResult)
BeforeEach(setup: fn() -> types.AssertionResult)
AfterEach(teardown: fn() -> types.AssertionResult)
AfterAll(teardown: fn() -> types.AssertionResult)
}
Constructors
-
ItTest( name: String, tags: List(String), run: fn() -> types.AssertionResult, ) -
DescribeGroup(name: String, children: List(UnitTest)) -
BeforeAll(setup: fn() -> types.AssertionResult) -
BeforeEach(setup: fn() -> types.AssertionResult) -
AfterEach(teardown: fn() -> types.AssertionResult) -
AfterAll(teardown: fn() -> types.AssertionResult)
Values
pub fn after_all(
teardown: fn() -> types.AssertionResult,
) -> UnitTest
Run teardown once after all tests in the current describe block.
Use after_all to clean up expensive resources that were set up by
before_all. This hook runs once after all tests complete, regardless
of whether tests passed or failed.
When to Use
- Stopping a database server started by
before_all - Removing temporary directories
- Shutting down external services
- Any cleanup that corresponds to
before_allsetup
Execution Behavior
- Runs exactly once, after the last test in the group completes
- Always runs, even if tests fail or
before_allfails - Nested
describeblocks each run their ownafter_allhooks
Example
import dream_test/unit.{describe, it, before_all, after_all, to_test_suite}
import dream_test/runner.{run_suite}
import dream_test/types.{AssertionOk}
describe("External API integration", [
before_all(fn() {
start_mock_server(port: 8080)
AssertionOk
}),
it("fetches users", fn() { ... }),
it("creates users", fn() { ... }),
it("handles errors", fn() { ... }),
after_all(fn() {
// Clean up even if tests failed
stop_mock_server()
AssertionOk
}),
])
|> to_test_suite("api_test")
|> run_suite()
Complete Lifecycle Example
Here’s a complete example showing all four hooks working together:
describe("Database tests", [
// Once at start: start the database
before_all(fn() { start_db(); AssertionOk }),
// Before each test: begin a transaction
before_each(fn() { begin_transaction(); AssertionOk }),
it("creates records", fn() { ... }),
it("queries records", fn() { ... }),
// After each test: rollback the transaction
after_each(fn() { rollback_transaction(); AssertionOk }),
// Once at end: stop the database
after_all(fn() { stop_db(); AssertionOk }),
])
Important: Requires Suite Mode
after_all hooks only work with to_test_suite + run_suite.
When using to_test_cases + run_all, they are silently ignored.
pub fn after_each(
teardown: fn() -> types.AssertionResult,
) -> UnitTest
Run teardown after each test in the current describe block.
Use after_each to clean up resources created during a test. This hook
runs even if the test fails, ensuring reliable cleanup.
When to Use
- Rolling back database transactions
- Deleting temporary files created by the test
- Resetting global state or mocks
- Closing connections or releasing resources
Execution Behavior
- Runs after every test in the group and all nested groups
- Always runs, even if the test or
before_eachhooks fail - Multiple
after_eachhooks in the same group run in reverse declaration order
Example
import dream_test/unit.{describe, it, before_each, after_each, to_test_cases}
import dream_test/runner.{run_all}
import dream_test/types.{AssertionOk}
describe("File operations", [
before_each(fn() {
create_temp_directory()
AssertionOk
}),
after_each(fn() {
// Always clean up, even if test crashes
delete_temp_directory()
AssertionOk
}),
it("writes files", fn() { ... }),
it("reads files", fn() { ... }),
])
|> to_test_cases("file_test")
|> run_all()
Hook Inheritance
Nested describe blocks inherit parent after_each hooks. Child hooks
run first (inner-to-outer order, reverse of before_each):
describe("Outer", [
after_each(fn() { teardown_outer(); AssertionOk }), // Runs 2nd
describe("Inner", [
after_each(fn() { teardown_inner(); AssertionOk }), // Runs 1st
it("test", fn() { ... }),
]),
])
Works in Both Modes
Like before_each, after_each works with both to_test_cases
and to_test_suite.
pub fn before_all(
setup: fn() -> types.AssertionResult,
) -> UnitTest
Run setup once before all tests in the current describe block.
Use before_all when you have expensive setup that should happen once
for the entire group rather than before each individual test.
When to Use
- Starting a database server
- Creating temporary files or directories
- Launching external services
- Any setup that’s slow or has side effects you want to share
Execution Behavior
- Runs exactly once, before the first test in the group
- If it returns
AssertionFailed, all tests in the group are skipped and marked asSetupFailed - Nested
describeblocks each run their ownbefore_allhooks
Example
import dream_test/unit.{describe, it, before_all, after_all, to_test_suite}
import dream_test/runner.{run_suite}
import dream_test/types.{AssertionOk}
describe("Database integration", [
before_all(fn() {
// This runs once before any test
start_test_database()
run_migrations()
AssertionOk
}),
it("creates users", fn() { ... }),
it("queries users", fn() { ... }),
it("updates users", fn() { ... }),
after_all(fn() {
stop_test_database()
AssertionOk
}),
])
|> to_test_suite("db_test")
|> run_suite()
Important: Requires Suite Mode
before_all hooks only work with to_test_suite + run_suite.
When using to_test_cases + run_all, they are silently ignored.
This is because flat mode loses the group structure needed to know where “all tests in a group” begins and ends.
pub fn before_each(
setup: fn() -> types.AssertionResult,
) -> UnitTest
Run setup before each test in the current describe block.
Use before_each when tests need a fresh, isolated state. This is the
most commonly used lifecycle hook.
When to Use
- Resetting database state between tests
- Creating fresh test fixtures
- Beginning a transaction to rollback later
- Clearing caches or in-memory state
Execution Behavior
- Runs before every test in the group and all nested groups
- If it returns
AssertionFailed, the test is skipped and markedSetupFailed - Multiple
before_eachhooks in the same group run in declaration order
Example
import dream_test/unit.{describe, it, before_each, after_each, to_test_cases}
import dream_test/runner.{run_all}
import dream_test/types.{AssertionOk}
describe("Shopping cart", [
before_each(fn() {
// Fresh cart for each test
clear_cart()
AssertionOk
}),
it("starts empty", fn() {
get_cart_items()
|> should()
|> equal([])
|> or_fail_with("New cart should be empty")
}),
it("adds items", fn() {
add_to_cart("apple")
get_cart_items()
|> should()
|> contain("apple")
|> or_fail_with("Cart should contain apple")
}),
])
|> to_test_cases("cart_test")
|> run_all()
Hook Inheritance
Nested describe blocks inherit parent before_each hooks. Parent hooks
run first (outer-to-inner order):
describe("Outer", [
before_each(fn() { setup_outer(); AssertionOk }), // Runs 1st
describe("Inner", [
before_each(fn() { setup_inner(); AssertionOk }), // Runs 2nd
it("test", fn() { ... }),
]),
])
Works in Both Modes
Unlike before_all, before_each works with both to_test_cases
and to_test_suite. Use whichever fits your needs.
pub fn describe(
name: String,
children: List(UnitTest),
) -> UnitTest
Group related tests under a common description.
Groups can be nested to any depth. The group names form a hierarchy that appears in test output and failure messages.
Example
describe("String utilities", [
describe("trim", [
it("removes leading spaces", fn() { ... }),
it("removes trailing spaces", fn() { ... }),
]),
describe("split", [
it("splits on delimiter", fn() { ... }),
]),
])
Output
String utilities
trim
✓ removes leading spaces
✓ removes trailing spaces
split
✓ splits on delimiter
pub fn it(
name: String,
run: fn() -> types.AssertionResult,
) -> UnitTest
Define a single test case.
The test body is a function that returns an AssertionResult. Use the
should API to build assertions that produce this result.
Example
it("calculates the sum correctly", fn() {
add(2, 3)
|> should()
|> equal(5)
|> or_fail_with("Expected 2 + 3 to equal 5")
})
Naming Conventions
Good test names describe the expected behavior:
- ✓ “returns the user when credentials are valid”
- ✓ “rejects empty passwords”
- ✗ “test1”
- ✗ “works”
pub fn skip(
name: String,
run: fn() -> types.AssertionResult,
) -> UnitTest
Skip a test case.
Use skip to temporarily disable a test without removing it. The test
will appear in reports with a - marker and won’t affect the pass/fail
outcome.
This is designed to be a drop-in replacement for it — just change it
to skip to disable a test, and change it back when ready to run again.
Example
describe("Feature", [
it("works correctly", fn() { ... }), // Runs normally
skip("needs fixing", fn() { ... }), // Skipped
it("handles edge cases", fn() { ... }), // Runs normally
])
Output
Feature
✓ works correctly
- needs fixing
✓ handles edge cases
Summary: 3 run, 0 failed, 2 passed, 1 skipped
When to Use
- Test is broken and you need to fix it later
- Test depends on unimplemented functionality
- Test is flaky and needs investigation
- Temporarily disable slow tests during development
Note
The test body is preserved but not executed. This makes it easy to
toggle between it and skip without losing your test code.
pub fn to_test_cases(
module_name: String,
root: UnitTest,
) -> List(types.TestCase)
Convert a test tree into a flat list of runnable test cases.
This function walks the UnitTest tree and produces TestCase values
that the runner can execute. Each test case includes:
name- The test’s own name (fromit)full_name- The complete path including alldescribeancestorstags- Currently empty (tag support coming soon)kind- Set toUnitfor all tests from this DSLbefore_each_hooks- Inherited hooks to run before the testafter_each_hooks- Inherited hooks to run after the test
Hook Handling
before_each/after_eachhooks are collected and attached to each testbefore_all/after_allhooks are ignored (useto_test_suiteinstead)
Example
let test_cases =
describe("Math", [
it("adds", fn() { ... }),
it("subtracts", fn() { ... }),
])
|> to_test_cases("math_test")
// test_cases is now a List(TestCase) ready for run_all()
Parameters
module_name- The name of the test module (used for identification)root- The rootUnitTestnode (typically fromdescribe)
pub fn to_test_suite(
module_name: String,
root: UnitTest,
) -> types.TestSuite
Convert a test tree into a structured test suite.
Use to_test_suite when you need before_all or after_all hooks.
Unlike to_test_cases, this preserves the group hierarchy required
for once-per-group semantics.
When to Use Each Mode
| Scenario | Function | Runner |
|---|---|---|
| Simple tests, no hooks | to_test_cases | run_all |
Only before_each/after_each | to_test_cases | run_all |
Need before_all or after_all | to_test_suite | run_suite |
| Expensive setup shared across tests | to_test_suite | run_suite |
How It Works
describe("A", [ TestSuite("A")
before_all(setup), → before_all: [setup]
it("test1", ...), items: [
describe("B", [ SuiteTest(test1),
it("test2", ...), SuiteGroup(TestSuite("B", ...))
]), ]
])
The tree structure is preserved, allowing the runner to execute
before_all before entering a group and after_all after leaving.
Example
import dream_test/unit.{describe, it, before_all, after_all, to_test_suite}
import dream_test/runner.{run_suite}
import dream_test/reporter/bdd.{report}
import dream_test/types.{AssertionOk}
import gleam/io
pub fn main() {
tests()
|> to_test_suite("integration_test")
|> run_suite()
|> report(io.print)
}
pub fn tests() {
describe("Payment processing", [
before_all(fn() {
start_payment_gateway_mock()
AssertionOk
}),
describe("successful payments", [
it("processes credit cards", fn() { ... }),
it("processes debit cards", fn() { ... }),
]),
describe("failed payments", [
it("handles declined cards", fn() { ... }),
it("handles network errors", fn() { ... }),
]),
after_all(fn() {
stop_payment_gateway_mock()
AssertionOk
}),
])
}
Parameters
module_name- Name of the test module (appears in output)root- The rootUnitTestnode (typically fromdescribe)
Returns
A TestSuite that can be executed with run_suite or run_suite_with_config.
pub fn with_tags(
unit_test: UnitTest,
tags: List(String),
) -> UnitTest
Add tags to a unit test for filtering.
Tags allow you to categorize tests and run subsets of your test suite.
The actual filtering logic is provided via RunnerConfig.test_filter,
giving you full control over how tags are interpreted.
Example
describe("Calculator", [
it("adds numbers", fn() { ... })
|> with_tags(["unit", "fast"]),
it("complex calculation", fn() { ... })
|> with_tags(["integration", "slow"]),
])
Filtering
Provide a filter function in RunnerConfig:
let config = RunnerConfig(
max_concurrency: 4,
default_timeout_ms: 5000,
test_filter: Some(fn(config) { list.contains(config.tags, "unit") }),
)
Note
This function is for unit tests (it). For Gherkin scenarios, use
dream_test/gherkin/feature.with_tags instead.
If applied to a non-test node (e.g., describe), the tags are ignored.
Tags only apply to individual tests. Calling with_tags replaces any
existing tags (use list.append if you need to combine).