Skip to content
Test Types & Methods

PCBA Functional Testing: A Complete Guide

A complete guide to PCBA functional testing (FCT) with Python, OpenHTF, and TofuPilot, covering power rails, communication, current draw, and traceability.

JJulien Buteau
intermediate15 min readMarch 14, 2026

PCBA functional testing (FCT) verifies that an assembled circuit board works as designed. It's the last test before the board ships. Unlike ICT (in-circuit test), which checks individual components, FCT tests the board as a system: power rails, communication interfaces, firmware, and current draw. This guide shows you how to build a production FCT with Python, OpenHTF, and TofuPilot.

PCBA Test Methods Compared

MethodWhat It TestsWhen to UseEquipment
ICT (In-Circuit Test)Individual components: resistors, capacitors, ICsHigh volume, after SMT reflowBed-of-nails fixture, dedicated ICT tester
Flying ProbeSame as ICT, no fixture neededLow volume, prototypesFlying probe machine
FCT (Functional Test)Board-level behavior: power, comms, firmwareEvery board before shippingFixture + instruments + test script
Boundary Scan (JTAG)IC connections, digital logicComplex BGA boardsJTAG adapter
AOI (Automated Optical)Solder joints, component placementAfter reflow, before ICT/FCTAOI machine

Most production lines run AOI after reflow, then FCT before packaging. ICT is optional and cost-effective only at high volumes (10K+ boards/year).

FCT Test Coverage

A typical FCT checks these categories:

CategoryWhat to TestTypical Measurements
PowerAll voltage rails, current draw3.3V, 5V, 1.8V rails; idle and active current
CommunicationUART, SPI, I2C, USB, EthernetFirmware version query, self-test response
Digital I/OGPIO, LED, buttonsLED state, button response, logic levels
AnalogADC readings, DAC outputCalibrated voltage, sensor readings
Passive componentsPull-up/down resistorsResistance measurement (power off)
FirmwareVersion, self-test, flash integrityVersion string, CRC check

Step 1: Define Your Test Fixture

A test fixture connects the DUT (device under test) to your instruments. For FCT, you typically need:

InstrumentPurposeConnection
Bench power supplyPower the DUTBanana plugs to fixture
Multimeter (DMM)Measure voltage, current, resistanceTest probes to fixture
UART adapterCommunicate with DUT firmwareUSB-to-serial to fixture
Optional: oscilloscopeVerify signal integrityProbes to test points

Step 2: Create Instrument Plugs

Each instrument gets an OpenHTF Plug. The plug handles connection setup and teardown. Plugs are injected into test phases using the @htf.plug() decorator.

fct/plugs.py
import openhtf as htf
from openhtf.plugs import BasePlug


class PowerSupplyPlug(BasePlug):
    """Bench power supply control."""

    def setUp(self):
        self.output_on = False
        # Replace with real instrument connection (e.g., PyVISA)

    def set_voltage(self, channel: int, voltage: float):
        """Set output voltage on a channel."""
        pass  # Replace: psu.write(f":INST:SEL CH{channel}"); psu.write(f":VOLT {voltage}")

    def set_current_limit(self, channel: int, current: float):
        """Set current limit on a channel."""
        pass  # Replace: psu.write(f":CURR {current}")

    def enable_output(self):
        self.output_on = True
        # Replace: psu.write(":OUTP ON")

    def disable_output(self):
        self.output_on = False
        # Replace: psu.write(":OUTP OFF")

    def tearDown(self):
        self.disable_output()


class MultimeterPlug(BasePlug):
    """DMM for voltage, current, and resistance measurements."""

    def setUp(self):
        pass  # Replace with PyVISA connection

    def measure_voltage(self) -> float:
        return 3.31  # Replace: float(dmm.query(":MEAS:VOLT:DC?"))

    def measure_current(self) -> float:
        return 0.12  # Replace: float(dmm.query(":MEAS:CURR:DC?"))

    def measure_resistance(self) -> float:
        return 4720.0  # Replace: float(dmm.query(":MEAS:RES?"))

    def tearDown(self):
        pass


class UartPlug(BasePlug):
    """UART interface for DUT communication."""

    def setUp(self):
        pass  # Replace: serial.Serial("/dev/ttyUSB0", 115200)

    def send_command(self, cmd: str) -> str:
        if cmd == "AT+VERSION?":
            return "2.1.0"  # Replace with real serial read
        if cmd == "AT+STATUS?":
            return "OK"
        return ""

    def tearDown(self):
        pass

