Skip to content
Test Types & Methods

Write a Functional Test Spec for PCBA

Learn how to build a functional test specification for PCBA production, map schematic nets to measurements with datasheet-derived limits, and translate to.

JJulien Buteau
intermediate10 min readMarch 14, 2026

A functional test specification defines every measurement your board must pass before it ships. It maps schematic nets to physical test points, sets pass/fail limits from datasheets, and becomes the contract between hardware design and production test. This guide shows you how to build that document and turn it into executable OpenHTF phases tracked in TofuPilot.

What a Functional Test Spec Is

A functional test spec is a structured table that answers three questions for every signal on your board:

  1. What are you measuring? (net name, test point, measurement type)
  2. What is the expected value? (nominal from schematic or datasheet)
  3. What is the acceptable range? (min/max limits with tolerance)

It is not a test script. It is the source of truth that drives the test script.

Mapping Schematic Nets to Test Points

Start with your schematic and export a net list. For each net that carries a testable signal, identify the physical access point on the board.

Net NameTest PointSignal TypeNominalNotes
VCC_3V3TP1DC voltage3.3 VLDO output
VCC_5V0TP2DC voltage5.0 VUSB VBUS
XTAL_OUTTP3Frequency12 MHzCrystal oscillator
I2C_SDATP4Logic high3.3 VPull-up to VCC_3V3
VBATTP5DC voltage3.7 VLi-ion nominal
LED_PWMTP6Duty cycle50%Default firmware state

Rules for choosing test points:

  • Prefer dedicated test vias over component pads
  • Include a ground reference TP near each measurement cluster
  • Number test points in physical order to reduce fixture wiring complexity

Defining Limits from Datasheets

Limits come from two sources: component datasheets and system-level margins. Trace every limit to a document and a line item.

Voltage Regulators

For an LDO like the TLV1117-3.3, the datasheet specifies:

  • Output voltage accuracy: +/-1% over temperature
  • Line regulation: 0.2% typical
  • Load regulation: 0.4% typical

At 3.3 V nominal with +/-1% accuracy plus 0.4% load regulation:

NetNominalMinMaxSource
VCC_3V33.300 V3.234 V3.366 VTLV1117-3.3 DS, Table 6.6

Crystal Oscillators

A 12 MHz crystal with +/-20 ppm tolerance at 25 C:

  • 20 ppm of 12 MHz = 240 Hz
NetNominalMinMaxSource
XTAL_OUT12 000 000 Hz11 999 760 Hz12 000 240 HzABM8 DS, ppm spec

Logic Signals

Use the component's VIH and VIL thresholds, not 50% of VCC.

NetTypeMinMaxSource
I2C_SDA (high)V_OH2.97 V3.63 VGPIO spec, 0.9VCC to 1.1VCC
I2C_SCL (low)V_OL0 V0.33 VGPIO spec, V_OL max 0.1*VCC

Test Specification Document Structure

Store the spec as a versioned table. One row per measurement.

IDTest PointNetPhase NameMeasurement TypeNominalMinMaxUnitDatasheet Ref
M01TP1VCC_3V3power_railsDC voltage3.3003.2343.366VTLV1117-3.3 DS Table 6.6
M02TP2VCC_5V0power_railsDC voltage5.0004.7505.250VUSB 2.0 spec section 7.2.1
M03TP3XTAL_OUTclock_checkFrequency120000001199976012000240HzABM8 DS ppm spec
M04TP4I2C_SDAi2c_busDC voltage3.3002.9703.630VMCU GPIO spec
M05TP5VBATbattery_voltageDC voltage3.7003.0004.200VLi-ion cell spec
M06TP6LED_PWMled_driverDuty cycle50.045.055.0%Firmware requirement

Keep this table in a CSV or spreadsheet under version control alongside the firmware. Tag each spec revision to the hardware revision it applies to.

Translating the Spec into OpenHTF Phases

Each phase corresponds to a functional block in the spec. Measurements within a phase map to individual rows by ID.

tests/pcba_functional_test.py
import openhtf as htf
from openhtf.util import units
from tofupilot.openhtf import TofuPilot

from plugs.dmm import DmmPlug
from plugs.counter import FrequencyCounterPlug


