Embedded Systems Testing with Python and pytest
Embedded systems testing has a reputation for being painful — manual probe sessions, proprietary test frameworks, and tests that only run on physical hardware. Python and pytest change the equation. You can write expressive, maintainable tests that run against real hardware or simulators, integrate with CI, and give embedded teams the same fast feedback loops that web teams take for granted.
Why pytest for Embedded Testing?
pytest's fixture system is a natural fit for hardware test setups:
- Fixtures handle device lifecycle — connect, configure, test, disconnect, reset
- Parametrize tests across hardware variants — run the same test suite against multiple board revisions
- Markers separate test categories —
@pytest.mark.hilfor hardware-required tests,@pytest.mark.simfor simulator-only tests - Plugins extend coverage —
pytest-htmlfor hardware test reports,pytest-timeoutfor catching hung tests
Setting Up the Test Environment
Serial Communication
Most embedded devices communicate over UART. Use pyserial to drive them from Python tests:
# conftest.py
import serial
import pytest
import time
@pytest.fixture(scope="session")
def serial_device(request):
"""Connect to device under test via serial port."""
port = request.config.getoption("--device-port", default="/dev/ttyUSB0")
baud = request.config.getoption("--baud-rate", default=115200)
device = serial.Serial(port, baudrate=baud, timeout=5)
time.sleep(2) # Wait for device to initialize after serial connect
# Flush any startup output
device.flushInput()
yield device
device.close()
def pytest_addoption(parser):
parser.addoption("--device-port", action="store", default="/dev/ttyUSB0")
parser.addoption("--baud-rate", action="store", default=115200, type=int)Device Abstraction Layer
Wrap the serial interface in a higher-level device class. This makes tests readable and isolates protocol details:
# device.py
import serial
import json
import time
class EmbeddedDevice:
def __init__(self, port: str, baud: int = 115200):
self.ser = serial.Serial(port, baudrate=baud, timeout=5)
time.sleep(2)
def send_command(self, command: str) -> str:
"""Send a command and return the response."""
self.ser.write(f"{command}\r\n".encode())
response = self.ser.readline().decode('utf-8', errors='replace').strip()
return response
def send_json_command(self, command: dict) -> dict:
"""Send a JSON command and parse the JSON response."""
payload = json.dumps(command)
self.ser.write(f"{payload}\r\n".encode())
response_line = self.ser.readline().decode('utf-8', errors='replace').strip()
return json.loads(response_line)
def reset(self):
"""Reset device to known state."""
self.send_command("RESET")
time.sleep(2)
self.ser.flushInput()
def get_firmware_version(self) -> str:
return self.send_command("VERSION")
def read_sensor(self, sensor_id: str) -> dict:
return self.send_json_command({"cmd": "read_sensor", "id": sensor_id})
def set_config(self, key: str, value) -> bool:
response = self.send_json_command({"cmd": "set_config", "key": key, "value": value})
return response.get("ok") is True
def close(self):
self.ser.close()Writing Embedded Tests
Basic Firmware Tests
# tests/test_firmware.py
import pytest
import re
@pytest.mark.hil # Requires real hardware
class TestFirmwareBasics:
def test_firmware_version_format(self, serial_device):
"""Firmware version should follow semantic versioning."""
device = EmbeddedDevice(serial_device.port)
version = device.get_firmware_version()
assert re.match(r'^\d+\.\d+\.\d+$', version), \
f"Version '{version}' does not match MAJOR.MINOR.PATCH format"
def test_device_responds_to_ping(self, serial_device):
device = EmbeddedDevice(serial_device.port)
response = device.send_command("PING")
assert response == "PONG"
def test_reset_clears_state(self, serial_device):
device = EmbeddedDevice(serial_device.port)
# Set some state
device.set_config("threshold", 50)
assert device.send_json_command({"cmd": "get_config", "key": "threshold"})["value"] == 50
# Reset
device.reset()
# State should be back to defaults
result = device.send_json_command({"cmd": "get_config", "key": "threshold"})
assert result["value"] == 100 # Default valueSensor Tests
# tests/test_sensors.py
import pytest
SENSOR_IDS = ["temp-01", "humidity-01", "pressure-01"]
@pytest.mark.hil
@pytest.mark.parametrize("sensor_id", SENSOR_IDS)
def test_sensor_reading_in_range(serial_device, sensor_id):
"""All sensors should return readings within valid physical ranges."""
device = EmbeddedDevice(serial_device.port)
reading = device.read_sensor(sensor_id)
assert "value" in reading, f"Sensor {sensor_id} returned no value"
assert "unit" in reading, f"Sensor {sensor_id} returned no unit"
assert "timestamp" in reading
if "temp" in sensor_id:
assert -40.0 <= reading["value"] <= 85.0, \
f"Temperature {reading['value']}°C out of sensor range"
elif "humidity" in sensor_id:
assert 0.0 <= reading["value"] <= 100.0
elif "pressure" in sensor_id:
assert 300.0 <= reading["value"] <= 1100.0 # hPa
@pytest.mark.hil
def test_sensor_reading_frequency():
"""Device should be able to read all sensors at 10Hz."""
device = EmbeddedDevice("/dev/ttyUSB0")
readings = []
start = time.time()
for _ in range(100):
reading = device.read_sensor("temp-01")
readings.append(reading)
elapsed = time.time() - start
rate_hz = len(readings) / elapsed
assert rate_hz >= 10.0, f"Sensor reading rate {rate_hz:.1f}Hz is below 10Hz requirement"Configuration and NVM Tests
# tests/test_config.py
import pytest
@pytest.mark.hil
class TestNonVolatileMemory:
def test_config_persists_across_reset(self, serial_device):
"""Configuration written to NVM should survive a power cycle."""
device = EmbeddedDevice(serial_device.port)
# Write config
device.set_config("report_interval_ms", 5000)
# Reset (power cycle simulation)
device.reset()
# Config should persist
result = device.send_json_command({"cmd": "get_config", "key": "report_interval_ms"})
assert result["value"] == 5000, \
"Configuration did not survive reset — NVM write may have failed"
def test_config_bounds_validation(self, serial_device):
"""Device should reject out-of-range configuration values."""
device = EmbeddedDevice(serial_device.port)
# Try setting report interval below minimum (100ms)
result = device.send_json_command({
"cmd": "set_config",
"key": "report_interval_ms",
"value": 10 # Below minimum
})
assert result.get("error") == "out_of_range"
# Original value should be unchanged
current = device.send_json_command({"cmd": "get_config", "key": "report_interval_ms"})
assert current["value"] != 10Hardware Abstraction for CI
Running HIL tests requires physical hardware, which breaks most CI pipelines. The solution: a hardware abstraction layer (HAL) that runs against a simulator in CI and against real hardware on a dedicated test server.
# hal/base.py
from abc import ABC, abstractmethod
class DeviceHAL(ABC):
@abstractmethod
def read_sensor(self, sensor_id: str) -> dict:
pass
@abstractmethod
def set_config(self, key: str, value) -> bool:
pass
@abstractmethod
def reset(self):
pass
# hal/serial_hal.py
class SerialDeviceHAL(DeviceHAL):
def __init__(self, port: str):
self.device = EmbeddedDevice(port)
def read_sensor(self, sensor_id: str) -> dict:
return self.device.read_sensor(sensor_id)
def set_config(self, key: str, value) -> bool:
return self.device.set_config(key, value)
def reset(self):
self.device.reset()
# hal/simulator_hal.py
class SimulatorDeviceHAL(DeviceHAL):
"""Simulated device for CI environments without hardware."""
def __init__(self):
self._config = {"report_interval_ms": 1000, "threshold": 100}
self._sensor_values = {
"temp-01": {"value": 22.5, "unit": "celsius"},
"humidity-01": {"value": 45.0, "unit": "percent"},
}
def read_sensor(self, sensor_id: str) -> dict:
import time
if sensor_id not in self._sensor_values:
return {"error": "sensor_not_found"}
reading = self._sensor_values[sensor_id].copy()
reading["timestamp"] = int(time.time() * 1000)
return reading
def set_config(self, key: str, value) -> bool:
if key == "report_interval_ms" and (value < 100 or value > 60000):
return False
self._config[key] = value
return True
def reset(self):
self._config = {"report_interval_ms": 1000, "threshold": 100}
# conftest.py
@pytest.fixture
def device(request):
use_hardware = request.config.getoption("--use-hardware", default=False)
if use_hardware:
port = request.config.getoption("--device-port", default="/dev/ttyUSB0")
return SerialDeviceHAL(port)
else:
return SimulatorDeviceHAL()With this pattern, the same tests run in CI against the simulator and on the hardware test server against real devices:
# CI (no hardware)
pytest tests/ -v
<span class="hljs-comment"># Hardware test server
pytest tests/ -v --use-hardware --device-port=/dev/ttyUSB0Timing and Reliability
Embedded tests deal with real hardware timing — serial buffers, boot sequences, processing delays. Use explicit waits rather than time.sleep:
def wait_for_ready(device, timeout=10, poll_interval=0.1):
"""Wait for device to signal it's ready, up to timeout seconds."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
response = device.send_command("STATUS")
if response == "READY":
return True
except serial.SerialException:
pass
time.sleep(poll_interval)
return False
@pytest.mark.hil
def test_boot_within_sla(serial_device):
device = EmbeddedDevice(serial_device.port)
device.reset()
start = time.time()
ready = wait_for_ready(device, timeout=10)
boot_time = time.time() - start
assert ready, "Device never reached READY state"
assert boot_time < 5.0, f"Boot took {boot_time:.2f}s, exceeds 5s SLA"CI/CD Integration
# .github/workflows/embedded-tests.yml
jobs:
simulator-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pytest pyserial
- run: pytest tests/ -v -m "not hil" # Skip hardware tests in CI
hardware-tests:
runs-on: [self-hosted, embedded-hardware] # Self-hosted with hardware attached
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: pip install pytest pyserial
- run: pytest tests/ -v --use-hardware --device-port=/dev/ttyUSB0
timeout-minutes: 30Python and pytest bring modern software testing practices to embedded development. The key is building the right abstraction layer so your tests can run fast in simulation and validate rigorously on real hardware.