Test architecture¶
Tests are organized into three tiers. CI runs Tier 1 only — it is fast and deterministic. Tiers 2 and 3 are heavier and run in a developer worktree or before a release. Please run at least Tier 1 locally before opening a PR.
Tier 1 — CI (fast, deterministic)¶
What every PR must pass. Mirrors .github/workflows/ci.yml.
# Rust unit + integration (~1,025 tests across 23 binaries plus 12 doctests).
# --no-default-features disables the Python-linking extension-module feature.
PYO3_PYTHON=$(which python3) cargo test --no-default-features
# Python deterministic tests (~4,490). Since #658 this is what bare `pytest` runs.
pytest
# The serial tier: wall-clock parallelism tests that cannot share the box (#997 review).
pytest -m serial -n 0
CI's own command is pytest tests/ --ignore=tests/test_typing.py -m "not formal and
not hypothesis and not serial", followed by the serial tier in a step of its own, so
the two marker expressions are not identical — the local default also carries
not slow. Nothing in that tier executes under CI conditions
regardless: measured with CI=1 and no bench extra, all five slow tests skip. The
executed set matches; the expression does not, and a green local run means what a
green CI run means for that reason rather than by definition.
Counts were stale in both directions before #658 and are worth stating measured
rather than approximated, because they are how a reader notices a tier stopped
running. The three opt-in tiers below are excluded from the default by addopts
and each is one command away.
build.rs compile-time assertions are always on at zero runtime cost: they assert that
every transliteration table value is ASCII, that the tr39 digit-policy override values
are ASCII (#587), and that entry counts match expectations. If one fails, cargo build
fails.
Drift gates¶
Four checks in Tier 1 guard something a normal test cannot: they compare a generated or published artifact against the source of truth, so they fail when the two drift apart rather than when behaviour is wrong. Each exists because the drift they catch happened.
| Gate | Guards | Fails when |
|---|---|---|
bindings/cabi/disarm.h diff (C ABI (safer-ffi) job) |
The committed C header | An exported signature changes without the header being regenerated (#580) |
tests/test_doc_table_counts.py |
32 documented row counts across 16 files | A table is regenerated and prose still quotes the old figure (#591) |
build.rs ASCII assertions |
Generated table values | A generated value is non-ASCII, against #341's contract (#587) |
JvmSignatureTest (Java binding (JDK …) job) |
Published JVM signatures | A Kotlin default argument deletes an arity that shipped (#588) |
Two of them read a build product rather than source text, which is the point:
JvmSignatureTest reflects over the compiled facade, and the header gate diffs the
regenerated header. Source-level assertions would not have caught either defect.
When you regenerate a table, read the data diff, not just the test output. A change to
gen_confusables.py can silently remove rows, and a passing suite does not prove it
did not — that is how an over-broad filter deleted Ç → C during #593.
Tier 2 — Hypothesis / property-based (opt-in)¶
Property-based / fuzz tests across the Unicode input space. 587 tests, ~67s on a release build — the figures here read "~440 / ~40s" until #658 measured them.
pytest -m hypothesis
Bare pytest used to include these, which meant a contributor paid the tier on
every local run while no CI job ran it. nightly-hypothesis.yml runs it at 03:17
UTC with a freshly generated seed and a 10× oracle budget, which explores more
input space than one more fixed-seed pass ever did. Run it locally when you touch
the input-handling boundary; the nightly is the safety net.
Until the #997 review the nightly was a fixed-seed pass, twice over.
--hypothesis-seed=random is not a request for a random seed: Hypothesis tries
int() on it and, failing, seeds with the string "random" — the same every night.
And on GitHub Actions no seed would have helped: Hypothesis loads its own ci profile
whenever CI is set, and that profile's derandomize=True makes every test ignore
--hypothesis-seed. The workflow now generates a seed, logs it (and quotes it in the
failure issue), and runs under a nightly profile registered in tests/conftest.py —
the ci settings minus derandomize. To reproduce a nightly failure:
pytest -m hypothesis --hypothesis-profile=nightly --hypothesis-seed=<seed from the log>
Tier 2b — Expensive, opt-in (slow)¶
pytest -m slow
The slow marker existed, described itself as deselectable, and nothing deselected
it (#658) — so it had no effect and everyone paid it. Both things it covers are
gated elsewhere:
test_cabi_header_driftmirrors thecabiCI job and skips underCI. It costs a coldcargobuild — about 25s — on the first run after a Rust change, and it appends a[patch.crates-io]block tobindings/cabi/Cargo.tomlthat an interrupted run leaves behind.test_performance_claims' ratio floors need the pinned comparators from thebenchextra and skip without them. They also fail against a debugmaturin developbuild, which is a false alarm rather than a regression.
The suite runs in parallel by default¶
-n auto --dist loadfile is in addopts, so bare pytest already uses every core.
Pass -n 0 to turn it off when you want a debugger, or a stable test order — or when
you are running one small file. Worker startup is about 0.6s on four cores, which a
whole suite repays many times over and a single file does not: tests/test_slugify.py
is 1.1s under -n auto and 0.5s under -n 0. (An earlier version of this section
said a single file was, if anything, faster in parallel; it was measured on a file
heavy enough to hide the startup.)
--dist loadfile is not optional. register_lang mutates process-global state
that cannot be undone, so tests must stay grouped by file; per-file distribution
preserves that.
Measured on four cores, without coverage:
| serial | -n 2 |
-n auto |
|
|---|---|---|---|
| whole suite | 93.4s | 65.2s | 35.1s |
An earlier version of this section reported serial 6.1s and concluded auto
over-provisions and CI should stay serial. That was true of a six-second suite and
stopped being true as it grew — the advice outlived its measurement by enough to cost
about a minute a run. If you change the suite's shape, re-measure this table rather
than trusting it.
--dist loadgroup, pinning only the modules that touch process-global state so the rest
distribute per test, looks like it should beat loadfile and does not: 45.0s, and it
fails test_docs_index_drift, whose tests depend on sharing a worker.
Coverage, not the test count, is most of CI's wall clock. COVERAGE_CORE=sysmon
puts coverage.py on CPython 3.12's sys.monitoring rather than its settrace hook —
120.4s against 34.6s here, with the measurement identical to the statement. CI sets it
on the test job; it needs 3.12, so it is not set in pyproject.toml, where a
contributor on 3.10 would meet a fallback warning.
Anything that spawns pytest per file must pass -n 0. scripts/run_doc_tests.py
runs one pytest process per doc page, several at once; each inherited -n auto and
started a full set of workers to run a handful of examples — 34.8s for the whole run,
against 5.8s with -n 0. Use -n 0, not -p no:xdist: unloading the plugin leaves
the -n auto in addopts as an unrecognised option.
The serial tier¶
A test that measures wall-clock parallelism cannot run beside xdist workers. #70's
GIL-release guard asserts that two threads finish two batches faster than one thread
can, which needs an idle core, and under -n auto every core has a worker on it. It
used to skip itself in that case — correct, and it meant CI, which runs -n auto, never
ran it at all.
Those tests are marked @pytest.mark.serial. addopts deselects them, CI's parallel
step deselects them in its own -m, and a separate CI step runs pytest -m serial -n 0
with the runner to itself. tests/test_serial_tier.py fails if any of the three goes
missing. Locally, run pytest -m serial -n 0. The guard counts the cores in the
process's affinity mask (taskset, a container's cpuset), not the host's, and still
skips below two.
Don't let a stray virtualenv into the corpus¶
Two modules walk the whole tree — test_code_context_profile and
test_tree_invisible_characters — and both filter it through conftest.in_skipped_dir,
which finds virtual environments by pyvenv.cfg rather than by the name .venv.
(test_scan also mentions .venv, but it exercises the shipped scanner's own skip list
inside a tmp_path and never touches the repository.) An environment called venv/,
env/, .tox/ or .venv312/ would otherwise put site-packages in the corpus: slower,
and a false positive waiting for the first dependency that ships a literal bidi control
in a fixture.
Three details, each of which was once wrong:
- An environment is a path, not a name. A file is skipped when one of the detected
directories contains it, so tox's
.tox/docsdoes not take this repository'sdocs/with it. - Skip names are matched against the path inside the repository, never the absolute
one. Matching the absolute path emptied the corpus of any checkout under a directory
called
build,tmporpkg. .venvis still skipped by name at any depth, alongside the marker: a conda environment has nopyvenv.cfg, and the marker search only goes two levels down.
tests/test_corpus_excludes_virtualenvs.py runs each module's own collector over
synthetic trees for all of this, so it checks the property on every run — including CI,
where there is no virtual environment in the checkout to catch.
Tier 3 — Formal / pre-release (gated, opt-in)¶
Exhaustive enumeration — every Hangul syllable (11,172), the full BMP (63,488 code
points) both bare and crossed with composing marks, all CJK ideographs, 15 Indic blocks
— plus the seven formalized invariants (I1–I7). .github/workflows/tier3.yml runs the
same set.
Three of these now run in PR CI rather than only here (#658): the
transliterate, grapheme and width targets cost 0.62s against a profile the test
job has already built, so a regression in them surfaces on the pull request that
caused it. width_conformance was in no workflow and no documented gate before
that, so nothing had ever run it.
The three of them in one invocation, which is how CI runs them — each
cargo test repeats metadata resolution, so three invocations cost more than
three times the test time suggests:
# exhaustive_transliterate (16 tests), exhaustive_grapheme (#174, 4),
# width_conformance (#224, 1). All #[ignore]d; all also run in PR CI.
cargo test --no-default-features \
--test exhaustive_transliterate \
--test exhaustive_grapheme \
--test width_conformance \
-- --ignored
# Confusables on the Layer-2 API: the BMP crossed with composing marks, checked for
# idempotence and for output that is still confusable (#586). Deliberately separate
# from the lib-level sweep below, which tests Layer 1 — testing the layer beneath the
# one the bindings call is how #586 survived a year. The same file sweeps
# `skeleton_key` for idempotence over every scalar, and over the BMP crossed with
# every composing mark with and without a control between (F1 of the Lean model in
# formal/lean/Confusables). Under --release that is about a minute; the fold sweeps
# alone take about a second.
cargo test --no-default-features --release \
--test exhaustive_confusables -- --ignored
# The anomaly detector gives NFC and NFD spellings one verdict, over every Unicode scalar
# alone and in seven contexts (Finding 3 of the Lean model in formal/lean/Detection).
# PR CI runs the normalization-active subset from the same file. --release keeps it
# near 20s.
cargo test --no-default-features --release \
--test exhaustive_anomalies -- --ignored
# Lib-level ignored tests: the Layer-1 fold∘compose gate (#522) and the presets
# non-ASCII fast-path sweep, both unreachable from an integration test.
cargo test --no-default-features --release --lib -- --ignored
# Python formal invariant tests (12 tests)
pytest -m formal
# Docs site — --strict fails on broken internal links and missing-nav pages.
# CI's docs.yml runs this too, but is path-filtered (docs/**, mkdocs.yml,
# python/disarm/**), so a version-bump-only release PR never triggers it.
pip install --require-hashes -r requirements/docs.txt
mkdocs build --strict
Please don't remove
#[ignore],@pytest.mark.formal, or@pytest.mark.hypothesisfrom these tests — they are excluded from CI intentionally. If you add new property-based tests, mark them withpytestmark = pytest.mark.hypothesis.