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(());
}
| Item | Import |
|---|---|
expect, expect_eq, bail, ... | import std::test::assert; |
TestError | import std::test::error; |
Fixture | import std::test::fixture; |
prefix_case, prefix_named_case | import std::test::table; |
run_all, RunOptions, TestOutcome | import std::test::runner; |
TestDescriptor | import std::test::descriptor; |
Assertions
Every assertion returns Result<(), TestError>, so ? propagates a failure out of the test function.
| Function | Passes 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_none | The option has / lacks a value. |
expect_ok<T, E>(r: &Result<T, E>, message) / expect_err | The 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
| Annotation | Effect |
|---|---|
![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.