Testing¶
The suite is the specification of everything this documentation claims. If a page
here says a gate discards refused text, tests/unit/test_rejection.py is what
makes that true tomorrow.
The reference is tests/README.md,
in the repository. It is written by the people who own the suite and it says what
may and may not go in each slice. This page does not repeat it — it says what the
layout is, how to run it, and which tests are load-bearing for the promises made
on the other pages.
The layout¶
The suite is split by what a test is allowed to touch, not by what it is about:
tests/
conftest.py the synthetic PDFs — the only conftest, deliberately
unit/ one module under test, no rendering, no engine
integration/ several layers at once: the cascade, engines, workers
contract/ protocol conformance and substitutability
packaging/ the repository's own guarantees
The obvious alternative — one directory per package — is the arrangement this
replaced. Splitting by reach means the first thing a failure tells you is how
far the damage travelled: a red unit is one function; a red integration with
a green unit is a seam between layers, which is a different bug with a
different fix.
.venv/bin/python -m pytest tests/unit # the edit loop, under a second
.venv/bin/python -m pytest tests/integration # the seams
.venv/bin/python -m pytest tests/contract # the published contracts
.venv/bin/python -m pytest tests/packaging # what ships
.venv/bin/python -m pytest # everything — and what CI means
make test # the whole suite
make test-unit
make test-integration
make all # lint + typecheck + test + privacy, the pre-push gate
A bare pytest still runs everything: the split is addressed by path, never by
an addopts default that would let a slice quietly stop running.
Markers¶
addopts carries --strict-markers, so an unregistered marker is an error
rather than a typo that silently deselects nothing. Three are declared in
pyproject.toml: slow, apple, paddle. tests/README.md has the table and
what CI does with each.
The tests this documentation leans on¶
Every claim on the other pages is pinned somewhere. When you change behaviour, these are the files that will tell you the documentation has gone stale:
| what it guards | test |
|---|---|
| the README's API names, CLI subcommands, flags and extras all exist | tests/packaging/test_documentation.py |
Config has no host, port, URL or credential field |
tests/unit/test_config.py::test_no_field_points_at_a_network |
every shipped implementation still satisfies its protocol, by isinstance and by signature |
tests/contract/test_interfaces.py |
interfaces imports nothing at runtime, so the layering is a fact |
tests/contract/test_interfaces.py::test_the_module_pulls_in_nothing_at_import_time |
DocumentContext withholds readings and texts from steps |
tests/contract/test_interfaces.py::test_the_step_view_withholds_the_cascade_s_own_evidence |
the Gate protocol restates no measured threshold of its own |
tests/contract/test_interfaces.py::test_the_gate_contract_repeats_no_threshold_of_its_own |
| an engine and a step written from the protocol alone descend the real cascade | tests/contract/test_interfaces.py (FakeEngine, FakeStep, FakeContext) |
| the platform marker (install-time) | tests/packaging/test_packaging.py |
| the runtime registry (availability) | tests/integration/test_shipped_engines.py, tests/unit/test_registry.py |
| the pattern catalogue: resolution order, overlay, validation at load | tests/unit/test_patterns.py |
| Layer 2 never reuses the main OCR instance | tests/unit/test_paddle.py |
remote steps require an explicit url |
tests/unit/test_remote_steps.py, tests/integration/test_remote.py |
Two rules that will bite you¶
No real document, ever¶
The fixtures are generated on the fly by PyMuPDF with invented text
(tests/conftest.py). .gitignore blocks *.pdf at the root and pre-commit
blocks any added file above 1 MB. A failing test must point at the code, not at
one specific archive file.
That applies to identifiers inside comments and docstrings too. Documenting a
measurement with the case number it was made on looks harmless and is not:
scripts/privacy_check.py caught a real case number that had made its way into
this library's own examples, with a valid check digit. The examples use numbers
with an invalid check digit on purpose — that way the scanner stays quiet and
nobody has to decide case by case whether a number exists. See
ADR 0011.
The scanner runs as the first pre-commit hook, before the style ones, and on every CI push. The order is deliberate: a commit blocked on formatting costs thirty seconds; a real document published has no undo.
pre-commit inspects what will be committed
It stashes unstaged changes before running. Fixing a file without git add
leaves the hook looking at the old version.
The suite must pass on a bare pip install -e ".[dev]"¶
That is how most people will have the library, and it is what the CI quality matrix checks on 3.11, 3.12 and 3.13. A test that quietly needs an engine turns somebody else's CI red for a reason they cannot act on: mark it, or make it skip itself with a reason.
The guard rails¶
A handful of tests exist to protect an invariant rather than a behaviour: no
network field on Config, no real document in the repository, no protocol that
has drifted from what the code requires, no engine that raises instead of
reporting. What each one guards, and what to do when one of them goes red, is
written down once in
.github/GUARDRAILS.md.
Read it before deleting or xfail-ing anything in the table above — a guard rail
that is inconvenient is doing its job.
Writing a test for an extension¶
The payoff of the interfaces is that you do not need a PDF, an engine or PyMuPDF to exercise a step or an engine you wrote:
from autosxtract import Engine, Step, DocumentContext
assert isinstance(MyEngine(), Engine)
assert isinstance(MyStep(), Step)
Copy FakeContext and _assert_signature from tests/contract/test_interfaces.py.
isinstance against a runtime-checkable Protocol only verifies that the names
exist; the signature comparison is what catches the drift that motivated the
interfaces module in the first place.
Documentation and notebooks¶
make docs # builds this site if mkdocs.yml is present
make notebooks # executes every notebook headlessly
The site is built with --strict in CI, which turns two silent kinds of rot into
failures: a link to a page that was renamed, and a page that exists but is in no
navigation entry, so it is reachable only by guessing the URL.
.venv/bin/pip install mkdocs mkdocs-material # NOT project dependencies
.venv/bin/python -m mkdocs serve # live reload while writing
.venv/bin/python -m mkdocs build --strict # what CI runs
The pins live in docs/requirements.txt, which the docs workflow prefers over
its own fallback list. Neither ever enters pyproject.toml: pip install
autosxtract must not drag a documentation toolchain onto a user's machine.
A notebook that no longer runs is documentation that lies, which is why make
notebooks executes them rather than linting them.