A test is a function that checks your code still does what you meant. You do not install anything to write one, and there is no special syntax.
Write a test
Give a function a name starting with test_ and put an assert in it. That is all a test is:
fn double(n: integer) -> integer { n * 2 }
fn test_double_doubles() {
assert(double(4) == 8, "double(4) should be 8");
}
assert takes something that should be true, and a message. If it is true, nothing happens. If it is not, the message is what you will read, so write the message for the person who has to fix it — usually you, later.
Run your tests
Point loft at the file:
$ loft --tests calc.loft
ok calc.loft (2 fns: test_double_doubles, test_double_of_zero)
test result: ok. 2 passed; 1 file
It finds every function whose name starts with test_, runs it, and names the file and the functions it found. That result line does not end where it does above — there is a bracket after it, which the last section on this page is about.
The underscore is part of the rule. test_double is a test; testify, or a function called exactly test, is not — and nothing will tell you, because the run says ok having never called it. (A camel-case testDouble cannot catch you out: loft refuses that as a function name.)
There is one exception, and it is what makes a plain script runnable. If a file names NO test_ function at all, loft treats every function in it that takes no parameters as something to run. That is how the pages of this reference are checked: they have a main and no tests. Once one test_ appears in a file, it and its siblings are the whole set, and the helpers beside them go back to being helpers.
Give it a directory instead of a file and it looks in every .loft file underneath. Give it nothing and it starts from where you are:
$ loft --tests greeter
ok greeter/tests/greet.loft (1 fn: test_greet_names_the_person)
coverage: all 1 functions were entered by these tests
test result: ok. 1 passed; 1 file
Over a whole directory it also reports coverage — how many of your functions any of the tests actually entered. One file on its own gets no such line, because a file is not the set of functions you were trying to cover.
Run just one test
While you are fixing one thing, run only that one. Put :: and the test's name after the file:
$ loft --tests calc.loft::test_double_of_zero
ok calc.loft (1 fn: test_double_of_zero)
test result: ok. 1 passed; 1 file
When a test fails
A failure names the test and shows your message, once per test and once per file, and the run's exit status stops being zero — which is how a build script finds out:
$ loft --tests broken/failing.loft ; echo "exit $?"
FAIL broken/failing.loft::test_double_of_two — assertion failed: double(2) should be 5
FAIL broken/failing.loft (1 failed, 0 passed)
test result: FAILED. 1 failed; 0 passed; 1 total; 1 file
exit 1
The message is the part you wrote, which is why a vague message like "it works" costs you time and a specific one saves it.
Read the line after the result
The result line ends with a bracket saying what the run did NOT do:
$ loft --tests calc.loft
test result: ok. 2 passed; 1 file [ran on the interpreter only — native not exercised: loft test --native; no [sandbox] policy — admission not exercised]
That is deliberate. A run that says only "ok" cannot tell you whether the other half was checked or never ran at all. Run them the compiled way and the bracket says the opposite:
$ loft --tests --native calc.loft
test result: ok. 2 passed; 1 file [ran on native only — the interpreter not exercised: loft test; no [sandbox] policy — admission not exercised]
Both halves matter, because the two ways of running your program are meant to give the same answer and a test only checks the one it ran.
Once you have a project
When your code grows into a project with a loft.toml file (the next page), tests live in a tests/ folder and you run them with:
$ cd greeter && loft test
test result: ok. 1 passed; 1 file
fn double(n: integer) -> integer { n * 2 }
This page is itself run as a test, so the example below really does pass.
fn main() {
assert(double(4) == 8, "double(4) should be 8");
assert(double(0) == 0, "double(0) should be 0");
An assert that holds produces no output at all — silence is success.
println("both checks passed");
}
Run it yourself
This page is a loft program. It runs here, in your browser — press Run, then ask it something.
Stop on a line
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Testing your code
// @TITLE: How to write and run tests — a function whose name starts with test_.
// A test is a function that checks your code still does what you meant.
// You do not install anything to write one, and there is no special syntax.
// ## Write a test
// Give a function a name starting with 'test_' and put an 'assert' in it.
// That is all a test is:
//
// fn double(n: integer) -> integer { n * 2 }
//
// fn test_double_doubles() {
// assert(double(4) == 8, "double(4) should be 8");
// }
//
// 'assert' takes something that should be true, and a message. If it is true,
// nothing happens. If it is not, the message is what you will read, so write
// the message for the person who has to fix it — usually you, later.
// ## Run your tests
// Point loft at the file:
//
// $ loft --tests calc.loft
// ok calc.loft (2 fns: test_double_doubles, test_double_of_zero)
// test result: ok. 2 passed; 1 file
//
// It finds every function whose name starts with 'test_', runs it, and names the
// file and the functions it found. That result line does not end where it does
// above — there is a bracket after it, which the last section on this page is
// about.
//
// The underscore is part of the rule. 'test_double' is a test; 'testify', or a
// function called exactly 'test', is not — and nothing will tell you, because
// the run says ok having never called it. (A camel-case 'testDouble' cannot
// catch you out: loft refuses that as a function name.)
//
// There is one exception, and it is what makes a plain script runnable. If a
// file names NO 'test_' function at all, loft treats every function in it that
// takes no parameters as something to run. That is how the pages of this
// reference are checked: they have a 'main' and no tests. Once one
// 'test_' appears in a file, it and its siblings are the whole set, and the
// helpers beside them go back to being helpers.
//
// Give it a directory instead of a file and it looks in every '.loft' file
// underneath. Give it nothing and it starts from where you are:
//
// $ loft --tests greeter
// ok greeter/tests/greet.loft (1 fn: test_greet_names_the_person)
// coverage: all 1 functions were entered by these tests
// test result: ok. 1 passed; 1 file
//
// Over a whole directory it also reports coverage — how many of your functions
// any of the tests actually entered. One file on its own gets no such line,
// because a file is not the set of functions you were trying to cover.
//
// ## Run just one test
// While you are fixing one thing, run only that one. Put '::' and the test's
// name after the file:
//
// $ loft --tests calc.loft::test_double_of_zero
// ok calc.loft (1 fn: test_double_of_zero)
// test result: ok. 1 passed; 1 file
//
// ## When a test fails
// A failure names the test and shows your message, once per test and once per
// file, and the run's exit status stops being zero — which is how a build script
// finds out:
//
// $ loft --tests broken/failing.loft ; echo "exit $?"
// FAIL broken/failing.loft::test_double_of_two — assertion failed: double(2) should be 5
// FAIL broken/failing.loft (1 failed, 0 passed)
// test result: FAILED. 1 failed; 0 passed; 1 total; 1 file
// exit 1
//
// The message is the part you wrote, which is why a vague message like "it
// works" costs you time and a specific one saves it.
//
// ## Read the line after the result
// The result line ends with a bracket saying what the run did NOT do:
//
// $ loft --tests calc.loft
// test result: ok. 2 passed; 1 file [ran on the interpreter only — native not exercised: loft test --native; no [sandbox] policy — admission not exercised]
//
// That is deliberate. A run that says only "ok" cannot tell you whether the
// other half was checked or never ran at all. Run them the compiled way and the
// bracket says the opposite:
//
// $ loft --tests --native calc.loft
// test result: ok. 2 passed; 1 file [ran on native only — the interpreter not exercised: loft test; no [sandbox] policy — admission not exercised]
//
// Both halves matter, because the two ways of running your program are meant to
// give the same answer and a test only checks the one it ran.
//
// ## Once you have a project
// When your code grows into a project with a 'loft.toml' file (the next page),
// tests live in a 'tests/' folder and you run them with:
//
// $ cd greeter && loft test
// test result: ok. 1 passed; 1 file
fn double(n: integer) -> integer { n * 2 }
// This page is itself run as a test, so the example below really does pass.
fn main() {
assert(double(4) == 8, "double(4) should be 8");
assert(double(0) == 0, "double(0) should be 0");
// An assert that holds produces no output at all — silence is success.
println("both checks passed");
}