Guides

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

pytestOpenHTFNotes
test_* functionPhaseSame 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 = xValue, limits and unit now stored
assert x == expected.equals(expected)
assert re.match(p, s).matches_regex(p)
Fixture (function scope)PlugOne instance per run; tearDown replaces the fixture's yield cleanup
Fixture (session scope)Plug + module-level state, or configPlugs live for one run; long-lived connections can be cached at module level
conftest.py options / pytest.iniConfiguration (CONF.declare, YAML, --config-value)
@pytest.mark.parametrizephase.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() / raisingreturn 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 pluginsArgument order to htf.Test(...), phase groupsOrdering is native
input() in a fixtureUserInput.prompt() / prompt_for_test_start()Also renders in the Operator UI
--junitxmlOutputToJSON and other callbacksPer-unit record instead of per-run XML

Worked example

Before

conftest.py
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()
test_board.py
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

plugs.py
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 yield
main.py
import 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()
Terminal
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_failure or checkpoints to stop early.
  • One DUT per execution. test.execute() is one unit. A station loops; a bench script runs once.
  • Exceptions are ERROR, not FAIL. Assertions that raised in pytest become measurements; genuine exceptions mean a broken test unless listed in failure_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.

On this page

First-pass yield
0%4.1
Track with TofuPilot