Testing & Debugging
std/unittest gives you suites, tests and operators that print both operands when they fail. Debugging then starts from the same place the compiler already documents — stack traces, line information and assertions — rather than from a debugger you attach blindly.
Writing Tests
Tests live in their own files next to the code they exercise (tests/tfoo.nim is the community convention). You import the module under test and std/unittest, then describe behaviour in nested suite and test blocks.
Suites, Tests and Checks
Inside a test block, check verifies a condition and prints the operands when it is false, which removes most of the guesswork from a failure message. require does the same but aborts the whole test on the first failure, which is what you want when later assertions depend on earlier ones.
import std/unittest
proc add(a, b: int): int = a + b
proc divides(a, b: int): bool =
## A guard is part of the contract: dividing by zero is a programming error,
## so it must raise rather than return a meaningless 'false'.
if b == 0: raise newException(ValueError, "division by zero")
a mod b == 0
suite "add":
test "adds two positive numbers":
check add(2, 3) == 5 # failure prints: add(2, 3) == 5 -> false
test "is commutative":
let
a = 20
b = 12
# Several checks in one test: the first failure still reports every operand.
check add(a, b) == add(b, a)
check add(a, -a) == 0
suite "divides":
test "rejects a zero divisor with an exception":
# 'expect' asserts that the body raises the named exception. Testing the
# failure path is as important as testing the happy path.
expect ValueError:
discard divides(10, 0)
test "handles the happy path":
require divides(10, 5) # 'require': stop this test if it is false
check divides(9, 3)
check not divides(9, 2)
Mesages, Setup and Teardown
When an expression is not self-explanatory, pass a message to check; when several tests need the same expensive object, build it in setup and release it in teardown. Both run around every test in the enclosing scope, which is what keeps tests independent.
import std/[unittest, tables, strutils]
suite "word counter":
var words: Table[string, int] # shared per-suite state
setup:
# Runs before each test: every test starts from the same, known state.
words = initTable[string, int]()
teardown:
# Runs after each test, even when the test failed, so nothing leaks.
words.clear()
test "counts repetitions":
for word in "the quick the lazy the".splitWhitespace():
words.mgetOrPut(word, 0) += 1
check words["the"] == 3
check words.len == 3
check words.hasKey("quick"), "the token 'quick' must have been counted"
test "starts empty again":
# If 'teardown' did not clear the table, this check would see 3 entries.
check words.len == 0
Running and Organizing Tests
A test suite is an ordinary Nim program, so running it needs no framework-specific runner: compile it and execute it. Nimble adds the convention that makes this repeatable across a project.
From Compilation to Report
nim c -r tests/tall.nim # build the suite and run it at once
nim c -r -d:release tests/tall.nim # run the same suite optimized
nim c -r --threads:on tests/tthreads.nim
nimble test # runs the package's 'test' task
The runner groups output by suite, marks each test [OK] or [FAILED], and prints a summary at the end. A failing check shows the expression, both operands and the source location:
[Suite] word counter
[OK] counts repetitions
[FAILED] starts empty again
Error: unhandled exception: tests/tcounter.nim(24, 11): words.len == 0 [AssertionDefect]
Testing a release build matters more in Nim than in languages whose optimizations are cosmetic: -d:release turns stack traces off and -d:danger turns runtime checks off, so a suite that only ever runs in debug mode never exercises the code you ship. In a Nimble package the test task usually runs the debug build plus a release pass.
Hermetic Tests with Temporary State
A test that depends on a file left behind by another test is a test that fails only on somebody else's machine. Build a fresh sandbox per test and delete it in teardown, using std/tempfiles.
import std/[unittest, os, tempfiles, strutils]
proc writeReport(dir: string): string =
## The routine under test does real file I/O, so its tests need a real
## directory — but a private one, created and destroyed by the suite.
let path = dir / "report.txt"
writeFile(path, "rows=3\n")
readFile(path)
suite "report writing":
var sandbox: string
setup:
sandbox = createTempDir("nimtest_", "") # unique directory per test
teardown:
removeDir(sandbox) # even a failed test cleans up
test "writes and reads back a report":
let text = writeReport(sandbox)
check text.contains("rows=3")
check fileExists(sandbox / "report.txt")
test "starts from an empty directory":
# A new sandbox was created for this test, so nothing leaked from the first.
check not fileExists(sandbox / "report.txt")
Two rules keep a suite trustworthy. Tests must not depend on execution order: setup builds whatever a test needs and teardown removes it. And a test must be deterministic — no sleeps used to "wait for" something, no reliance on the current time or on network access. When a dependency is genuinely external, inject it as a parameter so the test can pass a stub instead.
Debugging
Debugging in Nim starts with what the compiler already emits. In the default debug build every frame carries a stack trace with file and line, and the runtime checks that turn undefined behaviour into a message are all enabled — the two reasons -d:danger must not be your development build.
Stack Traces and Assertions
proc divider(a, b: int): int =
## 'doAssert' is never removed by switching build modes: it documents an
## invariant that must hold for the program to make sense at all. Use it for
## preconditions with no sensible recovery path.
doAssert b != 0, "divider called with a zero divisor"
a div b
proc safeDivide(a, b: int): int =
## Ordinary 'assert' is compiled out in release and danger builds; use it for
## internal consistency checks that are too costly to keep in production.
assert a < 1_000_000
if b == 0:
# A recoverable condition is an exception, and it carries its own message.
raise newException(ValueError, "cannot divide " & $a & " by zero")
a div b
echo divider(9, 3) # 3
echo safeDivide(9, 0) # ValueError: cannot divide 9 by zero
The distinction between the two kinds of failure is worth internalising: a defect (index out of bounds, nil dereference, failed assertion) means the program itself is wrong and, in a debug build, terminates with a stack trace; an exception means the operation cannot be completed and is caught with try/except. Raising the wrong kind — catching a defect, or letting a recoverable error crash the process — is a design bug, not a style preference.
# Capture the trace of a live exception to log it, instead of only printing it.
proc inspect(): string =
try:
raise newException(IOError, "disk is full")
except IOError as error:
# 'getStackTrace' returns the captured frames as text; the default handler
# prints the same information, but a log file wants it explicitly.
result = error.msg & "\n" & getStackTrace(error)
echo inspect().splitLines()[0] # disk is full
Tests as a Release Gate
A suite that only runs on a developer's machine is documentation, not a gate. What makes it a gate is an exit status the pipeline can read, and a set of configurations that match what you ship.
Exit Status and Output
# Non-zero exit status stops a CI step: no test, no deploy.
nim c -r tests/tall.nim && nim c -r -d:release tests/tall.nim
The unittest runner exits with a non-zero status as soon as any test fails, which is all a CI step needs. Two habits make the report readable in a pipeline log: keep one suite per file so a failure names the area, and give every test a sentence that reads as a claim ("rejects an empty name") rather than a label ("test 3").
The Configurations Worth Testing
| Configuration | Why it is tested |
|---|---|
nim c -r tests/tall.nim | Debug build: traces and all runtime checks on |
nim c -r -d:release | The build you ship; catches assertions and trace assumptions |
nim c -r --threads:on | Channel and thread code is compiled in only with threads enabled |
nim c -r --mm:arc | Confirms no reliance on the tracing collector in a release binary |
Debug builds catch more, release builds catch what users will see: run both, and add a threads pass whenever the program spawns one. A test suite is also the cheapest place to try a change of memory management strategy, because it is the one program in the repository that exercises every routine.
The unittest documentation lists the remaining forms — expect for testing exceptions, require for aborting a test early, and per-suite setup/teardown — and the metaprogramming tutorial explains why check can print your expression as text.