Skip to content

Testing

Zhang has about 1,700 Rust tests and 150 web UI tests. They are organised in layers. Each fact is pinned once, at the lowest layer that owns it, and an upper layer checks it again only as part of a contract of its own: the journal lists the metadata of a posting, it does not prove again how that metadata attaches to the posting. This page says which layer owns what, where a new test goes, and the conventions the suites share.

Layer Where Owns Fixtures
Unit #[cfg(test)] mod tests next to the code one function, stage or route handler in isolation inline text only: no files, no fixtures, no zhang-testkit
Dialect the tests/ of zhang-core and of extensions/beancount syntax, escaping, metadata attachment, flags and round trips of one format; the same scenarios run through both parsers with each dialect’s own expectations inline text, through zhang_testkit::dialect
Engine and pipeline the tests/ of zhang-core (booking, pads, balance checks, account lifecycle, the plugin host) and of zhang-query (BQL, the tables, the functions, EXPLAIN, the optimizer’s equivalence checks) the semantics inline text, or a fixture ledger by name
Oracle zhang-query/tests (beanquery, eight case sets under one harness) and the oracle directories of extensions/beancount/tests (beancount) agreement with the reference tools, with every known difference listed as a deviation generated case files over integration-tests/fava-demo-ledger and the oracle ledgers
API the tests/ of zhang-server JSON shapes, status codes, error bodies, writes, ids, paging, authentication scratch ledgers and the kit’s http harness
Golden the golden suites of zhang-server/tests on every fixture ledger, the API answers what the engine, the store or an independent computation says; the ids of transactions stay what the golden file holds zhang_testkit::fixtures
End to end zhang-cli (one test per integration-tests/ directory, plus process tests of the binary) and frontend (pnpm test) the whole application the fixture corpus

The web UI tests are plain node --test files next to the code they test (frontend/src/**/*.test.ts).

  • A parsing or writing rule of .zhang or .bean: the dialect tests of that format’s crate. A rule both formats share goes into the shared scenario, with one expectation per dialect.
  • A booking, pad, balance, account or plugin-host fact: the tests/ of zhang-core. A BQL fact: the tests/ of zhang-query.
  • “beanquery answers X”: a case in the beanquery case sets of zhang-query/tests, generated with generate.py --set <set>; never a hand-written expected value. “beancount does X”: the oracle directory of that behaviour in extensions/beancount/tests, regenerated by its generate.py.
  • An HTTP contract: the tests/ of zhang-server, at handler level (respond(handler(state, ..).await)); through the router (send, call) only when the middleware, the path encoding, the content type or an extractor’s rejection is the point.
  • An invariant over every ledger: a golden suite of zhang-server/tests, iterating zhang_testkit::fixtures::every_fixture_ledger(), split into parts so that no single test runs for more than a few seconds.
  • A bug that needs a real ledger shape and an API answer: a directory in integration-tests/ with main.zhang (or main.bean, or both) and a validations.json. The end-to-end run checks the validations, and every golden suite loads the ledger from then on.
  • A web UI function: frontend/src/**/<name>.test.ts.
  • Test names are sentences in snake_case that state the fact: a_pad_books_the_difference_now, every_ledger_has_an_oracle.
  • A test never runs cargo, the network or a shell, except zhang-server/tests/sdk_plugins.rs, which builds the example plugins for wasm32-unknown-unknown. It needs the target installed; locally it is skipped without it, and left out with -E 'not binary(sdk_plugins)'.
  • A test that runs for more than a few seconds is split by layout: slices of its inputs, each a test of its own, with one test asserting that the slices cover every input. The inputs themselves are never dropped.
  • A golden file lies next to its test and is rewritten by running the test with UPDATE_GOLDEN=1 (zhang_testkit::golden); the diff of the file is then what the reviewer reads. Oracle cases are only ever regenerated by their generator with the tool versions its README.md pins; a difference zhang keeps on purpose is a deviation entry there, not an edited expectation.
  • Unit tests keep their own helpers. zhang-testkit is for tests/ only: a library compiled with cfg(test) and the copy of the same library the kit links against are two different crates to the compiler, so their types do not match.
  • Balance assertions in test ledgers are exact unless they are written with a ~ tolerance. In a .zhang file an indented line starting with * is a posting flag and one starting with # is a comment; .bean files follow beancount.
  • The in-source tests of a crate must build without the features other crates turn on: cargo test -p zhang-core runs without the plugin runtime, which zhang-server enables.

zhang-testkit is a dev-dependency of the crates with integration tests. It loads and compares; what a test expects stays in the test.

Need Use
a ledger from text ledger::load_text, ledger::load_text_as (a .bean main file), ledger::load_text_at (with a clock), ledger::load_transformed
a ledger on disk the test writes to or reloads ledger::Scratch (zhang, beancount, with_files, copy_of; removed on drop)
the fava demo ledger fixtures::fava_demo (loaded once per process) or ledger::fava_demo_ledger (a fresh load)
every ledger of integration-tests/ and examples/, or one by name fixtures::every_fixture_ledger, fixtures::fixture_ledger, fixtures::fixture_dir
the beancount oracle ledgers fixtures::oracle_ledgers
a ledger of either format from a directory fixtures::load_dir
a reproducible random source XorShift
the dialect scenarios: random transactions and text layouts, where metadata landed dialect
a golden file golden::assert_text, golden::assert_json
a beanquery case set, run and compared under one set of rules (feature query) oracle
a handler’s State, a response as JSON, the server’s router and requests through it (feature server) http

The crate’s own documentation (cargo doc -p zhang-testkit --features query,server --open) lists every entry point.

Terminal window
cargo nextest run # every Rust test
cargo nextest run -p zhang-query # one crate
cargo nextest run -E 'test(journals)' # tests whose name matches
cargo test --doc # the doctests (nextest does not run them)
cargo test -p zhang-core # zhang-core alone, without the plugin runtime
cd frontend && pnpm test # the web UI

Running a single integration-test binary still works with cargo test -p <crate> --test <binary>. CI runs the Rust tests in three groups, zhang-query, zhang-server and the rest, each as cargo nextest run with a package filter, plus the doctests and cargo test -p zhang-core in the last group; a test’s group is its package.

A crate’s integration tests are one binary: tests/<name>/main.rs declares the modules, one file per area under tests/<name>/ (zhang-core/tests/core/, zhang-server/tests/api/ and tests/golden/, and so on). Every integration-test binary links the crate and its few hundred dependency crates, so 74 of them cost half of a CI job’s build phase, while the merged crate’s extra front-end work was measured at well under a second. A test keeps a binary of its own only when it changes process-global state, which would leak into the tests running next to it: an environment variable read once per process (zhang-server/tests/account_journal_limit.rs), the working directory (zhang-server/tests/document_cache.rs), and sdk_plugins.rs, which runs cargo. Nextest runs every test in its own process either way, so the merge changes nothing about isolation at run time.

Add a test to the module that owns its area; add a module (a file under tests/<name>/ and a mod line in main.rs) only for a new area; add a binary only for process-global state, and say why in its first doc comment.