NanoLang's testing system is intentionally constrained: shadow tests are mandatory, they run at compile time, and there is no way to skip them. This removes a whole class of decisions ("should I test this?") but leaves important choices about *how* to test well. This section covers those choices.
Shadow Tests vs Property Tests
Use Shadow Tests For...
**Specific, known correct values.** Shadow tests excel at pinning exact behavior:
fn celsius_to_fahrenheit(c: float) -> float {
return (+ (* c 1.8) 32.0)
}
shadow celsius_to_fahrenheit {
assert (== (celsius_to_fahrenheit 0.0) 32.0) # water freezes
assert (== (celsius_to_fahrenheit 100.0) 212.0) # water boils
assert (== (celsius_to_fahrenheit -40.0) -40.0) # scales cross
}
**Documenting behavior at boundaries.** Boundaries are where bugs live. Shadow tests make them explicit:
fn clamp(v: int, lo: int, hi: int) -> int {
if (< v lo) { return lo }
if (> v hi) { return hi }
return v
}
shadow clamp {
assert (== (clamp 5 0 10) 5) # in range: unchanged
assert (== (clamp -1 0 10) 0) # just below min: returns min
assert (== (clamp 11 0 10) 10) # just above max: returns max
assert (== (clamp 0 0 10) 0) # at min: unchanged
assert (== (clamp 10 0 10) 10) # at max: unchanged
}
**Testing side-effect-free void functions.** When a void function does something observable (like print to stdout), call it in the shadow block to at least verify it doesn't crash:
fn print_greeting(name: string) -> void {
(println (+ "Hello, " name))
}
shadow print_greeting {
(print_greeting "World")
(print_greeting "") # edge case: empty string
}
**Regression tests.** When you fix a bug, add a shadow test for the exact input that exposed it. That way the bug can never come back silently.
Use Property Tests For...
**Algebraic or mathematical laws.** These are properties that hold for *all* inputs, not just the ones you happen to think of:
from "modules/proptest/proptest.nano" import forall_int_pair, int_pair, int_range,
prop_pass, prop_fail, report_passed,
PropertyReport
fn prop_max_commutative(a: int, b: int) -> string {
let max_ab: int = (cond ((> a b) a) (else b))
let max_ba: int = (cond ((> b a) b) (else a))
if (== max_ab max_ba) {
return (prop_pass)
} else {
return (prop_fail "max not commutative")
}
}
shadow prop_max_commutative {
let gen: IntPairGenerator = (int_pair (int_range -1000 1000) (int_range -1000 1000))
let r: PropertyReport = (forall_int_pair "max_commutative" gen prop_max_commutative)
assert (report_passed r)
}
**Invariants that should always hold.** A parser should always return a result of the same length as the input. A serialiser/deserialiser should round-trip.
**Finding edge cases you didn't think of.** If you're not sure what all the edge cases are, let proptest explore the input space:
fn prop_string_length_nonneg(x: int) -> string {
let s: string = (int_to_string x)
if (>= (str_length s) 1) {
return (prop_pass)
} else {
return (prop_fail "empty int_to_string result")
}
}
shadow prop_string_length_nonneg {
let report: PropertyReport = (forall_int "int_to_string_nonempty"
(int_range -100000 100000)
prop_string_length_nonneg)
assert (report_passed report)
}
**Both approaches are complementary.** A typical well-tested function has both: shadow tests for specific values and property tests for invariants.
Test Organization
One Shadow Block Per Function, Always
Every function needs exactly one shadow block immediately following its definition. Do not put shadow tests far from the function — the colocation is the point:
# Good: test immediately follows function
fn is_palindrome(s: string) -> bool {
let len: int = (str_length s)
if (<= len 1) { return true }
let mut i: int = 0
let mut ok: bool = true
while (and ok (< i (/ len 2))) {
let left: int = (char_at s i)
let right: int = (char_at s (- (- len i) 1))
if (not (== left right)) { set ok false } else { (print "") }
set i (+ i 1)
}
return ok
}
shadow is_palindrome {
assert (is_palindrome "racecar")
assert (is_palindrome "a")
assert (is_palindrome "")
assert (not (is_palindrome "hello"))
assert (is_palindrome "abba")
}
Property Tests Belong in Shadow Blocks Too
Run forall_* inside a shadow block for the property function:
fn prop_palindrome_reverse_invariant(x: int) -> string {
# A reversed palindrome is still a palindrome
# (This is a contrived property but demonstrates the pattern)
return (prop_pass)
}
shadow prop_palindrome_reverse_invariant {
let r: PropertyReport = (forall_int "palindrome_reverse"
(int_range 0 100)
prop_palindrome_reverse_invariant)
assert (report_passed r)
}
Group Related Functions Together
NanoLang files have no class or namespace scoping, so use comments to group related functions:
# ============================================================
# String utility functions
# ============================================================
fn str_starts_with(s: string, prefix: string) -> bool {
let plen: int = (str_length prefix)
if (> plen (str_length s)) { return false }
return (== (str_substring s 0 plen) prefix)
}
shadow str_starts_with {
assert (str_starts_with "hello world" "hello")
assert (not (str_starts_with "hello" "world"))
assert (str_starts_with "x" "")
assert (str_starts_with "" "")
}
fn str_ends_with(s: string, suffix: string) -> bool {
let slen: int = (str_length s)
let suflen: int = (str_length suffix)
if (> suflen slen) { return false }
return (== (str_substring s (- slen suflen) suflen) suffix)
}
shadow str_ends_with {
assert (str_ends_with "hello world" "world")
assert (not (str_ends_with "hello" "world"))
assert (str_ends_with "x" "")
assert (str_ends_with "" "")
}
Naming Conventions
Shadow Blocks
Shadow blocks must be named after the function they test — this is enforced by the compiler. There is no choice here.
Property Functions
Name property functions with a prop_ prefix followed by the invariant being described:
fn prop_abs_nonneg(x: int) -> string { ... } # abs returns non-negative
fn prop_length_preserving(x: int) -> string { ... } # operation preserves length
fn prop_commutative_add(a: int, b: int) -> string { ... } # addition commutes
fn prop_sort_idempotent(arr: array<int>) -> string { ... } # sorting twice = sorting once
This naming convention makes it immediately clear what invariant each property checks.
What to Test
Always Test
- **Every code path** — every
if/elsebranch should be exercised by at least one assertion - **Boundary values** — 0, -1, max value, empty string, empty array
- **The happy path** — at least one "normal" correct input
- **Error/edge inputs** — what happens with zero, negative, or empty inputs
Prioritise Testing
- **Pure functions** — functions with no side effects are the easiest to test and the most valuable
- **Functions with complex conditional logic** — more branches = more opportunities for bugs
- **Functions that encode domain rules** — business logic bugs are expensive
Accept Shallow Tests For
- **Simple pass-through functions** — if a function just calls another function with the same args, a shallow test is fine
- **Void functions with only side effects** — calling the function to verify it doesn't crash is sufficient
- **
mainfunctions** — typicallyshadow main { assert true }is acceptable
Test Isolation
Shadow Tests Run in Declaration Order
NanoLang shadow tests do not run in a sandbox — they share the program's address space and any mutable global state. If your function uses mutable globals, your shadow test must set them to a known state before each assertion:
let mut counter: int = 0
fn increment() -> int {
set counter (+ counter 1)
return counter
}
shadow increment {
set counter 0 # reset state before testing
assert (== (increment) 1)
assert (== (increment) 2)
set counter 100
assert (== (increment) 101)
}
Avoid Testing Two Functions Through One Shadow Block
Each shadow block should test *its* function, not the function plus other functions it calls. If f calls g, test g separately in g's shadow block. Trust that g works when testing f.
This is already enforced by the language structure (each shadow block is named after a specific function), but it bears repeating as a design principle.
Handling Side Effects
Functions That Write Files or Network
Functions that perform I/O should be wrapped so the core logic can be tested without side effects:
# Hard to test:
fn save_user_data(filename: string, name: string, age: int) -> void {
let content: string = (+ name (+ "," (int_to_string age)))
(file_write filename content)
}
# Better: separate serialization from I/O
fn format_user_data(name: string, age: int) -> string {
return (+ name (+ "," (int_to_string age)))
}
shadow format_user_data {
assert (== (format_user_data "Alice" 30) "Alice,30")
assert (== (format_user_data "" 0) ",0")
}
fn save_user_data(filename: string, name: string, age: int) -> void {
let content: string = (format_user_data name age)
(file_write filename content)
}
shadow save_user_data {
# Cannot easily test file I/O in shadow; just ensure it compiles and doesn't crash
# on a temp path
(save_user_data "/tmp/test_user.txt" "Test" 1)
}
Functions That Use Time
If a function's behavior depends on the current time, make time an explicit parameter so tests can provide a fixed value:
# Hard to test predictably:
fn is_business_hours() -> bool { ... }
# Testable:
fn is_business_hours_at(hour: int) -> bool {
return (and (>= hour 9) (< hour 17))
}
shadow is_business_hours_at {
assert (is_business_hours_at 9)
assert (is_business_hours_at 16)
assert (not (is_business_hours_at 8))
assert (not (is_business_hours_at 17))
}
Common Patterns
Testing with a Helper
When multiple shadow blocks need the same setup, extract it into a helper function:
fn make_test_vector(x: float, y: float) -> Vector2D {
return (vec_new x y)
}
shadow make_test_vector {
let v: Vector2D = (make_test_vector 3.0 4.0)
assert (== v.x 3.0)
assert (== v.y 4.0)
}
fn vec_length(v: Vector2D) -> float { ... }
shadow vec_length {
let v: Vector2D = (make_test_vector 3.0 4.0)
assert (== (vec_length v) 5.0)
}
Testing for "No Crash" on Bad Input
For functions that should handle bad input gracefully rather than crash:
fn safe_head(arr: array<int>) -> int {
if (== (array_length arr) 0) { return -1 }
return (array_get arr 0)
}
shadow safe_head {
assert (== (safe_head []) -1) # empty: safe fallback
assert (== (safe_head [42]) 42) # single element
assert (== (safe_head [1, 2, 3]) 1) # multiple elements
}
Floating-Point Comparisons
Never use == for floating-point results that involve computation. Instead, check that the result is within a small epsilon:
fn approx_equal(a: float, b: float, eps: float) -> bool {
let diff: float = (- a b)
let abs_diff: float = (cond ((< diff 0.0) (- 0.0 diff)) (else diff))
return (< abs_diff eps)
}
shadow approx_equal {
assert (approx_equal 1.0 1.0 0.001)
assert (approx_equal 1.0001 1.0 0.001)
assert (not (approx_equal 1.1 1.0 0.001))
}
fn vec_length_example(v: Vector2D) -> float {
return (sqrt (+ (* v.x v.x) (* v.y v.y)))
}
shadow vec_length_example {
let v: Vector2D = (vec_new 3.0 4.0)
assert (approx_equal (vec_length_example v) 5.0 0.0001)
}
Summary Checklist
Before committing a function, verify:
- [ ] Shadow block exists immediately after the function
- [ ] Shadow block tests the happy path (at least one normal input)
- [ ] Shadow block tests boundary values (0, empty, negative, max)
- [ ] Shadow block exercises both branches of every
if/else - [ ] Functions with complex domain logic have a
prop_*property test - [ ] Mutable global state is reset at the start of shadow blocks that use it
- [ ] Floating-point comparisons use epsilon checks, not
== - [ ] Side-effecting logic is separated from pure logic so the pure part can be tested
- [ ] Property function names start with
prop_and describe the invariant
---
**Previous:** 20.2 coverage
**Next:** Chapter 21: Configuration