# Test Infrastructure

> **What this is.** The fixtures, builders, helpers, conventions, and
> tool wiring that the tests COMPOSE WITH. Read after
> [`TAXONOMY.md`](TAXONOMY.md) (which tells you what to test) and
> before [`RECIPES.md`](RECIPES.md) (which shows you how to write one).
>
> Reusable: §1 (markers), §2 (directory layout), §4 (gate runner) port
> verbatim. §3 (fixtures) and §5 (subprocess harness) port with rename.

## §1. Pytest markers

The marker set MIRRORS the test taxonomy. Adding a marker requires
adding a row to `pyproject.toml`'s `[tool.pytest.ini_options].markers`
— `--strict-markers` is on, so a typo is a hard fail (TS-04).

| Marker | Default? | Tag tests when… |
|---|---|---|
| (none) | ✅ runs | Unit, functional, integration, contract, property, concurrency. The default suite. |
| `smoke` | ✅ runs | Build-broken / boot-broken detection. Fast. |
| `e2e` | opt-in | Needs the broker binary built + spawnable. |
| `use_case` | opt-in | Reproduces a FEEDBACK paper or a `recipes/` doc. |
| `fuzz` | ✅ runs | Random / generated inputs. Fast variants in default; long variants nightly. |
| `stress` | nightly | Sustained-load. Long-running. |
| `perf` | nightly | Has a numeric latency / throughput budget. |
| `conformance` | release | Asserts a G1–G4 / S1–S5 design promise. |
| `contract` | ✅ runs | Wire format / API surface pin. |

Convention: a test can carry multiple markers when it genuinely
straddles. The most-expensive marker wins for gating (`pytest -m
'not e2e'` excludes anything tagged `e2e`, regardless of other tags).

## §2. Directory layout

```
mgf-hrb/
├── tests/                      ← Python tests
│   ├── unit/                   default suite (unit + functional + integration + contract + property)
│   │   └── mgf/hrb/
│   │       ├── test_client.py
│   │       ├── test_domain.py
│   │       ├── test_cli.py
│   │       ├── test_cli_doctor.py
│   │       ├── test_cli_explain.py
│   │       ├── test_config.py
│   │       └── test_identity.py
│   ├── smoke/                  @pytest.mark.smoke  (planned — see COVERAGE_MAP)
│   ├── e2e/                    @pytest.mark.e2e
│   │   └── test_e2e.py
│   ├── use_cases/              @pytest.mark.use_case  (planned)
│   ├── fuzz/                   @pytest.mark.fuzz  (planned)
│   ├── stress/                 @pytest.mark.stress  (planned)
│   ├── perf/                   @pytest.mark.perf  (planned)
│   ├── conformance/            @pytest.mark.conformance  (planned — S-suite for SDK)
│   └── conftest.py             shared fixtures (when needed)
│
├── hrb-code/src/
│   ├── *_test.zig              Zig unit + functional + concurrency
│   ├── conformance.zig         S1–S5 conformance (Zig side)
│   ├── stub_usb_test.zig       stress (Zig side)
│   ├── ring_test.zig           includes a BENCHMARK marker
│   └── ...
│
└── docs/testing/               ← this directory
    ├── TAXONOMY.md
    ├── INFRASTRUCTURE.md       ← you are here
    ├── EXECUTION.md
    ├── COVERAGE_MAP.md
    └── RECIPES.md
```

Two conventions:

1. **Python**: tests live UNDER `tests/`, grouped by marker family.
   File names mirror the module under test (`test_client.py` →
   `mgf.hrb._client`).
2. **Zig**: tests live next to source as `<module>_test.zig`. This
   is the Zig idiom; do not move to a separate dir. The build wires
   each file into `zig build test`.

## §3. Shared fixtures and builders

### Python (`tests/unit/mgf/hrb/test_client.py::_FakeTransport`)

The canonical in-memory peer for testing `Client` without a broker.
Pattern:

```python
class _FakeTransport:
    def __init__(self, responses: list[dict]) -> None:
        self.responses = list(responses)
        self.requests: list[dict] = []
        self.closed = False

    def call(self, **req) -> dict:
        self.requests.append(req)
        resp = self.responses.pop(0)
        if not resp.get("ok"):
            raise from_wire(resp.get("err", "Unknown"))
        return resp


def _make_client(responses: list[dict]) -> tuple[Client, _FakeTransport]:
    t = _FakeTransport(responses)
    return Client(t, cap_id=1, client_id=1), t
```

Reuse: when a test needs a `Client` but not a real broker, use
`_make_client([{"ok": True, ...}])`. Scripted responses model the
wire shape; assertions read `t.requests` to verify what went OUT.

### Zig: per-test backend builders

`fake_backend.zig::FakeBackend` is the in-memory backend. Tests
build a Core (or facade) around it via:

```zig
fn seeded(fb: *FakeBackend) !kabi.KernelBackend {
    try fb.add("hw:/pci/0000:01:00.0/fn0", .claimable, .pcie_function);
    try fb.add("hw:/usb/bus1/dev4/if0", .claimable, .usb_interface);
    return fb.backend();
}
```

