Getting Started

Use pytest for Hardware Testing

Use pytest for hardware testing: where fixtures fit, what pytest does not give you, and how to get structured results into a dashboard.

JJulien Buteau
beginner10 min readMarch 14, 2026

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 casepytestWhy
Firmware CI on a HIL benchGoodRuns in any pipeline, parallel via xdist, familiar
EVT/DVT bring-up and validationGoodFlexible, exploratory, easy to select and skip
Driver and instrument-library unit testsGoodThis is exactly what it is for
Characterisation sweepsWorkableParametrize is a natural fit
Production functional testPoorNo structured measurements, no operator UI, no traceability
Operator-driven station testPoorNo 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.py
24 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

test_sweep.py
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

test_rf.py
import pytest@pytest.mark.rf@pytest.mark.slowdef test_transmit_power(spectrum_analyser):    power = spectrum_analyser.measure_channel_power()    assert 8.0 <= power <= 14.0
run_subset.sh
pytest -m "not slow"          # Skip the long RF testspytest -m rf                  # Only the RF bench tests

The Gap: Measurements Disappear

This is the structural problem, and it is not a style preference.

test_power.py
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.py
37 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 passed
test_power.py
def 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:

plugs/dmm.py
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()
phases/power_rails.py
def power_rails(measurements, dmm):    measurements.rail_3v3 = dmm.dc_voltage()
procedure.yaml
24 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.4

Run 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.

StageTool
Firmware CI, HIL regressionpytest
EVT/DVT validationpytest
PVT and production FCTTofuPilot Framework or OpenHTF
Operator-driven station testTofuPilot Framework or OpenHTF

See OpenHTF vs pytest for the framework comparison.

Common Mistakes

MistakeConsequenceFix
Limits only inside assertsNo Cpk, no drift, no limit historyRecord values with limits
Function-scoped instrument fixturesSession reopened per test, slow and leakyscope="session"
Relying on JUnit XML for production recordsVerdicts without measurementsUpload structured runs
Using pytest for operator-driven testNo prompts, no scan, no kioskUse a hardware framework
xdist against one shared instrumentInterleaved commands, wrong readingsOne instrument per worker, or serialise
Test ordering carrying statePasses alone, fails in a full runKeep tests independent, or use a real sequencer

More Guides

Put this guide into practice