#test Region

Purpose

The #test region contains test cases for the module. These are compiled and run via tape test <file> — they’re not included in normal builds.

tape
#test
test "addition works" {
    @assert(1 + 1 == 2);
}

test "string concatenation" {
    let result = "hello" + " " + "world";
    @assert(result == "hello world");
}

Test syntax

Each test is declared with test "name" { body }. The name becomes part of the generated function name: letters, digits and underscores stay, every other character (spaces, hyphens, punctuation) becomes an underscore.

tape
test "descriptive name" {
    // test body — regular tape code
    @assert(some_condition);
}

Tests return 0 on success. If any assertion fails, the test returns -1. If skipped, it returns -2. An assertion in a helper fn the test calls fails the test too (§Helper functions).

Test names are unique in a file: a second test "name", a second setup or teardown, or two names that become one once punctuation turns into underscores ("a b" and "a_b") is a compile error naming the first.

Assertions

AssertionPurposeDesugars to
@assert(cond)Fails if condition is falseif (!cond) { report; fail; }
@assert_err(expr, Variant)Expects expr (a T or E) to fail with the error Variant; reports what it got otherwiseexpr or { if (err != Variant) { report; fail; } hit = true; }; if (!hit) { report; fail; }
@error_of(expr)Not an assertion: the error of expr (a call or variable of type T or E) as a ?E, null when it succeededthe error, or null; a call’s success value is dropped
@assert_trap(expr)Expects expression (or a { block }) to cause a trap; which trap, and its message, are not comparedtrap_begin; expr; if (!trap_end) { report; fail; }
@fail(msg)Unconditional failure with message; msg is a []const u8: a literal, or a string the test built (@fail(err.message()))report(msg); fail;
@skip(cond)Skip test if condition is trueif (cond) { return -2; }

report prints what failed followed by at <file>:<line>, the line of the builtin. fail ends the running test as failed: in a test body (or setup/teardown) it is return -1;; in a helper fn of the region (§Helper functions) it ends the test from inside the helper, whatever the helper returns. @skip skips the test it is written in, so it belongs in the test body; in a helper it is a compile error.

The assertions are #test code: in a function of another region @assert is a compile error (@assert at run time is only available in a #test region; use @trap or an if), as are @fail, @assert_err and @assert_trap (@fail is only available in a #test region). The compile-time @assert of @tape code is a different thing and works anywhere @tape code does.

@assert with comparison operators (==, !=, <, >, <=, >=) on integers (or bools and enums) reports both left and right values on failure via __tt_assert_fail_cmp. Other conditions, comparisons of floats or strings, and comparisons with null (@assert(find(x) == null)) use __tt_assert_fail with the expression text. Either way each operand is evaluated exactly once, left then right, as in the plain comparison, and the values reported are the ones compared: @assert(next() == 2) calls next once, passing or failing.

@assert_err names the error the expression must fail with. When it succeeds instead, or fails with another error, the report says what it got, the error by its description (as "{err}" writes it, without allocating):

tape
test "parse rejects empty input" {
    @assert_err(conv.parse_i64(""), conv.ParseError.Empty);
}
//   assertion failed: @assert_err(conv.parse_i64("7"), conv.ParseError.Empty)
//     expected: conv.ParseError.Empty
//          got: success
//     at parse_test.tape:2

@error_of gives the error itself, for a test that checks several outcomes in a row or a helper that returns one. It is only for #test regions; elsewhere @error_of is only available in a #test region:

tape
test "mkdir errors" {
    @assert(@error_of(fs.mkdir(dir))! == io.IoError.AlreadyExists);
    @assert(@error_of(fs.remove(missing))! == io.IoError.NotFound);
    @assert(@error_of(fs.mkdir(fresh)) == null);      // succeeded
}

A succeeding call’s value is dropped, but a resource it opened is not closed: open a file you expect to open with or { } and close it.

Setup and teardown

tape
#test
setup {
    // runs before each test
}

teardown {
    // runs after each test
}

test "example" {
    @assert(1 == 1);
}

setup and teardown are optional. They run before/after every test function in the file.

Each test starts fresh

