Test Types & Methods

PCBA Functional Testing: A Complete Guide

PCBA functional test with Python: what to measure, how to structure the procedure, fixture and instrument plugs, and tracking yield per measurement.

JJulien Buteau
intermediate15 min readMarch 14, 2026
One test, from hardware to results.
Unit under test
Test procedure
Upload results
Trace the unit

Functional circuit test (FCT) is the last electrical gate before a board ships or goes into a higher assembly. In-circuit test proves the components are present and correct; functional test proves the assembled board does its job. This guide covers what to measure, how to structure the test, and how to track the results so the line tells you when something changes.

What FCT Covers

AreaTypical checks
PowerRail voltages under load, inrush, quiescent and active current
ProgrammingFlash firmware, verify the image, read back the version
Digital I/OGPIO loopback, interrupt response, bus enumeration
AnalogADC accuracy, DAC output, reference voltage, sensor readback
CommunicationUART, SPI, I2C, CAN, Ethernet link and data integrity
RFTransmit power, receive sensitivity, frequency error
CalibrationMeasure offsets, compute coefficients, write them to the DUT
SafetyHipot, leakage, ground bond where applicable

FCT is usually 100 % inspection: every board, every time. That makes it the largest source of test data you have, and the place where first pass yield is normally measured.

FCT vs the Other PCBA Tests

TestCatchesMisses
AOI/SPI/AXISolder and placement defects, visuallyAnything electrical
ICT / flying probeWrong, missing, reversed components; shorts and opensWhether the system works
FCTSystem-level failures, firmware, calibrationComponent-level root cause
Burn-inInfant mortalityAnything a short test would find

A board that passes ICT and fails FCT usually has a design-marginal or firmware problem, not an assembly defect. That distinction is only visible if both stages record measurements rather than verdicts.

Structuring the Test

The fixture, the instruments and the DUT connection each become a plug. The test steps become phases. The limits live in the procedure as data.

The plugs

plugs/fixture.py
class Fixture:    """Pogo-pin fixture: lid detection and DUT power."""    def lid_closed(self) -> bool:        return True  # Replace with a real interlock read    def power_on(self):        pass  # Replace with a real relay or PSU call    def power_off(self):        pass    def close(self):        self.power_off()
plugs/dmm.py
class Multimeter:    def measure_dc_voltage(self) -> float:        return 3.31  # Replace with a PyVISA query    def measure_dc_current_ma(self) -> float:        return 41.3
plugs/dut.py
class Dut:    """Serial console or debug probe on the board under test."""    def query_firmware(self) -> str:        return "2.1.0"    def read_adc_counts(self) -> int:        return 2048    def gpio_loopback(self) -> bool:        return True

The phases

phases/safety_check.py
def safety_check(measurements, fixture):    """Refuse to energise the board if the lid is open."""    measurements.lid_closed = fixture.lid_closed()
phases/power_rails.py
def power_rails(measurements, fixture, dmm):    fixture.power_on()    measurements.rail_3v3 = dmm.measure_dc_voltage()    measurements.quiescent_current = dmm.measure_dc_current_ma()
phases/firmware_check.py
def firmware_check(measurements, dut):    measurements.firmware_version = dut.query_firmware()
phases/analog_check.py
def analog_check(measurements, dut):    measurements.adc_counts = dut.read_adc_counts()
phases/digital_check.py
def digital_check(measurements, dut):    measurements.gpio_loopback = dut.gpio_loopback()

The procedure

procedure.yaml
73 lines
name: PCBA Functional Testversion: 1.0.0description: FCT for PCBA-100, per TP-100 rev Bunit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: fixture    python: plugs.fixture:Fixture  - name: dmm    python: plugs.dmm:Multimeter  - name: dut    python: plugs.dut:Dutmain:  - name: Safety Check    python: phases.safety_check    measurements:      - name: Lid Closed        description: Fixture interlock before power is applied        validators:          - operator: "=="            expected_value: true  - name: Power Rails    python: phases.power_rails    depends_on: [safety_check]    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4      - name: Quiescent Current        unit: mA        validators:          - operator: "<="            expected_value: 45.0  - name: Firmware Check    python: phases.firmware_check    depends_on: [power_rails]    measurements:      - name: Firmware Version        validators:          - operator: "=="            expected_value: "2.1.0"  - name: Analog Check    python: phases.analog_check    depends_on: [power_rails]    measurements:      - name: ADC Counts        validators:          - operator: ">="            expected_value: 2000          - operator: "<="            expected_value: 2096  - name: Digital Check    python: phases.digital_check    depends_on: [power_rails]    measurements:      - name: GPIO Loopback        validators:          - operator: "=="            expected_value: true

