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>
384 lines
15 KiB
Markdown
384 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.
|
|
|
|
### `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
|