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