Embedded Systems Testing with Python and pytest

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.hil for hardware-required tests, @pytest.mark.sim for simulator-only tests
  • Plugins extend coveragepytest-html for hardware test reports, pytest-timeout for 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 value

Sensor 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"] != 10

Hardware 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/ttyUSB0

Timing 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: 30

Python 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.

Read more

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB delivers Cassandra-compatible APIs with a rewritten Seastar-based engine that achieves dramatically higher throughput. Testing ScyllaDB applications requires validating both Cassandra compatibility and ScyllaDB-specific behaviors like shard-per-core data distribution. This guide covers both angles. ScyllaDB Testing Landscape ScyllaDB is a drop-in replacement for Cassandra at the API level—which means

By HelpMeTest