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, run)- A single test with a name 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, 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, 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 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.