Skip to content

check_test_layout

rhiza_hooks.check_test_layout

Check that the test layout mirrors the source layout.

Enforces a strict test/source parity so tests are easy to locate and no test drifts loose from what it covers:

  • every source module <src>/…/xyz.py has a test file <tests>/…/test_xyz.py (nested packages are mirrored);
  • every top-level class A in a source module has a matching TestA class in that test file;
  • no test file lacks a corresponding source module (no orphan test files);
  • no Test* class lacks a corresponding source class (no orphan test classes).

The reverse direction is the one that pays for itself: a renamed or retired module leaves its tests behind, and those tests keep passing against nothing.

__init__.py and conftest.py are ignored on both sides, and the tests/benchmarks/ and tests/stress/ trees are exempt entirely — those hold benchmarks and stress tests that need not mirror a source module. Test functions are unconstrained — the rules bind files and classes only.

Repositories that deliberately organise tests by behaviour rather than 1:1 mirroring (and guarantee per-module coverage another way, e.g. a 100% coverage gate) can opt out via a [tool.check_test_layout] table in pyproject.toml::

[tool.check_test_layout]
enforce = false
reason = "Tests are grouped by behaviour; coverage is enforced by pytest."

enforce = false requires a non-empty reason so the deviation is always documented — an undocumented opt-out is indistinguishable from neglect. The same table accepts exempt_dirs = [...] to extend the built-in benchmarks/stress exemptions when parity is enforced, and exempt_files = [...] to exempt individual test files by their path relative to the tests root — for a single loose file, exempt_dirs cannot express it, since its first path component is the file itself and exempting that reads as a directory that does not exist.

The configuration lives in pyproject.toml rather than under .rhiza/ deliberately: the layout it describes is a property of the Python project, not of the template that syncs its infrastructure, and a repo that is not rhiza-managed must still be able to configure this hook.

Exit codes

0 - the layout is clean, or parity is intentionally not enforced 1 - violations were found (every one is listed), or the opt-out is misconfigured

check(src, tests, config=None)

Return a list of layout violations (empty when the layout is clean).

The two directions are independent passes — see :func:_forward_violations and :func:_orphan_violations — and this is their concatenation, forward first so a missing test file is reported before its knock-on orphans.

Source code in rhiza_hooks/check_test_layout.py
def check(src: Path, tests: Path, config: Mapping[str, object] | None = None) -> list[str]:
    """Return a list of layout violations (empty when the layout is clean).

    The two directions are independent passes — see :func:`_forward_violations`
    and :func:`_orphan_violations` — and this is their concatenation, forward
    first so a missing test file is reported before its knock-on orphans.
    """
    config = config or {}
    return [
        *_forward_violations(src, tests),
        *_orphan_violations(src, tests, _exempt_dirs(config), _exempt_files(config)),
    ]

main(argv=None)

Run the hook and return a process exit code.

--src/--tests/--config are resolved against the current working directory (pre-commit runs hooks from the repository root); the defaults are anchored to the repository root itself, so the hook also behaves when invoked from a subdirectory by hand.

Source code in rhiza_hooks/check_test_layout.py
def main(argv: list[str] | None = None) -> int:
    """Run the hook and return a process exit code.

    ``--src``/``--tests``/``--config`` are resolved against the current working
    directory (pre-commit runs hooks from the repository root); the defaults are
    anchored to the repository root itself, so the hook also behaves when invoked
    from a subdirectory by hand.
    """
    parser = argparse.ArgumentParser(description="Check test/source layout parity.")
    parser.add_argument(
        "filenames",
        nargs="*",
        help="Filenames (ignored: parity is a property of the whole tree, not of one file)",
    )
    parser.add_argument("--src", default=None, help="Source directory (default: <repo root>/src).")
    parser.add_argument("--tests", default=None, help="Tests directory (default: <repo root>/tests).")
    parser.add_argument(
        "--config",
        default=None,
        help="pyproject.toml providing [tool.check_test_layout] (default: <repo root>/pyproject.toml).",
    )
    args = parser.parse_args(argv)

    repo_root = find_repo_root()
    src = Path(args.src) if args.src else repo_root / "src"
    tests = Path(args.tests) if args.tests else repo_root / "tests"
    config = _read_config(Path(args.config) if args.config else repo_root / "pyproject.toml")

    if not config.get("enforce", True):
        reason = str(config.get("reason", "")).strip()
        if not reason:
            print(
                "Test-layout check misconfigured: [tool.check_test_layout] enforce=false "
                "requires a non-empty 'reason' documenting the intentional layout.",
                file=sys.stderr,
            )
            return 1
        print(f"Test layout OK: parity not enforced by request — {reason}")
        return 0

    errors = check(src, tests, config)
    if errors:
        print("Test-layout check failed:", file=sys.stderr)
        for err in errors:
            print(f"  ✗ {err}", file=sys.stderr)
        return 1
    print("Test layout OK: tests mirror sources 1:1")
    return 0