Skip to content
Getting Started

Structure a Production Test Script

Learn how to organize an OpenHTF test script for production use, including phase ordering, plug management, configuration, and multi-SKU support with TofuPilot.

JJulien Buteau
beginner11 min readMarch 14, 2026

A production test script runs hundreds of times a day. It needs to be reliable, maintainable, and easy for operators to use. This guide covers how to structure an OpenHTF test script that's ready for the production floor, not just your dev bench.

Test Script Anatomy

Every production test script has the same structure:

structure.txt
imports
plug classes (instrument drivers)
phase functions (test steps)
test assembly (connect everything)
main (entry point)

Keep this order. It's what every test engineer on your team will expect.

Step 1: Define Your Plugs

Plugs manage instrument connections. One plug per instrument type. setUp() opens the connection. tearDown() closes it. OpenHTF handles the lifecycle automatically.

production/plugs.py
import openhtf as htf
from openhtf.plugs import BasePlug


class PowerSupplyPlug(BasePlug):
    """Bench power supply control."""

    def setUp(self):
        self.output_on = False
        # Replace: rm = pyvisa.ResourceManager("@py")
        # self.instr = rm.open_resource("TCPIP::192.168.1.101::INSTR")

    def enable(self):
        self.output_on = True
        # Replace: self.instr.write(":OUTP ON")

    def disable(self):
        self.output_on = False
        # Replace: self.instr.write(":OUTP OFF")

    def tearDown(self):
        self.disable()


class DmmPlug(BasePlug):
    """Digital multimeter for voltage and current measurements."""

    def setUp(self):
        self._readings = iter([3.31, 5.01, 0.12])
        # Replace: rm = pyvisa.ResourceManager("@py")
        # self.instr = rm.open_resource("TCPIP::192.168.1.100::INSTR")

    def read_voltage(self) -> float:
        return next(self._readings)
        # Replace: return float(self.instr.query(":MEAS:VOLT:DC?"))

    def read_current(self) -> float:
        return next(self._readings)
        # Replace: return float(self.instr.query(":MEAS:CURR:DC?"))

    def tearDown(self):
        pass
        # Replace: self.instr.close()

Rules for plugs:

  • One plug per instrument type (not per instrument instance)
  • tearDown() must always leave the instrument safe (output off, connection closed)
  • Don't put measurement limits in plugs. Plugs read raw values. Phases apply limits.

Step 2: Write Phase Functions

Each phase tests one logical thing. Keep phases focused and independent.

production/phases.py
import openhtf as htf
from openhtf.util import units


@htf.measures(
    htf.Measurement("rail_3v3")
    .in_range(3.2, 3.4)
    .with_units(units.VOLT)
    .doc("3.3V supply rail"),
    htf.Measurement("rail_5v0")
    .in_range(4.8, 5.2)
    .with_units(units.VOLT)
    .doc("5.0V supply rail"),
)
@htf.plug(psu=PowerSupplyPlug, dmm=DmmPlug)
def test_power_rails(test, psu, dmm):
    """Power the DUT and verify voltage rails."""
    psu.enable()
    test.measurements.rail_3v3 = dmm.read_voltage()
    test.measurements.rail_5v0 = dmm.read_voltage()


@htf.measures(
    htf.Measurement("idle_current")
    .in_range(0.05, 0.20)
    .with_units(units.AMPERE)
    .doc("Board idle current draw"),
)
@htf.plug(dmm=DmmPlug)
def test_current(test, dmm):
    """Measure idle current consumption."""
    test.measurements.idle_current = dmm.read_current()

Rules for phases:

  • One @htf.measures block per phase. Declare everything the phase will measure.
  • Use @htf.plug() decorator, not type hints, for plug injection.
  • Phase names should describe what they test: test_power_rails, not phase_1.
  • Add .doc() to every measurement. It shows up in TofuPilot and reports.

Step 3: Order Phases Correctly

Phase order matters in production. Test the most critical thing first. If power rails fail, don't waste time testing communication.

Phase OrderPhaseWhy This Order
1test_power_railsIf power fails, everything else will too
2test_currentHigh current = short = stop before damage
3test_communicationVerify firmware is alive before functional tests
4test_functionalBoard-level behavior
5test_calibrationFine-tuning (only if previous steps pass)

Step 4: Assemble the Test

production/main.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot


def main():
    test = htf.Test(
        test_power_rails,
        test_current,
        procedure_id="FCT-001",
        part_number="PCBA-100",
    )
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Scan serial number: "))


if __name__ == "__main__":
    main()

procedure_id identifies the test type. part_number identifies the product. Both show up in TofuPilot for filtering and analytics.

Step 5: Support Multiple SKUs

When the same test station tests different products, use a factory function.

production/multi_sku.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot


SKU_CONFIG = {
    "PCBA-100": {
        "procedure_id": "FCT-001",
        "phases": [test_power_rails, test_current],
    },
    "PCBA-200": {
        "procedure_id": "FCT-002",
        "phases": [test_power_rails, test_current],
    },
}


def create_test(part_number: str) -> htf.Test:
    """Create a test configured for a specific SKU."""
    config = SKU_CONFIG[part_number]
    return htf.Test(
        *config["phases"],
        procedure_id=config["procedure_id"],
        part_number=part_number,
    )


def main():
    part_number = input("Scan part number: ").strip()
    if part_number not in SKU_CONFIG:
        print(f"Unknown part number: {part_number}")
        return

    test = create_test(part_number)
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Scan serial number: "))

File Organization

For a single-product test station, one file is fine. For multi-product or complex tests, split into modules:

project_structure.txt
fct/
├── __init__.py
├── plugs/
│   ├── __init__.py
│   ├── power_supply.py      # PowerSupplyPlug
│   ├── dmm.py                # DmmPlug
│   └── uart.py               # UartPlug
├── phases/
│   ├── __init__.py
│   ├── power.py              # test_power_rails
│   ├── current.py            # test_current
│   └── communication.py      # test_communication
├── config.py                  # SKU configs, limits
└── main.py                    # Test assembly and entry point

When to split: If your test file exceeds 300 lines, split it. If you have more than 5 plugs, split them into a plugs/ directory. If you test more than 3 SKUs, move config to its own file.

Common Mistakes

MistakeProblemFix
Putting limits in plugsCan't reuse plug for different productsKeep limits in @htf.measures
Using type hints for plug injectiondut: DutPlug doesn't injectUse @htf.plug(dut=DutPlug) decorator
Not calling tearDown() in plugsInstruments left in unknown stateAlways implement tearDown()
Testing everything in one phaseOne failure masks othersSplit into focused phases
Hardcoding instrument addressesCan't move to different stationUse config file or environment variables
No timeout on phasesHung test blocks the stationAdd @htf.PhaseOptions(timeout_s=30)

More Guides

Put this guide into practice