Arduino and ESP32 Firmware Testing: CI Pipeline with AUnit and Unity

Arduino and ESP32 Firmware Testing: CI Pipeline with AUnit and Unity

Arduino and ESP32 projects often lack automated testing, relying on manual "flash and observe" workflows. This guide builds a complete CI pipeline: unit tests that run on your host machine in milliseconds, integration tests that run on real hardware, and GitHub Actions that tie everything together.

The Testing Strategy

┌─────────────────────────────────────────────────────┐
│  Level 3: Hardware Integration Tests                 │
│  (PlatformIO + pytest over serial, on-device)        │
├─────────────────────────────────────────────────────┤
│  Level 2: Native Unit Tests (no hardware needed)     │
│  (AUnit or Unity compiled for x86 host)              │
├─────────────────────────────────────────────────────┤
│  Level 1: Static Analysis                            │
│  (cppcheck, clang-tidy, arduino-lint)                │
└─────────────────────────────────────────────────────┘

Run levels 1 and 2 on every commit in CI with no hardware. Level 3 runs on self-hosted runners with physical boards.

Project Structure

my_firmware/
├── src/
│   ├── main.cpp               # Arduino setup()/loop()
│   ├── sensor_reader.h/.cpp   # Business logic
│   ├── data_uploader.h/.cpp   # WiFi/MQTT logic
│   └── config.h               # Configuration
├── test/
│   ├── native/                # Host-side unit tests
│   │   ├── test_sensor_reader.cpp
│   │   ├── test_data_parser.cpp
│   │   └── mocks/
│   │       ├── arduino_mock.h     # Mock Arduino.h
│   │       └── wire_mock.h        # Mock Wire (I2C)
│   └── embedded/              # On-device integration tests
│       └── test_wifi_connect.cpp
├── platformio.ini
└── .github/workflows/
    └── ci.yml

PlatformIO Configuration

; platformio.ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
    bblanchon/ArduinoJson@^7.0.0
    knolleary/PubSubClient@^2.8

[env:native]
platform = native
build_flags = 
    -DUNIT_TEST
    -DARDUINO=100
    -std=c++17
    -I test/native/mocks
    -I src
lib_deps =
    throwtheswitch/Unity@^2.5.2

[env:esp32dev_test]
extends = env:esp32dev
build_type = test
test_framework = unity
test_filter = embedded/*

Mocking Arduino APIs

The key challenge: Arduino.h uses platform-specific types and hardware. Create a minimal mock:

// test/native/mocks/arduino_mock.h
#pragma once
#include <cstdint>
#include <cstring>
#include <string>
#include <functional>

// Arduino types
using byte = uint8_t;
using word = uint16_t;

// Arduino constants
#define HIGH 1
#define LOW  0
#define INPUT  0x0
#define OUTPUT 0x1
#define INPUT_PULLUP 0x2

// Time functions
extern uint32_t mock_millis_value;
inline uint32_t millis() { return mock_millis_value; }
inline uint32_t micros() { return mock_millis_value * 1000; }
inline void delay(uint32_t ms) { mock_millis_value += ms; }

// GPIO functions
extern std::function<void(uint8_t, uint8_t)> mock_pinMode_fn;
extern std::function<void(uint8_t, uint8_t)> mock_digitalWrite_fn;
extern std::function<int(uint8_t)> mock_digitalRead_fn;
extern std::function<int(uint8_t)> mock_analogRead_fn;

inline void pinMode(uint8_t pin, uint8_t mode) {
    if (mock_pinMode_fn) mock_pinMode_fn(pin, mode);
}
inline void digitalWrite(uint8_t pin, uint8_t val) {
    if (mock_digitalWrite_fn) mock_digitalWrite_fn(pin, val);
}
inline int digitalRead(uint8_t pin) {
    return mock_digitalRead_fn ? mock_digitalRead_fn(pin) : LOW;
}
inline int analogRead(uint8_t pin) {
    return mock_analogRead_fn ? mock_analogRead_fn(pin) : 0;
}

// Serial mock
class FakeSerial {
    std::string output_;
public:
    void begin(long baud) {}
    template<typename T>
    void print(T val) { output_ += std::to_string(val); }
    void println(const char* s) { output_ += s; output_ += '\n'; }
    std::string getOutput() { return output_; }
    void clear() { output_.clear(); }
};
extern FakeSerial Serial;

Writing Native Unit Tests with AUnit

AUnit mirrors the Arduino test style but compiles natively:

// test/native/test_sensor_reader.cpp
#include <unity.h>
#include "arduino_mock.h"
#include "../../src/sensor_reader.h"

// Reset mock state before each test
void setUp(void) {
    mock_millis_value = 0;
    mock_analogRead_fn = nullptr;
    mock_digitalWrite_fn = nullptr;
}

void tearDown(void) {}

void test_sensor_reader_reads_correct_pin(void) {
    uint8_t last_read_pin = 0;
    mock_analogRead_fn = [&](uint8_t pin) -> int {
        last_read_pin = pin;
        return 2048;  // mid-scale
    };
    
    SensorReader reader(/*pin=*/A0);
    reader.read();
    
    TEST_ASSERT_EQUAL_UINT8(A0, last_read_pin);
}

