Chapter 17: Testing and Debugging
Code without tests is a house without a foundation. Tova makes testing a first-class part of the language with built-in test blocks, a rich assertion library, and error messages designed to show you exactly what went wrong and where. This chapter teaches you to write tests that catch bugs before your users do, and to debug effectively when something slips through.
By the end, you will build a complete test suite for a calculator module.
Test Blocks
In Tova, tests are declared with the test keyword followed by a descriptive name and a block of assertions:
test "addition works correctly" {
assertEq(2 + 2, 4)
}
test "strings can be joined" {
result = "Hello" ++ " " ++ "World"
assertEq(result, "Hello World")
}
test "empty arrays have zero length" {
assertEq(len([]), 0)
}Each test block is an isolated unit. Variables declared inside one test do not leak into another. If any assertion fails, the test reports the failure with the name you gave it.
Run all tests with:
tova testOr run tests in a specific file:
tova test calculator.tovaNaming Tests Well
The name you give a test is its documentation. When a test fails six months from now, the name is the first thing you see. Make it describe the behavior, not the implementation:
// Good: describes the behavior
test "divide returns Err when divisor is zero" { ... }
test "user search returns None for unknown name" { ... }
test "sorted output preserves duplicate values" { ... }
// Bad: describes the implementation
test "test divide function" { ... }
test "check result" { ... }
test "test 3" { ... }Assertions
Tova provides four core assertion functions. Each one produces a clear error message when it fails.
assert(condition) -- Truthy Check
The simplest assertion. Passes if the condition is truthy, fails otherwise:
test "basic assertions" {
assert(10 > 5)
assert(len("hello") == 5)
assert(contains([1, 2, 3], 2))
}assertEq(actual, expected) -- Equality
Checks that two values are equal. On failure, it shows both the actual and expected values:
test "equality assertions" {
assertEq(2 + 3, 5)
assertEq("hello" |> upper(), "HELLO")
assertEq([1, 2] |> len(), 2)
}assertNe(actual, expected) -- Inequality
Checks that two values are not equal:
test "inequality assertions" {
assertNe("hello", "world")
assertNe([], [1, 2, 3])
assertNe(Ok(1), Err("fail"))
}assertThrows(fn) -- Expects an Error
Passes if the function throws an exception. Useful for testing JavaScript interop or boundary validation:
test "invalid JSON throws" {
assertThrows(fn() {
JSON.parse("{broken")
})
}Snapshot Testing
For complex output that is tedious to assert field-by-field, Tova supports snapshot testing with assert_snapshot:
test "formatted report matches snapshot" {
report = generate_report(sample_data)
assertSnapshot(report)
}The first time a snapshot test runs, it saves the output as the "expected" snapshot. On subsequent runs, it compares the current output against the saved snapshot. If the output changes intentionally, update the snapshot:
tova test --update-snapshotsSnapshots are saved alongside your test files in a __snapshots__ directory. Commit them to version control so your team shares the same expectations.
When to Use Snapshots
Snapshots are ideal for formatted output, serialized data structures, or generated code. Avoid them for simple values where assertEq is clearer. Overusing snapshots leads to tests that nobody reads when they fail.
Running Tests
The tova test command finds and runs all test blocks in your project:
# Run all tests
tova test
# Run tests in a specific file
tova test math.tova
# Run tests matching a pattern
tova test --filter "divide"
# Run with verbose output (shows passing tests too)
tova test --verbose
# Update snapshots
tova test --update-snapshotsTest Output
When tests pass:
math.tova
[PASS] addition works correctly
[PASS] divide returns Ok for valid input
[PASS] divide returns Err when divisor is zero
3 passed, 0 failedWhen a test fails:
math.tova
[PASS] addition works correctly
[FAIL] divide returns Ok for valid input
assert_eq failed:
expected: 5
actual: 4
at math.tova:12:3
1 passed, 1 failedThe failure message tells you what was expected, what you got, and exactly where in the file the assertion lives.
Test Organization
Grouping by Feature
Organize tests into files that mirror your source structure:
my-project/
src/
math.tova
users.tova
parser.tova
tests/
math.test.tova
users.test.tova
parser.test.tovaWithin a test file, group related tests by placing them close together with consistent naming:
// math.test.tova
// --- Addition ---
test "add positive numbers" {
assertEq(calc_add(2, 3).unwrap(), 5)
}
test "add negative numbers" {
assertEq(calc_add(-2, -3).unwrap(), -5)
}
test "add zero" {
assertEq(calc_add(5, 0).unwrap(), 5)
}
// --- Division ---
test "divide valid inputs" {
assertEq(calc_divide(10, 2).unwrap(), 5)
}
test "divide by zero returns Err" {
assert(calc_divide(10, 0).isErr())
}Setup and Shared Helpers
When multiple tests need the same data, define helper functions and data at the top of the file:
// test helpers
fn make_test_users() {
[
{ name: "Alice", role: "admin", active: true },
{ name: "Bob", role: "editor", active: true },
{ name: "Charlie", role: "viewer", active: false }
]
}
fn make_test_config() {
{ max_retries: 3, timeout_ms: 5000, debug: false }
}
test "find active users" {
users = make_test_users()
active = users |> filter(fn(u) u.active)
assertEq(len(active), 2)
}
test "find admin users" {
users = make_test_users()
admins = users |> filter(fn(u) u.role == "admin")
assertEq(len(admins), 1)
assertEq(admins[0].name, "Alice")
}Testing Result and Option
Functions that return Result or Option need specific testing patterns. You want to verify both the success and failure paths.
Testing Result Functions
fn parse_port(text) {
n = toInt(text)
if n == null { return Err("Not a number: {text}") }
if n < 1 || n > 65535 { return Err("Port out of range: {n}") }
Ok(n)
}
// Test the happy path
test "parse_port accepts valid port" {
result = parse_port("8080")
assert(result.isOk())
assertEq(result.unwrap(), 8080)
}
// Test each error case
test "parse_port rejects non-numeric input" {
result = parse_port("abc")
assert(result.isErr())
}
test "parse_port rejects out-of-range port" {
result = parse_port("99999")
assert(result.isErr())
}
// Test error messages are helpful
test "parse_port error message includes input" {
result = parse_port("abc")
match result {
Err(msg) => assert(contains(msg, "abc"))
Ok(_) => assert(false) // Should never reach here
}
}Testing Option Functions
fn find_by_name(items, name_query) {
for item in items {
if item.name == name_query { return Some(item) }
}
None
}
test "find_by_name returns Some when found" {
items = [{ name: "Alice", id: 1 }, { name: "Bob", id: 2 }]
result = find_by_name(items, "Bob")
assert(result.isSome())
assertEq(result.unwrap().id, 2)
}
test "find_by_name returns None when not found" {
items = [{ name: "Alice", id: 1 }]
result = find_by_name(items, "Zoe")
assert(result.isNone())
}
test "find_by_name with unwrapOr provides default" {
items = []
result = find_by_name(items, "Anyone")
user = result.unwrapOr({ name: "Guest", id: 0 })
assertEq(user.name, "Guest")
}Testing flatMap Chains
When your code chains multiple Result operations, test each link in the chain independently, then test the full chain:
fn parse_and_validate(input) {
parse_port(input)
.flatMap(fn(port) {
if port < 1024 { Err("Privileged port: {port}") }
else { Ok(port) }
})
.map(fn(port) { port: port, secure: port == 443 || port == 8443 })
}
// Test individual steps
test "parse step rejects garbage" {
assert(parse_port("xyz").isErr())
}
// Test the full chain
test "parse_and_validate full success" {
result = parse_and_validate("8080")
assert(result.isOk())
assertEq(result.unwrap().port, 8080)
assertEq(result.unwrap().secure, false)
}
test "parse_and_validate rejects privileged port" {
result = parse_and_validate("80")
assert(result.isErr())
}Testing Async Code
Async test functions use async and await just like regular async code:
async fn fetch_user(id) {
await sleep(10)
if id <= 0 { Err("Invalid ID") }
else { Ok({ id: id, name: "User {id}" }) }
}
test "fetch_user returns user for valid id" {
result = await fetch_user(1)
assert(result.isOk())
assertEq(result.unwrap().name, "User 1")
}
test "fetch_user returns Err for invalid id" {
result = await fetch_user(-1)
assert(result.isErr())
}Testing Parallel Operations
test "parallel fetches all succeed" {
results = await Promise.all([
fetch_user(1),
fetch_user(2),
fetch_user(3)
])
assertEq(len(results), 3)
for result in results {
assert(result.isOk())
}
}
test "parallel fetch with one failure" {
results = await Promise.all([
fetch_user(1),
fetch_user(-1),
fetch_user(3)
])
assert(results[0].isOk())
assert(results[1].isErr())
assert(results[2].isOk())
}Timeouts in Async Tests
Async tests can hang if the awaited operation never completes. If your test runner supports it, set a timeout. As a defensive pattern, you can wrap the await in a Promise.race with a timer.
Rich Error Messages
One of Tova's standout features is its error diagnostics. When something goes wrong at compile time, Tova does not just say "error on line 12." It shows you exactly what happened, with source context, carets pointing to the problem, and a suggested fix.
Anatomy of a Tova Error
error: Type mismatch in function argument
--> calculator.tova:15:20
|
15 | result = calc_add("five", 3)
| ^^^^^^
|
= expected: Number
= got: String
= help: calc_add takes two numeric argumentsEvery error message has:
- Error type -- What category of problem it is
- Location -- File, line, and column
- Source context -- The actual code with carets pointing to the problem
- Details -- What was expected vs. what was found
- Help -- A suggestion for how to fix it
This is inspired by Rust and Elm's error messages. The goal is that you can fix the problem from the error message alone, without having to search the documentation.
Multi-line Errors
When an error spans multiple lines, the diagnostics highlight the full range:
error: Exhaustive match missing variant
--> parser.tova:28:3
|
28 | / match token {
29 | | Number(n) => handle_number(n)
30 | | String(s) => handle_string(s)
31 | | }
| |__^
|
= missing variants: Boolean, Null
= help: add a wildcard arm: _ => ...Analyzer Warnings
The Tova analyzer catches potential issues before your code runs. These are not errors -- your code will still compile -- but they often point to bugs.
Unused Variable Warnings
warning: Unused variable 'temp'
--> math.tova:8:3
|
8 | temp = compute(x)
| ^^^^
|
= help: if intentional, prefix with underscore: _tempThe analyzer only warns about unused variables inside function scopes, not at the module level. To suppress a warning, prefix the variable with _:
fn process(data) {
_unused = setup() // No warning: underscore prefix
transform(data)
}Exhaustive Match Warnings
When you match on a type with known variants, the analyzer checks that you handle every case:
type Shape {
Circle(Float)
Rectangle(Float, Float)
Triangle(Float, Float, Float)
}
fn area(shape) {
match shape {
Circle(r) => 3.14159 * r * r
Rectangle(w, h) => w * h
// Warning: missing variant Triangle
}
}Add the missing arm to silence the warning and handle all cases:
fn area(shape) {
match shape {
Circle(r) => 3.14159 * r * r
Rectangle(w, h) => w * h
Triangle(a, b, c) => {
// Heron's formula
s = (a + b + c) / 2
sqrt(s * (s - a) * (s - b) * (s - c))
}
}
}Type Checking Warnings
The analyzer catches undefined identifiers and suspicious operations:
warning: Undefined identifier 'userr'
--> app.tova:12:10
|
12 | full_name = userr.name
| ^^^^^
|
= help: did you mean 'user'?Treat Warnings as Errors
In production code, treat every warning as a bug waiting to happen. The analyzer found it before your users did. Fix warnings immediately rather than accumulating them.
Debugging Tips
When tests fail or behavior is unexpected, here are practical strategies.
Print Debugging
The simplest and often most effective approach. Add print() calls to trace values through your code:
fn process_data(items) {
print("Input: {len(items)} items")
filtered = items |> filter(fn(x) x.active)
print("After filter: {len(filtered)} items")
transformed = filtered |> map(fn(x) x.value * 2)
print("After transform: {transformed}")
total_value = transformed |> sum()
print("Total: {total_value}")
total_value
}Stepping Through Pipelines
When a pipe chain produces unexpected results, break it apart:
// Instead of debugging this all at once:
result = data
|> filter(fn(x) x.score > 80)
|> map(fn(x) x.name)
|> sorted()
|> take(5)
// Break it down:
step1 = data |> filter(fn(x) x.score > 80)
print("After filter: {step1}")
step2 = step1 |> map(fn(x) x.name)
print("After map: {step2}")
step3 = step2 |> sorted()
print("After sort: {step3}")
result = step3 |> take(5)
print("Final: {result}")Isolating Failures
When a test fails in a chain of operations, write a test for each step:
// Original failing test
test "full pipeline produces correct output" {
result = raw_data |> parse_input() |> validate_data() |> transform_data()
assertEq(result, expected)
}
// Break into smaller tests to find the bug
test "parse step works" {
parsed = raw_data |> parse_input()
assertEq(parsed, expected_parsed)
}
test "validate step works" {
validated = known_good_parsed |> validate_data()
assertEq(validated, expected_validated)
}
test "transform step works" {
transformed = known_good_validated |> transform_data()
assertEq(transformed, expected)
}Testing Edge Cases
The bugs are always in the edges. Test these systematically:
// Empty input
test "handles empty array" {
assertEq(process([]), [])
}
// Single element
test "handles single element" {
assertEq(process([42]), [42])
}
// Negative numbers
test "handles negative values" {
result = calc_add(-5, -3)
assertEq(result.unwrap(), -8)
}
// Zero
test "handles zero" {
assertEq(calc_multiply(0, 1000000).unwrap(), 0)
}
// Large values
test "handles large numbers" {
result = calc_add(999999999, 1)
assert(result.isOk())
}
// Special strings
test "handles empty string" {
result = parse_port("")
assert(result.isErr())
}Benchmarking
When you need to know how fast your code is, Tova supports bench blocks for performance measurement:
bench "sum with loop" {
var total = 0
for i in range(10000) {
total += i
}
}
bench "sum with pipe" {
range(10000) |> sum()
}
bench "sum with formula" {
n = 10000
n * (n - 1) / 2
}Run benchmarks with:
tova test --benchManual Benchmarking
For more control, write your own benchmark harness:
fn run_benchmark(label, iterations, f) {
start_time = Date.now()
for _ in range(iterations) {
f()
}
elapsed = Date.now() - start_time
per_op = elapsed / toFloat(iterations)
print("{label}: {elapsed}ms total ({per_op}ms/op)")
}
// Compare approaches
run_benchmark("loop sum", 1000, fn() {
var total = 0
for i in range(10000) { total += i }
})
run_benchmark("pipe sum", 1000, fn() {
range(10000) |> sum()
})
run_benchmark("formula sum", 1000, fn() {
n = 10000
n * (n - 1) / 2
})Benchmark Pitfalls
- Run benchmarks multiple times. A single run can be misleading due to JIT warmup and GC pauses.
- Benchmark with realistic data sizes, not toy inputs.
- Measure the bottleneck. If your code spends 95% of its time in I/O, optimizing the 5% CPU work will not help.
- Do not optimize until you have measured. Write correct code first.
Using the Built-in Benchmark Suite
For comprehensive performance testing, Tova includes a benchmark runner:
cd benchmarks
./run_benchmarks.sh # Full suite: Tova vs Go vs Python
./run_benchmarks.sh --tova-only # Just Tova
./run_benchmarks.sh --quick # Fast mode, fewer iterationsThe suite covers 14 benchmarks across categories like sorting, recursion, numeric computation, and data processing.
Project: Calculator Test Suite
Let us put it all together. Here is a complete test suite for a calculator module that covers basic operations, error handling, edge cases, and expression parsing.
First, the calculator module:
// ===== The Calculator Module =====
fn calc_add(a, b) { Ok(a + b) }
fn calc_subtract(a, b) { Ok(a - b) }
fn calc_multiply(a, b) { Ok(a * b) }
fn calc_divide(a, b) {
if b == 0 { Err("Division by zero") }
else { Ok(a / b) }
}
fn calc_power(base, exp) {
if exp < 0 { Err("Negative exponent not supported") }
else { Ok(pow(base, exp)) }
}
fn calc_sqrt_safe(n) {
if n < 0 { Err("Cannot take square root of negative number") }
else { Ok(sqrt(n)) }
}
fn parse_expression(expression) {
parts = split(expression, " ")
if len(parts) != 3 {
return Err("Invalid expression: expected 'a op b'")
}
a = toFloat(parts[0])
op = parts[1]
b = toFloat(parts[2])
match op {
"+" => calc_add(a, b)
"-" => calc_subtract(a, b)
"*" => calc_multiply(a, b)
"/" => calc_divide(a, b)
_ => Err("Unknown operator: {op}")
}
}Now the tests, organized by feature:
// --- Basic Arithmetic ---
test "add returns correct sum" {
assertEq(calc_add(2, 3).unwrap(), 5)
assertEq(calc_add(-1, 1).unwrap(), 0)
assertEq(calc_add(0, 0).unwrap(), 0)
}
test "subtract returns correct difference" {
assertEq(calc_subtract(10, 3).unwrap(), 7)
assertEq(calc_subtract(3, 10).unwrap(), -7)
}
test "multiply returns correct product" {
assertEq(calc_multiply(4, 5).unwrap(), 20)
assertEq(calc_multiply(-2, 3).unwrap(), -6)
assertEq(calc_multiply(0, 100).unwrap(), 0)
}
// --- Error Handling ---
test "divide by zero returns Err" {
result = calc_divide(10, 0)
assert(result.isErr())
match result {
Err(msg) => assert(contains(msg, "zero"))
_ => assert(false)
}
}
test "negative exponent returns Err" {
assert(calc_power(2, -1).isErr())
}
test "square root of negative returns Err" {
assert(calc_sqrt_safe(-4).isErr())
}
// --- Expression Parser ---
test "parse simple expressions" {
assertEq(parse_expression("3 + 4").unwrap(), 7)
assertEq(parse_expression("10 / 2").unwrap(), 5)
assertEq(parse_expression("6 * 7").unwrap(), 42)
}
test "parse unknown operator returns Err" {
result = parse_expression("3 ^ 4")
assert(result.isErr())
}
test "parse malformed expression returns Err" {
assert(parse_expression("3+4").isErr())
assert(parse_expression("").isErr())
assert(parse_expression("1 + 2 + 3").isErr())
}
// --- Edge Cases ---
test "floating point division" {
val = calc_divide(1, 3).unwrap()
assert(val > 0.333 && val < 0.334)
}
test "large number arithmetic" {
assert(calc_multiply(999999, 999999).isOk())
}This test suite demonstrates every technique from the chapter: descriptive names, testing both success and failure paths, checking error messages, verifying edge cases, and organized grouping.
Try "Calculator Tests" in PlaygroundExercises
Exercise 17.1: Write a test suite for a stack module that implements push(stack, value), pop(stack) (returns Option), peek(stack) (returns Option), and isEmpty(stack). Test the empty stack case, push-then-pop ordering, and peek not removing elements.
Exercise 17.2: Write a function validate_email(text) that returns Result. It should check: non-empty, contains exactly one @, has text before and after @, and the domain contains a dot. Write at least 8 tests covering valid emails, missing @, multiple @, empty local part, and empty domain.
Exercise 17.3: Write an async function retry(f, max_attempts) that calls f() up to max_attempts times, returning the first Ok result or the last Err. Write tests for: succeeds on first try, succeeds on third try (use a mutable counter), and fails after all attempts exhausted.
Challenge
Build a test framework mini-clone. Implement:
- A
suite(name, tests)function that takes a name and an array of test functions - A
run_suite(s)function that runs each test and collects results - An
expect(value)function that returns an object with.toBe(expected),.toContain(item),.toBeGreaterThan(n), and.toThrow()methods - A
report(results)function that prints a formatted summary with pass/fail counts and failure details - Support for
before_eachandafter_eachsetup/teardown hooks
Run it against your calculator module and compare the output to Tova's built-in test runner.