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
  }),
])
HookRunsRequires
before_allOnce before all tests in groupto_test_suite
before_eachBefore each testEither mode
after_eachAfter each test (always)Either mode
after_allOnce after all tests in groupto_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 function
  • DescribeGroup(name, children) - A group of tests under a shared name
  • BeforeAll(setup) - Runs once before all tests in the group
  • BeforeEach(setup) - Runs before each test in the group
  • AfterEach(teardown) - Runs after each test in the group
  • AfterAll(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

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_all setup

Execution Behavior

  • Runs exactly once, after the last test in the group completes
  • Always runs, even if tests fail or before_all fails
  • Nested describe blocks each run their own after_all hooks

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_each hooks fail
  • Multiple after_each hooks 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 as SetupFailed
  • Nested describe blocks each run their own before_all hooks

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 marked SetupFailed
  • Multiple before_each hooks 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 (from it)
  • full_name - The complete path including all describe ancestors
  • tags - Currently empty (tag support coming soon)
  • kind - Set to Unit for all tests from this DSL
  • before_each_hooks - Inherited hooks to run before the test
  • after_each_hooks - Inherited hooks to run after the test

Hook Handling

  • before_each/after_each hooks are collected and attached to each test
  • before_all/after_all hooks are ignored (use to_test_suite instead)

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 root UnitTest node (typically from describe)
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

ScenarioFunctionRunner
Simple tests, no hooksto_test_casesrun_all
Only before_each/after_eachto_test_casesrun_all
Need before_all or after_allto_test_suiterun_suite
Expensive setup shared across teststo_test_suiterun_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 root UnitTest node (typically from describe)

Returns

A TestSuite that can be executed with run_suite or run_suite_with_config.

Search Document