Every test that needs a Core does `var fb = FakeBackend.init(testing.allocator); defer fb.deinit(); var core = Core.init(testing.allocator, try seeded(&fb)); defer core.deinit();`.
This pattern is copy-pasted across `core_test.zig`, `cap_test.zig`,
`facade_test.zig`, `stub_usb_test.zig`. No formal fixture system in
Zig stdlib (yet); copy-paste is the idiom.

### E2E broker harness

`tests/e2e/test_e2e.py` shows the pattern: `subprocess.Popen` of the
broker binary, wait-for-socket, run checks, SIGTERM in `finally`,
close the stdout pipe explicitly (else the unraisable-warning fires
under Python 3.13's stricter close-on-gc model — see `e083bf9`).

When the e2e suite grows past one file, the harness factors into
`tests/e2e/conftest.py::broker` fixture that yields a connected Client.

## §4. Gate runner

Local: four commands, run in this order. CI runs the same four
identically.

```bash
# Gate 1 — lint
ruff check src tests

# Gate 2 — types
mypy --strict src

# Gate 3 — Python tests (unit + functional + integration + contract + property)
pytest -q --cov=mgf.hrb --cov-fail-under=80

# Gate 4 — Zig tests (unit + concurrency + ring fuzz + USB synth)
cd hrb-code && zig build test --summary all
```

Opt-in gates:

```bash
# E2E (needs broker binary)
cd hrb-code && zig build && cd .. && pytest -m e2e

# Use-case (needs broker + maybe fixtures)
pytest -m use_case

# Stress / perf (nightly)
pytest -m stress
pytest -m perf

# Conformance (release)
pytest -m conformance
```

Every gate has a single exit code. CI fails on any non-zero.
TS-06 (mgf-common) requires coverage ≥ 80% on the Python suite —
encoded in `[tool.coverage.report].fail_under`.

## §5. Subprocess harness for cross-process tests

E2E and use-case tests need to spawn the real broker. The pattern:

```python
import subprocess, time, signal
from pathlib import Path

SOCK = "/tmp/mgf-hrb.sock"
SERVER = Path("hrb-code/zig-out/bin/mgf-hrb-server")

proc = subprocess.Popen(
    [str(SERVER)],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
)
try:
    # Wait for the socket to appear (broker has finished binding).
    for _ in range(50):
        if Path(SOCK).exists():
            break
        time.sleep(0.05)

    # ... run the test against a connected Client ...

finally:
    proc.send_signal(signal.SIGTERM)
    try:
        proc.wait(timeout=2)
    except subprocess.TimeoutExpired:
        proc.kill()
    # Close the captured stdout pipe — Python 3.13's
    # unraisable-exception flow otherwise fails the test on GC.
    if proc.stdout is not None:
        proc.stdout.close()
    if Path(SOCK).exists():
        Path(SOCK).unlink()
```

Notes:

* Always close `proc.stdout` explicitly — see commit `e083bf9`.
* SIGTERM with a 2-second timeout, then SIGKILL — bounded shutdown.
* Clean up the socket file on the way out so the next test (or a
  parallel one) doesn't trip on a stale socket.

## §6. Builders for typed records

The `from_wire` defensive-coercion methods on `BackendCapability`,
`LeaseRecord`, `ResourceDetail` are TEST-FRIENDLY by design. Tests
build the wire dict (the shape the broker emits) and let `from_wire`
project; assertions read fields off the typed record.

Anti-pattern: instantiating the dataclass with positional args. The
field set evolves (forward-compat optionals); keyword-only at the
test layer keeps tests resilient to the addition of new fields.

## §7. Zig test discovery

`hrb-code/build.zig` declares every Zig test file in a single
`inline for` block. ADD a new `*_test.zig` file → ADD its path to
the list. The convention is to keep the list alphabetized loosely
by domain (core, cap, request, facade, identity, usb, ring, etc.).

`zig build test --summary all` reports the total test count and
runs every step in parallel where possible. A test file failing
hangs that step; other steps still run.

## §8. CI matrix (planned)

GitHub Actions or Codeberg CI matrix runs:

| Job | Triggers | Suites |
|---|---|---|
| `lint+types` | every push | gates 1 + 2 |
| `tests-default` | every push | gates 3 + 4 |
| `tests-e2e` | every push | `pytest -m e2e` (broker built) |
| `tests-fuzz-long` | nightly | extended fuzz seeds |
| `tests-stress` | nightly | `pytest -m stress` + Zig stress |
| `tests-perf` | nightly + release | `pytest -m perf` |
| `tests-conformance` | release | `pytest -m conformance` |

When CI exists, this section graduates to a concrete runbook in
[`EXECUTION.md`](EXECUTION.md).

## §9. Hooks the federation provides

`mgf-common.pytest_plugin` (loaded automatically when `mgf-common` is
a dep) provides:

* `decode_json_logs` — fixture for parsing the structured-logging
  output, useful for testing what the broker logged.
* Five pre-registered fixtures (see mgf-common's
  `pytest_plugin.py`).

We DON'T currently use these explicitly; they're available for tests
that want to inspect log output.
