Write Unit Tests: Create testable firmware functions using Unity and PlatformIO
Debug with Serial Output: Implement structured logging with debug levels
Use Hardware Debuggers: Set breakpoints and inspect variables with JTAG/SWD
Debug Remotely: Monitor deployed devices via OTA logging and telnet
For Beginners: Testing & Debugging
Testing and debugging are how you find and fix problems in your IoT code before it reaches users. Testing means writing code that checks if your main code works correctly – like having a quality inspector verify every part of your product. Debugging means investigating why code is not working as expected – like being a detective who solves mysteries by gathering clues. IoT debugging is especially challenging because you are working with physical hardware, sensors, and wireless connections, where problems can come from code, electronics, or even environmental factors.
Sensor Squad: Finding the Bugs
“Debugging IoT devices is like being a detective!” said Max the Microcontroller. “When something goes wrong, you have to gather clues. The first tool is serial output – printing messages to your computer that tell you what the code is doing step by step.”
Sammy the Sensor shared a common problem. “Sometimes I read a sensor value of negative 999. Is the sensor broken? Is the wiring loose? Is the code reading the wrong pin? Serial debug messages at each step help you narrow down where the problem is.” Lila the LED described a more advanced tool. “A hardware debugger connects to the microcontroller’s JTAG or SWD port. It lets you pause the code at any line, inspect variables, and step through execution one line at a time. It is like watching the code in slow motion.”
“Unit tests catch bugs before they reach the hardware!” added Max. “You write small test functions that verify each piece of code works correctly. Does the temperature conversion formula produce the right output? Does the MQTT message format correctly? Run these tests automatically every time you change code.” Bella the Battery concluded, “And for deployed devices, use remote logging. Send debug messages to the cloud so you can troubleshoot problems without being physically present. But be careful – too much logging wastes my energy and bandwidth!”
Key Concepts
Firmware: Low-level software stored in a device’s non-volatile flash memory that directly controls hardware peripherals.
SDK (Software Development Kit): Collection of libraries, tools, and documentation provided by a platform vendor to accelerate application development.
RTOS (Real-Time Operating System): Lightweight OS providing task scheduling and timing guarantees for embedded systems with concurrent requirements.
Over-the-Air (OTA) Update: Mechanism for delivering new firmware to deployed devices without physical access or a cable connection.
Unit Test: Automated test verifying a single function or module in isolation, catching bugs before hardware integration.
CI/CD Pipeline: Automated build, test, and deployment workflow that validates firmware quality on every code change.
Hardware Abstraction Layer (HAL): Software interface decoupling application code from specific hardware, enabling portability across MCU variants.
22.2 Prerequisites
Before diving into this chapter, you should be familiar with:
IoT firmware debugging requires systematic isolation of hardware, firmware, connectivity, and cloud layers because symptoms in one layer often originate in another, making holistic diagnostic tools and skills essential.
Interactive: IoT Troubleshooting Simulator
22.3 Why Test Embedded Code?
Firmware bugs discovered after deployment are 100-1,000 times more expensive to fix than bugs caught during development. A smart thermostat recall costs $15-50 per unit (shipping, reflashing, return handling), while a unit test catches the same bug for $0. For a 50,000-unit deployment, that difference is $750,000 to $2.5M versus essentially free.
The IoT testing challenge: Unlike web applications where you can deploy a fix in minutes, firmware on deployed devices may be unreachable, require physical access, or brick the device if the OTA update fails. Testing before shipping is your only reliable safety net.
22.4 Unit Testing
22.4.1 Worked Example: Testing a Sensor Calibration Module with Mocks
Scenario: Your soil moisture sensor outputs raw ADC values (0-4095). A calibration function converts these to volumetric water content (0-100%). You need to verify the conversion is correct without connecting real hardware.
Why mock the hardware: Unit tests must run on your development PC in milliseconds, not on a physical ESP32 with a real sensor. Mocking the ADC read function lets you inject known values and verify outputs deterministically.
// sensor_calibration.h - The code under test#ifndef SENSOR_CALIBRATION_H#define SENSOR_CALIBRATION_H// Abstract the hardware read so tests can mock itexternint(*adc_read_fn)(int channel);typedefstruct{int dry_value;// ADC reading in dry airint wet_value;// ADC reading in water} CalibrationData;float raw_to_moisture(int raw_adc, CalibrationData cal);bool is_reading_valid(int raw_adc);float apply_temperature_compensation(float moisture,float temp_c);#endif
// test_sensor_calibration.cpp - Unit tests with Unity framework#include <unity.h>#include "sensor_calibration.h"static CalibrationData test_cal ={.dry_value =3800,.wet_value =1200};void test_dry_soil_returns_zero(void){float moisture = raw_to_moisture(3800, test_cal); TEST_ASSERT_FLOAT_WITHIN(0.5,0.0, moisture);}void test_saturated_soil_returns_hundred(void){float moisture = raw_to_moisture(1200, test_cal); TEST_ASSERT_FLOAT_WITHIN(0.5,100.0, moisture);}void test_midpoint_returns_fifty(void){int midpoint =(3800+1200)/2;// 2500float moisture = raw_to_moisture(midpoint, test_cal); TEST_ASSERT_FLOAT_WITHIN(2.0,50.0, moisture);}void test_out_of_range_high_is_invalid(void){ TEST_ASSERT_FALSE(is_reading_valid(4096));// Above 12-bit max TEST_ASSERT_FALSE(is_reading_valid(-1));// Negative}void test_temperature_compensation_hot(void){// At 40C, capacitive sensors read 3-5% wetter than actualfloat compensated = apply_temperature_compensation(50.0,40.0); TEST_ASSERT_TRUE(compensated <50.0);// Should reduce reading}void test_temperature_compensation_normal(void){// At 25C (calibration temp), no adjustmentfloat compensated = apply_temperature_compensation(50.0,25.0); TEST_ASSERT_FLOAT_WITHIN(0.1,50.0, compensated);}int main(void){ UNITY_BEGIN(); RUN_TEST(test_dry_soil_returns_zero); RUN_TEST(test_saturated_soil_returns_hundred); RUN_TEST(test_midpoint_returns_fifty); RUN_TEST(test_out_of_range_high_is_invalid); RUN_TEST(test_temperature_compensation_hot); RUN_TEST(test_temperature_compensation_normal);return UNITY_END();}
Running these tests: pio test -e native executes on your PC in under 1 second. No hardware needed. Run on every commit.
22.4.2 Basic PlatformIO Unit Tests
PlatformIO Unit Tests:
#include <Arduino.h>#include <unity.h>void test_temperature_reading(){float temp = readTemperature(); TEST_ASSERT_TRUE(temp >-40.0&& temp <85.0);}void test_sensor_initialization(){bool initialized = initSensor(); TEST_ASSERT_TRUE(initialized);}void setup(){ UNITY_BEGIN(); RUN_TEST(test_temperature_reading); RUN_TEST(test_sensor_initialization); UNITY_END();}void loop(){// Tests run once in setup}
# Run tests on host machine (fast, no hardware)pio test -e native# Run tests on actual hardwarepio test -e esp32dev# Run specific test folderpio test -e native -f test_sensor_logic
Putting Numbers to It
Unit Test Coverage ROI: Explore how test coverage affects return on investment for an IoT firmware project:
Show code
viewof initial_test_hours = Inputs.range([10,200], {value:60,step:5,label:"Initial test writing (hours)"})viewof annual_maintenance_hours = Inputs.range([2,40], {value:10,step:1,label:"Annual test maintenance (hours)"})viewof hourly_rate = Inputs.range([50,150], {value:75,step:5,label:"Developer hourly rate ($)"})viewof bugs_caught_dev = Inputs.range([5,50], {value:18,step:1,label:"Bugs caught pre-deployment"})viewof hours_per_dev_bug = Inputs.range([1,5], {value:2,step:0.5,label:"Hours to fix dev bug"})viewof field_bugs_prevented = Inputs.range([1,10], {value:3,step:1,label:"Field bugs prevented"})viewof hours_per_field_bug = Inputs.range([20,80], {value:40,step:5,label:"Hours to fix field bug"})viewof project_years = Inputs.range([1,5], {value:2,step:1,label:"Project lifecycle (years)"})
Key insight: Tests pay for themselves after preventing just 2-3 field bugs. Field debugging costs 10-20x more than development testing due to remote access, limited visibility, and user impact.
Explore how debug log levels work in IoT firmware. Adjust the current log level to see which messages pass through the filter and which are suppressed. This helps you understand how to control verbosity in production versus development.
{const levels = ["ERROR","WARN","INFO","DEBUG"];const levelIndex = levels.indexOf(debugLevel);const tsOn = showTimestamps.includes("Show timestamps");const messages = [ {level:"ERROR",text:"Wi-Fi connection failed after 5 retries",ts:12450}, {level:"ERROR",text:"Sensor read returned NaN - bus error",ts:15230}, {level:"WARN",text:"Battery voltage below 3.3V threshold",ts:8120}, {level:"WARN",text:"MQTT reconnect attempt 2 of 5",ts:14890}, {level:"INFO",text:"Sensor initialized on I2C address 0x44",ts:1020}, {level:"INFO",text:"MQTT connected to broker.example.com",ts:3450}, {level:"INFO",text:"OTA update check: firmware up to date",ts:9800}, {level:"DEBUG",text:"ADC raw value: 2847, converted: 45.2%",ts:5670}, {level:"DEBUG",text:"Free heap: 142,380 bytes",ts:6100}, {level:"DEBUG",text:"Task stack watermark: 1,024 bytes",ts:7340}, {level:"DEBUG",text:"I2C transaction: 0x44 wrote [0x2C, 0x06]",ts:5690} ];const colors = {"ERROR":"#E74C3C","WARN":"#E67E22","INFO":"#16A085","DEBUG":"#7F8C8D" };const sorted = messages.slice().sort((a, b) => a.ts- b.ts);const passed = sorted.filter(m => levels.indexOf(m.level) <= levelIndex);const blocked = sorted.length- passed.length;const rows = sorted.map(m => {const visible = levels.indexOf(m.level) <= levelIndex;const tsStr = tsOn ?`[${m.ts}] `:"";return`<div style="padding: 4px 8px; margin: 2px 0; border-radius: 4px; font-family: monospace; font-size: 0.85em; ${visible ?`background: ${colors[m.level]}15; border-left: 3px solid ${colors[m.level]};`:'background: #f0f0f0; color: #bbb; text-decoration: line-through; border-left: 3px solid #ddd;'}">${tsStr}<span style="color: ${visible ? colors[m.level] :'#ccc'}; font-weight: bold;">[${m.level}]</span> ${m.text} </div>`; }).join("");returnhtml`<div style="background: var(--bs-light, #f8f9fa); padding: 1rem; border-radius: 8px; border-left: 4px solid #3498DB;"> <div style="display: flex; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap;"> <span style="background: #16A085; color: white; padding: 2px 10px; border-radius: 12px; font-size: 0.85em;">${passed.length} visible</span> <span style="background: #7F8C8D; color: white; padding: 2px 10px; border-radius: 12px; font-size: 0.85em;">${blocked} filtered out</span> </div> <div style="display: grid; gap: 0.15rem;">${rows} </div> <p style="margin-top: 0.75rem; font-size: 0.85em; color: #7F8C8D;"><strong>Tip:</strong> In production, use ERROR or WARN to minimize serial overhead. Switch to DEBUG during development to see all messages.</p> </div>`;}
22.6 Hardware Debugging
JTAG/SWD Debugging:
Set breakpoints in code
Step through execution
Inspect variables
View call stack
Supported by professional IDEs (STM32CubeIDE, PlatformIO with debugger)
PlatformIO Debug Configuration:
[env:esp32dev]platform = espressif32board = esp32devframework = arduino; Debug configurationdebug_tool = esp-builtin ; For ESP32-S3/C3; debug_tool = esp-prog ; For ESP32 with ESP-PROGdebug_init_break = tbreak setupdebug_speed =5000build_type = debug
Common GDB Commands:
# In PlatformIO Debug Console
info registers # Show CPU registers
print variable_name # Print variable value
watch variable_name # Break when variable changes
bt # Backtrace (call stack)
list # Show source code
continue # Resume execution
See how LED blink patterns communicate error codes on devices without screens. Select an error type and adjust the blink speed to understand how embedded engineers use simple LED patterns for hardware debugging.
#include <TelnetStream.h>void setup(){ Serial.begin(115200); TelnetStream.begin();}void loop(){// Output goes to both Serial and Telnet TelnetStream.println("Debug message over telnet"); Serial.println("Debug message over serial");// Read Telnet commandsif(TelnetStream.available()){char cmd = TelnetStream.read(); handleCommand(cmd);}}
Explore the tradeoffs of remote debug logging on battery-powered IoT devices. Adjust the logging frequency, message size, and connection type to see how debug logging affects battery life and data usage.
Answer these questions about your debugging scenario and get a recommended technique with rationale. This helps you build intuition for choosing the right debugging approach.
Show code
viewof bugLocation = Inputs.select( ["In development (on my desk)","Deployed in the field","Intermittent / hard to reproduce"], {value:"In development (on my desk)",label:"Where is the device?"})viewof bugType = Inputs.select( ["Logic error (wrong output)","Crash or freeze","Communication/protocol issue","Timing-sensitive bug","Memory leak (slow degradation)"], {value:"Logic error (wrong output)",label:"What type of bug?"})viewof hasDebugHW = Inputs.checkbox( ["JTAG/SWD probe available","Logic analyzer available","Network connectivity"], {value: ["Network connectivity"],label:"Available tools"})
Show code
{const loc = bugLocation;const bug = bugType;const hw = hasDebugHW;const hasJtag = hw.includes("JTAG/SWD probe available");const hasLogic = hw.includes("Logic analyzer available");const hasNet = hw.includes("Network connectivity");let primary ="";let secondary ="";let reason ="";let icon ="";let color ="";if (loc ==="Deployed in the field") {if (hasNet) { primary ="OTA / MQTT Logging"; secondary ="Telnet Remote Console"; reason ="Device is remote -- use network-based logging to capture diagnostics without physical access. Add targeted log statements around the suspected area."; icon ="📡"; color ="#9B59B6"; } else { primary ="LED Blink Codes"; secondary ="Periodic local log to flash storage"; reason ="No network access to deployed device. Use LED patterns for real-time status and store detailed logs to flash memory for later retrieval."; icon ="💡"; color ="#E67E22"; } } elseif (bug ==="Communication/protocol issue") {if (hasLogic) { primary ="Logic Analyzer"; secondary ="Serial logging of protocol state"; reason ="Protocol issues require seeing exact signal timing, bit patterns, and bus transactions. A logic analyzer decodes I2C/SPI/UART frames and reveals timing violations."; icon ="🔍"; color ="#3498DB"; } else { primary ="Serial Protocol Logging"; secondary ="Oscilloscope for signal integrity"; reason ="Without a logic analyzer, add serial logging at each protocol step (send, receive, parse) to identify where communication breaks down."; icon ="📋"; color ="#3498DB"; } } elseif (bug ==="Timing-sensitive bug"|| bug ==="Crash or freeze"|| bug ==="Memory leak (slow degradation)") {if (hasJtag) { primary ="Hardware Debugger (JTAG/SWD)"; secondary ="Watchpoints + memory inspection"; reason = bug ==="Memory leak (slow degradation)"?"Use JTAG to monitor heap size over time, set watchpoints on allocation functions, and identify objects that grow without being freed.": bug ==="Crash or freeze"?"JTAG lets you catch the crash in real-time, examine the call stack, inspect registers, and identify the exact line of failure. No code modification needed.":"JTAG does not alter timing like serial prints do. Set hardware breakpoints that pause without overhead to catch race conditions and timing bugs."; icon ="🎯"; color ="#E74C3C"; } else { primary ="Structured Serial Logging"; secondary ="Unit tests to isolate logic"; reason ="Without a hardware debugger, use minimal serial logging (timestamps + key variables). For timing bugs, keep prints short to minimize timing distortion."; icon ="📝"; color ="#16A085"; } } else { primary ="Unit Tests + Serial Debugging"; secondary ="Code review and assertions"; reason ="Logic errors are best caught by writing unit tests that verify expected outputs. Use serial prints to trace execution flow and compare actual versus expected values."; icon ="✅"; color ="#16A085"; }returnhtml`<div style="background: var(--bs-light, #f8f9fa); padding: 1rem; border-radius: 8px; border-left: 4px solid ${color};"> <div style="display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem;"> <span style="font-size: 1.5em;">${icon}</span> <div> <div style="font-weight: bold; color: #2C3E50; font-size: 1.1em;">Recommended: ${primary}</div> <div style="font-size: 0.85em; color: #7F8C8D;">Also consider: ${secondary}</div> </div> </div> <div style="background: white; padding: 0.75rem; border-radius: 6px; border: 1px solid #e0e0e0;"> <strong style="color: ${color};">Why this approach:</strong> <p style="margin: 0.5rem 0 0 0; font-size: 0.9em; color: #2C3E50;">${reason}</p> </div> <div style="margin-top: 0.75rem; font-size: 0.8em; color: #7F8C8D;"> Scenario: <em>${loc}</em> | Bug type: <em>${bug}</em> | Tools: <em>${hw.length>0? hw.join(", ") :"None selected"}</em> </div> </div>`;}
22.9 Debugging Technique Selection
22.10 Knowledge Check
Quiz: Testing and Debugging
Matching Quiz: Debugging Tools
Ordering Quiz: Testing Pyramid for IoT Firmware
Common Pitfalls
1. Testing Only the Happy Path
Writing tests only for expected inputs and successful outcomes leaves failure modes untested. In IoT firmware, the most common real-world scenarios (connection timeout, sensor read failure, corrupted packet) are exactly the edge cases omitted from happy-path test suites. Explicitly write test cases for every error branch and boundary condition.
2. Not Testing on Target Hardware
Unit tests running on a PC simulator can pass while the same code fails on target hardware due to endianness differences, timer resolution, or peripheral timing constraints. Include at least one hardware-in-the-loop test stage that exercises critical paths on real or emulated target hardware.
3. Treating Code Coverage as a Quality Guarantee
High code coverage (>90%) gives a false sense of security if tests only exercise code paths without asserting correct outputs. A test that calls every function without checking return values provides coverage but no quality assurance. Define assertions for every test case and review coverage together with mutation testing results.
22.11 What’s Next
If you want to…
Read this
Apply debugging skills to a full prototype project