test-style
How kotatsu's tests are organized and written — levels (unit, system, integration), the module-first layout, dependency and trust rules, naming, zest checks, determinism. Read BEFORE writing, moving o
SKILL.md.claude/skills/test-style/SKILL.md
GitHubkotatsu Test Style
Levels
A test's level is decided by what it touches, not by the module it tests.
| Level | May touch | Lives in |
|---|---|---|
| unit | the process's memory; an event loop, its timers and in-memory transports count as memory | tests/<module>/unit/ → unit_tests |
| system | the operating system: files, processes, sockets, pipes, signals, threads and thread pools, environment | tests/<module>/system/ → system_tests |
| integration | several programs talking over real protocols, black box | tests/<module>/integration/ → ctest |
- The moment a test opens a file, spawns a process, binds a socket, installs a signal handler or starts a thread, it is a system test. zest's own I/O does not count: printing, and reading or writing snapshots through
zest::snapshot, are fine in a unit test. - Compile-time facts that must hold are
ZSTATIC_EXPECTin a unit case. - Nothing reaches the network beyond loopback. Tests that need the internet are not part of any suite.
- A target that must build without another, as ipc's codecs without the event loop, is checked by a program linked against it alone, without zest, which needs the loop; so is a check that replaces what a whole process shares, as fs's of remove_all() under failing allocations replaces the global operator new:
tests/<module>/<name>/main.cpp, run by ctest and Bazel as<module>_<name>(labelunit, orsystemfor one that touches the operating system); it prints each failed check and exits with 1. - zest's own runner check (
tests/zest/integration/) is integration-level, but it is the one integration test in C++ and CMake rather than TypeScript: it runs from CMake as a bootstrap stage (see Trust), so it needs nothing beyond the build.
Layout
Tests are grouped by module, then by level. tests/<module>/ mirrors include/kotatsu/<module>/; the modules are support, fs, sys, meta, codec with one directory per backend (codec/json, codec/toml, codec/fbs, codec/bincode, codec/blob, codec/dyn, codec/debug), cli, async, ipc with ipc/lsp, http and zest. tests/examples/ holds the tests of examples/, by the module an example shows (tests/examples/async/, tests/examples/ipc/): unit tests of what an example builds on, and the example programs run end to end. tests/harness/ holds what every module's integration tests share.
tests/<module>/
unit/<path mirroring the headers>/...
system/... same shape
integration/*.test.ts integration tests, TypeScript; nothing else but drivers/
integration/drivers/*.cpp the programs they spawn, one per file, or one per
directory of sources
<name>/main.cpp a check that is a program of its own (Levels),
in kotatsu_check_programs so that it builds into the
build root beside the runtime DLLs
harness/*.h, harness/*.ts helpers for this module's tests and the modules above,
integration tests' helpers included
CMakeLists.txt kotatsu_add_module_tests(LIBS <the module's libraries>)
kotatsu_add_integration_tests(LIBS ... PROGRAMS ...
DEBUG_ONLY ...) if it has integration/; PROGRAMS are
targets built elsewhere
tests/BUILD.bazel the same for Bazel: each module's harness library,
its <module>_<level>_tests binaries, its drivers and
its <module>_integration test
tests/harness/*.ts what every module's integration tests share
tests/fixtures/ types shared by several modules' tests
tests/snapshots/<suite>/ snapshot files
- The tests of header
<module>/a/b.hareunit/a/b_tests.cpp. A large header's tests split by aspect, intounit/a/b_<aspect>_tests.cppor into a directoryunit/a/b/<aspect>_tests.cpp. - codec's protocol (the core headers in
codec/,codec::repr, attributes, formats) is tested once, by a kit every backend runs:codec/harness/visit/kit.hholds the primitives andCaps, and each area (values.h,attrs.h,repr.h,variants.h,probing.h) registers its cases for a backend. A backend adapts itself incodec/<backend>/harness/backend.hand runs each area incodec/<backend>/unit/visit/<area>_tests.cpp, oneZEST_CASE_GROUPper file; blob, which lays out the struct itself rather than speaking the protocol, has novisit/tests. Fixtures only the codec's tests use live incodec/harness/fixtures/. - Build options are checked once, where
tests/CMakeLists.txt(or the parent module'sCMakeLists.txt) adds the module's directory.tests/BUILD.bazellists the modules too, building every test binary per module and level, with its snapshots and the files beside its sources as data.
Dependencies
What each module's headers include, and so what its tests may use:
support;fson support;syson fs;metaon support;clion codec's core and meta, privately sys.codec: its core (the headers incodec/itself) on meta; the backendsblob,debug,dyn,tomlandfbson the core;bincodeandjsonon the core anddyn.asyncon support,fsandsys;ipc's codecs (codec.h,codec/,framing.h,protocol.h) and its blocking client (blocking_client.h) on codec (json,dyn,bincode), the rest ofipcon those and async;ipc/lspon ipc;httpon async and codecjson.zeston all of the above that it checks, prints, parses or runs with. Every test includes zest; that is the one exception.
Rules:
- A module's tests include only what their module depends on, plus the harnesses of those modules:
#include "support/harness/throws.h", rooted attests/. Shared fixtures intests/fixtures/follow the same rule for the lowest module that uses them. The build does not enforce this; review does. - Tests use the public API:
include/kotatsu/, never a header fromsrc/, nor anything fromexamples/outsidetests/examples/. - Behaviour defined once in the library is tested once. Backends of one protocol share one suite through the module's harness; a backend's own files test only what is specific to that backend.
Integration tests
- An integration test is a TypeScript file on node's test runner (
node:test,node:assert/strict), run by node directly (type stripping, no build step) and type-checked bypixi run typecheck. Its npm packages are the rootpackage.json's devDependencies (vscode-jsonrpc,vscode-languageserver-protocol,vscode-uri,vscode-languageserver-textdocument,fast-check), installed bypixi run npm-ci. - A driver is a C++ program under test,
integration/drivers/<name>.cpp, or the sources inintegration/drivers/<name>/for one too heavy for a single translation unit, linked against the module's libraries bykotatsu_add_integration_tests; an example it runs is named inPROGRAMS. ctest passes each one's path inKOTATSU_<NAME>; a test whose driver is unset or missing fails, it never skips. A driver too heavy to build outside a plain Debug build, and the tests that run it, are named inDEBUG_ONLY; MinGW's GCC leaves them out too, its assembler being too slow for them. - All of a module's
integration/*.test.tsare one ctest test,<module>_integration(labelintegration); it needs nothing from zest and runs beside the other stages. Driver(tests/harness/driver.ts) spawns a driver and hands its stdio to a channel in its protocol: JSON lines (tests/harness/jsonl.ts) for a driver that is no JSON-RPC peer; in ipc's harness, a vscode-jsonrpc connection (connection.ts), or a raw channel (raw.ts, andsession.ts, which pairs responses with requests and keeps the strays;jsonrpc_driver.tshas what jsonrpc_driver's tests share).Driver.spawntakes the test's context: a driver the test did not finish with is killed when the test ends, and how it ended printed with its stderr.- A driver logs one
[<level>] <message>line each to stderr, ipc's throughtest::stderr_logger()(ipc/harness/stderr_logger.h). - Every test ends with
expectExit(code), orexpectSignal(signal)for a driver a signal ends, which checks how the driver ended (expectExittakes a list of codes where a host may end it with either) and fails on a sanitizer report in the driver's stderr, and on a warn or error line the case did not declare withexpectLog(pattern, count): a message the driver drops with a warning fails the case that sent it. An allowance is a number of lines, one unless it says more, and never switches the check off; only input that may make a driver log anything takesInfinity. The count is checked at exit over the whole run, each line taking the first allowance it matches with one left, so declare an allowance just before the step that logs it, for the lines that step logs. - Cases are named like zest cases:
snake_case, shaped<subject>_<behaviour>,_failsfor an error. A file's comment says what its cases have in common. - No sleeps: wait for the message or the exit that says it happened. The runner's timeout (
--test-timeoutinkotatsu_add_integration_tests) bounds each test, driver included. - A randomized test runs on fast-check through
fuzz()(tests/harness/fuzz.ts): a fixed seed, reported with the shrunk counterexample;KOTATSU_FUZZ_SEEDandKOTATSU_FUZZ_RUNSoverride the seed and the 100 runs for long runs by hand, which then have no time limit. Otherwise the run still going at 60% of the test's timeout (FUZZ_TIMEOUT) is cut short: a failure found by then is reported, shrunk as far as the time allowed, and a leg too slow for every run passes on those it made. A run that stalls fails throughwithin()before the test times out, and a run that fails kills its driver and reports its stderr (Driver.reported). Strings are drawn fromanyChar, half ASCII and half any code point, so that quotes, backslashes, control characters and text past ASCII all come up. - LSP's types are drawn from the pinned metaModel (
ipc/lsp/harness/protocol_values.ts), which also says whether protocol.h reads a value. Where kotatsu reads LSP otherwise,ipc/lsp/harness/known_deviations.tshas an entry marked "bug" or "design" that says how; the fix of a bug deletes its entry, and the tests then check it. - Behaviour the library owes but does not have yet is a case written for the correct behaviour, skipped with the reason, in words rather than a plan's numbering (
{ skip: "an error response drops its data" }); the fix removes the skip.
Trust
zest decides pass or fail with meta's comparisons, prints operands with the debug codec, matches test filters with support's glob patterns, parses its options with cli, drives its worker processes with kotatsu::async and writes their logs with kotatsu::fs. ctest therefore runs in stages, each needing the one before:
zest_bootstrap_unit,zest_bootstrap_system: the tests of what zest relies on, in this process, without the worker pool. Their filters are intests/CMakeLists.txt.zest_runner: the worker pool end to end, failedZASSERTs and crash tests, LoopFixture's watchdog, andrun_cli's command line.unit_tests,system_tests: everything.
A test in a bootstrap suite must not judge itself with what it tests: meta's comparison tests use unary checks (ZEXPECT(eq(a, b))) or parenthesized plain bools (ZEXPECT((a.x == 1))), never a split comparison. A new suite covering something zest relies on joins a bootstrap filter; a renamed one updates it.
Naming
- Files:
<stem>[_<aspect>]_tests.cpp,.cpponly. - Suites: the file's path under
tests/, without the level directory and the_testssuffix, its components joined by_, and a word dropped when it repeats the word before it. For example,tests/codec/toml/unit/toml_variant_tests.cppiscodec_toml_variant,tests/meta/unit/schema/schema_attrs_tests.cppismeta_schema_attrs, andtests/async/unit/runtime/when/cancel_tests.cppisasync_runtime_when_cancel. - One suite per file. Suite names are unique across both binaries, since the suite name is also its snapshot directory: when one header has both unit and system tests, the system file names its aspect (
io/loop_threads_tests.cppbeside the unitio/loop_tests.cpp). - Cases:
snake_case, shaped<subject>_<behaviour>. No suite prefix, no numbering. A case about an error ends in_fails, a thrown exception included. Spell itroundtrip, notround_trip. - Fixture types are PascalCase.
Namespaces
- A test file puts its fixtures and suite in an anonymous namespace inside the namespace it tests:
namespace kotatsu::ipc { namespace { ... } }, so that the names of two files never collide. zest registers a suite in any namespace. Insidekotatsu::async,fs::names async's file system,kotatsu::async::fs; the synchronous one is writtenkotatsu::fs::. - Harness headers use
namespace kotatsu::test. - Specializations of library templates (traits,
codec::repr) are declared in the library's namespace, as C++ requires; the types they name stay in the test's anonymous namespace. - Test types never live in a library namespace outside an anonymous one. No
using namespaceat namespace scope exceptstd::literals.
Checks
- One expression per check.
ZEXPECT(a == b)reports both operands;ZEXPECT(x)andZEXPECT(!x)reportx. Splita && binto two checks. ZASSERTbefore anything that relies on the check: dereferencing an optional, expected or pointer, indexing,.value(). A failedZASSERTends the test's process without unwinding, so it serves alike in a coroutine, a helper that returns a value, a callback or another thread.- What a failed
ZASSERTwould leave behind outside the process (a temporary directory, a child process) is cleaned up by azest::FatalHook, declared after what it cleans up: a member of the owning type, astest::TempDirhas, or a local beside a resource the test cannot change. A suite's constructor and destructor are its setup and teardown; a failedZASSERTskips the destructor. - An expected error is checked for what it is, not only that it happened:
ZASSERT(!result); ZEXPECT(result.error().kind == ...). - Predicates:
contains,starts_with,ends_withfor text and ranges,type_eq<A, B>()for types,throws(fn)/throws<E>(fn)and!throws(fn)for exceptions. ZEST_CONTEXTin helpers and loops, naming the input a failing check was about.- Large expected text (pretty output, schemas, diagnostics) is a snapshot:
ZEXPECT(zest::snapshot(text)), orzest::snapshot(text, "name")for more than one in a case; a value that is not text is snapshotted as the debug codec renders it. Updating snapshots is a deliberate act. - A case that must crash its process (an abort, a failed
assert,std::terminate) is declaredcrashes = true; it passes only by crashing, and crashes by defined means, never by undefined behaviour. A crash that depends on the build mode is a constant attribute, e.g.crashes = !KOTATSU_ENABLE_EXCEPTIONS. ZEXPECT((a == b))compares with the type's own operator; use it only for a type meta cannot compare.- Every case checks something. Checks inside a callback or coroutine are backed by a check, after the run, that they ran.
Determinism
- Cases are independent. Each case runs on a fresh instance of its suite, so a case never clears or resets suite members, at its start or its end. Cases run in no set order, spread over worker processes that run at once, so a case never relies on another having run, or on its leftovers. A worker does run several cases one after another, so its process-wide state (globals and singletons, the environment, the current directory) is not fresh: a case that changes it restores it with an RAII guard such as
test::ScopedEnv. - Unit tests never order events by sleeping; they use events, latches or the loop's own ordering. A timer is fine as the subject of a test.
- System tests order events the same way wherever they can: wait for the event, a relay or a semaphore that says the other side is ready. A wait is left only to show that something does not happen, with a comment saying so.
- System tests bind port 0 and read the port back. A case that needs a port twice (a second bind to share it, or to find it in use) binds port 0 first and reuses the port it read back.
- System tests create temporary directories under the system temp directory with an owner that removes them on every path, and never use fixed file names. Windows named pipes live outside the file system; they take a random name instead (e.g. the temporary directory's name).
serial = trueandskip = truecarry a one-line comment giving the reason. A case that can only tell at run time that it cannot run (no IPv6 loopback, no pseudo-terminal) callszest::skip()and returns; a comment on the check, or on the helper it asks, gives the reason.- A randomized test uses a fixed seed and prints it when it fails.
Running
pixi run test [preset] runs every stage. To run one binary by hand, e.g. with a filter, run it from the repo root with the snapshot directory:
./build/debug/unit_tests --snapshot-dir=tests/snapshots --test-filter='codec_json_*'
pixi run integration-test [preset] runs only the integration tests. To run some by hand, do what ctest does (ctest -N -V -L integration prints its command), from the repo root with the driver's path set (lsp_stub_server.exe on Windows), and filter by name:
KOTATSU_LSP_STUB_SERVER=build/debug/lsp_stub_server pixi run node --import ./tests/check_npm_packages.ts --test --test-timeout=20000 --test-name-pattern='^hover_' tests/ipc/lsp/integration/requests.test.ts