Restructures tests/hardware/ so that fixture access is controlled by
directory layout — pytest only walks upward through conftest.py files,
so a PSU test physically cannot request fio/alm/nad.
Layout:
- tests/hardware/conftest.py (unchanged: PSU fixtures)
- tests/hardware/mum/conftest.py NEW: _require_mum (session autouse),
fio (session), nad (session),
alm (session), _reset_to_off
(function autouse)
- tests/hardware/mum/** MUM tests + swe5/ + swe6/
- tests/hardware/psu/** PSU-only tests
- tests/hardware/babylin/** deprecated BabyLIN E2E
What this removes (was duplicated before):
- 7 verbatim copies of the `fio` fixture
- 6 copies of the `alm` fixture
- 6 copies of the `_reset_to_off` autouse
- 9 inline `if config.interface.type != "mum": pytest.skip(...)` gates
What this changes by design:
- fio / alm / nad scope: module → session. NAD discovery happens once
per run instead of once per module. The helpers are immutable beyond
their constructor args, so sharing them is safe; per-test state is
reset by the autouse `_reset_to_off`.
- test_overvolt.py: `_park_at_nominal` is now `_reset_to_off`, which
cleanly overrides the conftest's LED-only version (PSU + LED reset).
- test_mum_alm_animation_generated.py keeps a local `_reset_to_off` +
`_force_off` so its "no AlmTester anywhere" demonstration is preserved
via fixture override; the local `nad` is also retained because it
uses the typed `AlmStatus.receive` API.
Docs:
- docs/24_test_wiring.md NEW — describes the three-layer fixture
topology, lifecycle sequence diagram, helper class wiring, and the
playbook for adding a new framework component.
- docs/05_architecture_overview.md: add MCF (mum conftest) node to the
Mermaid diagram + mention it in the components list.
- docs/19_frame_io_and_alm_helpers.md: replace the per-module
fixture-wiring example with a request-fixtures-by-name snippet plus
the override pattern.
- Path references swept across docs/02, docs/14, docs/18, docs/20,
docs/README to point at the new locations.
Verified: pytest --collect-only collects 93 tests with no errors;
30 unit tests and 10 mock-only smoke tests pass; fixture-per-test
output shows PSU tests cannot see fio/alm/nad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
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; for what the
config knobs do see 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
flowchart TB
subgraph User_Inputs[User inputs]
YAML["config/test_config.yaml<br/>+ optional config/owon_psu.yaml<br/>+ $ECU_TESTS_CONFIG / $OWON_PSU_CONFIG"]
end
subgraph Loader[ecu_framework.config]
LC["load_config(workspace_root)<br/>YAML + env + overrides → EcuTestConfig"]
end
subgraph Fixtures_Top[tests/conftest.py - session-scoped]
F_CONFIG["config<br/>→ EcuTestConfig"]
F_LIN["lin<br/>→ LinInterface"]
F_LDF["ldf<br/>→ LdfDatabase"]
F_FLASH["flash_ecu<br/>→ runs HexFlasher"]
F_RP["rp<br/>→ record_property helper"]
end
subgraph Fixtures_HW[tests/hardware/conftest.py - session-scoped]
F_PSU_PRIV["_psu_or_none<br/>opens PSU once"]
F_PSU_AUTO["_psu_powers_bench<br/>autouse=True"]
F_PSU["psu<br/>public, skips when unavailable"]
end
subgraph Fixtures_MUM[tests/hardware/mum/conftest.py]
F_REQ_MUM["_require_mum<br/>session, autouse"]
F_FIO["fio<br/>session"]
F_NAD["nad<br/>session"]
F_ALM["alm<br/>session"]
F_RESET["_reset_to_off<br/>function, autouse"]
end
subgraph Adapters[ecu_framework adapters]
MOCK["MockBabyLinInterface"]
MUM["MumLinInterface"]
BABY["BabyLinInterface<br/>DEPRECATED"]
OWON["OwonPSU"]
HEX["HexFlasher"]
end
subgraph Tests[tests/]
UNIT["tests/unit/*<br/>only config-level fixtures"]
HW_PSU["tests/hardware/psu/*<br/>psu only (no fio/alm)"]
HW_MUM["tests/hardware/mum/*<br/>fio + alm + psu (inherited)"]
HW_BABY["tests/hardware/babylin/*<br/>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:
@pytest.fixture(scope="session")
def config() -> EcuTestConfig:
return load_config(str(WORKSPACE_ROOT))
- Session-scoped →
load_config()runs once per test run. WORKSPACE_ROOTis derived from__file__so the same fixture works whether pytest is launched from the repo root, fromtests/, or from a Pi deployment.- Every other fixture downstream takes
configas a parameter, so swapping YAML files (orECU_TESTS_CONFIG=...) reroutes the entire stack.
lin — the polymorphism boundary in action
tests/conftest.py:33-87:
@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) usetry/except: MUM needspymumclient, BabyLIN needs native DLLs — neither is present in CI. Thetrykeeps mock-only environments importable; selecting a missing adapterpytest.skip()s cleanly. - LDF + frame_lengths merge (
tests/conftest.py:62-73): the LDF (ifinterface.ldf_pathis set) provides default frame lengths, then YAMLframe_lengthsoverrides per ID. This merge lives in the fixture, not inMumLinInterface, so the adapter doesn't depend onldfparser.
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:
@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 thelinfixture, so flashing automatically inherits the chosen adapter. One config switch (interface.type) reroutes both LIN traffic and flashing.- Import is lazy — pulling
HexFlasheronly 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
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:
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.
flowchart LR
LIN[LinInterface<br/>session fixture] --> FIO
LDFDB[LdfDatabase<br/>ldf fixture] --> FIO[FrameIO<br/>per test, local]
FIO --> ALM[AlmTester<br/>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 for
the plugin's hooks and outputs.
Why this shape works
Five invariants make the wiring durable:
- Tests depend on abstractions only. Every
ecu_frameworkimport in a test file is either a dataclass (EcuTestConfig,LinFrame) or an ABC (LinInterface). Concrete adapters are selected by configuration, never by import path. - One YAML switch flips the whole stack. Changing
interface.typereroutes LIN, flashing (viaHexFlasher(lin)), and any helper built onlin— without touching a single test. - Fixture scope = lifecycle. Session-scoped fixtures (
config,lin,psu) mean expensive bench setup happens once per run. Cleanup is centralized in fixture teardowns. - Optional features fail gracefully via
pytest.skip. No PSU →psuskips. Nopymumclient→ MUM-typed config skips. No LDF →ldfskips. The same conftest runs on a developer laptop, a CI runner, and a wired-up Pi bench. - 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:
- Add the implementation under
ecu_framework/can/:base.pywith aCanInterfaceABC +CanFramedataclass- One or more adapter modules (
mock.py,vector.py, ...) __init__.pyre-exporting the public surface
- Add a config section in
ecu_framework/config/loader.py:- A
CanConfigdataclass - A
can: CanConfigfield onEcuTestConfig - A matching entry in the
basedefaults dict - A coercion line in
_to_dataclass
- A
- Add a fixture in
tests/conftest.pymirroringlin:@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() - Write tests that take
canas 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— static component catalog and Mermaid architecture diagram02_configuration_resolution.md— what YAML knobs exist and how they merge23_config_loader_internals.md— how the loader is implemented under the hood11_conftest_plugin_overview.md— the reporting plugin (orthogonal to fixture wiring)04_lin_interface_call_flow.md— what each LIN adapter does once selected by thelinfixture19_frame_io_and_alm_helpers.md— the helpersFrameIOandAlmTestercovered above