void test_sensor_reader_converts_adc_to_voltage(void) {
    mock_analogRead_fn = [](uint8_t) -> int { return 2048; };
    
    SensorReader reader(A0);
    float voltage = reader.readVoltage();
    
    // 2048/4095 * 3.3V ≈ 1.65V on ESP32
    TEST_ASSERT_FLOAT_WITHIN(0.05f, 1.65f, voltage);
}

void test_sensor_reader_filters_noise(void) {
    // Alternating high-noise values
    int values[] = {100, 900, 150, 850, 120};
    int idx = 0;
    mock_analogRead_fn = [&](uint8_t) -> int {
        return values[idx++ % 5];
    };
    
    SensorReader reader(A0);
    reader.enableAveraging(5);  // Average 5 samples
    float averaged = reader.readVoltage();
    
    // Average of {100, 900, 150, 850, 120} = 424, ≈ 0.34V
    TEST_ASSERT_FLOAT_WITHIN(0.05f, 0.34f, averaged);
}

void test_sensor_reader_detects_threshold_exceeded(void) {
    mock_analogRead_fn = [](uint8_t) -> int { return 3500; };  // High value
    
    SensorReader reader(A0);
    reader.setThreshold(3000);
    
    bool exceeded = reader.isThresholdExceeded();
    TEST_ASSERT_TRUE(exceeded);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_sensor_reader_reads_correct_pin);
    RUN_TEST(test_sensor_reader_converts_adc_to_voltage);
    RUN_TEST(test_sensor_reader_filters_noise);
    RUN_TEST(test_sensor_reader_detects_threshold_exceeded);
    return UNITY_END();
}

Testing JSON Parsing Logic

// test/native/test_data_parser.cpp
#include <unity.h>
#include <ArduinoJson.h>  // Works natively
#include "../../src/data_parser.h"

void test_parses_valid_sensor_payload(void) {
    const char* json = R"({
        "device_id": "esp32-001",
        "temperature": 23.5,
        "humidity": 65.2,
        "battery_mv": 3850
    })";
    
    DataParser parser;
    SensorPayload payload;
    bool result = parser.parse(json, &payload);
    
    TEST_ASSERT_TRUE(result);
    TEST_ASSERT_EQUAL_STRING("esp32-001", payload.device_id);
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 23.5f, payload.temperature);
    TEST_ASSERT_FLOAT_WITHIN(0.01f, 65.2f, payload.humidity);
    TEST_ASSERT_EQUAL_INT(3850, payload.battery_mv);
}

void test_rejects_missing_required_fields(void) {
    const char* json = R"({"temperature": 23.5})";  // Missing device_id
    
    DataParser parser;
    SensorPayload payload;
    bool result = parser.parse(json, &payload);
    
    TEST_ASSERT_FALSE(result);
    TEST_ASSERT_EQUAL_INT(PARSE_ERR_MISSING_FIELD, parser.getLastError());
}

void test_handles_malformed_json(void) {
    const char* json = "{this is not valid json";
    
    DataParser parser;
    SensorPayload payload;
    bool result = parser.parse(json, &payload);
    
    TEST_ASSERT_FALSE(result);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_parses_valid_sensor_payload);
    RUN_TEST(test_rejects_missing_required_fields);
    RUN_TEST(test_handles_malformed_json);
    return UNITY_END();
}

On-Device Integration Tests (ESP32)

For tests that require real WiFi, SPIFFS, or hardware peripherals:

// test/embedded/test_wifi_connect.cpp
#include <unity.h>
#include <WiFi.h>
#include "../../src/wifi_manager.h"

// Credentials from test config (not committed)
#define TEST_SSID "TestNetwork"
#define TEST_PASS "testpass123"

void setUp(void) {}
void tearDown(void) {
    WiFi.disconnect();
}

void test_connects_to_wifi_within_timeout(void) {
    WifiManager wifi;
    bool connected = wifi.connect(TEST_SSID, TEST_PASS, /*timeout_ms=*/10000);
    
    TEST_ASSERT_TRUE(connected);
    TEST_ASSERT_EQUAL(WL_CONNECTED, WiFi.status());
}

void test_gets_valid_ip_after_connect(void) {
    WifiManager wifi;
    wifi.connect(TEST_SSID, TEST_PASS, 10000);
    
    IPAddress ip = WiFi.localIP();
    TEST_ASSERT_NOT_EQUAL(0, ip[0]);  // Not 0.0.0.0
}

void test_reconnects_after_disconnect(void) {
    WifiManager wifi;
    wifi.connect(TEST_SSID, TEST_PASS, 10000);
    WiFi.disconnect();
    delay(1000);
    
    bool reconnected = wifi.reconnect(/*timeout_ms=*/15000);
    TEST_ASSERT_TRUE(reconnected);
}

void setup() {
    delay(2000);
    UNITY_BEGIN();
    RUN_TEST(test_connects_to_wifi_within_timeout);
    RUN_TEST(test_gets_valid_ip_after_connect);
    RUN_TEST(test_reconnects_after_disconnect);
    UNITY_END();
}

