# 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