A hardware test plan defines what gets tested, at which stage, with what equipment, and against which criteria. Without one, test coverage depends on whoever wrote the last script. This guide walks through structuring a test plan from EVT through production and implementing it in Python with TofuPilot.
What a Test Plan Covers
| Section | Content |
|---|---|
| Product overview | What the product does, key specifications |
| Test stages | Which tests run at EVT, DVT, PVT, production |
| Test procedures | Step-by-step instructions per test |
| Equipment list | Instruments, fixtures, software |
| Pass/fail criteria | Measurement limits per test |
| Data requirements | What gets recorded, where, retention period |
| Roles | Who writes tests, who runs them, who reviews results |
The test plan is a living document. It starts rough in EVT and gets refined through each build stage.
Step 1: Map Requirements to Test Stages
Start with the product requirements and decide when each one gets verified.
| Requirement | EVT | DVT | PVT | Production |
|---|---|---|---|---|
| Output voltage within 5% | X | X | X | X |
| Survives 1m drop | X | |||
| Operates -20C to 60C | X | X | ||
| Firmware boots in under 2s | X | X | X | X |
| Leakage current below 500uA | X | X | ||
| FPY above 95% | X | X |
Not every requirement needs testing at every stage. EVT focuses on basic functionality. DVT covers environmental and stress. PVT validates the manufacturing process. Production tests run on every unit.
Step 2: Write the Plan as a Procedure
The most common failure mode is a test plan in a document and limits in code, drifting apart within weeks. Keep the limits in one place that is both the plan and the executable.
A procedure .yaml declares the sequence, the measurements and the limits as data. It reads like the plan and runs like the test.
procedure.yaml34 lines
name: Production Functional Testversion: 1.0.0description: Production acceptance per TP-100 rev Bunit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"main: - name: Production Checks python: phases.production_checks measurements: - name: Output Voltage unit: V description: TP-100 rev B section 3.1 validators: - operator: ">=" expected_value: 4.75 - operator: "<=" expected_value: 5.25 - name: Boot Time unit: ms description: TP-100 rev B section 3.4 validators: - operator: "<=" expected_value: 2000 - name: Leakage Current unit: uA description: TP-100 rev B section 5.2 validators: - operator: "<=" expected_value: 500def production_checks(measurements): """Production test: voltage, boot time, safety.""" measurements.output_voltage = 5.02 measurements.boot_time = 1350 measurements.leakage_current = 85.0The description field on each measurement is where the test plan paragraph reference goes, so the document and the data point at each other. The version is recorded with every run, so results taken before a limit change stay interpretable.
Step 3: Specify Equipment and Fixtures
Document every instrument in the test plan. This prevents "it works on my bench" problems.
| Instrument | Model | Purpose | Calibration |
|---|---|---|---|
| DMM | Keysight 34461A | Voltage and current | Annual |
| Power supply | Rigol DP832 | DUT power | Annual |
| Fixture | Custom PCB jig v2.1 | Pogo pin contact | Per shift check |
| PC | Any, Python 3.10+ | Test execution | N/A |
In the procedure, each instrument is a plug, so the equipment list has a direct counterpart in code:
plugs: - name: dmm python: plugs.dmm:Multimeter - name: psu python: plugs.psu:PowerSupplyRecord the instrument identity on the run so a result can be traced back to the bench that produced it:
def record_equipment(run, dmm): run.metadata["dmm_id"] = dmm.identify()Step 4: Set Limits from Data, Not Guesses
Start with limits from the datasheet or design spec. Tighten them after DVT data shows the actual distribution.
The practical move is one procedure per stage, with the same measurement names and progressively tighter validators. Same names means the distributions are comparable across stages.
main: - name: Voltage python: phases.voltage measurements: - name: Output Voltage unit: V validators: - operator: ">=" expected_value: 4.5 - operator: "<=" expected_value: 5.5main: - name: Voltage python: phases.voltage measurements: - name: Output Voltage unit: V validators: - operator: ">=" expected_value: 4.75 - operator: "<=" expected_value: 5.25EVT runs wide while you learn the design. Production runs tight, set from what the DVT and PVT distributions actually showed. Because the measurement name is identical, TofuPilot shows one distribution across all stages and the Cpk tells you whether the tighter limit is survivable before you impose it on the line.
See how to set measurement limits using production data for the statistics.
Step 5: Run It and Collect From the Start
tofupilot run ./procedure.yaml --uploadLog every run from the first prototype. EVT data informs DVT limits, DVT data informs production limits, and none of that works if the early data was never captured.
TofuPilot tracks results across all test stages. Open the Analytics tab to see measurement distributions, yield trends, and failure patterns. This data feeds back into the test plan as you refine limits and add or remove test steps.
If You Already Run OpenHTF
Existing OpenHTF suites run natively on the TofuPilot CLI with no rewrite. The same production checks expressed with decorators:
production_test.py26 lines
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures( htf.Measurement("output_voltage_V") .in_range(minimum=4.75, maximum=5.25) .with_units(units.VOLT), htf.Measurement("boot_time_ms") .in_range(maximum=2000) .with_units(units.MILLISECOND), htf.Measurement("leakage_current_uA") .in_range(maximum=500) .with_units(units.MICROAMPERE),)def phase_production_checks(test): """Production test: voltage, boot time, safety.""" test.measurements.output_voltage_V = 5.02 test.measurements.boot_time_ms = 1350 test.measurements.leakage_current_uA = 85.0test = htf.Test(phase_production_checks)test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.
Common Mistakes
| Mistake | Fix |
|---|---|
| Testing everything at every stage | Map requirements to specific stages |
| Limits from the datasheet only | Refine from actual production data |
| Test plan in a Word doc, code elsewhere | Declare limits in the procedure; reference the plan section in description |
| Renaming measurements between stages | Keep names identical so distributions are comparable |
| No calibration tracking | Record instrument identity and calibration state on the run |
| Skipping EVT data collection | Start logging from the first prototype |
A test plan is not a one-time document. Review it after every build stage and update limits, procedures, and equipment based on what the data shows.
