#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.
#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.
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
| Assertion | Purpose | Desugars to |
|---|---|---|
@assert(cond) | Fails if condition is false | if (!cond) { report; fail; } |
@assert_err(expr, Variant) | Expects expr (a T or E) to fail with the error Variant; reports what it got otherwise | expr 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 succeeded | the 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 compared | trap_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 true | if (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):
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:
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
#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.
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 againTable-driven tests
Generate multiple test cases from data rows:
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:
#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):
#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:7The 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:
#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:
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 themVariables
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:
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:
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 themFixture 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:
#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:
tape test app.tape --substitute io=./mock_io.tapeThis 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.
#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
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 substitutionOutput
With --verbose every test’s result is listed as it runs; by default only
the failed ones are, then the totals:
test "addition_works" ... ok
test "string_concatenation" ... FAILED
test "skipped_example" ... skipped
results: 1 passed, 1 failed, 1 skipped, 3 totalExit code is 0 if all pass, 1 if any fail.
What #test supports
| Feature | Status |
|---|---|
test "name" { ... } | Implemented |
setup { ... } / teardown { ... } | Implemented |
@assert, @fail, @skip | Implemented |
@assert_err, @assert_trap | Implemented |
@error_of | Implemented |
Table-driven tests (with [...] as (...)) | Implemented |
Helper fn declarations | Implemented |
const declarations | Implemented |
Fixture struct/enum/tagged types and @tape { } blocks | Implemented |
--substitute for mocking | Implemented |
--filter for test name matching | Implemented |
| Test isolation (state reset between tests) | Implemented |
Last modified: