Skip to content
CryoCryo home
StdlibTesting

test

Cryo's built-in unit-test framework. Tests live in files under a project's tests/ directory, with each file's namespace declaration carrying ![config(testing)]. Inside such a file, every ![test] function is auto-discovered by the compiler and run by cryo test.

std::test is deliberately not in the prelude — test files import what they need explicitly, and the synthesized test main does the same.

![config(testing)]
namespace MyApp::Tests::Math;

import std::test::*;

![test]
function add_works() -> Result<(), TestError> {
    expect_eq(2 + 2, 4)?;
    return Result::Ok(());
}
ItemImport
expect, expect_eq, bail, ...import std::test::assert;
TestErrorimport std::test::error;
Fixtureimport std::test::fixture;
prefix_case, prefix_named_caseimport std::test::table;
run_all, RunOptions, TestOutcomeimport std::test::runner;
TestDescriptorimport std::test::descriptor;

Assertions

Every assertion returns Result<(), TestError>, so ? propagates a failure out of the test function.

FunctionPasses when
expect(condition: boolean, message: Str)The condition holds.
expect_true / expect_false(condition, message)The obvious.
expect_eq<T>(a: T, b: T) / expect_ne<T>(a, b)The values compare equal / unequal. The failure message renders both.
expect_close_f64(a, b, tolerance)The values are within tolerance.
expect_some<T>(opt: &Option<T>, message) / expect_noneThe option has / lacks a value.
expect_ok<T, E>(r: &Result<T, E>, message) / expect_errThe result is Ok / Err.

bail(message) fails the test outright, and bail_other(message) reports a non-assertion failure — a setup problem rather than a wrong answer.

TestError

type enum TestError { Failed(String); Other(String); }

Failed is an assertion that did not hold; Other is everything else. static failed(message) and static other(message) construct, message_str() reads, and Display is implemented. It owns its message, so it carries a drop.

Annotations

AnnotationEffect
![test]Marks a function for discovery.
![ignore]Skipped unless cryo test --ignored is passed.
![should_panic]Passes only if the body panics or returns Err.
![test]
![should_panic]
function out_of_range() -> Result<(), TestError> {
    core::panic("expected", FILE, LINE);
    return Result::Ok(());
}

Isolation

Each test runs in its own forked child. A panic or an Err return in one test cannot affect any other.

This is intentionally heavier than running everything in-process. Cryo has no in-process panic catch under the default abort strategy, so isolation by fork is the simplest correct option.

Fixtures

A fixture is a value a test needs set up beforehand and torn down after — a temp directory, a seeded RNG, a probe allocator, a socket. The trait standardizes only the setup half:

type trait Fixture {
    static setup() -> Result<This, TestError>;
}

Teardown is the value's own Drop impl. Because Cryo runs drop glue on every scope exit — including the early-return paths ? and return Result::Err(...) take — a fixture bound as a local is torn down on success and on assertion failure, with no per-test cleanup code.

![test]
function writes_a_file() -> Result<(), TestError> {
    mut tmp: TmpDir = TmpDir::setup()?;
    // ... the directory is removed however this function exits ...
    return Result::Ok(());
}

Define setup() once, in the Fixture impl. A second inherent setup() that called TmpDir::setup() would recurse into itself.

The one path Drop cannot cover is a hard process abort; there the OS reclaims everything.

Table-driven tests

v1.0 uses the inline-loop pattern: the test owns a loop over a const array of cases and calls the ordinary assertions on each row. The helpers here turn a per-row failure into a message naming the offending case, so a failure in row 7 reads case [7]: ... instead of an anonymous assertion failure.

type struct Case { input: i32; want: i32; }

![test]
function doubling_table() -> Result<(), TestError> {
    const cases: Case[] = [
        Case { input: 1, want: 2 },
        Case { input: 2, want: 4 },
        Case { input: 3, want: 6 },
    ];
    mut i: i64 = 0;
    while (i < cases.length) {
        const c: &Case = &cases[i];
        match (expect_eq(c.input * 2, c.want)) {
            Result::Ok(_)  => { }
            Result::Err(e) => { return Result::Err(prefix_case(i as u64, e)); }
        }
        i = i + 1;
    }
    return Result::Ok(());
}

prefix_named_case labels the row by name rather than index.

Compile-fail tests

Code that must be rejected with a specific error is not a ![test] function. Those live as standalone files under tests/negative/, each carrying ![config(negative, <code>)]. cryo test compiles each on its own and checks the diagnostic matches.

The runner

run_all(argc, argv, descriptors, count) -> i32 is the entry point the compiler-synthesized test main calls. It parses the CLI arguments, forks per test, and prints results.

RunOptions is the parsed configuration: a name filter and whether it must match exactly, include_ignored, list_only, quiet, output format, color mode, per-test timeout_secs, parallel jobs, and whether to capture or show_output.

TestDescriptor carries a test's name, function pointer, and its ignore and should-panic flags — its layout is matched by the compiler-synthesized main.

For the toolchain side — discovery, forking, and reporting — see the test framework page.