pytest is the first thing most Python engineers reach for, and it works for hardware within limits that are worth understanding before you build a production line on it. This guide covers the patterns that work, the gaps that matter, and how to get structured results out.
Where pytest Fits
pytest was built to test software. Applied to hardware it is excellent for some jobs and structurally wrong for others.
| Use case | pytest | Why |
|---|---|---|
| Firmware CI on a HIL bench | Good | Runs in any pipeline, parallel via xdist, familiar |
| EVT/DVT bring-up and validation | Good | Flexible, exploratory, easy to select and skip |
| Driver and instrument-library unit tests | Good | This is exactly what it is for |
| Characterisation sweeps | Workable | Parametrize is a natural fit |
| Production functional test | Poor | No structured measurements, no operator UI, no traceability |
| Operator-driven station test | Poor | No prompts, no serial scan, no kiosk |
The dividing line is whether a human operator and a serial number are involved. If the results feed a developer, pytest is a good answer. If they feed a production record, it is missing the parts that matter.
The Patterns That Work
Fixtures for instrument lifecycle
Session-scoped fixtures are genuinely the right abstraction for an instrument, and one of pytest's real strengths here:
conftest.py24 lines
import pytestimport pyvisa@pytest.fixture(scope="session")def dmm(): """One VISA session for the whole test session.""" rm = pyvisa.ResourceManager("@py") instr = rm.open_resource("TCPIP::192.168.1.100::INSTR") instr.timeout = 5000 instr.write("*RST") yield instr instr.close()@pytest.fixture(scope="session")def psu(): rm = pyvisa.ResourceManager("@py") instr = rm.open_resource("TCPIP::192.168.1.101::INSTR") instr.timeout = 5000 yield instr for ch in (1, 2, 3): instr.write(f":OUTP CH{ch},OFF") instr.close()The teardown after yield runs even when a test fails, which is what you want for de-energising a board.
Parametrize for sweeps
import pytest@pytest.mark.parametrize("voltage", [3.0, 3.3, 3.6])def test_operates_across_supply_range(dut, psu, voltage): psu.write(f":APPL CH1,{voltage},0.5") assert dut.self_test_passes(), f"Self-test failed at {voltage} V"Markers for hardware requirements
import pytest@pytest.mark.rf@pytest.mark.slowdef test_transmit_power(spectrum_analyser): power = spectrum_analyser.measure_channel_power() assert 8.0 <= power <= 14.0pytest -m "not slow" # Skip the long RF testspytest -m rf # Only the RF bench testsThe Gap: Measurements Disappear
This is the structural problem, and it is not a style preference.
def test_rail_3v3(dmm): voltage = float(dmm.query(":MEAS:VOLT:DC?")) assert 3.2 <= voltage <= 3.4, f"Rail out of range: {voltage} V"That test is correct and tells you nothing tomorrow. The value existed inside the assert and was discarded. You cannot compute Cpk, cannot see that the rail drifted from 3.31 to 3.38 over three weeks, and cannot justify a limit change. The limits exist only as a comparison expression, so nothing can report them or tell you they changed.
JUnit XML does not fix this. It records which test passed, not what was measured.
Getting Structured Results Out
If pytest is the right tool for your stage, push the measurements somewhere that keeps them. The Python SDK takes a run with steps and measurements:
conftest.py37 lines
import pytestfrom tofupilot import TofuPilotClient@pytest.fixture(scope="session")def recorder(): """Collect measurements during the session, upload one run at the end.""" steps = [] yield steps client = TofuPilotClient() client.create_run( procedure_id="FCT-001", unit_under_test={"serial_number": "SN-001", "part_number": "PCBA-100"}, run_passed=all(s["step_passed"] for s in steps), steps=steps, )def record(steps, name, value, unit=None, lower=None, upper=None): passed = True if lower is not None and value < lower: passed = False if upper is not None and value > upper: passed = False steps.append({ "name": name, "step_passed": passed, "measurements": [{ "name": name, "value": value, "unit": unit, "lower_limit": lower, "upper_limit": upper, }], }) return passeddef test_rail_3v3(dmm, recorder): voltage = float(dmm.query(":MEAS:VOLT:DC?")) assert record(recorder, "rail_3v3", voltage, "V", 3.2, 3.4)The value is now kept with its limits, so yield, Cpk and drift all become possible. The cost is the helper above and the discipline to use it in every test, which is the part that erodes as a suite grows.
When to Stop Using pytest
The signals are consistent across teams:
- An operator needs a screen, and you are looking at building one.
- Someone asks for yield per measurement over the last month.
- The line needs four units tested at once on one fixture.
- A station loses network and a shift of results is gone.
- Limits changed and nobody can say which results used which limits.
At that point the missing pieces are not pytest's job, and building them is a project.
The Production Alternative
A purpose-built hardware framework keeps the same Python you already wrote and adds the parts pytest does not have. The instrument fixture becomes a plug, the test function becomes a phase, and the assert becomes declared limits:
import pyvisaclass Multimeter: def __init__(self): rm = pyvisa.ResourceManager("@py") self.instr = rm.open_resource("TCPIP::192.168.1.100::INSTR") self.instr.timeout = 5000 self.instr.write("*RST") def dc_voltage(self) -> float: return float(self.instr.query(":MEAS:VOLT:DC?")) def close(self): self.instr.close()def power_rails(measurements, dmm): measurements.rail_3v3 = dmm.dc_voltage()procedure.yaml24 lines
name: PCBA Functional Testversion: 1.0.0unit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"plugs: - name: dmm python: plugs.dmm:Multimetermain: - name: Power Rails python: phases.power_rails measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4Run it with tofupilot run ./procedure.yaml. The mapping from pytest is direct: session fixture to plug, test function to phase, assert to validators, conftest.py to the plugs/ directory.
Using Both
The common arrangement is pytest for firmware CI and bring-up, a hardware framework for production, both uploading to the same dashboard. That is a sensible split rather than a compromise: the two stages genuinely have different requirements.
| Stage | Tool |
|---|---|
| Firmware CI, HIL regression | pytest |
| EVT/DVT validation | pytest |
| PVT and production FCT | TofuPilot Framework or OpenHTF |
| Operator-driven station test | TofuPilot Framework or OpenHTF |
See OpenHTF vs pytest for the framework comparison.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Limits only inside asserts | No Cpk, no drift, no limit history | Record values with limits |
| Function-scoped instrument fixtures | Session reopened per test, slow and leaky | scope="session" |
| Relying on JUnit XML for production records | Verdicts without measurements | Upload structured runs |
| Using pytest for operator-driven test | No prompts, no scan, no kiosk | Use a hardware framework |
| xdist against one shared instrument | Interleaved commands, wrong readings | One instrument per worker, or serialise |
| Test ordering carrying state | Passes alone, fails in a full run | Keep tests independent, or use a real sequencer |