@htf.plug(dmm=DmmPlug)
@htf.measures(
    htf.Measurement("vcc_3v3_voltage")
    .in_range(minimum=3.234, maximum=3.366)
    .with_units(units.VOLT)
    .doc("M01: VCC_3V3 at TP1, TLV1117-3.3 DS Table 6.6"),
    htf.Measurement("vcc_5v0_voltage")
    .in_range(minimum=4.750, maximum=5.250)
    .with_units(units.VOLT)
    .doc("M02: VCC_5V0 at TP2, USB 2.0 spec 7.2.1"),
)
def power_rails(test, dmm):
    test.measurements.vcc_3v3_voltage = dmm.measure_dc_voltage(channel=1)
    test.measurements.vcc_5v0_voltage = dmm.measure_dc_voltage(channel=2)


@htf.plug(counter=FrequencyCounterPlug)
@htf.measures(
    htf.Measurement("xtal_frequency")
    .in_range(minimum=11_999_760, maximum=12_000_240)
    .with_units(units.HERTZ)
    .doc("M03: XTAL_OUT at TP3, ABM8 DS ppm spec"),
)
def clock_check(test, counter):
    test.measurements.xtal_frequency = counter.measure_frequency(channel=1)


@htf.plug(dmm=DmmPlug)
@htf.measures(
    htf.Measurement("i2c_sda_high_voltage")
    .in_range(minimum=2.970, maximum=3.630)
    .with_units(units.VOLT)
    .doc("M04: I2C_SDA at TP4, MCU GPIO spec"),
)
def i2c_bus(test, dmm):
    test.measurements.i2c_sda_high_voltage = dmm.measure_dc_voltage(channel=3)


@htf.plug(dmm=DmmPlug)
@htf.measures(
    htf.Measurement("vbat_voltage")
    .in_range(minimum=3.000, maximum=4.200)
    .with_units(units.VOLT)
    .doc("M05: VBAT at TP5, Li-ion cell spec"),
)
def battery_voltage(test, dmm):
    test.measurements.vbat_voltage = dmm.measure_dc_voltage(channel=4)


@htf.plug(counter=FrequencyCounterPlug)
@htf.measures(
    htf.Measurement("led_pwm_duty_cycle")
    .in_range(minimum=45.0, maximum=55.0)
    .doc("M06: LED_PWM at TP6, firmware requirement"),
)
def led_driver(test, counter):
    test.measurements.led_pwm_duty_cycle = counter.measure_duty_cycle(channel=2)


def main():
    test = htf.Test(
        power_rails,
        clock_check,
        i2c_bus,
        battery_voltage,
        led_driver,
        test_name="PCBA Functional Test",
    )

    with TofuPilot(test):
        test.execute(test_start=lambda: input("Enter DUT serial number: "))


if __name__ == "__main__":
    main()

The .doc() string on each measurement links it back to the spec ID, making the test output self-auditing. When a measurement fails in TofuPilot, you can trace it directly to the datasheet row that defined the limit.

Tracking Spec Coverage in TofuPilot

After your first run, cross-reference the spec table against the TofuPilot measurement list:

Spec IDMeasurement NameTofuPilot Status
M01vcc_3v3_voltagepresent
M02vcc_5v0_voltagepresent
M03xtal_frequencypresent
M04i2c_sda_high_voltagepresent
M05vbat_voltagepresent
M06led_pwm_duty_cyclepresent

A measurement that never fails across hundreds of units may indicate limits that are too loose. Review the distribution in TofuPilot's measurement analytics and tighten limits if the histogram shows all values clustered far from the edges.

Updating Limits

When hardware revision B changes the LDO, update the spec table and code together:

tests/pcba_functional_test.py
from openhtf.util import units

# AP2112K-3.3: Vout accuracy +/-1.5%, load reg 0.3%
# Min: 3.300 * (1 - 0.015 - 0.003) = 3.2406
# Max: 3.300 * (1 + 0.015 + 0.003) = 3.3594
htf.Measurement("vcc_3v3_voltage")
    .in_range(minimum=3.2406, maximum=3.3594)
    .with_units(units.VOLT)
    .doc("M01: VCC_3V3 at TP1, AP2112K-3.3 DS rev B"),

Commit the spec table update and the code change in the same commit so the audit trail stays intact.

More Guides

Put this guide into practice