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
| Method | What It Tests | When to Use | Equipment |
|---|---|---|---|
| ICT (In-Circuit Test) | Individual components: resistors, capacitors, ICs | High volume, after SMT reflow | Bed-of-nails fixture, dedicated ICT tester |
| Flying Probe | Same as ICT, no fixture needed | Low volume, prototypes | Flying probe machine |
| FCT (Functional Test) | Board-level behavior: power, comms, firmware | Every board before shipping | Fixture + instruments + test script |
| Boundary Scan (JTAG) | IC connections, digital logic | Complex BGA boards | JTAG adapter |
| AOI (Automated Optical) | Solder joints, component placement | After reflow, before ICT/FCT | AOI 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:
| Category | What to Test | Typical Measurements |
|---|---|---|
| Power | All voltage rails, current draw | 3.3V, 5V, 1.8V rails; idle and active current |
| Communication | UART, SPI, I2C, USB, Ethernet | Firmware version query, self-test response |
| Digital I/O | GPIO, LED, buttons | LED state, button response, logic levels |
| Analog | ADC readings, DAC output | Calibrated voltage, sensor readings |
| Passive components | Pull-up/down resistors | Resistance measurement (power off) |
| Firmware | Version, self-test, flash integrity | Version 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:
| Instrument | Purpose | Connection |
|---|---|---|
| Bench power supply | Power the DUT | Banana plugs to fixture |
| Multimeter (DMM) | Measure voltage, current, resistance | Test probes to fixture |
| UART adapter | Communicate with DUT firmware | USB-to-serial to fixture |
| Optional: oscilloscope | Verify signal integrity | Probes 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.
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):
passStep 3: Write the Power Rail Test
This phase powers the DUT and measures all voltage rails.
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.
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.
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.
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.
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 Rule | Why | Impact on FCT |
|---|---|---|
| Add test points to all voltage rails | DMM access without probing ICs | Direct voltage measurement |
| Break out UART/SPI/I2C to header | Communication test without bed-of-nails | Firmware verification |
| Add a power-on LED | Visual sanity check | Boolean measurement |
| Include a self-test in firmware | Board can verify its own peripherals | Single command validates multiple subsystems |
| Label test points on silkscreen | Operators can probe manually if needed | Reduces fixture complexity |
| Route test points to board edge | Pogo pin access in fixture | Faster fixture build |
Common FCT Failure Modes
| Failure | Typical Cause | How to Detect |
|---|---|---|
| Voltage rail out of spec | Wrong resistor value in regulator divider, cold solder joint | Measure rail voltage with limits |
| Excessive current draw | Solder bridge, damaged IC | Measure idle and active current |
| Communication timeout | Missing pullup, wrong baud rate, unflashed IC | Query firmware, check response time |
| Wrong firmware version | Flashing error, wrong binary | Query version string, compare to expected |
| Intermittent failure | Marginal solder joint, loose connector | Run test multiple times, check measurement variance |