Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Quickstart

unittest2 is a Nim module for writing automated tests.

It is mostly compatible with Nim's built-in unittest, while adding better output, flexible reporting, and advanced execution features like test discovery.

Installation

$ nimble install -y unittest2

Add unittest2 to your .nimble file:

requires "unittest2"

First Test File

unittest2 follows this test structure:

  • Module. A .nim file that has an import unittest2 in it, usually sits in tests directory of your project, and is named starting with a t by convention, e.g. tmath.nim. A module holds the tests that cover a particular topic or a module in your project.
  • Suite. Defined with suite block, it's a group of related tests. In he tmath.nim example, you could have a suite like "Overflow checks" and "Basic arithmetic."
  • Test case. A single atomic check under a test block. In tmath.nim suite "Basic arithmetic," you could have tests like "Addition"and "Subtraction."

Aside from test, you can use setup and teardown blocks inside suite.

setup block defines the code that runs before each test.

teardown block defines the code that runs after each test.

Create test.nim:

import unittest2

# A suite groups related tests and can share common setup/teardown code.
suite "math":
  setup:
    # Code inside setup runs before every single test in this suite.
    discard

  test "2 + 2 == 4":
    # 'check' verifies that a condition is true. 
    # If it fails, the test is marked as FAILED and the values are printed.
    check:
      1 + 1 == 2

  test "out of bounds raises":
    let xs = @[1, 2, 3]
    # 'expect' verifies that the code block raises a specific exception.
    # It is useful for testing error conditions.
    expect IndexDefect:
      discard xs[4]

Compile and run:

nim c -r test.nim

This exits with 0 on success and 1 when any test fails.

See command options:

# Running the compiled binary directly
./test --help

Compilation Flags

unittest2 behavior can be customized using compilation flags (-d:flag).

A detailed list of all available flags, including execution modes and advanced features, can be found on the Compilation Flags page.

Compile-Time Testing

One of unittest2 standout features is the ability to run tests during compilation. Use staticTest, runtimeTest, or dualTest for explicit control over when your tests execute.

You can also opt into compile-time execution globally for all test blocks:

nim c -d:unittest2Static=true test.nim

Contributing and Testing This Repository

Run tests locally:

# this calls a task in "config.nims"
nim test

Build the documentation locally:

# Build both the book and apidocs
nim docs

# Build only the book
nim book

# Build only the apidocs
nim apidocs

Navigate the generated docs:

Tip: start from the API index, then jump to symbols in unittest2.nim for implementation details.

Running and Filtering Tests

When your test suite grows, running every single test every time can become slow and noisy. unittest2 allows you to pick exactly which tests you want to execute. You can run a single test case, all tests in a specific suite, or a group of tests that match a certain naming pattern.

To filter tests, you pass filter arguments to the compiled test binary. These arguments come after any regular options (like --xml or --verbose).

A filter can target:

  • An exact test name.
  • A specific test inside a specific suite using the suite::test syntax.
  • An entire suite using the suite:: syntax.
  • Multiple tests or suites using a * wildcard.

If you provide multiple filter arguments, unittest2 will run any test that matches at least one of them.

Run Individual Tests

Using the tests from the Quickstart example:

# Runs only the "2 + 2 == 4" test
nim c -r test.nim "2 + 2 == 4"

# Runs both specified tests
nim c -r test.nim "2 + 2 == 4" "out of bounds raises"

If a test name is unique across your entire project, you can simply use its name as the filter.

Run a Single Suite

To run all tests within the math suite, use the suite name followed by two colons (::):

nim c -r test.nim "math::"

Run a Specific Test in One Specific Suite

If multiple suites have tests with the same name, or if you want to be explicit, use the suite::test syntax:

nim c -r test.nim "math::2 + 2 == 4"

Wildcard Pattern Matching

Filters support a wildcard * to match partial names.

Important

You can use exactly one asterisk per filter. Patterns like *bug* are not supported and will look for a literal * at the end.

The asterisk can be placed at the beginning, end, or in the middle of a filter:

# Run all suites that start with "ma"
nim c -r test.nim "ma*::"

# Run tests in the "math" suite that start with "out"
nim c -r test.nim "math::out*"

