ecu-tests/docs/24_test_wiring.md
Hosam-Eldin Mostafa a3c50eabf2 docs(architecture): add Duck typing section with FrameIO and lin-fixture examples
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>
2026-05-14 20:30:30 +02:00

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&#40;workspace_root&#41;<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-scopedload_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:

@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 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 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

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:

  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:
    @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