The dependency graph carries real meaning here. Safety runs first and everything waits on it. Power runs next. Firmware, analog and digital all depend on power but not on each other, so the engine runs those three concurrently and the cycle time is the longest of them rather than their sum.

Run it with tofupilot run ./procedure.yaml.

Three Decisions That Determine Whether the Data Is Useful

Record the value, not the verdict. rail_3v3 = 3.31 with limits attached supports Cpk, drift detection and a limit review later. A boolean power_ok supports none of those, and cannot tell you the rail has been creeping toward the limit for three weeks.

Keep measurement names stable. The name is the identity of the measurement across every run ever taken. Renaming Rail 3V3 to 3V3 Rail splits the history in two. Pick names at the start and leave them alone.

Continue past failures where it is safe. A board that fails the 3.3 V rail will usually fail several downstream checks too, and knowing which ones is diagnostic. A board that fails the lid interlock must stop immediately. That is exactly the distinction depends_on encodes.

Testing Several Boards at Once

When the fixture holds more than one board, declare slots. Each slot gets its own unit, its own measurements and its own outcome, and they run concurrently:

procedure.yaml
execution:  slots:    - name: Slot 1    - name: Slot 2    - name: Slot 3    - name: Slot 4

A failure in one slot stops that slot only; the rest keep testing. See how to run parallel tests on multiple DUTs.

Calibration Inside FCT

If the board needs calibration coefficients written into it, that is an FCT phase, and both the raw measurement and the computed coefficient belong in the record:

phases/calibrate.py
def calibrate(measurements, dut, dmm):    reference = dmm.measure_dc_voltage()    counts = dut.read_adc_counts()    gain = reference / counts if counts else 0.0    measurements.reference_voltage = reference    measurements.adc_counts = counts    measurements.adc_gain = gain    dut.write_calibration(gain)

Storing the coefficient per serial means a field failure can be checked against what was actually programmed into that unit.

Reading the Results

QuestionWhere it is answered
What is our yield this shift?First pass yield on the procedure
Which step costs us the most units?Pareto of failing measurements
Is a measurement drifting toward its limit?Control chart on that measurement
Are our limits too tight?Cpk per measurement
Is one fixture worse than the others?Filter by slot or station
What did this returned board measure?The unit history for that serial

If You Already Run OpenHTF

Existing OpenHTF suites run natively on the TofuPilot CLI with no rewrite:

fct.py
24 lines
import openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass DutPlug(BasePlug):    def read_voltage(self) -> float:        return 3.31@htf.measures(    htf.Measurement("rail_3v3")    .in_range(3.2, 3.4)    .with_units(units.VOLT),)@htf.plug(dut=DutPlug)def test_power_rails(test, dut):    test.measurements.rail_3v3 = dut.read_voltage()test = htf.Test(test_power_rails, part_number="PCBA-100")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

MistakeConsequenceFix
Recording pass/fail onlyNo Cpk, no drift detection, no limit reviewRecord the measured value with limits
Renaming measurementsHistory splits, trends breakFix names early and keep them
One giant phaseNo retry granularity, no parallelism, poor diagnosticsSplit into phases along dependency lines
Powering the board before the interlock checkSafety incident, damaged fixtureGate power on a safety phase with depends_on
Limits copied from the datasheet foreverEither scrapping good boards or shipping marginal onesReview limits against the measured distribution
Not recording the fixture or stationCannot tell a bad fixture from a bad lotRecord station and slot on every run

More Guides

Put this guide into practice