# Run all tests that end with "raises"
nim c -r test.nim "*raises"

# Match "2 + 2 == 4" using a wildcard
nim c -r test.nim "2 * 4"

Notes on Matching

  • testOnly matches any test named exactly testOnly, regardless of which suite it belongs to.
  • If a test name itself contains ::, unittest2 will still correctly match it as an exact test-name filter.

Example with :: inside the test name:

nim c -r test.nim "HTTP :: returns 200"

Command-Line Options

unittest2 supports runtime options via command-line arguments and environment variables. Command-line options take precedence.

Core Flags

  • --help: Print usage and exit.
  • --xml:<file> or --xml=<file>: Write a JUnit-compatible XML report using JUnitOutputFormatter.
  • --console: Force console output formatter (ConsoleOutputFormatter).
  • --output-level:<level> or --output-level=<level>: Set the OutputLevel. One of VERBOSE, COMPACT, FAILURES, NONE.
  • --verbose or -v: Shortcut for --output-level=VERBOSE.

Environment Variables

You can configure unittest2 using environment variables. To maintain compatibility with the built-in unittest module, both UNITTEST2_ and NIMTEST_ prefixes are supported.

VariableValuesDescription
UNITTEST2_OUTPUT_LVLVERBOSE, COMPACT, FAILURES, NONESets the default output verbosity.
UNITTEST2_ABORT_ON_ERROR(any)If defined, stops the test run immediately after the first failure.
NIMTEST_COLORalways, neverExplicitly enables or disables colored output.
NIMTEST_NO_COLOR(any)If defined, disables colored output (unless NIMTEST_COLOR is set to always).

Compatibility Fallbacks

For seamless integration with existing tools and CI pipelines, unittest2 also recognizes these standard Nim testing variables:

  • NIMTEST_OUTPUT_LVL: Fallback for UNITTEST2_OUTPUT_LVL. Recognizes PRINT_ALL (VERBOSE), PRINT_FAILURES (FAILURES), and PRINT_NONE (NONE).
  • NIMTEST_ABORT_ON_ERROR: Fallback for UNITTEST2_ABORT_ON_ERROR.

Parameter Parsing Behavior

By default, unittest2 parses the current process arguments automatically.

To disable this behavior (e.g., if your application needs to handle its own arguments):

nim c -d:unittest2DisableParamFiltering=true -r test.nim -- --your-app-args

Then you can call parseParameters manually in your test harness if you still want unittest2 to process some of the arguments.

Compilation Flags

unittest2 behavior can be customized using compilation flags (-d:flag). These flags allow you to select the execution model, enable advanced features, and configure compatibility with the standard library.

Execution Modes

  • -d:unittest2Compat=true|false: Select execution mode (default is true).
    • true: Compatibility Mode. Single-pass execution, identical to std/unittest.
    • false: Collect-and-Run Mode. Two-phase execution (discovery then run). See Operation Modes for details.
    • Set with -d:unittest2Compat=false to disable compatibility mode.

Advanced Features

  • -d:unittest2Static=true: Enable Compile-Time Execution.
    • Runs your tests in the Nim VM during compilation.
    • Verifies that your code works correctly at compile-time.
    • Use staticTest, runtimeTest, or dualTest for explicit control.
    • Set with -d:unittest2Static=true.
  • -d:unittest2ListTests=true: List Tests without running them.
    • Useful for integration with external test runners or IDEs.
    • Requires Collect-and-Run Mode (-d:unittest2Compat=false).
    • Set with -d:unittest2ListTests=true.
  • -d:unittest2DisableParamFiltering=true: Disable automatic argument parsing.
    • Use this if your test binary needs to handle its own CLI arguments. Call parseParameters manually if needed.
    • Set with -d:unittest2DisableParamFiltering=true.
  • -d:unittest2NoCollect=true: Disable test collection mode.
    • This is an experimental feature that affects the order in which tests and suites are evaluated.
    • Set with -d:unittest2NoCollect=true.
  • -d:unittest2PreviewIsolate=true: Enable preview isolation mode.
    • Each test is run in a separate process to ensure complete isolation. This may be removed in the future.
    • Set with -d:unittest2PreviewIsolate=true.