Each test starts from a fresh program state: every module variable at its initial value — the module’s own (its #test region’s included) and those of every module it imports, whether a number, a flag, an array, a struct, a string or an optional — with each initializer run again. setup then runs in that state, before the test. Nothing a test leaves in a variable reaches another test, so their order doesn’t matter and --filter running one test alone gives the same result. This holds in the VM and natively alike.

tape
var ready: bool = false;
var table: [256]u8;

fn lookup(i: i64) -> u8 {
    if (!ready) {
        for (k in 0..256) { table[k] = ((k * 7) % 256) as u8; }
        ready = true;
    }
    return table[i];
}

#test
test "first" { @assert(lookup(3) == 21); }
test "second" { @assert(!ready); @assert(lookup(5) == 35); }   // ready is false again

Table-driven tests

Generate multiple test cases from data rows:

tape
test "squares" with [1, 1], [2, 4], [3, 9], [4, 16] as (input, expected) {
    @assert(input * input == expected);
}

Each row [values...] becomes a separate test function (__test_squares_0, __test_squares_1, etc.). The as (names...) clause binds row values to local variables in the body.

Helper functions

Regular function declarations are allowed inside #test:

tape
#test
fn make_test_data() -> i64 {
    return 42;
}

test "uses helper" {
    let x = make_test_data();
    @assert(x == 42);
}

A helper can assert. @assert, @fail, @assert_err and @assert_trap work in any fn of the #test region, whatever it returns and however deep the test calls it. A failure there fails the test that is running: the report is the same as in a test block, at the helper’s line, and the test stops at the call (teardown still runs):

tape
#test
fn check_parse(text: []const u8, want: i64) -> i64 {
    let n = conv.parse_i64(text) or {
        @fail("parse_i64({text}): {err}");
        return 0;
    };
    @assert(n == want);
    return n;
}

fn check_rejects(text: []const u8) {
    @assert_err(conv.parse_i64(text), conv.ParseError.Empty);
}

test "parse" {
    @assert(check_parse("42", 42) == 42);
    check_rejects("");
    check_parse("41", 42);          // fails the test here
    io.println("not reached");
}
//   assertion failed: n == want
//     expected: 42
//          got: 41
//     at parse_test.tape:7

The region’s functions, constants, variables and fixture types join the module for tape test, so they can’t take the name of anything the module declares: a #test fn helper next to a #code fn helper is a compile error, not a replacement.

Constants

const declarations are allowed at the top level of #test, for fixtures the tests share. Like the region’s functions, they’re compiled only for tape test:

tape
#test
const SAMPLE: string = "a,b,,c";
const FIELDS: i64 = 4;

test "split keeps empty fields" {
    @assert(count_fields(SAMPLE) == FIELDS);
}

A #test constant needs a name of its own. Reusing the name of something the #code region declares (a const, let/var, fn or type), or of another #test constant, is a compile error that points at both declarations:

plaintext
lib.tape:12:7: error: #test const 'LIMIT' (lib.tape:12) has the name of the #code const 'LIMIT' declared at lib.tape:2; rename one of them

Variables

var and let declarations are allowed at the top level of #test too, for state only the tests need: a fake’s call counter, a value a test hook records. They’re compiled only for tape test (and checked by tape check), so that state doesn’t have to live in #code and ship in release builds:

tape
fn wire(msg: string) { io.println(msg); }
var transport: fn(string) = wire;           // the hook a test replaces
fn send(msg: string) { transport("> {msg}"); }

#test
var sent: i64 = 0;
var last: string = "";
let LIMIT: i64 = 3;

fn fake_transport(msg: string) {
    sent = sent + 1;
    last = msg;
}

test "send goes through the transport" {
    transport = fake_transport;
    send("hi");
    send("there");
    @assert(sent == 2);
    @assert(sent < LIMIT);
    @assert(last == "> there");
}

test "a fresh counter" {
    @assert(sent == 0);         // back at its initial value
    @assert(last == "");
    @assert(transport == wire);
}

They follow the rules of a module var or let in #code, and like every module variable they’re back at their initial value at the start of each test (§Each test starts fresh). They take no pub and no attribute. The name rule of a #test constant applies: reusing the name of a #code declaration, or of another #test constant, variable or function, is a compile error that points at both:

plaintext
lib.tape:30:5: error: #test var 'sent' (lib.tape:30) has the name of the #code var 'sent' declared at lib.tape:4; rename one of them

Fixture types and generated code

A #test region can declare struct, enum and tagged types and run @tape { } blocks at its top level. They join the module for tape test only, so a generator’s tests can instantiate it for types of their own without every program that imports the module compiling them:

tape
#test
pub struct Person { name: string; age: i64; }

@tape { gen_vec(Person); }

test "a vector of people" {
    var v: Vec_Person = vec_Person_new();
    v.push(Person { name: "Ada", age: 36 });
    @assert(v.len() == 1);
}

The types come after the module’s own and the @tape blocks run after its blocks, so they can use everything the #code region declares.

Module substitution

For dependency injection in tests, use --substitute:

bash
tape test app.tape --substitute io=./mock_io.tape

This replaces the io module with a mock implementation during test compilation (the path is resolved from the current directory, not from the test file). The mock must declare every pub item of io with the same signature (it may add more, for the tests to use); see tape test.

PlannedImport statements inside #test are parsed but skipped. The design allows #test to import modules directly — either for test-only utilities, or to override a module that #code imports (acting as inline substitution without the --substitute flag).

Running tests

bash
tape test module.tape                          # run all tests in file
tape test                                      # every file with a #test region under .
tape test src/                                 # ... under src/
tape test module.tape --filter concat          # tests whose name contains "concat"
tape test module.tape --substitute M=path      # with module substitution

Output

With --verbose every test’s result is listed as it runs; by default only the failed ones are, then the totals:

plaintext
  test "addition_works" ... ok
  test "string_concatenation" ... FAILED
  test "skipped_example" ... skipped

results: 1 passed, 1 failed, 1 skipped, 3 total

Exit code is 0 if all pass, 1 if any fail.

What #test supports

FeatureStatus
test "name" { ... }Implemented
setup { ... } / teardown { ... }Implemented
@assert, @fail, @skipImplemented
@assert_err, @assert_trapImplemented
@error_ofImplemented
Table-driven tests (with [...] as (...))Implemented
Helper fn declarationsImplemented
const declarationsImplemented
Fixture struct/enum/tagged types and @tape { } blocksImplemented
--substitute for mockingImplemented
--filter for test name matchingImplemented
Test isolation (state reset between tests)Implemented

Last modified: