Test Fixture Pattern — Module-Scoped Immutable Template + Per-Test Copy
A reusable performance pattern distilled from the 2026-08-05 tests/tools/ test_search.py surgery in orchestrata-ultimate.
The lesson (measured)
The suite's rule is function-scope fixtures only (no module/class scope for
mutable state). Applied blindly, a function-scoped real_registry fixture rebuilt
build_tool_registry() for every test — and ToolSearchTool.create() runs a
cache-warm fuzzy sweep (~0.6–0.9 s of difflib.SequenceMatcher) each time. Paid
~128×/module, that pure rebuild cost dominated the module:
- Solo wall: 43 s → 22 s after the fix, 80/80 tests unchanged.
- The module had been a ~150 s LPT batch anchor in the gate's longest lane (batched executor packs by longest-processing-time; one slow module stretches the whole gate).
The pattern
- Build the expensive object ONCE in a module-scoped fixture, treating it as an immutable template — nothing may mutate it after construction.
- Each test receives a cheap per-test copy made through the public API (a
constructor/factory that reuses the template's expensive precomputed core — never
deepcopyof internals, never handing tests the shared instance itself when they mutate it). - Mark the module-scoped fixture with the sanctioned
# T4-immutable-module-scope: <reason>marker — the explicit, reviewed exemption token for the function-scope-only rule (T4), so the test reviewer passes it deliberately rather than by loophole.
When it applies
- The fixture's product is expensive to build AND immutable after construction (registries built once and only queried, parsed corpora, warmed caches, compiled artifacts).
- Tests only read from it, or operate on per-test copies obtained via public API.
- The rebuild cost is a measured hot spot (profile first — the test_search case was found via per-module duration data, not guesswork).
When function scope must stay
- Any mutable state: registries tests register into, stateful clients, connections to real infra (Redis/graph/kernel), objects carrying per-test isolation ids. Sharing these across tests reintroduces exactly the ordering coupling and batch flakiness the function-scope rule exists to prevent.
- When "immutability" is only conventional and the type offers mutators — unless the per-test copy step guarantees tests never touch the template.
- When the build cost is trivial: keep the simple function-scope form; the marker is for measured wins, not a default.
Pointers (orchestrata-ultimate)
tests/tools/test_search.py (the applied example) · suite standards: AGENTS.md
§ Test Standards, scripts/test-review.py (T-rules) · batch impact:
per-module durations in .cache/test_durations.json drive LPT packing in
scripts/run_tests.py.