Standard Library Compatibility

These flags provide compatibility with the original unittest module:

  • -d:nimUnittestOutputLevel=VERBOSE|COMPACT|FAILURES|NONE: Set the default output level at compile time.
  • -d:nimUnittestColor=auto|on|off: Set the color output mode at compile time.
  • -d:nimUnittestAbortOnError=true: Set whether to abort on the first error at compile time.

Operation Modes

unittest2 supports two distinct execution models: Compatibility Mode and Collect-and-Run Mode.

Compatibility Mode (Single Pass)

This is the default behavior (-d:unittest2Compat=true).

In this mode, unittest2 behaves like the standard unittest module: each test is executed immediately when its test block is reached. There is no separate discovery phase.

  • Pros: Full compatibility with existing unittest code.
  • Cons: Limited reporting (cannot know total test count upfront), no support for advanced features like test listing or process isolation.

Collect-and-Run Mode (Two Phase)

Enable with:

nim c -d:unittest2Compat=false -r test.nim

Note

-d:unittest2Compat=false is required to enable this mode.

In this mode, unittest2 execution is split into two phases:

  1. Discovery: The program runs, but test blocks are only registered, not executed.
  2. Execution: Once discovery is complete, unittest2 iterates through all registered suites and tests, executing them and reporting results.

Benefits

  • Accurate Progress: Since the total number of tests that match your filters is known after discovery, formatters can show accurate [1/50] progress indicators.
  • Test Listing: You can use -d:unittest2ListTests=true to see exactly which tests match your filters without executing them.
  • Advanced Filtering: This mode provides the foundation for advanced features like running filtered tests in separate processes or in parallel.

Preparing Your Test Suite

To ensure your test suite works correctly in Collect-and-Run mode, follow these guidelines:

  1. Avoid side effects in suite blocks: Any code inside a suite block but outside a test, setup, or teardown block will execute during the Discovery phase. If this code modifies global state, it might affect tests in unexpected ways.
  2. Use setup and teardown: Always put initialization and cleanup logic inside setup and teardown. These are guaranteed to run before and after each test during the Execution phase.
  3. Independence: Ensure each test is independent. Collect-and-Run mode makes it easier to run tests in isolation, so they shouldn't rely on being run in a specific order.

Migration Caveats

  • Top-level code: Code at the top level of your test module executes during Discovery.
  • Global state: If you initialize global variables at the top level, they are initialized once before any test runs. If tests modify them, ensure they are reset in setup.

Porting from std/unittest

Most test code can be ported from std/unittest with minimal changes, but there are important semantic differences when using certain execution modes.

Quick Porting Checklist

  1. Replace import unittest with import unittest2.
  2. Keep your existing suite, test, check, and expect blocks.
  3. Reduce dependencies on global initialization order.

Execution Order and Discovery

The most significant difference occurs when using Collect-and-Run Mode (-d:unittest2Compat=false). Unlike std/unittest, which executes tests as they are encountered, unittest2 first "discovers" all tests before running any of them.

Code placed directly inside a suite block (but outside of test or setup) will execute during this discovery phase.

import unittest2

# In Collect-and-Run mode (-d:unittest2Compat=false), 
# execution is split into Discovery and Execution phases.

suite "execution order":
  # This code runs during the Discovery phase.
  echo "1. Discovery phase: suite body runs"

  setup:
    # This runs during the Execution phase, before each test.
    echo "3. Execution phase: setup runs before each test"

  test "example test":
    # This runs during the Execution phase.
    echo "4. Execution phase: test body runs"

  # This also runs during the Discovery phase.
  echo "2. Discovery phase: suite body continues"

To maintain consistent behavior between modes, always place initialization logic inside setup and cleanup logic inside teardown.

Strategy for Large Codebases

  • Migrate incrementally: Port one package or module at a time.
  • Use Compatibility Mode initially: Keep -d:unittest2Compat=true (the default) to match the single-pass execution of std/unittest.
  • Switch to Collect-and-Run Mode: Once your tests are independent and free of side effects in the suite body, disable compatibility mode to benefit from better reporting and advanced filtering.

Examples

Resource Lifecycle with setup and teardown

