Migrate a pytest Hardware Suite to OpenHTF
Convert a pytest suite that drives hardware into OpenHTF — test functions to phases, fixtures to plugs, assert ranges to measurements with limits, parametrize to with_args, markers to run_if, and conftest options to configuration — with a mapping table and worked example.
Last updated · Verified with OpenHTF 1.6.1
pytest suites that talk to hardware are common: fixtures open instruments, test_* functions assert on readings. They run fine on a bench and fall short on a line — no per-unit record, no measured values, no operator flow. The good news: the structure maps almost one-to-one onto OpenHTF.
Mapping
| pytest | OpenHTF | Notes |
|---|---|---|
test_* function | Phase | Same body, decorated; return value controls flow instead of raising |
assert lo <= x <= hi, "label" | @htf.measures(htf.Measurement("label").in_range(lo, hi)) + test.measurements.label = x | Value, limits and unit now stored |
assert x == expected | .equals(expected) | |
assert re.match(p, s) | .matches_regex(p) | |
| Fixture (function scope) | Plug | One instance per run; tearDown replaces the fixture's yield cleanup |
| Fixture (session scope) | Plug + module-level state, or config | Plugs live for one run; long-lived connections can be cached at module level |
conftest.py options / pytest.ini | Configuration (CONF.declare, YAML, --config-value) | |
@pytest.mark.parametrize | phase.with_args(...) / with_plugs(...) | See the with_plugs example |
@pytest.mark.skipif(cond) | @htf.PhaseOptions(run_if=lambda: not cond) | run_if leaves no record; PhaseResult.SKIP records a skip |
pytest.fail() / raising | return htf.PhaseResult.STOP (or FAIL_AND_CONTINUE) | Raising still works: outcome ERROR; add types to failure_exceptions to make it FAIL |
@pytest.mark.flaky(reruns=3) | @htf.PhaseOptions(repeat_limit=3) + return PhaseResult.REPEAT | |
| Test ordering plugins | Argument order to htf.Test(...), phase groups | Ordering is native |
input() in a fixture | UserInput.prompt() / prompt_for_test_start() | Also renders in the Operator UI |
--junitxml | OutputToJSON and other callbacks | Per-unit record instead of per-run XML |
Worked example
Before
import pytest, pyvisa
def pytest_addoption(parser):
parser.addoption("--dmm", default="USB0::0x2A8D::0x0101::MY53220001::INSTR")
@pytest.fixture
def dmm(request):
rm = pyvisa.ResourceManager()
inst = rm.open_resource(request.config.getoption("--dmm"))
yield inst
rm.close()import pytest
def test_vout(dmm):
v = float(dmm.query("MEAS:VOLT:DC?"))
assert 4.95 <= v <= 5.05, "Vout"
@pytest.mark.parametrize("channel,limit", [(1, 0.02), (2, 0.05)])
def test_iq(dmm, channel, limit):
dmm.write(f"ROUT:CHAN {channel}")
i = float(dmm.query("MEAS:CURR:DC?"))
assert i <= limit, f"Iq ch{channel}"After
import pyvisa
from openhtf.core import base_plugs
class Dmm(base_plugs.BasePlug):
def __init__(self, resource): # was: the fixture body
self._rm = pyvisa.ResourceManager()
self._inst = self._rm.open_resource(resource)
def vdc(self): return float(self._inst.query("MEAS:VOLT:DC?"))
def idc(self, channel):
self._inst.write(f"ROUT:CHAN {channel}")
return float(self._inst.query("MEAS:CURR:DC?"))
def tearDown(self): self._rm.close() # was: code after yieldimport openhtf as htf
from openhtf.plugs import user_input
from openhtf.util import configuration, units
from plugs import Dmm
CONF = configuration.CONF
DMM = CONF.declare("dmm", default_value="USB0::0x2A8D::0x0101::MY53220001::INSTR") # was: --dmm
DmmPlug = configuration.bind_init_args(Dmm, DMM)
@htf.plug(dmm=DmmPlug)
@htf.measures(htf.Measurement("vout").in_range(4.95, 5.05).with_units(units.VOLT))
def measure_vout(test, dmm): # was: test_vout
test.measurements.vout = dmm.vdc()
@htf.PhaseOptions(name="measure_iq_ch{channel}")
@htf.plug(dmm=DmmPlug)
@htf.measures(htf.Measurement("iq_ch{channel}").in_range(maximum="{limit}", type=float).with_units(units.AMPERE))
def measure_iq(test, dmm, channel, limit): # was: parametrized test_iq
del limit
test.measurements[f"iq_ch{channel}"] = dmm.idc(channel)
def main():
test = htf.Test(
measure_vout,
measure_iq.with_args(channel=1, limit=0.02), # was: parametrize rows
measure_iq.with_args(channel=2, limit=0.05),
)
test.execute(test_start=user_input.prompt_for_test_start())
if __name__ == "__main__":
main()python main.py --config-value dmm=TCPIP0::192.168.1.60::inst0::INSTR # was: pytest --dmm=...Things that change in behaviour
- All phases run by default. pytest stops a test function at the first failed assert; OpenHTF records the failed measurement and continues to the next phase. Use
stop_on_first_failureor checkpoints to stop early. - One DUT per execution.
test.execute()is one unit. A station loops; a bench script runs once. - Exceptions are
ERROR, notFAIL. Assertions that raised in pytest become measurements; genuine exceptions mean a broken test unless listed infailure_exceptions(Test Options). - Fixture scopes collapse to "per run". Expensive connections that should outlive a run can be opened lazily at module level and wrapped by a plug that does not close them.
Keep pytest for the code
Unit-test the plugs with pytest and a mocked pyvisa — the instrument classes are now plain Python, easy to test. See OpenHTF vs pytest for the division of labour.
Alternative: run pytest as-is
If the suite must stay pytest, TofuPilot runs pytest suites on stations and promotes assert lo <= x <= hi, "label" to measurements with limits — Pytest on TofuPilot. You get per-unit records without a rewrite, minus OpenHTF's plugs, operator prompts and flow control.
Related
From custom scripts
Turn an existing Python test script — a main() with prints, ifs and a CSV writer — into an OpenHTF test in five refactoring steps, keeping behaviour identical while gaining measurements with limits, plugs, a JSON record and an operator UI.
From TestStand
Map NI TestStand concepts to OpenHTF — sequences and step groups to tests and phase groups, numeric limit steps to measurements, preconditions to run_if and branches, station globals to configuration, report and database loggers to output callbacks — and plan a station-by-station transition.