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

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 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 (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.

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).

Search Document