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>
391 lines
15 KiB
Markdown
391 lines
15 KiB
Markdown
# 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<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`:
|
|
|
|
```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.
|
|
|
|
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](05_architecture_overview.md#duck-typing-how-the-polymorphism-actually-works)
|
|
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`:
|
|
|
|
```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<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`](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
|