# Test Wiring: From YAML to Test Cases
This document explains **how a test reaches a live `LinInterface`** (or PSU, or
LDF database). For a *static* catalog of components see
[`05_architecture_overview.md`](05_architecture_overview.md); for *what* the
config knobs do see [`02_configuration_resolution.md`](02_configuration_resolution.md).
This file focuses on the *dynamic resolution* — the fixture plumbing that
glues the framework to the test suite at session start.
## The big idea
**Tests never import a concrete adapter.** They never call `load_config()`
directly. The only thing test files import from `ecu_framework` is **types**
(`EcuTestConfig`, `LinFrame`, `LinInterface`, `OwonPSU`) for annotations.
Behavior arrives via pytest fixtures, which are the single seam between the
framework and the test suite.
Concretely: the choice of LIN adapter (mock / MUM / BabyLIN) is made by the
`lin` fixture at session start based on `config.interface.type`. A test that
writes `def test_x(lin):` works against all three with no per-test changes.
## End-to-end wiring
```mermaid
flowchart TB
subgraph User_Inputs[User inputs]
YAML["config/test_config.yaml
+ optional config/owon_psu.yaml
+ $ECU_TESTS_CONFIG / $OWON_PSU_CONFIG"]
end
subgraph Loader[ecu_framework.config]
LC["load_config(workspace_root)
YAML + env + overrides → EcuTestConfig"]
end
subgraph Fixtures_Top[tests/conftest.py - session-scoped]
F_CONFIG["config
→ EcuTestConfig"]
F_LIN["lin
→ LinInterface"]
F_LDF["ldf
→ LdfDatabase"]
F_FLASH["flash_ecu
→ runs HexFlasher"]
F_RP["rp
→ record_property helper"]
end
subgraph Fixtures_HW[tests/hardware/conftest.py - session-scoped]
F_PSU_PRIV["_psu_or_none
opens PSU once"]
F_PSU_AUTO["_psu_powers_bench
autouse=True"]
F_PSU["psu
public, skips when unavailable"]
end
subgraph Fixtures_MUM[tests/hardware/mum/conftest.py]
F_REQ_MUM["_require_mum
session, autouse"]
F_FIO["fio
session"]
F_NAD["nad
session"]
F_ALM["alm
session"]
F_RESET["_reset_to_off
function, autouse"]
end
subgraph Adapters[ecu_framework adapters]
MOCK["MockBabyLinInterface"]
MUM["MumLinInterface"]
BABY["BabyLinInterface
DEPRECATED"]
OWON["OwonPSU"]
HEX["HexFlasher"]
end
subgraph Tests[tests/]
UNIT["tests/unit/*
only config-level fixtures"]
HW_PSU["tests/hardware/psu/*
psu only (no fio/alm)"]
HW_MUM["tests/hardware/mum/*
fio + alm + psu (inherited)"]
HW_BABY["tests/hardware/babylin/*
legacy E2E"]
end
YAML --> LC
LC --> F_CONFIG
F_CONFIG --> F_LIN
F_CONFIG --> F_LDF
F_CONFIG --> F_FLASH
F_CONFIG --> F_PSU_PRIV
F_CONFIG --> F_REQ_MUM
F_LIN --> F_FLASH
F_LIN --> F_FIO
F_LDF --> F_FIO
F_FIO --> F_NAD
F_FIO --> F_ALM
F_NAD --> F_ALM
F_ALM --> F_RESET
F_LIN -.selects.-> MOCK
F_LIN -.selects.-> MUM
F_LIN -.selects.-> BABY
F_FLASH --> HEX
F_PSU_PRIV --> OWON
F_PSU_PRIV --> F_PSU_AUTO
F_PSU_PRIV --> F_PSU
UNIT --> F_CONFIG
HW_PSU --> F_PSU
HW_MUM --> F_ALM
HW_MUM --> F_FIO
HW_MUM --> F_PSU
HW_BABY --> F_LIN
```
The dotted edges from `lin` are the **polymorphism boundary**: which adapter
is wired in is decided at fixture instantiation time, by config alone.
## Three-layer conftest topology
Pytest discovers `conftest.py` files automatically by directory and walks
**upward** from each test file. A test only sees fixtures defined in its own
directory or any ancestor — which is how this codebase enforces "MUM tests
can use `fio`, PSU tests can't" without any runtime allow-list.
| File | Scope | Fixtures it provides | Why split |
|---|---|---|---|
| `tests/conftest.py` | Whole test suite | `config`, `lin`, `ldf`, `flash_ecu`, `rp` | Framework primitives every test type needs |
| `tests/hardware/conftest.py` | All hardware tests | `_psu_or_none`, `_psu_powers_bench` (autouse), `psu` | PSU powers the ECU on the bench, so any hardware test benefits |
| `tests/hardware/mum/conftest.py` | MUM-only tests | `_require_mum` (autouse), `fio`, `nad`, `alm`, `_reset_to_off` (autouse) | LDF I/O + ALM state are only meaningful when `interface.type == "mum"` |
The hardware directory is partitioned by adapter type:
```
tests/hardware/
├── conftest.py # session: PSU fixtures
├── mum/ # MUM-only tests
│ ├── conftest.py # session: fio, alm, nad + autouse _require_mum / _reset_to_off
│ ├── test_mum_*.py
│ ├── test_overvolt.py # uses both PSU (inherited) and MUM fio/alm
│ ├── swe5/ # SWE.5 integration tests (all MUM-backed)
│ └── swe6/ # SWE.6 validation tests (all MUM-backed)
├── psu/ # PSU-only tests; cannot see fio/alm
│ ├── test_owon_psu.py
│ └── test_psu_voltage_settling.py
└── babylin/ # legacy BabyLIN E2E (deprecated)
└── test_e2e_power_on_lin_smoke.py
```
Each leaf directory carries an empty `__init__.py` so pytest's import
mechanism walks upward to `tests/hardware/` (which has no `__init__.py`)
and prepends it to `sys.path`. That keeps the bare imports
`from frame_io import FrameIO` / `from alm_helpers import AlmTester`
working from any subdirectory, without changes to the helper modules.
The split keeps unit tests fast and import-light: they don't transitively pull
in `pyserial` for an Owon driver they never use.
## Per-component wiring
### `config` — the root of the dependency tree
`tests/conftest.py:27-30`:
```python
@pytest.fixture(scope="session")
def config() -> EcuTestConfig:
return load_config(str(WORKSPACE_ROOT))
```
- **Session-scoped** → `load_config()` runs **once per test run**.
- `WORKSPACE_ROOT` is derived from `__file__` so the same fixture works
whether pytest is launched from the repo root, from `tests/`, or from a
Pi deployment.
- Every other fixture downstream takes `config` as a parameter, so swapping
YAML files (or `ECU_TESTS_CONFIG=...`) reroutes the entire stack.
### `lin` — the polymorphism boundary in action
`tests/conftest.py:33-87`:
```python
@pytest.fixture(scope="session")
def lin(config: EcuTestConfig) -> Iterator[LinInterface]:
if config.interface.type == "mock": lin = MockBabyLinInterface(...)
elif config.interface.type == "mum": lin = MumLinInterface(...)
elif config.interface.type == "babylin": lin = BabyLinInterface(...) # deprecated
...
lin.connect()
yield lin
lin.disconnect()
```
Two details that matter:
- **Conditional adapter imports at the top of the file** (`tests/conftest.py:13-21`)
use `try/except`: MUM needs `pymumclient`, BabyLIN needs native DLLs —
neither is present in CI. The `try` keeps mock-only environments importable;
selecting a missing adapter `pytest.skip()`s cleanly.
- **LDF + frame_lengths merge** (`tests/conftest.py:62-73`): the LDF (if
`interface.ldf_path` is set) provides default frame lengths, then YAML
`frame_lengths` overrides per ID. This merge lives in the fixture, not in
`MumLinInterface`, so the adapter doesn't depend on `ldfparser`.
`yield lin` then `disconnect()` means **one shared connection** for the whole
session, with deterministic teardown.
### `flash_ecu` — built on top of `lin`
`tests/conftest.py:113-126`:
```python
@pytest.fixture(scope="session", autouse=False)
def flash_ecu(config, lin):
if not config.flash.enabled: pytest.skip("Flashing disabled in config")
from ecu_framework.flashing import HexFlasher # lazy import
flasher = HexFlasher(lin) # ← reuses the lin fixture
...
```
- `autouse=False` — only runs when a test explicitly requests it.
- `HexFlasher(lin)` reuses the `lin` fixture, so flashing automatically
inherits the chosen adapter. One config switch (`interface.type`) reroutes
both LIN traffic and flashing.
- Import is **lazy** — pulling `HexFlasher` only when needed keeps unit-test
collection time low.
### `power` — the three-tier PSU fixture ladder
`tests/hardware/conftest.py:61-154`:
| Fixture | Scope | Visibility | Purpose |
|---|---|---|---|
| `_psu_or_none` | session | private (`_` prefix) | Open PSU once, park at nominal V/I, leave output ON |
| `_psu_powers_bench` | session, **`autouse=True`** | private | Forces `_psu_or_none` to materialize even for tests that don't ask for PSU |
| `psu` | session | public | Tests that read measurements / perturb voltage request this; skips cleanly when PSU isn't configured |
The autouse fixture is load-bearing. On a bench where the Owon **powers the
ECU**, a pure MUM test (which never names `psu`) would run with no power on
the ECU and fail mysteriously. The autouse forces PSU setup at session start
even when no test references it by name. Comments in
`tests/hardware/conftest.py:3-10` document exactly this incident.
## Session lifecycle
```mermaid
sequenceDiagram
autonumber
participant Pytest
participant Conftest as tests/conftest.py
participant HWConftest as tests/hardware/conftest.py
participant Loader as ecu_framework.config
participant LIN as ecu_framework.lin
participant PSU as ecu_framework.power
participant Test as test function
Pytest->>Conftest: collect + resolve fixtures
Pytest->>HWConftest: (only for tests/hardware/**)
Note over Conftest,Loader: session start
Conftest->>Loader: load_config(WORKSPACE_ROOT)
Loader-->>Conftest: EcuTestConfig
Conftest->>LIN: build adapter from config.interface.type
LIN-->>Conftest: LinInterface
Conftest->>LIN: lin.connect()
HWConftest->>PSU: resolve port, open, park at nominal V
PSU-->>HWConftest: OwonPSU (or None)
Note over Test: per test
Pytest->>Test: invoke test_x(lin, psu, ldf, ...)
Test->>LIN: send / receive frames
Test->>PSU: optional perturb voltage
Test-->>Pytest: pass / fail
Note over Conftest,PSU: session end
HWConftest->>PSU: close (sends output 0)
Conftest->>LIN: lin.disconnect()
```
Two invariants this diagram shows:
- **Setup happens once, teardown happens once.** Session-scoped fixtures only
build and tear down at the session boundary, not per test.
- **Teardown is LIFO.** PSU closes before LIN disconnects, matching the
reverse of construction order — which is what you want, since on this
bench the LIN ECU is powered by the PSU.
## Helpers — where they sit
The helper *classes* `FrameIO` (`tests/hardware/frame_io.py`) and `AlmTester`
(`tests/hardware/alm_helpers.py`) are plain classes — not fixtures. They take
a `LinInterface` and an LDF (and a NAD, for `AlmTester`) as constructor
arguments. **Instances are exposed as session-scoped fixtures** in
`tests/hardware/mum/conftest.py`, so MUM tests just request them by name:
```python
def test_alm_status(fio, alm, rp):
fio.send("ALM_Req_A", AmbLightColourRed=255)
status = fio.receive("ALM_Status")
alm.force_off()
```
The fixtures are session-scoped because `FrameIO` and `AlmTester` are
immutable beyond their constructor args, and per-test state hygiene is
handled by the autouse `_reset_to_off` (also in `mum/conftest.py`). A test
that genuinely needs a fresh instance can still build one locally:
`FrameIO(lin, ldf)` works inside any test body. They are a convenience
layer, not a required indirection.
```mermaid
flowchart LR
LIN[LinInterface
session fixture] --> FIO
LDFDB[LdfDatabase
ldf fixture] --> FIO[FrameIO
per test, local]
FIO --> ALM[AlmTester
per test, local]
ALM --> T[test body]
FIO --> T
LIN -.also direct access.-> T
```
## The pytest plugin — orthogonal
`conftest_plugin.py` (registered by the root `conftest.py:13-32`) is
**independent of the framework wiring above**. It parses test docstrings for
`Title:`, `Description:`, `Requirements:`, `Steps:` and attaches them as
`user_properties` on JUnit / HTML reports. It writes
`reports/requirements_coverage.json` and `reports/summary.md`. It does not
touch `ecu_framework` at all — it operates purely on test metadata.
See [`11_conftest_plugin_overview.md`](11_conftest_plugin_overview.md) for
the plugin's hooks and outputs.
## Why this shape works
Five invariants make the wiring durable:
1. **Tests depend on abstractions only.** Every `ecu_framework` import in a
test file is either a dataclass (`EcuTestConfig`, `LinFrame`) or an ABC
(`LinInterface`). Concrete adapters are selected by *configuration*, never
by import path.
2. **One YAML switch flips the whole stack.** Changing `interface.type`
reroutes LIN, flashing (via `HexFlasher(lin)`), and any helper built on
`lin` — without touching a single test.
3. **Fixture scope = lifecycle.** Session-scoped fixtures (`config`, `lin`,
`psu`) mean expensive bench setup happens once per run. Cleanup is
centralized in fixture teardowns.
4. **Optional features fail gracefully via `pytest.skip`.** No PSU → `psu`
skips. No `pymumclient` → MUM-typed config skips. No LDF → `ldf` skips.
The same conftest runs on a developer laptop, a CI runner, and a wired-up
Pi bench.
5. **Plugin metadata is orthogonal.** The reporting plugin reads test
docstrings, not the framework state. Adding/removing framework features
doesn't touch report generation.
## Adding a new framework component
The playbook is fixed. To add e.g. a CAN adapter:
1. **Add the implementation** under `ecu_framework/can/`:
- `base.py` with a `CanInterface` ABC + `CanFrame` dataclass
- One or more adapter modules (`mock.py`, `vector.py`, ...)
- `__init__.py` re-exporting the public surface
2. **Add a config section** in `ecu_framework/config/loader.py`:
- A `CanConfig` dataclass
- A `can: CanConfig` field on `EcuTestConfig`
- A matching entry in the `base` defaults dict
- A coercion line in `_to_dataclass`
3. **Add a fixture** in `tests/conftest.py` mirroring `lin`:
```python
@pytest.fixture(scope="session")
def can(config: EcuTestConfig) -> Iterator[CanInterface]:
if config.can.type == "mock": can = MockCan(...)
elif config.can.type == "vector": can = VectorCan(...)
...
can.connect(); yield can; can.disconnect()
```
4. **Write tests** that take `can` as a parameter. Done.
Helpers, reporting, and unit-test infrastructure inherit the wiring for free.
The playbook is the closest thing this framework has to a "you must read
this first" contract for contributors.
## See also
- [`05_architecture_overview.md`](05_architecture_overview.md) — static
component catalog and Mermaid architecture diagram
- [`02_configuration_resolution.md`](02_configuration_resolution.md) — what
YAML knobs exist and how they merge
- [`23_config_loader_internals.md`](23_config_loader_internals.md) — how the
loader is implemented under the hood
- [`11_conftest_plugin_overview.md`](11_conftest_plugin_overview.md) — the
reporting plugin (orthogonal to fixture wiring)
- [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what
each LIN adapter does once selected by the `lin` fixture
- [`19_frame_io_and_alm_helpers.md`](19_frame_io_and_alm_helpers.md) — the
helpers `FrameIO` and `AlmTester` covered above