Testing & Debugging

A test suite is the executable specification of your module, and in Nim it is also just code: 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

ConfigurationWhy it is tested
nim c -r tests/tall.nimDebug build: traces and all runtime checks on
nim c -r -d:releaseThe build you ship; catches assertions and trace assumptions
nim c -r --threads:onChannel and thread code is compiled in only with threads enabled
nim c -r --mm:arcConfirms 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.