Nim Testing: unittest, nimble test, and testament (2026)
Verified guidance on Nim's std/unittest module (check vs require, suites, setup/teardown), nimble test conventions, when testament's compiler-oriented test runner is and is not appropriate for application code, and the honest current state of property-based testing in Nim.
Introduction / Overview
Nim's testing landscape has three layers that serve different purposes and must not be confused: std/unittest, the stdlib assertion/grouping module that most projects write their tests against; nimble test, the package manager's convention-based command that discovers and runs those tests; and testament, the Nim compiler's own internal test runner, which is occasionally, but not typically, adopted by application projects. A fourth area - property-based testing - remains genuinely immature in the Nim ecosystem as of this writing, which is itself an important, verified fact for anyone (human or LLM) reaching for a Hypothesis- or proptest-equivalent library by habit.
This document is verified against the current std/unittest documentation, Nimble's test-task documentation, and the testament documentation and its own stated purpose in the Nim compiler repository. Facts that are fast-moving or not fully settled in the ecosystem (property-based testing library maturity, incremental compilation status) are flagged explicitly rather than asserted as fact.
When to use this guidance: Setting up a test suite for a new Nim library or application, deciding whether testament is worth adopting outside compiler development, or wiring nimble test into CI.
Core Concepts
checkcontinues,requireaborts: within astd/unittesttestblock, a failedcheckrecords the failure and keeps executing the remaining statements in that test; a failedrequirequits the entire test binary immediately, skipping any remaining checks and anyteardownblock. This is a load-bearing distinction - usingrequirewherecheckwas intended silently skips test coverage and teardown on the first failure of a run.suitegroups tests and sharessetup/teardown: asuite "name":block groups relatedtest "...":blocks and can declaresetup:(run before each test in the suite) andteardown:(run after each test, but only if the test did notrequire-abort).nimble testis convention-based, not configuration-based by default: with notask testdefined in the.nimblefile, Nimble compiles and runs every file intests/whose name begins witht(e.g.tests/tinventory.nim); a project can override this entirely with a customtask test, "...": exec "..."block.testamentis, first and foremost, the tool that tests the Nim compiler and standard library - its own documentation describes it as responsible for executing the compiler's and stdlib's test suites, with process isolation, multi-backend (C/C++/JS) support, and rich expected-output specification per test file. It is a legitimate, if uncommon, choice for application projects that specifically need that process isolation and multi-backend matrix; it is not the default recommendation for ordinary application/library testing, wherestd/unittestremains the norm.- Property-based testing is immature in Nim as of this writing: the closest analogue to Python's Hypothesis or Rust's
proptest,nim-works/npbt, is explicitly labeled work-in-progress by its own repository. There is no widely adopted, production-grade property-based testing library in the Nim ecosystem today - treat any code that assumes one exists with the same skepticism as an assumed-but-unverified stdlib function. - Each test file is typically its own compilation unit:
nim c -r tests/tfoo.nimperforms full semantic analysis, C code generation, and a C-compiler invocation for that one file. A test suite spread across many small files pays this overhead once per file; consolidating related tests into fewer files/binaries amortizes it.
Best Practices
1. Structure suites with suite, setup, and teardown
Rationale: Grouping related tests under a suite block with shared setup/teardown avoids duplicating fixture logic in every test block, mirroring the role fixtures play in pytest (see Python Testing Guide) without a dependency-injection mechanism of its own.
import std/unittest
type Inventory = object
stock: int
suite "inventory management":
var inventory: Inventory
setup:
inventory = Inventory(stock: 10)
teardown:
inventory.stock = 0
test "reserving stock decreases the count":
inventory.stock -= 3
check inventory.stock == 7
test "reserving more than available stock fails":
proc reserve(inv: var Inventory, qty: int) =
if qty > inv.stock:
raise newException(ValueError, "insufficient stock")
inv.stock -= qty
expect ValueError:
reserve(inventory, 999)
Why this works well in production: setup re-runs before every test in the suite, giving each test a fresh inventory value with no leakage between tests - the same isolation guarantee function-scoped pytest fixtures provide, without needing to reason about fixture scope explicitly, since std/unittest has only this one, always-per-test granularity.
2. Use check for assertions that should not halt the rest of the test, require only for preconditions
Rationale: A test verifying multiple independent properties of one result should use check for each property so a single failing assertion does not hide failures in the others reported in the same run; require is reserved for a genuine precondition without which the rest of the test body is meaningless (e.g., a setup call that must succeed before anything else can be asserted).
import std/unittest
test "parsing a well-formed record":
let record = parseRecord("42,active,2026-07-07")
require record.isSome # nothing else makes sense to check if this fails
check record.get.id == 42
check record.get.status == "active"
check record.get.date == "2026-07-07"
Production tip: require's abort behavior can be widened to make every check in the binary behave like require via -d:nimUnittestAbortOnError:on - useful transiently while debugging a single failing test locally (stop at the first failure instead of scrolling through a long report), but should not be a permanent CI setting, since it silently reduces the number of independent failures a single CI run reports.
3. Let nimble test discover tests by convention, and pin the source path with tests/config.nims
Rationale: Nimble's default test task compiles and runs every tests/t*.nim file without any configuration; the one piece of setup nearly every project needs is telling those test files where to find the package's own source, since a test file living in tests/ does not automatically see src/.
orders/
├── orders.nimble
├── src/
│ └── orders/
│ └── inventory.nim
└── tests/
├── config.nims # switch("path", "$projectDir/../src")
├── tinventory.nim
└── tbilling.nim
# tests/config.nims
switch("path", "$projectDir/../src")
nimble test
Why this works well in production: this convention requires zero entries in the .nimble file itself - Nimble's built-in test task already knows to look in tests/, and config.nims is picked up automatically by any nim c/nim r invocation rooted in that directory, including the one nimble test performs internally.
4. Define an explicit task test when the default convention is not enough
Rationale: Projects that need a single consolidated test binary (to amortize compile time - see Core Concepts), a non-default test directory, or extra compiler flags (e.g., --mm:arc for a test matrix against multiple memory-management modes) should override Nimble's default task explicitly rather than fight it.
# orders.nimble
task test, "Run the full test suite":
exec "nim c -r --mm:orc tests/tester.nim"
exec "nim c -r --mm:arc tests/tester.nim"
# tests/tester.nim -- a single consolidated binary importing every test module
import tinventory
import tbilling
Why this works well in production: compiling once and running a consolidated tester.nim (which simply imports every individual test module so their top-level test/suite blocks register and run) avoids paying full compile-and-link overhead once per test file, which matters once a suite grows past a few dozen files.
5. Reach for testament only when you specifically need process isolation or a multi-backend matrix
Rationale: testament ships with the Nim compiler and is what the Nim project itself uses to validate the compiler and standard library - process-isolated execution per test, .nim test files annotated with an expected-output/exit-code specification block, support for running the same test against multiple backends (C, C++, JS) in one pass, categorized test suites, and HTML report generation. Its specification format embeds expectations directly in the test file:
discard """
exitcode: 0
output: '''
7
'''
"""
echo 3 + 4
testament pattern "tests/*.nim"
testament category mycategory
When this is worth adopting for an application: a project that must verify identical behavior across the C, C++, and JS backends (a library specifically designed to be portable across all three), or one that needs strict process isolation per test case (fuzzing-adjacent test suites, tests that may crash the process and must not take down the rest of the run) benefits from testament's design. Absent one of those specific needs, the added specification-file format and megatest/batching machinery are unnecessary ceremony compared to std/unittest plus nimble test.
6. Fake dependencies through explicit interfaces rather than reaching for a mocking framework
Rationale: Nim has no dominant, widely-adopted mocking framework comparable to Python's unittest.mock/pytest-mock or Rust's mockall. The idiomatic pattern - closer to Go's or Rust's trait-based faking than to Python's monkey-patch-based mocking - is to depend on a small object/procedural interface (a set of proc types stored in an object, or a concept/generic parameter) and substitute a fake implementation in tests.
type
PaymentGateway = object
charge: proc (amountCents: int): bool {.closure.}
proc newStripeGateway(): PaymentGateway =
PaymentGateway(charge: proc (amountCents: int): bool =
# real HTTP call to Stripe
true
)
proc newFakeGateway(shouldSucceed: bool): PaymentGateway =
PaymentGateway(charge: proc (amountCents: int): bool = shouldSucceed)
proc checkout(gateway: PaymentGateway, amountCents: int): bool =
gateway.charge(amountCents)
test "checkout fails cleanly when the gateway declines":
let gateway = newFakeGateway(shouldSucceed = false)
check checkout(gateway, 500) == false
Why this works well in production: this pattern requires no additional dependency, is fully type-checked at compile time (a fake with the wrong signature fails to compile rather than failing at test run time), and reads as ordinary Nim rather than framework-specific mocking DSL syntax.
Common Pitfalls & Anti-Patterns
Pitfall: Adopting testament by default for ordinary application testing
Problem: testament is documented and primarily used as the Nim compiler's own internal test-suite runner. An LLM that sees testament referenced in Nim compiler documentation may over-index on it and generate a testament-based test suite for an ordinary web service or CLI tool, adding a specification-file format and a runner with a much larger surface area than the problem requires.
Recommended approach: Default to std/unittest plus nimble test for application and library code; reach for testament only per the specific criteria in Best Practice #5 (multi-backend verification, process-isolated crash-tolerant test runs).
Pitfall: Using require where check was intended
Problem: A test block with several require calls in a row silently stops reporting after the first failure - subsequent assertions in the same test never execute, and CI output shows only "1 failure" when several properties may actually be broken, misleading whoever triages the failure.
Recommended approach: Default to check for every assertion that is not itself a hard precondition for the rest of the test; reserve require for setup calls whose failure makes the remaining assertions meaningless (see Best Practice #2).
Pitfall: Assuming a mature property-based testing library exists
Problem: Generated Nim test code that imports a nonexistent or barely-started property-based testing package (by analogy by generalizing habits from Python/Rust) will fail to compile, or will pull in nim-works/npbt and hit its documented work-in-progress limitations.
Recommended approach: Default to manual, example-based check/require assertions with deliberately chosen boundary values (empty sequences, zero, negative numbers, Unicode edge cases) written by hand; if property-based testing is genuinely required, verify npbt's current state directly against its repository before depending on it, and budget for filling gaps yourself.
Pitfall: One .nim file per test case in a large suite
Problem: Each additional test file is a separate nim c invocation - separate semantic analysis, C codegen, and C-compiler/linker invocation - so a suite with hundreds of one-test files pays large, repeated fixed overhead independent of the tests' own runtime.
Recommended approach: Group related tests into fewer files per logical suite (see Best Practice #4's consolidated tester.nim pattern), and rely on nimcache object-file reuse (the default incremental C-object caching Nim already performs between compiles) rather than fragmenting further.
Testing Strategies
- Unit tests: pure functions and business logic, expressed as
testblocks inside asuite, asserted withcheck; the majority of a well-structured suite. - Integration tests: real database/file-system/network interaction, isolated into their own suite or files so they can be excluded from a fast default
nimble testrun when needed (e.g., via a build-time flag checked withwhen defined(integrationTests):). - Exception-path tests:
expect SomeException: ...(astd/unittesttemplate) asserts that a block raises a specific exception type; prefer it over manually wrapping atry/exceptand asserting a boolean flag was set. - Concurrency tests: see Nim Concurrency for thread/async-specific testing guidance - these should not be mixed into ordinary
std/unittestsuites without accounting for timing nondeterminism. - Recommended test stack for a typical Nim project in 2026:
std/unittestfor assertions and grouping,nimble test(default convention, or a customtask test) as the CI entry point, hand-written boundary-value cases in place of property-based testing until the ecosystem's tooling matures further.
Performance, Security & Scalability Considerations
- Compile time is part of your test suite's cost: unlike interpreted-language test runners, every
nim c -rpays full compilation before a single assertion runs; consolidating test files (Best Practice #4) and relying onnimcache's object-file reuse between runs are the practical levers available today. - Whole-program incremental compilation (IC) is not yet a reliable, general-purpose speedup: Nim has an ongoing, multi-year effort toward incremental compilation (tied to newer compiler-frontend work), but as of this writing it is not a settled, universally-enabled feature of stable releases - do not assume
--incrementalor similar flags will reliably cut test-suite compile time in a current stable Nim installation; verify against the specific installed version's release notes. - CI non-interactive test execution:
nimble testandchoosenim-installed toolchains run entirely under the invoking user's own account with no elevated privileges required, which matters for CI runners and autonomous agent sandboxes (see Autonomous, Least-Privilege, Portable Execution Environments) - never invokenimble/choosenimviasudoin CI, since both are designed to install and operate entirely within the user's home directory. - Test isolation across processes:
std/unittest-based suites run all tests in one process by default; a test that corrupts global state (a mutated module-levelvar) can silently affect a later test in the same binary - prefer function-local state over module-levelvars in test helpers for exactly this reason.
Edge Cases & Advanced Usage
- Parametrized-style tests without a built-in
parametrize:std/unittesthas no direct equivalent topytest.mark.parametrize; the idiomatic Nim pattern is aforloop over aseqof input/expected-output tuples inside a singletestblock, withcheck(notrequire) per case so all cases are evaluated and reported even if one fails.
test "normalize trims and lowercases":
let cases = @[
(" Hello ", "hello"),
("WORLD", "world"),
("", ""),
]
for (input, expected) in cases:
check normalize(input) == expected
- Testing exception-effect (
{.raises.}) correctness: because Nim's effect system is checked at compile time, a proc's declared{.raises: [...].}set is itself part of its public contract; anim checkinvocation (not just a runtime test) that fails to compile because a proc's body can raise something outside its declared set is a legitimate part of a project's test/CI matrix, distinct fromstd/unittestruntime assertions. unittest2(status-im/nim-unittest2): a drop-in-compatible evolution ofstd/unittestfrom the Status organization, offering performance and API refinements while preserving thetest/suite/check/requirevocabulary; verify its current feature delta against stdlibunittestbefore adopting if compatibility with vanillastd/unittest-based tooling matters.
When Not to Use / Alternatives
| Dimension | std/unittest + nimble test | testament |
|---|---|---|
| Setup complexity | Minimal - write test/suite blocks, run nimble test | Higher - expected-output specification blocks, category/pattern invocation |
| Multi-backend (C/C++/JS) verification | Manual (separate nim c/nim cpp/nim js invocations per suite) | Built in |
| Process isolation per test | No (one process runs the whole suite by default) | Yes |
| Ecosystem familiarity | High - the default assumption in nearly all Nim tutorials and libraries | Lower - primarily associated with Nim compiler development |
| Best fit | Ordinary application/library test suites | Compiler/language-tooling projects, or libraries needing verified cross-backend behavior |
Choose testament only when process isolation or a multi-backend test matrix is a genuine, stated requirement - not by default because it ships with the compiler.
Choose unittest2 over stdlib unittest only after confirming its current feature set still tracks closely enough with stdlib unittest for your team's familiarity needs; otherwise stdlib unittest remains the safer default for a codebase other contributors will recognize immediately.
Related Documentation
- Nim Language Overview & Ecosystem
- Nim Best Practices
- Nim Common Pitfalls & Anti-Patterns
- Nim Metaprogramming
- Nim Concurrency
- Nim FFI & Interop
- Unit Testing Principles
- Autonomous, Least-Privilege, Portable Execution Environments
Glossary
check: Astd/unittestassertion that records a failure but allows the remaining statements in the currenttestblock to continue executing.require: Astd/unittestassertion that, on failure, immediately quits the entire test binary, skipping remaining checks andteardown.suite: Astd/unittestconstruct grouping relatedtestblocks, with optional sharedsetup/teardown.testament: Nim's own compiler/stdlib test-suite runner, with process isolation, multi-backend support, and an expected-output specification format embedded in each test file.nimcache: The directory Nim uses to cache generated C/C++ files and object files between compiles, providing partial, object-level build speedup independent of whole-program incremental compilation.- Property-based testing: A testing style generating inputs from a strategy rather than hand-picking examples; not yet mature in the Nim ecosystem as of this writing (
nim-works/npbtis work-in-progress).
Maintenance note: When updating this document, re-verify npbt's maturity and any incremental-compilation (IC) progress against the nim-lang/Nim repository, since both are explicitly flagged here as fast-moving and unsettled.