Getting Started

Build Your First Hardware Test

Build your first hardware test in Python: a working procedure with limits, a plug for your instrument, and results on the dashboard in minutes.

JJulien Buteau
beginner12 min readMarch 14, 2026
One test, from hardware to results.
Unit under test
Test procedure
Upload results
Trace the unit

This guide takes you from nothing to a working hardware test that records structured measurements, evaluates limits, and uploads results. It runs on your laptop first, with a fake instrument, so you can see the whole shape before any hardware is involved.

Prerequisites

  • Python 3.10+
  • The TofuPilot CLI
install.sh
curl -fsSL https://www.tofupilot.app/install | sh

On Windows:

install.ps1
$p = "$env:TEMP\tp-install.ps1"; irm https://www.tofupilot.app/install.ps1 -OutFile $p; powershell -ExecutionPolicy Bypass -File $p; ri $p -EA 0

No account is needed to run a procedure locally.

Step 1: Create the Files

A procedure is a directory with a .yaml file, a phases/ folder for your test logic, and a plugs/ folder for your instrument drivers.

scaffold.sh
mkdir -p first-test/phases first-test/plugscd first-test

Step 2: Write the Plug

A plug is a plain Python class that owns a connection to something: an instrument, a fixture, a DUT. Start with a fake one that returns a fixed value, so the test runs before the hardware exists.

plugs/dmm.py
class Multimeter:    """Fake multimeter. Replace the body with real instrument calls."""    def read_voltage(self) -> float:        return 3.31

There is no base class to inherit and no decorator to apply. When you are ready for real hardware, the change is contained here:

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    def read_voltage(self) -> float:        return float(self.instr.query(":MEAS:VOLT:DC?"))    def close(self):        self.instr.close()

Step 3: Write the Phase

A phase is a plain function. It receives measurements and any plug it names as a parameter, injected by name.

phases/power_rail.py
def power_rail(measurements, dmm):    measurements.rail_3v3 = dmm.read_voltage()

That is the whole test logic. No limit checking, because limits are not logic: they are data, and they live in the procedure.

Step 4: Declare the Procedure

procedure.yaml
24 lines
name: My First Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: dmm    python: plugs.dmm:Multimetermain:  - name: Power Rail    python: phases.power_rail    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4

Three things to notice:

  • The measurement key comes from the name. Rail 3V3 becomes rail_3v3, which is what the phase assigns to.
  • The plug is matched by name. dmm in the plugs list is the dmm parameter in the phase.
  • The limits are data. Two validators, greater-or-equal and less-or-equal, make a range.

Step 5: Run It

run.sh
tofupilot run ./procedure.yaml

The first run creates a virtual environment for the procedure. You will be asked to confirm the serial and part number, then the phase runs and the result appears.

To see it fail, change the fake reading to something out of range:

plugs/dmm.py
def read_voltage(self) -> float:        return 9.9

Run it again and the phase fails with the reason, naming the validator that rejected the value. The measured value is still recorded; a failure is data, not an absence of data.

Step 6: Add a Second Measurement

Most real tests measure several things in one phase.

phases/power_rail.py
def power_rail(measurements, dmm):    measurements.rail_3v3 = dmm.read_voltage()    measurements.rail_5v0 = dmm.read_voltage()
procedure.yaml
- name: Power Rail    python: phases.power_rail    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4      - name: Rail 5V0        unit: V        validators:          - operator: ">="            expected_value: 4.8          - operator: "<="            expected_value: 5.2

Measurements are not only numeric. A string check and a boolean check look like this:

procedure.yaml
- name: Firmware Version        validators:          - operator: "=="            expected_value: "2.1.0"      - name: Label Present        validators:          - operator: "=="            expected_value: true

Step 7: Add a Second Phase

Phases are the unit of sequencing and of retry. Split the test when the steps are independent, or when you want them to fail separately.

phases/firmware.py
def firmware(measurements, dmm):    measurements.firmware_version = "2.1.0"
procedure.yaml
main:  - name: Power Rail    python: phases.power_rail    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4  - name: Firmware    python: phases.firmware    measurements:      - name: Firmware Version        validators:          - operator: "=="            expected_value: "2.1.0"

Independent phases run in parallel by default. If one must follow another, say so:

procedure.yaml
- name: Firmware    python: phases.firmware    depends_on: [power_rail]

Step 8: Send Results to the Dashboard

Create a free account, then link the procedure and run it with --upload:

upload.sh
tofupilot logintofupilot linktofupilot run ./procedure.yaml --upload

Every measurement arrives with its name, value, unit, limits and outcome, which is what makes first pass yield, Cpk and control charts work without any extra code.

What to Do Next

If You Prefer Everything in Python

OpenHTF is the other purpose-built option, with the sequence and limits expressed as decorators instead of YAML. The same first test:

first_test.py
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("rail_3v3")    .in_range(3.2, 3.4)    .with_units(units.VOLT),)def power_rail(test):    test.measurements.rail_3v3 = 3.31test = htf.Test(power_rail, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

Install with pip install "tofupilot[openhtf]". Both frameworks run on the same CLI and upload to the same dashboard, so this is a style choice rather than a lock-in. See TofuPilot Framework vs OpenHTF.

Common First-Test Mistakes

MistakeSymptomFix
Phase parameter does not match the plug namePlug is not injectedThe parameter name must equal the name in plugs
Assigning a key that is not declaredMeasurement never appearsThe key comes from the measurement name, in snake_case
Checking limits inside the phaseLimits are invisible to analyticsDeclare validators in the procedure instead
One phase for the entire testEverything fails together, no retry granularitySplit independent steps into phases
Sharing one instrument across parallel phasesIntermittent instrument errorsUse depends_on, or give each phase its own instrument

More Guides

Put this guide into practice