machdocs
Home GitHub

Testing

A test is a named block of statements the runner can execute on its own. Tests live inline with the code they exercise, and mach test collects every one across the project, builds it, and runs it.

Declaring a test

A test is a declaration at module scope - the same level as fun, rec, and val. It takes no parameters and is not callable from ordinary code; it exists only for the runner to invoke.

test "label" { ... }

The label is required and must be a string literal - not an identifier or a bare word. The body is an ordinary block that may use anything in scope in the enclosing module, just like a function body.

test "date: is_leap_year" {
    if (!is_leap_year(2000)) { ret 1; }
    if (is_leap_year(1900))  { ret 1; }
    if (is_leap_year(2023))  { ret 1; }
    ret 0;
}

test "log: nil message does not crash" {
    debug(nil);
    info(nil);
    ret 0;
}
Note

A pub modifier is syntactically accepted before test but carries no meaning - a test is never part of a module's public surface.

Reporting pass and fail

Each test lowers to a zero-parameter, i32-returning function, and its return value is read as a process-style status. There are no built-in assertion intrinsics; a test signals failure by returning non-zero, typically by returning early from a failed check.

ret 0;     # pass
ret 1;     # fail - any non-zero ret is a failure

Falling off the end of a body returns 0 (the default terminator for a non-void function), so a body that never returns explicitly is treated as a pass. The compiler attaches no special meaning to particular non-zero codes.

Note

The 0 = pass, non-zero = fail convention is the runner's interpretation of each exit status, not a rule enforced by the type system. Some older standard-library tests are inconsistent and even return 1 on success. When writing new tests, prefer ret 0 for pass and a non-zero ret for failure.

Where tests live

Tests are not tied to a single file. Write test "..." { } declarations directly in the relevant src/ module, or group them under src/test/. The runner discovers every test across every module in the build automatically - there is no separate corpus project.

Because the build includes dependencies, every test in a dependency is collected too.

Restriction

A project that depends on a library whose tests do not follow the 0 = pass convention will see those tests reported as failures. Use --filter to scope a run to your own tests while a dependency's suite is being reconciled.

Running with mach test

mach test builds one standalone executable per test declaration - the test plus the project's transitive code, with a synthesized main calling just that test - then spawns each as a separate process and reports a per-test line.

mach test <path> [options]

Each executed test prints a name file:line PASS|FAIL line. A crashing test reports its signal and the run continues. A test build always links executables, even for a library target.

Selecting and listing

FlagEffect
--filter <pattern>run only tests whose name contains <pattern>
--listlist the collected tests and exit, running nothing
--runner <cmd>launch every test as <cmd> <exe> instead of exec'ing it directly
mach test .                 # build and run every test
mach test . --filter date   # only tests whose name contains date
mach test . --list          # enumerate tests, run nothing

The build and global flags apply too. --runner names a host-side launcher for foreign-target tests the host cannot exec directly, e.g. mach test . --target windows --runner wine. Without it, a test the host cannot launch reports FAIL(exit 127) when execve rejects the binary, or FAIL(spawn) when the spawn itself fails - with no auto-detection.

Exit codes

See also

  • CLI - the full mach command surface, including mach test
  • Functions - a test body is checked like a function body
  • Statements - if, ret, and the blocks a test body uses
  • Project layout - where src/ modules and tests live