Hardware-in-the-Loop Testing: A Practical Guide for Embedded Engineers
Unit tests run on your laptop in milliseconds. They catch logic bugs early and give you fast feedback.
Unit tests run on your laptop in milliseconds. They catch logic bugs early and give you fast feedback. But they do not tell you whether your firmware drives the motor correctly, charges the battery without overcurrent, or handles a 3.0 V brownout without corrupting flash. For those questions, you need hardware. Hardware-in-the-loop (HIL) testing puts real hardware into the test loop under software control so you can get the rigor of automated testing with the realism of physical interaction.
This post explains how HIL testing works, how to architect a HIL rig, what tooling exists, and how to write tests that actually catch real-world failures.
What HIL Testing Is
In a HIL setup, the device under test (DUT) — your embedded board — is connected to a test fixture that can:
- Stimulate it: inject voltages, toggle GPIO lines, send CAN frames, simulate sensor readings
- Observe it: read GPIO states, measure current draw, capture UART output, sample ADC lines
- Control it: power cycle, reset, flash new firmware, fault-inject
The test fixture is controlled by a test host (typically a PC or a Raspberry Pi) running test scripts. The scripts send commands to the fixture hardware, then read back measurements and assert on them.
The DUT runs unmodified production firmware. This is the key distinction from simulation or emulation — you are testing the real binary on the real chip.
A Minimal HIL Architecture
A practical HIL rig has four layers:
┌─────────────────────────────────────┐
│ Test Orchestrator │ <- Python/pytest scripts on host PC
└───────────────┬─────────────────────┘
│ USB/Ethernet
┌───────────────▼─────────────────────┐
│ Test Controller │ <- Raspberry Pi or NI/Keysight instrument
│ - GPIO control │
│ - UART/SPI/I2C/CAN │
│ - Power measurement │
│ - Relay board for power cycling │
└───────────────┬─────────────────────┘
│ Wires, connectors
┌───────────────▼─────────────────────┐
│ Test Fixture PCB │ <- Custom breakout + level shifters
└───────────────┬─────────────────────┘
│
┌───────────────▼─────────────────────┐
│ Device Under Test (DUT) │ <- Your production board
└─────────────────────────────────────┘Many teams start with a Raspberry Pi 4 as the test controller. It has 40 GPIO pins, hardware UART/SPI/I2C, USB for JTAG/SWD debug probes, and runs a full Linux userspace where your test framework lives. Add a relay board (Waveshare 4-channel relay HAT, ~$15) for power cycling, and a current sense amplifier breakout (INA219, ~$5) on the power rail for current measurement.
Tooling Options
pytest + pyserial + RPi.GPIO
The simplest stack for teams already writing Python tests. You write standard pytest functions that call GPIO, serial, and I2C libraries:
# tests/test_uart_boot.py
import pytest
import serial
import RPi.GPIO as GPIO
import time
RESET_PIN = 18
UART_PORT = "/dev/ttyUSB0"
BAUD = 115200
@pytest.fixture(autouse=True)
def reset_dut():
"""Hard-reset the DUT before each test."""
GPIO.setmode(GPIO.BCM)
GPIO.setup(RESET_PIN, GPIO.OUT)
GPIO.output(RESET_PIN, GPIO.LOW)
time.sleep(0.1)
GPIO.output(RESET_PIN, GPIO.HIGH)
time.sleep(0.5) # boot time
yield
GPIO.cleanup()
def test_boot_banner_appears():
with serial.Serial(UART_PORT, BAUD, timeout=2.0) as ser:
data = ser.read(256).decode("ascii", errors="replace")
assert "Firmware v" in data, f"Expected boot banner, got: {data!r}"
def test_boot_completes_under_500ms():
start = time.monotonic()
with serial.Serial(UART_PORT, BAUD, timeout=2.0) as ser:
while time.monotonic() - start < 2.0:
line = ser.readline().decode("ascii", errors="replace")
if "READY" in line:
elapsed = time.monotonic() - start
assert elapsed < 0.5, f"Boot took {elapsed:.3f}s, expected < 0.5s"
return
pytest.fail("DUT never printed READY within 2 seconds")Robot Framework
Robot Framework is popular in automotive and industrial firmware testing because it produces human-readable test reports and supports keyword-driven tests that non-engineers can understand:
*** Settings ***
Library SerialLibrary
Library GPIOLibrary
Suite Setup Connect To DUT
*** Test Cases ***
DUT Boots And Reports Firmware Version
Reset DUT
${banner}= Read Until READY timeout=2.0
Should Contain ${banner} Firmware v
Log Boot output: ${banner}
Motor Reaches Setpoint Within 500ms
Reset DUT
Send Command MOTOR_START 1000
${response}= Wait For Pattern SPEED_OK timeout=0.5
Should Not Be Empty ${response}pytest-embedded
The pytest-embedded project from Espressif provides first-class pytest integration for ESP32 and other embedded targets. It handles flashing, serial capture, and device lifecycle automatically:
# tests/test_motor.py
import pytest
def test_pwm_output_frequency(dut):
dut.write(b"SET_PWM 1000\r\n")
dut.expect(r"PWM_SET: 1000 Hz", timeout=1.0)
def test_overcurrent_shuts_down_motor(dut, power_monitor):
dut.write(b"MOTOR_START\r\n")
dut.expect("MOTOR_RUNNING", timeout=0.5)
power_monitor.inject_fault(overcurrent=True)
dut.expect("FAULT: OVERCURRENT", timeout=0.2)
dut.expect("MOTOR_STOPPED", timeout=0.2)Writing Meaningful HIL Tests
The most common mistake in HIL testing is writing tests that only check whether the firmware starts. Here are test patterns that actually catch real bugs:
Power Consumption Verification
# tests/test_power.py
import time
from ina219 import INA219
INA_ADDRESS = 0x40
SHUNT_OHMS = 0.1
def test_idle_current_under_5ma(ina_sensor, dut_fixture):
"""Firmware must enter sleep mode when idle."""
dut_fixture.send_command("ENTER_IDLE")
time.sleep(0.2) # settle
samples = [ina_sensor.current() for _ in range(10)]
avg_ma = sum(samples) / len(samples)
assert avg_ma < 5.0, f"Idle current {avg_ma:.2f} mA exceeds 5 mA limit"
def test_active_current_under_50ma(ina_sensor, dut_fixture):
dut_fixture.send_command("START_WORK")
time.sleep(0.1)
samples = [ina_sensor.current() for _ in range(20)]
avg_ma = sum(samples) / len(samples)
assert avg_ma < 50.0, f"Active current {avg_ma:.2f} mA exceeds 50 mA limit"Fault Injection
def test_brownout_recovery(dut_fixture, power_relay):
"""DUT must resume operation after 100ms power loss."""
dut_fixture.send_command("START_WORK")
dut_fixture.expect("WORKING", timeout=0.5)
# Cut power for 100ms
power_relay.off()
time.sleep(0.1)
power_relay.on()
# Should reboot and return to working state
dut_fixture.expect("Firmware v", timeout=1.0) # boot banner
dut_fixture.expect("WORKING", timeout=2.0) # resumed
def test_watchdog_fires_on_hang(dut_fixture):
"""Injecting a simulated hang must trigger WDT reset within 2s."""
dut_fixture.send_command("SIMULATE_HANG")
# Should NOT get a response for 1-2 seconds
# but should get a reset boot banner before 2s + WDT period
dut_fixture.expect("Firmware v", timeout=4.0)GPIO Timing
def test_pwm_duty_cycle_accuracy(oscilloscope, dut_fixture):
"""PWM output must be 50% plus or minus 2% at 1 kHz."""
dut_fixture.send_command("SET_PWM 1000 50")
time.sleep(0.05)
measurement = oscilloscope.measure_duty_cycle(channel=1)
assert 48.0 <= measurement <= 52.0, \
f"PWM duty cycle {measurement:.1f}% outside +-2% tolerance"Structuring HIL Tests for CI
HIL tests run on dedicated hardware, not on shared CI agents. You need a self-hosted runner connected to the test rig. On GitHub Actions:
# .github/workflows/hil.yml
name: HIL Tests
on:
push:
branches: [main]
pull_request:
paths:
- 'firmware/**'
jobs:
hil:
runs-on: [self-hosted, hil-rig-1]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Build firmware
run: make -C firmware all
- name: Flash DUT
run: |
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
-c "program firmware/build/app.elf verify reset exit"
- name: Run HIL tests
run: |
cd hil_tests
pip install -r requirements.txt
pytest -v --tb=short --timeout=30
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: hil-results
path: hil_tests/reports/Label your GitHub Actions runner with hil-rig-1 to ensure HIL jobs only run on the machine physically connected to your test fixture.
Managing Test Parallelism and Resource Contention
HIL rigs are shared resources. Two CI jobs must not flash the same DUT simultaneously. Use a locking mechanism:
# conftest.py
import filelock
import pytest
@pytest.fixture(scope="session", autouse=True)
def rig_lock():
lock = filelock.FileLock("/tmp/hil_rig_1.lock")
with lock.acquire(timeout=300):
yieldIf you have multiple identical rigs, use pytest-xdist with custom worker selection to distribute tests across rigs without conflicts.
Common HIL Test Failures and Their Causes
Flaky timing assertions — Fixed sleeps are fragile. Replace time.sleep(0.1) with polling loops that check a condition with a timeout. A device that takes 80 ms in summer and 120 ms in a cold lab will fail your 100 ms assertion intermittently.
Serial buffer overflow — Your test reads serial output too slowly and the DUT's UART FIFO fills up. Use a background thread to drain the buffer continuously and filter for expected patterns.
State leakage between tests — A test that ends in an unexpected DUT state corrupts the next test. Always reset or power-cycle the DUT in teardown, not just in setup.
Power supply noise causing false faults — Relay switching causes voltage spikes. Add a 10 µF decoupling cap near the DUT power connector and a 50 ms delay after relay transitions before making measurements.
What HIL Testing Cannot Replace
HIL testing is powerful but not free. Each test takes seconds to minutes, not milliseconds. You cannot run 500 HIL tests in a 10-minute CI window. Use HIL tests for:
- Integration behavior between firmware subsystems
- Hardware-specific edge cases: ADC accuracy, PWM frequency, UART timing
- Power consumption verification
- Fault recovery and watchdog behavior
- End-to-end protocol validation (CAN, Modbus, BLE)
Use unit tests (Ceedling/Unity) for logic, algorithms, and state machines. Use HIL for everything that requires the chip to actually run.
Summary
HIL testing closes the gap between unit tests and real-world behavior. A Raspberry Pi, a relay board, and a current sensor get you 80% of the value for under $50 in hardware. The investment pays back the first time a CI job catches an overcurrent bug before it burns out a prototype. Build the rig incrementally — start with power cycling and UART capture, add fault injection once the basics are solid, and keep the tests focused on behaviors that unit tests cannot reach.