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.
public module test::error;
public module test::descriptor;
public module test::assert;
public module test::fixture;
public module test::table;
public module test::runner;
std::test is deliberately not in the prelude — test files import what they need explicitly, and the synthesized test main does the same. The module re-exports each sub-module, so import std::test::*; brings in the whole surface.
![config(testing)]
namespace MyApp::Tests::Math;
import std::test::*;
![test]
function add_works() -> Result<(), TestError> {
expect_eq(2 + 2, 4)?;
return Result::Ok(());
}
| File | What it holds | Import |
|---|---|---|
assert | expect, expect_eq, bail, ... | import std::test::assert; |
error | TestError | import std::test::error; |
descriptor | TestDescriptor, the record the compiler synthesises per test | import std::test::descriptor; |
fixture | Fixture | import std::test::fixture; |
table | prefix_case, prefix_named_case | import std::test::table; |
runner | run_all, RunOptions, TestOutcome | import std::test::runner; |
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.
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.
public function name() { ... }
public:Visible to any module that imports this one - the default for top-level items and for struct fields, so it is mostly written to be explicit. public: opens a visibility block that groups the members after it.
public module collections::array;In a directory's _module.cryo aggregator, lists a submodule and whether it is public. Importing the directory's namespace sees only the modules declared public here.
namespace std::test::fixture;Fixture: a per-test resource with deterministic setup and teardown.
module pagenamespace std::test::table;Parametrized (data-driven) tests: run one test body over a table of cases and identify which row failed.
module pagenamespace app::module;Every source file opens with one: its position in the module hierarchy. The compiler uses it to resolve imports and to mangle symbol names, so two parse functions in different modules never collide at link time.
import a::b;
import a::b::{ X, Y };
import a::b as c;
import a::b::*;Brings a module, or selected items from it, into scope. One path per declaration: use the brace form for several items, as to alias, and * for everything public (prefer the brace form; wildcards invite name collisions).
function name<T>(param: Type) -> Ret { ... }Declares a function. Parameters are written name: Type and are never inferred; the return type follows -> and defaults to void. Declaration order does not matter: every signature is collected before any body is checked, so functions may call each other freely and recurse.
function add_works() -> Result<(), TestError>primitive ()The unit type: a value that carries no information, and the empty tuple. Distinct from void, which is the absence of a value - Result<(), E> succeeds with nothing to return. 0 bytes.
type enum TestError {
Failed(String);
Other(String);
}Why a test failed. The runner prints the variant's message verbatim to stderr from the child process before exiting non-zero, so the parent can show the failing message in the test summary.
std::test::errorfunction expect_eq<T>(a: T, b: T) -> Result<(), TestError>
where T: Eq + DisplayOk(()) if a.equals(&b), otherwise Err carrying a multi-line "assertion left == right failed" message that Display-formats both values. Allocates the message on the heap; on allocation failure during message building, returns a generic fallback message.
value?Error propagation. On a Result<T, E> it yields the T of an Ok and otherwise returns the Err(e) from the enclosing function unchanged; on an Option<T> it yields the T of a Some or returns None. The enclosing function must return a matching Result / Option.
return value;Leaves the current function with the given value. A function whose return type is not void must return one; a bare return; is for void. In an async function it completes the future with the value.
Result::Ok(T)function out_of_range() -> Result<(), TestError>function panic(message: string, loc: intrinsics::Location) -> neverPanic with message, reporting the place loc: intrinsics::location() written at the call, or in a ![caller_location] function its caller's. The helpers below do that automatically.
type struct TestDescriptor {
name: Str;
ignored: boolean;
should_panic: boolean;
}One discovered ![test] function plus its sibling-directive flags.
type trait Fixture {
static setup() -> Result<This, TestError>;
}A per-test resource. setup() builds it; the value's Drop impl is the teardown. Bind the result as a local in the test function and let scope exit run the teardown on every path.
type struct RunOptions {
filter: String;
exact: boolean;
include_ignored: boolean;
list_only: boolean;
quiet: boolean;
format: TestFormat;
format_explicit: boolean;
color: ColorMode;
color_explicit: boolean;
color_active: boolean;
// … 7 more
}type enum TestOutcome {
Pass;
Fail(i32, i32, boolean);
Skipped;
DidNotPanic;
Timeout(u32);
RunnerError(i32);
}Result::Err(E)