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:
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.
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.
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.measuresblock 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, notphase_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 Order | Phase | Why This Order |
|---|---|---|
| 1 | test_power_rails | If power fails, everything else will too |
| 2 | test_current | High current = short = stop before damage |
| 3 | test_communication | Verify firmware is alive before functional tests |
| 4 | test_functional | Board-level behavior |
| 5 | test_calibration | Fine-tuning (only if previous steps pass) |
Step 4: Assemble the Test
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.
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:
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 pointWhen 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
| Mistake | Problem | Fix |
|---|---|---|
| Putting limits in plugs | Can't reuse plug for different products | Keep limits in @htf.measures |
| Using type hints for plug injection | dut: DutPlug doesn't inject | Use @htf.plug(dut=DutPlug) decorator |
Not calling tearDown() in plugs | Instruments left in unknown state | Always implement tearDown() |
| Testing everything in one phase | One failure masks others | Split into focused phases |
| Hardcoding instrument addresses | Can't move to different station | Use config file or environment variables |
| No timeout on phases | Hung test blocks the station | Add @htf.PhaseOptions(timeout_s=30) |