void loop() {}

pytest Serial Test Runner

Capture test results from the serial port in Python:

# test_runner.py
import serial
import subprocess
import sys
import re
import time

def run_embedded_tests(port: str, baud: int = 115200, timeout: int = 60):
    """Flash firmware and capture Unity test output from serial."""
    
    # Flash with PlatformIO
    result = subprocess.run(
        ["pio", "test", "-e", "esp32dev_test", "--upload-port", port],
        capture_output=True, text=True
    )
    
    if result.returncode != 0:
        print("Flash failed:", result.stderr)
        return False
    
    # Read serial output
    ser = serial.Serial(port, baud, timeout=1)
    output = []
    start = time.time()
    
    while time.time() - start < timeout:
        line = ser.readline().decode('utf-8', errors='ignore').strip()
        if line:
            output.append(line)
            print(line)
            
            if "UNITY_END" in line or "Tests" in line:
                break
    
    ser.close()
    
    # Parse results
    for line in output:
        if match := re.search(r'(\d+) Tests (\d+) Failures (\d+) Ignored', line):
            tests, failures, ignored = int(match[1]), int(match[2]), int(match[3])
            print(f"\nResults: {tests} tests, {failures} failures, {ignored} ignored")
            return failures == 0
    
    print("Could not parse test results")
    return False

if __name__ == "__main__":
    port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB0"
    success = run_embedded_tests(port)
    sys.exit(0 if success else 1)

GitHub Actions CI Pipeline

# .github/workflows/ci.yml
name: Firmware CI

on: [push, pull_request]

jobs:
  static-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install cppcheck
        run: sudo apt-get install -y cppcheck
      
      - name: Run cppcheck
        run: |
          cppcheck --error-exitcode=1 \
              --suppress=missingIncludeSystem \
              --enable=warning,style \
              src/

  native-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install PlatformIO
        run: pip install platformio
      
      - name: Run native unit tests
        run: pio test -e native -v
      
      - name: Parse test results
        run: |
          # PlatformIO outputs JUnit XML with --junit
          pio test -e native --junit-output-path=test_results.xml || true
      
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: native-test-results
          path: test_results.xml

  build-firmware:
    runs-on: ubuntu-latest
    needs: [static-analysis, native-tests]
    steps:
      - uses: actions/checkout@v4
      
      - name: Install PlatformIO
        run: pip install platformio
      
      - name: Build ESP32 firmware
        run: pio run -e esp32dev
      
      - name: Upload firmware artifact
        uses: actions/upload-artifact@v4
        with:
          name: firmware
          path: .pio/build/esp32dev/firmware.bin

  hardware-tests:
    runs-on: self-hosted  # Machine with ESP32 attached
    needs: build-firmware
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Download firmware
        uses: actions/download-artifact@v4
        with:
          name: firmware
      
      - name: Install dependencies
        run: |
          pip install platformio pyserial pytest
      
      - name: Flash and run embedded tests
        run: |
          python test_runner.py /dev/ttyUSB0
        env:
          TEST_WIFI_SSID: ${{ secrets.TEST_WIFI_SSID }}
          TEST_WIFI_PASS: ${{ secrets.TEST_WIFI_PASS }}

OTA Test Validation

Validate OTA updates work correctly:

// test/embedded/test_ota_update.cpp
#include <unity.h>
#include <Update.h>
#include "../../src/ota_manager.h"

void test_ota_validates_firmware_hash(void) {
    OtaManager ota;
    
    // Corrupt binary (flip a byte)
    const uint8_t bad_firmware[] = {0xFF, 0xFF, 0x00};
    bool result = ota.validateHash(bad_firmware, sizeof(bad_firmware),
                                    "INVALID_SHA256_HASH");
    
    TEST_ASSERT_FALSE(result);
}

void test_ota_rejects_downgrade(void) {
    OtaManager ota;
    
    // Simulate version 1.0.0 firmware on a 2.0.0 device
    FirmwareManifest manifest = {
        .version = "1.0.0",
        .min_version = "0.9.0"
    };
    
    bool allowed = ota.isUpdateAllowed(manifest, /*current_version=*/"2.0.0");
    TEST_ASSERT_FALSE(allowed);  // Downgrade not allowed
}

Summary

A complete Arduino/ESP32 CI pipeline has three layers:

Layer Tool Runs In Speed
Static analysis cppcheck, arduino-lint CI runner Seconds
Native unit tests PlatformIO + Unity CI runner Seconds
Hardware integration PlatformIO + pytest Self-hosted runner Minutes

The key to making native tests work is a comprehensive arduino_mock.h that replaces hardware-dependent APIs with controllable fakes. Business logic—parsing, state machines, calculations—goes in .cpp files that compile on any platform. Hardware-specific code—WiFi, SPIFFS, I2C—stays minimal and is tested only on real hardware.

This structure lets 80% of your test coverage run in CI without any hardware, catching logic bugs in seconds instead of minutes of manual flash-and-debug cycles.

Read more