Chapter 11 — Testing
Coil has testing built in: test cases live next to your code, examples in documentation comments run as tests, and functions that state their expectations as contracts are tested with generated inputs without you writing a single case.
Test cases#
A test file is a .hy file under tests/ with one or more
test("…") { … } blocks and no main. assert! from the stdlib module
tests checks a condition and fails the case when it is false:
use tests::assert;
fn fizzbuzz(int n) -> string {
if n % 15 == 0 {
return "FizzBuzz";
}
if n % 3 == 0 {
return "Fizz";
}
if n % 5 == 0 {
return "Buzz";
}
return show(n);
}
test("multiples of three are Fizz") {
assert!(fizzbuzz(3) == "Fizz");
assert!(fizzbuzz(9) == "Fizz");
}
test("other numbers are printed as they are") {
assert!(fizzbuzz(7) == "7", "7 is not a multiple of 3 or 5");
}
In a real project the function lives in src/ and the test file imports it
with use, as chapter 9 showed. The optional second argument of assert!
is a message to show when it fails.
Run every test with spool test, or with coil test:
coil test --root ~/.coil/stdlib/src tests
running 1 file (seed 0x331d79d4efd8a9c4, 4 jobs)
test result: ok. 2 passed; 0 failed; 2 total; finished in 0.05s
On a terminal a progress bar counts files while the run goes; passing files
print nothing (-v lists them). If a bug made fizzbuzz(9) return "9",
the run would end with the failing case, the condition that did not hold,
and anything the case printed:
running 1 file (seed 0x331d79d4efd8a9c4, 4 jobs)
FAILED tests/fizzbuzz.hy
failures:
---- tests/fizzbuzz.hy: multiples of three are Fizz ----
failed: Assertion: 'fizzbuzz(9) == "Fizz"' failed
failed:
tests/fizzbuzz.hy: multiples of three are Fizz
test result: FAILED. 1 passed; 1 failed; 2 total; finished in 0.05s
What a test prints to stdout or stderr never reaches the terminal while the
run goes: it shows under stdout: / stderr: in that case’s failure
(--show-output prints passing cases’ output too). To check what code
prints, capture it with io::capture_begin() and io::capture_end(), which
returns (stdout, stderr):
use io::{capture_begin, capture_end, stdout, write};
use string::to_bytes;
use tests::assert;
test("greets") {
capture_begin();
write(stdout(), to_bytes("hello\n"))?;
let (out, _) = capture_end();
assert!(out == "hello\n");
}
Each case runs in its own isolated VM, so one that panics does not stop the
others. --fail-fast stops at the first failure.
A test body can use ? on any Result or Option: a failure ends the case
and shows the error. That keeps tests that do IO short:
use io::file::{read_text};
use tests::assert;
test("the sample config is not empty") {
let text = read_text("tests/data/sample.conf")?;
assert!(text.len() > 0);
}
Tests that touch files or the network need the same permission flags as
any program: coil test --allow-read ….
Examples in documentation#
Code blocks in /// comments are examples, and coil test runs them too.
An example that compiles and runs without failing passes, so your
documentation cannot drift away from the code:
/// Returns the larger of `a` and `b`.
///
/// # Examples
///
/// ```
/// use mathx::{larger};
/// # use tests::assert;
/// assert!(larger(3, 9) == 9);
/// ```
fn larger(int a, int b) -> int {
if a > b {
return a;
}
return b;
}
Lines starting with # are hidden in editor hover but still run, which
suits setup like the assert import. Mark an example ```no_run
to only compile it, or ```ignore to skip it. Every page in the
reference comes from comments like these.
Contracts#
A function can state what it needs and what it promises. requires is
checked when it is called and ensures when it returns, with result
standing for the return value:
/// Scales a rating from 0 to 10 to a percentage.
fn to_percent(int rating) -> int
requires rating >= 0
ensures result >= 0 && result <= 100
{
return rating * 10;
}
A broken contract panics with a message that says which clause failed and,
for requires, which call was at fault.
Contracts also feed coil test. For each function in your project with a
contract, it generates a test that calls the function with random arguments
that satisfy the requires, small values first, and checks the ensures.
Run against the function above, it finds the bug, which no hand-written
case covered:
contracts of 1 project file
> Test "contract: to_percent" failed: to_percent(rating = 11): contract violated: ensures result >= 0 && result <= 100 in to_percent
The fix is either requires rating <= 10 or clamping the result. Classes
can state an invariant that every public method must keep, and
coil verify can prove contracts for all inputs with an SMT solver instead
of testing them. The contracts page covers all of
it.
Coverage and mutation testing#
spool coverage (or coil test --coverage) reports which lines your tests
ran. spool infect (or coil mutate) goes further: it makes small changes
to your code, such as turning < into <=, and reports the ones no test
noticed. Each of those points at a missing test.
Exercises#
- Write tests for the
Stack<T>class from chapter 4: popping an empty stack returnsNone, and items come out in reverse order. - Add an example to the documentation of a function you wrote earlier and
run it with
coil test. - Give
average(Vec<int> xs) -> intarequiresthat rules out empty input, and anensuresthat the result is between the smallest and the largest element. Letcoil testcheck it.
Next: Chapter 12 — Generators.