Test layout evolution: unit tests vs. legacy test/
When to use
You are about to add the first real unit tests to a repository
that already has a directory called test/ (or tests/), but on
inspection that directory is not unit tests — it drives the
real binary end-to-end against fixture projects, boots a server,
shells out, or otherwise takes seconds per case.
Signal phrases: "there's already a test/ folder, I'll add mine
in there", "let's just add a test_xxx.py next to the existing
integration script", or a PR diff where a 10-ms mocked test
suddenly depends on testdata/, PATH, or network.
Problem
Conflating the two kinds of tests causes recurring pain:
- Speed and feedback loop. Integration suites are slow (real
subprocess, real filesystem, real network) and can't run on
every save. Unit suites are ~10ms and should run on every
save. Put them in one directory and CI either pays the
integration cost every time or never runs the units.
- Import / discovery conflicts. The legacy
test/ is often
not structured as a Python package at all (it's shell scripts
- a driver). Dropping
test_*.py next to run_tests.sh
confuses pytest's collection and custom runners.
- Fixtures mismatch. Integration fixtures tend to live in
testdata/ with real files; unit tests prefer in-memory
mocks. Mixing them invites a unit test to accidentally open
a fixture file and become a slow integration test with a
unit test's name.
- Dependency creep. Unit tests mock
run_command, network,
clocks. Integration tests need those for real. One
conftest.py trying to serve both grows monkey-patches that
silently break the integration side.
- "Just refactor the old tests" is a trap. The integration
suite is usually load-bearing for release gating and has
accumulated non-obvious dependencies (env vars, working-dir
assumptions, testdata). Moving it is a multi-PR project that
should not block shipping the first unit test.
Solution
Introduce a parallel directory, leave the legacy suite alone,
defer the merge:
Pick a layout that makes the two roles obvious and can later
be unified without another rename. Common choices:
src/
test/ ← legacy, integration, runs real binary
tests/unit/ ← new, pure unit, mocked
or, if you prefer the final shape now:
src/test/unit/ ← new
src/test/integration/ ← move legacy here later
Prefer the first form if the legacy suite is large / risky to
touch; prefer the second if the legacy suite is small enough
to move in the same PR.
Document the split in one line in the repo's top-level
README or CONTRIBUTING.md: "src/test/ = integration, runs
real . src/tests/unit/ = unit, mocked, <10ms."
Give each suite its own runner. A runall.sh (or a
pytest config with a marker) per directory keeps local
iteration fast. In CI, run them as separate jobs so a broken
integration test doesn't block a unit-only PR and vice versa.
Ban cross-imports. Unit tests must not import from the
integration suite, and integration tests must not import
from unit helpers. If a helper is useful to both, promote it
to the product code (or a shared tests/common/).
Record the future merge as a TODO, not a blocker. A one-
line note ("will fold into src/test/{unit,integration}/
once the legacy driver is ported") is enough. Do it in a
separate PR when you have cycles.
Example
Real case from blade-build (v3, PR #1093):
src/test/ had long existed: a bash-driven suite that runs
the real blade against src/test/testdata/ subprojects and
diffs the output. Slow, load-bearing, not a Python package.
The v3 bugfix PR needed to add the first mocked unit tests
for ToolChain.cc_is (6 tests, all mock run_command,
milliseconds total).
Instead of dropping toolchain_test.py into src/test/ (and
fighting the existing driver), the PR created:
src/tests/unit/
toolchain_test.py # mocks run_command, pure unittest
runall.sh # `python -m unittest discover -s .`
Legacy src/test/ was untouched.
A note in the PR body and the commit message spelled out that
a future refactor may collapse both into
src/test/{unit,integration}/; the CI wiring for the new
directory was deferred to a follow-up PR (pyright + unit
job together), because that PR was already doing enough.
Net result: the new suite ran and guarded the bug fix without
destabilizing the existing gate.
Pitfalls
- Don't silently shadow.
tests/ vs. test/ at the same
level is fine on Unix but can look like a typo to new
contributors. Call it out in the README or rename one side
(e.g. integration_test/) to make the split unmissable.
- Don't rely on pytest's rootdir auto-magic to keep the two
suites separate. If both directories end up collected,
integration tests will run in your "fast" loop. Either set
testpaths in the unit suite's config or give the suites
distinct markers.
- Don't promise the future merge in the same PR. Adding the
unit directory and moving/renaming the integration suite in
one PR produces a diff that's impossible to review: the
reviewer can't tell whether a failing integration test broke
because of the move or because of the new code.
- Don't let "unit" tests reach for
testdata/. The
temptation is real ("I already have a fixture here…"). As soon
as they do, they're integration tests misfiled, and your
"<10ms" promise is gone.
- CI wiring is a separate concern. Adding the directory in
one PR and adding the CI job in the next is not only OK, it's
often better — it keeps the bug-fix PR small and lets the
CI-plumbing PR focus on caching, matrix, and runtime
tradeoffs without also arguing about test content.
See also
1---2name: test-layout-evolution3description: When adding unit tests to a repo whose `test/` dir is actually integration, create a parallel `tests/unit/` instead of mixing them.4---56# Test layout evolution: unit tests vs. legacy `test/`78## When to use910You are about to add the *first* real unit tests to a repository11that already has a directory called `test/` (or `tests/`), but on12inspection that directory is **not** unit tests — it drives the13real binary end-to-end against fixture projects, boots a server,14shells out, or otherwise takes seconds per case.1516Signal phrases: "there's already a `test/` folder, I'll add mine17in there", "let's just add a `test_xxx.py` next to the existing18integration script", or a PR diff where a 10-ms mocked test19suddenly depends on `testdata/`, `PATH`, or network.2021## Problem2223Conflating the two kinds of tests causes recurring pain:2425- **Speed and feedback loop.** Integration suites are slow (real26 subprocess, real filesystem, real network) and can't run on27 every save. Unit suites are ~10ms and should run on every28 save. Put them in one directory and CI either pays the29 integration cost every time or never runs the units.30- **Import / discovery conflicts.** The legacy `test/` is often31 not structured as a Python package at all (it's shell scripts32 + a driver). Dropping `test_*.py` next to `run_tests.sh`33 confuses pytest's collection and custom runners.34- **Fixtures mismatch.** Integration fixtures tend to live in35 `testdata/` with real files; unit tests prefer in-memory36 mocks. Mixing them invites a unit test to accidentally open37 a fixture file and become a slow integration test with a38 unit test's name.39- **Dependency creep.** Unit tests mock `run_command`, network,40 clocks. Integration tests need those for real. One41 `conftest.py` trying to serve both grows monkey-patches that42 silently break the integration side.43- **"Just refactor the old tests"** is a trap. The integration44 suite is usually load-bearing for release gating and has45 accumulated non-obvious dependencies (env vars, working-dir46 assumptions, testdata). Moving it is a multi-PR project that47 should not block shipping the first unit test.4849## Solution5051Introduce a **parallel directory**, leave the legacy suite alone,52defer the merge:53541. Pick a layout that makes the two roles obvious and can later55 be unified without another rename. Common choices:5657 ```58 src/59 test/ ← legacy, integration, runs real binary60 tests/unit/ ← new, pure unit, mocked61 ```6263 or, if you prefer the final shape now:6465 ```66 src/test/unit/ ← new67 src/test/integration/ ← move legacy here later68 ```6970 Prefer the first form if the legacy suite is large / risky to71 touch; prefer the second if the legacy suite is small enough72 to move in the same PR.73742. **Document the split** in one line in the repo's top-level75 README or `CONTRIBUTING.md`: "`src/test/` = integration, runs76 real <tool>. `src/tests/unit/` = unit, mocked, <10ms."77783. **Give each suite its own runner.** A `runall.sh` (or a79 pytest config with a marker) per directory keeps local80 iteration fast. In CI, run them as separate jobs so a broken81 integration test doesn't block a unit-only PR and vice versa.82834. **Ban cross-imports.** Unit tests must not import from the84 integration suite, and integration tests must not import85 from unit helpers. If a helper is useful to both, promote it86 to the product code (or a shared `tests/common/`).87885. **Record the future merge as a TODO**, not a blocker. A one-89 line note ("will fold into `src/test/{unit,integration}/`90 once the legacy driver is ported") is enough. Do it in a91 separate PR when you have cycles.9293## Example9495Real case from blade-build (v3, PR #1093):9697- `src/test/` had long existed: a bash-driven suite that runs98 the real `blade` against `src/test/testdata/` subprojects and99 diffs the output. Slow, load-bearing, not a Python package.100- The v3 bugfix PR needed to add the first mocked unit tests101 for `ToolChain.cc_is` (6 tests, all mock `run_command`,102 milliseconds total).103- Instead of dropping `toolchain_test.py` into `src/test/` (and104 fighting the existing driver), the PR created:105106 ```107 src/tests/unit/108 toolchain_test.py # mocks run_command, pure unittest109 runall.sh # `python -m unittest discover -s .`110 ```111112- Legacy `src/test/` was untouched.113- A note in the PR body and the commit message spelled out that114 a future refactor may collapse both into115 `src/test/{unit,integration}/`; the CI wiring for the new116 directory was **deferred to a follow-up PR** (pyright + unit117 job together), because that PR was already doing enough.118119Net result: the new suite ran and guarded the bug fix without120destabilizing the existing gate.121122## Pitfalls123124- **Don't silently shadow.** `tests/` vs. `test/` at the same125 level is fine on Unix but can look like a typo to new126 contributors. Call it out in the README or rename one side127 (e.g. `integration_test/`) to make the split unmissable.128- **Don't rely on pytest's rootdir auto-magic** to keep the two129 suites separate. If both directories end up collected,130 integration tests will run in your "fast" loop. Either set131 `testpaths` in the unit suite's config or give the suites132 distinct markers.133- **Don't promise the future merge in the same PR.** Adding the134 unit directory and moving/renaming the integration suite in135 one PR produces a diff that's impossible to review: the136 reviewer can't tell whether a failing integration test broke137 because of the move or because of the new code.138- **Don't let "unit" tests reach for `testdata/`**. The139 temptation is real ("I already have a fixture here…"). As soon140 as they do, they're integration tests misfiled, and your141 "<10ms" promise is gone.142- **CI wiring is a separate concern.** Adding the directory in143 one PR and adding the CI job in the next is not only OK, it's144 often *better* — it keeps the bug-fix PR small and lets the145 CI-plumbing PR focus on caching, matrix, and runtime146 tradeoffs without also arguing about test content.147148## See also149150- [detect-tool-vendor-by-query](../detect-tool-vendor-by-query/SKILL.md)151- [agent-work-artifacts-layout](../agent-work-artifacts-layout/SKILL.md)152- [python-code-audit-sweep](../python-code-audit-sweep/SKILL.md)