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