The previous commit fixed the FrameIO/LDF diagram by labeling the
ldf-lookup edge as "duck-typed" without defining the term. This commit
adds a dedicated section explaining what duck typing means in this
codebase, why both architectural seams (FrameIO's ldf injection and the
lin fixture's adapter swap) rely on it, and the Python idioms behind it.
Content covers:
- The "walks like a duck" slogan and what it means in code: shape of
used methods is the contract, not the class.
- Example 1 — FrameIO and the untyped `ldf` parameter: shows the
contract (single .frame() call) and the absence of any
`from ecu_framework.lin.ldf import LdfDatabase`. Includes the
counter-example of what nominal typing would have meant for
module dependencies and testability.
- Example 2 — the lin fixture and adapter polymorphism: same idiom,
with LinInterface providing the nominal anchor.
- EAFP ("Easier to Ask Forgiveness than Permission") as the supporting
Python idiom, contrasted with LBYL.
- The trade-off section: implicit contracts and runtime-only errors,
and how the codebase mitigates them.
Cross-linked from 24_test_wiring.md's `lin` polymorphism-boundary
discussion so readers of either doc can navigate to the explanation.
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.
The mechanism that makes the swap actually work is duck typing —
tests call lin.send(...) and lin.receive(...) without caring which
concrete adapter is underneath. See
05_architecture_overview.md § Duck typing
for the full explanation, the FrameIO example, and the Python idiom
(EAFP) it relies on.
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