Skip to content

Testing

Nomi tests are just Nomi code. A test declaration names a behavior and runs a block. Put tests next to the code they exercise or in a conventional *_test.nomi file, then run them with nomi test.

Use assert for the shape you expect and refute for the shape you expect not to hold. They work with ordinary booleans, and failed assertions produce source-located reports.

test "arithmetic has the expected shape" {
  assert 1 + 1 == 2
  refute 2 * 2 == 5
}

Maybe and Result values can be asserted directly. assert checks success shapes; refute checks failure shapes. These checks return the original value, so payload extraction stays explicit.

fn parse_id(text: String): Result<Int, String> {
  case String.to_int(text) {
    Some(id) -> Ok(id)
    None -> Err("expected a numeric id")
  }
}

test "assert checks successful results" {
  assert parse_id("42")
  assert Ok(id) = parse_id("42")

  assert id == 42
}

test "refute checks result errors" {
  refute parse_id("wat")
  assert Err(message) = parse_id("wat")

  assert message == "expected a numeric id"
}

You can also assert a pattern. Pattern assertions use the same pattern language as case arms, which is useful when the expected shape matters and the bound names are the values you want to inspect next.

test "assert can destructure expected shapes" {
  assert [left, right] = [10, 32]

  assert left + right == 42
}

That same pattern form works well with enum variants because the whole expected shape is visible at the assertion site.

import {
  std/calendar.{Error}
  std/calendar.Date
}

test "assert can match an error variant" {
  assert Err(Error.InvalidFormat(_)) = Date"not-a-date"
}

Use try when a fallible value is setup for the rest of the test. Use a pattern assertion when the returned shape is part of what the test is checking.

import std/calendar.Date

test "formats parsed date" {
  d = try Date.parse("2026-05-04")

  assert Date.to_string(d) == "2026-05-04"
}

Use //! directly above a declaration when the test is a compact example of that declaration’s behavior. Each contiguous prompt group is a regular runnable test under nomi test.

//! assert answer() == 42
//! assert (answer() + 1) == 43
//
fn answer(): Int {
  42
}

//! assert label("ready") == "[ready]"
//
fn label(text: String): String {
  "[" + text + "]"
}

When setup is useful, put it in the attached test before the assertion.

//! value = parse_int("42")
//! assert value == Some(42)
//
fn parse_int(text: String): Maybe<Int> {
  String.to_int(text)
}

One attached test may contain shared setup and multiple assertions.

//! value = parse_int_again("42")
//! assert value == Some(42)
//! refute value == None
//
fn parse_int_again(text: String): Maybe<Int> {
  String.to_int(text)
}

assert and refute can wrap pipelines. Put the keyword at the head so the whole pipeline reads as the assertion subject.

test "pipeline assertions validate transformed values" {
  assert "  ADA LOVELACE  "
    |> String.trim()
    |> String.to_lower()
    |> String.equal?("ada lovelace")

  refute "  GRACE HOPPER  "
    |> String.trim()
    |> String.to_lower()
    |> String.contains?("ada")
}

Use tests to group related cases. A group can define setup, which runs before each descendant test and returns an explicit context value. Tests opt into the fields they need in the declaration header.

tests "user labels" {
  setup {name: "Ada", role: "admin"}

  test "setup context is explicit", {name, role} {
    assert name == "Ada"
    assert role == "admin"
  }

  tests "nested setup" {
    setup {name, role} {name, role: "owner", active: True}

    test "nested setup can extend context", {name, role, active} {
      assert name == "Ada"
      assert role == "owner"
      assert active
    }
  }
}

Setup is for shared test context, not hidden globals. Ordinary bindings inside a setup expression do not leak into tests unless they are returned as part of the context value.

When code under test reads active app fields such as MyApp.logger, use a group boot expression. It runs before setup and stages the app value for descendant tests.

tests "app-backed behavior" {
  boot MyApp.load("test")

  test "reads active app" {
    assert MyApp.env == "test"
  }
}

Tests that involve waiting are slow when they pass and unreliable when the machine is busy. Declare clock Clock.Virtual on a group and its tests run under a clock that jumps forward whenever every task in the test is blocked, so waiting costs nothing.

import std/testing.Clock

tests "retry policy" {
  clock Clock.Virtual

  test "gives up after backing off" {
    timer.sleep(Duration.minutes(5))
    assert True
  }
}

That test finishes immediately, and it finishes the same way every time — the clock moves in response to the program rather than alongside it.

clock takes an ordinary expression naming a testing.Clock variant, the same way boot and setup take expressions — so the type has to be imported, exactly like any other reference. Import the file API object instead and it reads clock testing.Clock.Virtual.

The declaration is inherited by nested groups, and a nested group can change its mind with clock Clock.System. Tests outside any group that declares a clock use the real one, so nothing changes for suites that never opt in.

A test that can never finish is reported as a deadlock instead of hanging the run, which is what makes blocking calls without timeouts — like Supervisor.flush — safe to write.

It is off by default because the virtual clock changes behaviour:

  • Waiting on something outside the program, such as reading input or a real network call, never lets the clock move. The test is reported as deadlocked.
  • Background work still running when a test ends is an error, not something that carries on into the next test.
  • Instant.now starts at midnight on 1 January 2000.

The first two usually indicate a test worth fixing, but they are behaviour changes, so you opt in per run.