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.
Layers
Section titled “Layers”| 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).
Where a new test goes
Section titled “Where a new test goes”- A parsing or writing rule of
.zhangor.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/ofzhang-core. A BQL fact: thetests/ofzhang-query. - “beanquery answers X”: a case in the beanquery case sets of
zhang-query/tests, generated withgenerate.py --set <set>; never a hand-written expected value. “beancount does X”: the oracle directory of that behaviour inextensions/beancount/tests, regenerated by itsgenerate.py. - An HTTP contract: the
tests/ofzhang-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, iteratingzhang_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/withmain.zhang(ormain.bean, or both) and avalidations.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.
Conventions
Section titled “Conventions”- Test names are sentences in
snake_casethat 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 forwasm32-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 itsREADME.mdpins; a difference zhang keeps on purpose is a deviation entry there, not an edited expectation. - Unit tests keep their own helpers.
zhang-testkitis fortests/only: a library compiled withcfg(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.zhangfile an indented line starting with*is a posting flag and one starting with#is a comment;.beanfiles follow beancount. - The in-source tests of a crate must build without the features other crates turn on:
cargo test -p zhang-coreruns without the plugin runtime, whichzhang-serverenables.
The test kit
Section titled “The test kit”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.
Running the suites
Section titled “Running the suites”cargo nextest run # every Rust testcargo nextest run -p zhang-query # one cratecargo nextest run -E 'test(journals)' # tests whose name matchescargo test --doc # the doctests (nextest does not run them)cargo test -p zhang-core # zhang-core alone, without the plugin runtimecd frontend && pnpm test # the web UIRunning 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.
Binary layout
Section titled “Binary layout”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.
