How to Use pytest for Hardware Testing with TofuPilot
pytest is the most popular Python testing framework. You already know it from software testing. You can use it for hardware testing too. This guide shows how to structure hardware tests with pytest and upload results to TofuPilot.
Why pytest for Hardware
| Feature | How it helps hardware testing |
|---|---|
| Fixtures | Set up instruments, power supplies, connections |
| Parametrize | Run the same test across multiple DUTs or configurations |
| Markers | Tag tests by type (functional, safety, calibration) |
| Assertions | Define pass/fail criteria for measurements |
| Plugins | Extend with custom reporting, parallel execution |
pytest gives you the test runner. TofuPilot gives you the data platform. Together they replace ad-hoc scripts and Excel files.
Basic Hardware Test with pytest
import pytest
import pyvisa
from tofupilot import TofuPilotClient
# Fixtures for instrument setup
@pytest.fixture(scope="session")
def rm():
return pyvisa.ResourceManager()
@pytest.fixture(scope="session")
def dmm(rm):
inst = rm.open_resource("TCPIP::192.168.1.10::INSTR")
yield inst
inst.close()
@pytest.fixture(scope="session")
def psu(rm):
inst = rm.open_resource("TCPIP::192.168.1.11::INSTR")
inst.write("OUTP ON")
yield inst
inst.write("OUTP OFF")
inst.close()
@pytest.fixture(scope="session")
def tofupilot():
return TofuPilotClient()
# Tests
def test_vcc_3v3(dmm, tofupilot):
voltage = float(dmm.query("MEAS:VOLT:DC?"))
assert 3.25 <= voltage <= 3.35, f"3.3V rail out of spec: {voltage}V"
def test_vcc_1v8(dmm, tofupilot):
voltage = float(dmm.query("MEAS:VOLT:DC?"))
assert 1.75 <= voltage <= 1.85, f"1.8V rail out of spec: {voltage}V"
def test_idle_current(dmm, tofupilot):
current = float(dmm.query("MEAS:CURR:DC?")) * 1000 # mA
assert 30 <= current <= 60, f"Idle current out of spec: {current}mA"Uploading pytest Results to TofuPilot
Option 1: Upload in a Fixture (Recommended)
Collect measurements during tests and upload at the end.
import pytest
from tofupilot import TofuPilotClient
class TestCollector:
"""Collects measurements during a test session."""
def __init__(self):
self.measurements = []
self.steps = []
self.all_passed = True
def add_measurement(self, step_name, name, value, unit, limit_low=None, limit_high=None):
passed = True
if limit_low is not None and value < limit_low:
passed = False
if limit_high is not None and value > limit_high:
passed = False
if not passed:
self.all_passed = False
# Find or create step
step = next((s for s in self.steps if s["name"] == step_name), None)
if not step:
step = {"name": step_name, "step_type": "measurement", "status": True, "measurements": []}
self.steps.append(step)
measurement = {"name": name, "value": value, "unit": unit}
if limit_low is not None:
measurement["limit_low"] = limit_low
if limit_high is not None:
measurement["limit_high"] = limit_high
step["measurements"].append(measurement)
if not passed:
step["status"] = False
@pytest.fixture(scope="session")
def collector():
return TestCollector()
@pytest.fixture(scope="session", autouse=True)
def upload_results(collector):
yield # Run all tests
# Upload after all tests complete
client = TofuPilotClient()
serial = input("Enter DUT serial: ") if not hasattr(collector, "serial") else collector.serial
client.create_run(
procedure_id="PYTEST-BOARD-FUNCTIONAL",
unit_under_test={"serial_number": serial},
run_passed=collector.all_passed,
steps=collector.steps,
)def test_vcc_3v3(dmm, collector):
voltage = float(dmm.query("MEAS:VOLT:DC?"))
collector.add_measurement("Power Rails", "vcc_3v3", voltage, "V", limit_low=3.25, limit_high=3.35)
assert 3.25 <= voltage <= 3.35
def test_vcc_1v8(dmm, collector):
voltage = float(dmm.query("MEAS:VOLT:DC?"))
collector.add_measurement("Power Rails", "vcc_1v8", voltage, "V", limit_low=1.75, limit_high=1.85)
assert 1.75 <= voltage <= 1.85
def test_idle_current(dmm, collector):
current = float(dmm.query("MEAS:CURR:DC?")) * 1000
collector.add_measurement("Current Draw", "idle_current_ma", current, "mA", limit_low=30, limit_high=60)
assert 30 <= current <= 60Option 2: pytest Plugin
Write a pytest plugin that hooks into test results.
import pytest
from tofupilot import TofuPilotClient
def pytest_sessionfinish(session, exitstatus):
"""Upload results after all tests complete."""
client = TofuPilotClient()
passed = exitstatus == 0
# Collect results from the session
steps = []
for item in session.items:
report = item.stash.get("report", None)
if report:
steps.append({
"name": item.name,
"step_type": "measurement",
"status": report.passed,
"measurements": item.stash.get("measurements", []),
})
client.create_run(
procedure_id="PYTEST-FUNCTIONAL",
unit_under_test={"serial_number": session.config.getoption("--serial", "UNKNOWN")},
run_passed=passed,
steps=steps,
)Running Tests
# Run all hardware tests
pytest test_board.py -v
# Run with serial number
pytest test_board.py --serial UNIT-5501
# Run only power rail tests
pytest test_board.py -k "vcc" -v
# Run with markers
pytest test_board.py -m "safety" -vOrganizing Hardware Tests with pytest
Use Markers for Test Categories
import pytest
@pytest.mark.safety
def test_hipot(hipot_tester, collector):
"""Safety test - must pass for every unit."""
leakage = hipot_tester.run_test(1500)
collector.add_measurement("Safety", "hipot_leakage_ma", leakage, "mA", limit_high=5.0)
assert leakage < 5.0
@pytest.mark.functional
def test_communication(dut, collector):
"""Functional test - verifies basic operation."""
response = dut.ping()
collector.add_measurement("Communication", "uart_ping", 1 if response else 0, "bool", limit_low=1)
assert response
@pytest.mark.calibration
def test_adc_accuracy(dut, collector):
"""Calibration test - verifies measurement accuracy."""
error = dut.measure_adc_error()
collector.add_measurement("Calibration", "adc_error_pct", error, "%", limit_high=0.5)
assert error < 0.5Use Parametrize for Multi-Channel Tests
import pytest
@pytest.mark.parametrize("channel,expected_v,tolerance", [
(1, 3.3, 0.05),
(2, 1.8, 0.05),
(3, 5.0, 0.10),
(4, 12.0, 0.20),
])
def test_voltage_rail(dmm, collector, channel, expected_v, tolerance):
voltage = dmm.measure_channel(channel)
collector.add_measurement(
"Power Rails",
f"rail_ch{channel}_v",
voltage,
"V",
limit_low=expected_v - tolerance,
limit_high=expected_v + tolerance,
)
assert abs(voltage - expected_v) < tolerancepytest vs. OpenHTF
| Feature | pytest | OpenHTF |
|---|---|---|
| Learning curve | Low (most Python devs know it) | Medium (hardware-specific) |
| Hardware test features | General-purpose | Purpose-built (phases, plugs, measurements) |
| Operator UI | None built-in | Built-in web UI |
| Community | Massive | Small but focused |
| TofuPilot integration | Via custom code | Native callback |
Use pytest when your team already knows it and you want something running quickly. Use OpenHTF when you need the full hardware test framework with operator interface and structured phases.
Both upload to TofuPilot the same way. The data in TofuPilot looks identical regardless of which framework generated it.