Getting Started

How to Write a Hardware Test Plan

Structure a hardware test plan from EVT to production: map requirements to stages, set limits from data, and implement it as a TofuPilot procedure.

JJulien Buteau
intermediate9 min readMarch 14, 2026

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

SectionContent
Product overviewWhat the product does, key specifications
Test stagesWhich tests run at EVT, DVT, PVT, production
Test proceduresStep-by-step instructions per test
Equipment listInstruments, fixtures, software
Pass/fail criteriaMeasurement limits per test
Data requirementsWhat gets recorded, where, retention period
RolesWho 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.

RequirementEVTDVTPVTProduction
Output voltage within 5%XXXX
Survives 1m dropX
Operates -20C to 60CXX
Firmware boots in under 2sXXXX
Leakage current below 500uAXX
FPY above 95%XX

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.yaml
34 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: 500
phases/production_checks.py
def production_checks(measurements):    """Production test: voltage, boot time, safety."""    measurements.output_voltage = 5.02    measurements.boot_time = 1350    measurements.leakage_current = 85.0

The 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.

InstrumentModelPurposeCalibration
DMMKeysight 34461AVoltage and currentAnnual
Power supplyRigol DP832DUT powerAnnual
FixtureCustom PCB jig v2.1Pogo pin contactPer shift check
PCAny, Python 3.10+Test executionN/A

In the procedure, each instrument is a plug, so the equipment list has a direct counterpart in code:

procedure.yaml
plugs:  - name: dmm    python: plugs.dmm:Multimeter  - name: psu    python: plugs.psu:PowerSupply

Record the instrument identity on the run so a result can be traced back to the bench that produced it:

phases/record_equipment.py
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.

evt/procedure.yaml
main:  - name: Voltage    python: phases.voltage    measurements:      - name: Output Voltage        unit: V        validators:          - operator: ">="            expected_value: 4.5          - operator: "<="            expected_value: 5.5
production/procedure.yaml
main:  - name: Voltage    python: phases.voltage    measurements:      - name: Output Voltage        unit: V        validators:          - operator: ">="            expected_value: 4.75          - operator: "<="            expected_value: 5.25

EVT 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

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

Log 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.py
26 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

MistakeFix
Testing everything at every stageMap requirements to specific stages
Limits from the datasheet onlyRefine from actual production data
Test plan in a Word doc, code elsewhereDeclare limits in the procedure; reference the plan section in description
Renaming measurements between stagesKeep names identical so distributions are comparable
No calibration trackingRecord instrument identity and calibration state on the run
Skipping EVT data collectionStart 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.

More Guides

Put this guide into practice