Writing Tests
Tests are part of the language: a test block sits next to the code it checks, and tessera test runs them. This chapter covers tests, table tests, benchmarks, and how to test code that touches the outside world: with fakes through interfaces, a temporary directory, or a clock on virtual time.
Test blocks
A test is a top-level block with a name. It passes if it runs to the end, and fails if anything in it panics, usually an assert or assert_eq:
// Turns a title into the part of a URL that names it:
// "Hello, World!" becomes "hello-world".
pub fn slug(title: Str) -> Str {
let mut words: List[Str] = []
let mut word = ""
for c in title.to_lower().chars() {
if (c >= "a" && c <= "z") || (c >= "0" && c <= "9") {
word = word + c
} else if word != "" {
words.push(word)
word = ""
}
}
if word != "" {
words.push(word)
}
words.join("-")
}
test "lowercases and joins words" {
assert_eq(slug("Hello World"), "hello-world")
}
test "drops punctuation" {
assert_eq(slug("Hello, World!"), "hello-world")
assert_eq(slug(" --what?-- "), "what")
}
test "keeps digits" {
assert(slug("Top 10 Tips").ends_with("10-tips"))
}$ tessera test slug.tsr
ok lowercases and joins words (0ms)
ok drops punctuation (0ms)
ok keeps digits (0ms)
3 tests, 0 failed (0ms)
assert(cond) fails unless the condition is true. assert_eq(a, b) fails unless the two values are equal, and its message shows both, which makes it the one you'll use most.
Here is what a failure looks like, with an earlier, simpler slug:
pub fn slug(title: Str) -> Str {
title.to_lower().replace(" ", "-")
}
test "lowercases and joins words" {
assert_eq(slug("Hello World"), "hello-world")
}
test "drops punctuation" {
assert_eq(slug("Hello, World!"), "hello-world")
}$ tessera test slug.tsr
ok lowercases and joins words (0ms)
FAIL drops punctuation
assertion failed: "hello,-world!" != "hello-world"
--> slug.tsr:10:5
2 tests, 1 failed (0ms)
Each test runs on its own, so one failure doesn't stop the others. tessera test exits with status 1 if any test failed, which is what CI looks at. --run TEXT runs only the tests whose name contains TEXT, --native runs them as native code, and --json reports results as JSON for tools.
Tests are type-checked by tessera check along with the rest of the file, and ignored by tessera run and tessera build.
Table tests
Most tests of a function like slug are the same check with different inputs. A table test writes the cases once, after for:
test "slug" for (title, want) in [
("Hello World", "hello-world"),
("Hello, World!", "hello-world"),
(" --what?-- ", "what"),
("Top 10 Tips", "top-10-tips"),
("", ""),
] {
assert_eq(slug(title), want)$ tessera test slug.tsr
ok slug ("Hello World", "hello-world") (0ms)
ok slug ("Hello, World!", "hello-world") (0ms)
ok slug (" --what?-- ", "what") (0ms)
ok slug ("Top 10 Tips", "top-10-tips") (0ms)
ok slug ("", "") (0ms)
5 tests, 0 failed (0ms)
$ tessera test slug.tsr --run "slug (\"Top"
ok slug ("Top 10 Tips", "top-10-tips") (0ms)
1 test, 0 failed (0ms)
Each case is a separate test, named by the test's name and the case's text, so a failure tells you exactly which case broke, and --run can pick one. The cases are written in place, as a list literal, so that every case is known, and named, before anything runs.
Tests don't get root
Tests receive no root. A test can compute, use groups and channels, and call any pure function, but it can't print, read files or reach the network on its own. That is on purpose: a test that depends on the machine it runs on is a flaky test.
So how do you test code that uses a capability? There are three answers, and listing 11-4 uses two of them.
// How long ago something happened, in words. The clock comes in through an
// interface, so a test can pass a clock it controls.
interface Ticker {
fn now_ms(self) -> Int
}
fn ago[C: Ticker](clock: C, since_ms: Int) -> Str {
let s = (clock.now_ms() - since_ms) / 1000
if s < 60 {
"just now"
} else if s < 3600 {
"{s / 60} minutes ago"
} else {
"{s / 3600} hours ago"
}
}
// Saves a counter in a file, adding one each time.
fn bump(fs: Fs, path: Str) -> Result[Int, IoError] {
let mut n = 0
if fs.exists(path) {
let text = fs.read(path)?
n = text.trim().parse_int() ?? 0
}
fs.write(path, "{n + 1}")?
Ok(n + 1)
}
fn main(root: Root) {
let start = root.clock.now_ms()
root.stdout.print(ago(root.clock, start))
}
struct FakeClock {
t: Int,
}
fn now_ms(self: FakeClock) -> Int {
self.t
}
test "ago" for (elapsed, want) in [(5, "just now"), (125, "2 minutes ago"), (7300, "2 hours ago")] {
let clock = FakeClock { t: 1_000_000 + elapsed * 1000 }
assert_eq(ago(clock, 1_000_000), want)
}
test "bump counts up" (fs: Fs) {
assert_eq(bump(fs, "count.txt"), Ok(1))
assert_eq(bump(fs, "count.txt"), Ok(2))
assert_eq(fs.read("count.txt"), Ok("2"))
}
test "bump on a read-only directory" (fs: Fs) {
let r = bump(fs.read_only(), "count.txt")
assert(r.is_err())
}$ tessera test main.tsr
ok ago (5, "just now") (0ms)
ok ago (125, "2 minutes ago") (0ms)
ok ago (7300, "2 hours ago") (0ms)
ok bump counts up (4ms)
ok bump on a read-only directory (1ms)
5 tests, 0 failed (5ms)
Fakes through an interface. ago doesn't take a Clock; it takes any C: Ticker, an interface with one method, now_ms. The real Clock has that method, so main passes root.clock. The test passes FakeClock, a plain struct whose now_ms returns whatever the test chose. The function under test can't tell the difference, and the test is exact.
A temporary directory. A test can ask for a real Fs by declaring it like a parameter: test "name" (fs: Fs). It gets a fresh, empty, writable directory, removed when the test ends. bump is tested against real files, without touching anything else on the machine. Narrowing works as everywhere: fs.read_only() checks the error path.
A test can also ask for clock: Clock, the real clock. Those three are all a test can ask for; there is no way to give a test the network.
Virtual time
Code with timeouts is notoriously hard to test: a test that waits five real seconds is slow, and one that races a timer is flaky. Tessera's third answer is a clock on virtual time:
// Waits for a reply on `replies`, but no longer than `ms`.
fn wait_reply(clock: Clock, replies: Chan[Str], ms: Int) -> Str {
select {
r = replies.recv() => r ?? "closed",
clock.after(ms) => "timed out",
}
}
fn main(root: Root) {
let replies: Chan[Str] = channel(1)
replies.send("pong")
root.stdout.print(wait_reply(root.clock, replies, 1000))
}
test "a slow reply times out" (clock: virtual Clock) {
let replies: Chan[Str] = channel(1)
let start = clock.now_ms()
let got = group g {
g.go(|| {
clock.sleep(60_000)
replies.send("late")
})
g.go(|| wait_reply(clock, replies, 5000))
}
assert_eq(got, ["timed out"])
assert_eq(clock.now_ms() - start, 60_000)
}
test "a quick reply arrives" (clock: virtual Clock) {
let replies: Chan[Str] = channel(1)
let got = group g {
g.go(|| {
clock.sleep(4_999)
replies.send("pong")
})
g.go(|| wait_reply(clock, replies, 5000))
}
assert_eq(got, ["pong"])
}$ tessera test main.tsr
ok a slow reply times out (0ms)
ok a quick reply arrives (0ms)
2 tests, 0 failed (0ms)
test "..." (clock: virtual Clock) gives the test a real Clock, so the code under test uses it as usual, but its time is virtual. It starts at the same moment every run, and it doesn't move while any task can make progress. When every task is waiting, the clock jumps straight to the earliest timer and rings it.
In the first test, one task sleeps for a minute and the other waits five seconds for a reply. Both are waiting, so the clock jumps 5000 ms: the select times out. Then it jumps to the minute: the sleeper wakes and sends. The whole test takes no real time, and clock.now_ms() - start is exactly 60000. In the second test the reply comes at 4999 ms, one millisecond before the deadline, and wins, every single time.
Groups, channels and select are the subject of chapter 13. What matters here is that timeout logic can be tested exactly, and the result never depends on how busy the machine is.
Benchmarks
A bench block times one operation:
fn count_words(text: Str) -> Int {
text.split(" ").count(|w| w != "")
}
test "count_words" {
assert_eq(count_words("a few words here"), 4)
}
bench "count_words on 1000 words" {
let text = "lorem ipsum dolor sit amet ".repeat_text(200)
count_words(text)
}
fn repeat_text(self: Str, n: Int) -> Str {
let mut parts: List[Str] = []
for _ in 0..<n {
parts.push(self)
}
parts.join("")
}$ tessera test main.tsr --bench --bench-time 100x
ok count_words (193ms)
bench count_words on 1000 words 100 25640 ns/op 1007 allocs/op
1 test, 1 bench, 0 failed (1.02s)
tessera test skips benchmarks; tessera test --bench runs them, always as native code. Each runs more and more iterations until a round takes a second (--bench-time changes that; 100x runs exactly 100 iterations), and reports the time and the number of allocations per operation. The numbers depend on the machine, so they aren't shown here.
The body's last value is kept, so the compiler can't remove the work that made it. Inside a loop, call keep(x) on values that would otherwise go unused.
Summary
test blocks sit next to the code, tessera test runs them, and table tests name every case. Tests get no capabilities by default, which keeps them repeatable; they can ask for a temporary directory or a clock, pass fakes through interfaces, and run timeouts on virtual time. bench blocks measure.
The next chapter is the heart of the book: capabilities in depth.