Nim Testing Guide: unittest2, Testament, and Doctests

Nim Testing Guide: unittest2, Testament, and Doctests

Nim has three testing approaches: the standard library unittest module (simple but limited), unittest2 (modern replacement with better output), and testament (Nim's own compiler test tool used for its standard library). For application code, unittest2 is the recommended choice. Nim also supports doctests through the runnableExamples pragma.

Key Takeaways

Use unittest2 instead of the standard unittest. The stdlib unittest has a confusing API and poor failure output. unittest2 is a drop-in replacement with cleaner output and better macro-based assertions.

suite and test macros organize tests. Tests are organized with suite "name": containing test "name": blocks. Setup/teardown use setup: and teardown: inside suites.

check is the primary assertion macro. Unlike assertion functions, check generates a failure message showing the expression that failed: check x == 5 prints "5 == 5 is false".

runnableExamples adds doctests to proc documentation. Code in runnableExamples blocks is compiled and run as part of nim doc generation. It's Nim's equivalent of Python doctests.

Testament is the Nim compiler's own test tool — avoid it for application tests. Testament is powerful but designed for compiler testing scenarios. Use unittest2 for application and library code.

The Nim Testing Landscape

Nim offers multiple testing approaches:

  • unittest (stdlib): included in Nim, simple but limited
  • unittest2: community library, better output, drop-in replacement
  • testament: Nim compiler's internal test tool, powerful but complex
  • runnableExamples: built into Nim's doc system, tests in docstrings

For new projects, use unittest2 for test suites and runnableExamples for documented library functions.

unittest2 Setup

nimble install unittest2

Or add to your .nimble file:

# myproject.nimble
requires "nim >= 2.0.0"
requires "unittest2 >= 0.2.0"

Writing Tests with unittest2

# tests/test_math.nim
import unittest2
import ../src/math_utils

suite "Basic arithmetic":
  test "addition":
    check add(2, 3) == 5
    check add(-1, 1) == 0

  test "subtraction":
    check subtract(10, 4) == 6

  test "factorial":
    check factorial(0) == 1
    check factorial(5) == 120

  test "factorial does not accept negative":
    expect ValueError:
      discard factorial(-1)

The source file:

# src/math_utils.nim
proc add*(a, b: int): int = a + b

proc subtract*(a, b: int): int = a - b

proc factorial*(n: int): int =
  if n < 0:
    raise newException(ValueError, "factorial requires non-negative input")
  if n <= 1: return 1
  result = n * factorial(n - 1)

Run with:

nimble test
<span class="hljs-comment"># or
nim c -r tests/test_math.nim

The check Macro

check is unittest2's primary assertion. It evaluates a boolean expression and prints the failing sub-expression:

suite "check macro examples":
  test "value comparison":
    let x = 42
    check x == 42          # passes
    # check x == 0         # fails: "x == 0 is false, x = 42"

  test "string comparison":
    let name = "Alice"
    check name == "Alice"
    check name.len == 5

  test "multiple checks in one test":
    let items = @[1, 2, 3, 4, 5]
    check items.len == 5
    check items[0] == 1
    check 3 in items
    check 99 notin items

Suite Setup and Teardown

suite "Database operations":
  var db: DatabaseConnection

  setup:
    # Runs before each test
    db = newConnection("test_db")
    db.createTables()

  teardown:
    # Runs after each test
    db.dropTables()
    db.close()

  test "insert and retrieve":
    db.insert("users", {"name": "Alice", "age": 30})
    let user = db.findByName("users", "Alice")
    check user.isSome()
    check user.get()["name"] == "Alice"

  test "delete removes record":
    db.insert("users", {"name": "Bob", "age": 25})
    db.delete("users", "name", "Bob")
    let result = db.findByName("users", "Bob")
    check result.isNone()

Exception Testing

Use expect to verify that code raises a specific exception:

suite "Error handling":
  test "parseint raises for invalid input":
    expect ValueError:
      discard parseInt("not-a-number")

  test "index out of bounds raises":
    let arr = [1, 2, 3]
    expect IndexDefect:
      discard arr[10]

  test "custom exception raised":
    expect InsufficientFundsError:
      withdraw(account, 9999.0)

Parametric Tests

Nim doesn't have built-in parametric tests, but you can use loops or templates:

suite "Parametric tests via loop":
  let testCases = [
    ("SAVE10", 10.0),
    ("SAVE20", 20.0),
    ("INVALID", 0.0),
  ]

  for (code, expected) in testCases:
    test "discount for code " & code:
      let discount = applyDiscount(100.0, code)
      check abs(discount - expected) < 0.001

runnableExamples (Doctests)

Add runnableExamples to proc documentation for executable examples:

proc parseInt*(s: string): int =
  ## Parses a string as an integer.
  ##
  runnableExamples:
    doAssert parseInt("42") == 42
    doAssert parseInt("-7") == -7

  # Implementation...
  result = 0
  for c in s:
    if c notin '0'..'9' and not (c == '-' and result == 0):
      raise newException(ValueError, "Invalid integer: " & s)
    if c != '-':
      result = result * 10 + (ord(c) - ord('0'))

Run doctests:

nim doc --project src/mymodule.nim
# Runs all runnableExamples during doc generation

runnableExamples blocks must use doAssert (not assert) to produce clear error messages.

Mocking in Nim

Nim doesn't have a mature mock library. Use dependency injection and procedure variables:

# src/notifier.nim
type
  SendProc* = proc(to: string, msg: string): bool

proc notifyUser*(userId: string, message: string, send: SendProc): bool =
  let email = getUserEmail(userId)
  if email.len == 0: return false
  return send(email, message)
# tests/test_notifier.nim
import unittest2
import ../src/notifier

suite "Notification":
  var capturedTo: string
  var capturedMsg: string
  var sendCallCount: int

  proc mockSend(to, msg: string): bool =
    capturedTo = to
    capturedMsg = msg
    inc sendCallCount
    return true

  setup:
    capturedTo = ""
    capturedMsg = ""
    sendCallCount = 0

  test "notifies user with correct email":
    discard notifyUser("u1", "Hello", mockSend)
    check capturedTo == "u1@example.com"

  test "sends correct message":
    discard notifyUser("u1", "test message", mockSend)
    check capturedMsg == "test message"

  test "returns false for unknown user":
    let result = notifyUser("unknown", "msg", mockSend)
    check result == false
    check sendCallCount == 0

Running Tests with Nimble

Structure your project for Nimble test discovery:

myproject/
├── myproject.nimble
├── src/
│   └── myproject.nim
└── tests/
    ├── test_math.nim
    └── test_parser.nim

In your .nimble file:

# myproject.nimble
task test, "Run all tests":
  exec "nim c -r tests/test_math.nim"
  exec "nim c -r tests/test_parser.nim"

Or use a test runner script:

# Run all test files
<span class="hljs-keyword">for f <span class="hljs-keyword">in tests/test_*.nim; <span class="hljs-keyword">do
  nim c -r <span class="hljs-string">"$f"
<span class="hljs-keyword">done

CI Integration

# .github/workflows/test.yml
name: Nim Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Nim
        uses: jiro4989/setup-nim-action@v1
        with:
          nim-version: 2.0.2

      - name: Install dependencies
        run: nimble install -y

      - name: Run tests
        run: nimble test

unittest vs. unittest2 vs. Testament

unittest (stdlib) unittest2 testament
Installation Built-in nimble install Nim compiler only
check macro No (uses check proc) Yes (expression-aware) No
Suite/setup Basic Clean Complex
Failure output Poor Good Excellent
Use case Quick scripts Application/library tests Compiler tests
Recommended for? Not recommended ✓ Application code Compiler/stdlib development

Summary

Nim testing with unittest2:

  • suite "name": / test "name": organizes tests
  • check expression is the primary assertion — shows failing sub-expressions
  • setup: and teardown: handle per-test lifecycle
  • expect ExceptionType: verifies exception raising
  • runnableExamples blocks add executable doctests to procedures
  • Mocking uses dependency injection with proc variables

Install unittest2 and start with your pure computation functions — Nim's functional style makes them easy to test in isolation.

Read more