Use setup and teardown to manage resources that should be fresh for every test. This ensures that tests are independent and don't leak state to each other.

import unittest2

type FakeDb = object
  connected: bool
  writes: int

var db: FakeDb

suite "database repository":
  setup:
    db.connected = true
    db.writes = 0

  teardown:
    db.connected = false

  test "write increments counter":
    check db.connected
    db.writes.inc
    check db.writes == 1

  test "state is reset":
    # db.writes will be 0 here because setup ran again
    check db.writes == 0

Compile-Time Testing

One of unittest2 features is the ability to run tests during compilation using staticTest. This allows you to verify code logic in the Nim VM before the program even runs.

import unittest2

func fastFib(n: int): int =
  if n <= 1:
    n
  else:
    fastFib(n - 1) + fastFib(n - 2)

# This test runs while the compiler is building your project!
staticTest "fibonacci constant folding":
  check fastFib(10) == 55

Tip

Combine this with -d:unittest2Static=true to run regular test blocks at compile-time as well.

Data-Driven Tests

Data-driven tests cover many edge cases by iterating over a collection of inputs and expected outputs. This pattern keeps your test code compact and easy to extend.

import unittest2, std/strutils

proc parsePort(s: string): int =
  let p = parseInt(s)
  if p < 1 or p > 65535:
    raise newException(ValueError, "port out of range")
  p

suite "parsePort":
  test "valid values":
    # A collection of inputs and expected outputs
    let cases = [("1", 1), ("80", 80), ("443", 443), ("65535", 65535)]
    for (input, expected) in cases:
      check parsePort(input) == expected

  test "invalid values raise":
    # A collection of inputs that should cause a ValueError
    let inputs = ["0", "70000", "-1", "abc"]
    for input in inputs:
      expect ValueError:
        discard parsePort(input)

Collect-and-Run Mode Optimization

When using the Collect-and-Run mode, it is important to separate the Discovery phase from the Execution phase. This example shows how to structure your suites for maximum compatibility.

import unittest2

# This file demonstrates patterns optimized for Collect-and-Run mode
# (-d:unittest2Compat=false)

suite "collect-and-run optimized suite":
  # 1. Declarations inside the suite are safe, but avoid complex
  # logic or side effects here, as they run during the Discovery phase.
  var counter: int

  setup:
    # 2. Always initialize or reset state in setup.
    # This runs during the Execution phase, just before each test.
    counter = 10

  test "first increment":
    counter.inc
    check counter == 11

  test "independent reset":
    # counter is 10 again because setup ran before this test
    counter.inc
    check counter == 11

  # 3. Code here would run during Discovery.
  # Use teardown for post-test cleanup.
  teardown:
    discard

Using Checkpoints for Debugging

The checkpoint macro is used for identifying which iteration of a loop failed by printing a message only on failure. It is useful for analyzing failures in complex loops or retry logic.

import unittest2

proc eventuallySucceeds(maxAttempts: int): bool =
  # Logic that might fail a few times
  false

suite "retry logic":
  test "fails with descriptive checkpoint":
    for i in 1 .. 3:
      checkpoint "Attempt number " & $i
      check eventuallySucceeds(i)

Dependency Injection

Using mocks or fakes allows you to test business logic without relying on external services like databases or APIs. This makes your tests faster and more deterministic.

import unittest2

type
  Mailer = ref object of RootObj
  RealMailer = ref object of Mailer
  FakeMailer = ref object of Mailer
    sentTo: seq[string]

method send(m: Mailer, userId: string) {.base.} =
  discard

method send(m: RealMailer, userId: string) =
  # Real external I/O in production
  discard

method send(m: FakeMailer, userId: string) =
  m.sentTo.add(userId)

proc activateUser(userId: string, mailer: Mailer): bool =
  if userId.len == 0:
    return false
  mailer.send(userId)
  true

suite "activateUser":
  var fake: FakeMailer

  setup:
    fake = FakeMailer(sentTo: @[])

  test "sends welcome mail for valid id":
    check activateUser("u-123", fake)
    check fake.sentTo == @["u-123"]

  test "rejects empty user id":
    check activateUser("", fake) == false
    check fake.sentTo.len == 0