Step 3: Write the Power Rail Test

This phase powers the DUT and measures all voltage rails.

fct/test_power.py
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.Measurement("rail_1v8")
    .in_range(1.7, 1.9)
    .with_units(units.VOLT)
    .doc("1.8V core rail"),
)
@htf.plug(psu=PowerSupplyPlug, dmm=MultimeterPlug)
def test_power_rails(test, psu, dmm):
    """Power the DUT and verify all voltage rails."""
    psu.set_voltage(1, 12.0)
    psu.set_current_limit(1, 1.0)
    psu.enable_output()

    test.measurements.rail_3v3 = dmm.measure_voltage()
    test.measurements.rail_5v0 = dmm.measure_voltage()
    test.measurements.rail_1v8 = dmm.measure_voltage()

Step 4: Write the Communication Test

Verify the DUT firmware responds correctly over UART.

fct/test_comms.py
import openhtf as htf


@htf.measures(
    htf.Measurement("firmware_version")
    .equals("2.1.0")
    .doc("Firmware version string"),
    htf.Measurement("self_test_status")
    .equals("OK")
    .doc("Board self-test result"),
)
@htf.plug(uart=UartPlug)
def test_communication(test, uart):
    """Query firmware version and self-test status."""
    test.measurements.firmware_version = uart.send_command("AT+VERSION?")
    test.measurements.self_test_status = uart.send_command("AT+STATUS?")

Step 5: Write the Current Draw Test

Excessive current draw usually means a short or damaged component.

fct/test_current.py
import openhtf as htf
from openhtf.util import units


@htf.measures(
    htf.Measurement("idle_current")
    .in_range(0.05, 0.20)
    .with_units(units.AMPERE)
    .doc("Board idle current consumption"),
)
@htf.plug(dmm=MultimeterPlug)
def test_current_draw(test, dmm):
    """Measure idle current draw."""
    test.measurements.idle_current = dmm.measure_current()

Step 6: Write the Passive Component Test

Check pullup resistors with the DUT powered off. Wrong resistor values cause intermittent communication failures that are hard to catch any other way.

fct/test_passives.py
import openhtf as htf
from openhtf.util import units


@htf.measures(
    htf.Measurement("i2c_pullup")
    .in_range(4500, 5100)
    .with_units(units.OHM)
    .doc("I2C SDA pullup resistance"),
)
@htf.plug(dmm=MultimeterPlug)
def test_pullup_resistors(test, dmm):
    """Verify I2C pullup resistor values (power off)."""
    test.measurements.i2c_pullup = dmm.measure_resistance()

Step 7: Assemble and Run

Connect all phases into a test with TofuPilot for production traceability.

fct/main.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot


def main():
    test = htf.Test(
        test_power_rails,
        test_communication,
        test_current_draw,
        test_pullup_resistors,
        procedure_id="PCBA-FCT-001",
        part_number="PCBA-200",
    )
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Scan serial number: "))


if __name__ == "__main__":
    main()

Every measurement flows into TofuPilot with its name, value, limits, units, and pass/fail status. You get FPY, Cpk, and control charts per measurement without extra code.

Design for Testability (DFT) Checklist

Good FCT coverage starts at PCB design. Follow these guidelines:

DFT RuleWhyImpact on FCT
Add test points to all voltage railsDMM access without probing ICsDirect voltage measurement
Break out UART/SPI/I2C to headerCommunication test without bed-of-nailsFirmware verification
Add a power-on LEDVisual sanity checkBoolean measurement
Include a self-test in firmwareBoard can verify its own peripheralsSingle command validates multiple subsystems
Label test points on silkscreenOperators can probe manually if neededReduces fixture complexity
Route test points to board edgePogo pin access in fixtureFaster fixture build

Common FCT Failure Modes

FailureTypical CauseHow to Detect
Voltage rail out of specWrong resistor value in regulator divider, cold solder jointMeasure rail voltage with limits
Excessive current drawSolder bridge, damaged ICMeasure idle and active current
Communication timeoutMissing pullup, wrong baud rate, unflashed ICQuery firmware, check response time
Wrong firmware versionFlashing error, wrong binaryQuery version string, compare to expected
Intermittent failureMarginal solder joint, loose connectorRun test multiple times, check measurement variance

More Guides

Put this guide into practice