Skip to content
Getting Started

Use pytest for Hardware Testing

Learn how to use pytest to automate hardware tests and upload results to TofuPilot for centralized tracking and analytics.

JJulien Buteau
beginner10 min readMarch 14, 2026

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

FeatureHow it helps hardware testing
FixturesSet up instruments, power supplies, connections
ParametrizeRun the same test across multiple DUTs or configurations
MarkersTag tests by type (functional, safety, calibration)
AssertionsDefine pass/fail criteria for measurements
PluginsExtend 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

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

conftest.py
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,
    )
test_board.py
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 <= 60

Option 2: pytest Plugin

Write a pytest plugin that hooks into test results.

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

terminal
# 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" -v

Organizing Hardware Tests with pytest

Use Markers for Test Categories

test_board.py
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.5

Use Parametrize for Multi-Channel Tests

test_multi_channel.py
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) < tolerance

pytest vs. OpenHTF

FeaturepytestOpenHTF
Learning curveLow (most Python devs know it)Medium (hardware-specific)
Hardware test featuresGeneral-purposePurpose-built (phases, plugs, measurements)
Operator UINone built-inBuilt-in web UI
CommunityMassiveSmall but focused
TofuPilot integrationVia custom codeNative 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.

More Guides

Put this guide into practice