Chapter 276: Performing Unit Tests on ESP32
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the importance and principles of unit testing in embedded systems.
- Learn about the Unity test framework and its integration within ESP-IDF.
- Structure a project component to include dedicated unit tests.
- Write, build, and run unit tests on a target ESP32 device.
- Use various assertion macros to validate function behavior.
- Troubleshoot common issues encountered during unit testing.
- Apply testing techniques to your own custom components.
Introduction
In software development, ensuring code quality and reliability is paramount. This is especially true in the world of embedded systems, where a software bug can lead to system failure, unpredictable behavior, or even physical damage. While manual testing is useful, it is often time-consuming, difficult to replicate, and prone to human error.
This is where automated testing, specifically unit testing, becomes an indispensable tool. Unit testing is a software testing method where individual units or components of a software are tested in isolation. For an ESP32 project, a “unit” might be a single function, a group of related functions, or an entire component that manages a peripheral.
By writing tests that automatically verify the correctness of your code’s smallest parts, you create a safety net. This net catches bugs early, simplifies debugging, facilitates code refactoring, and provides living documentation of how your code is intended to behave. ESP-IDF provides a powerful, integrated unit testing framework based on Unity, which we will explore in detail in this chapter.
Theory
What is Unit Testing?
Unit testing involves writing code to test your application code. The goal is to isolate a piece of code (a “unit”) and verify that it behaves exactly as expected under various conditions. A complete set of unit tests exercises a function’s logic by providing it with known inputs and then checking if the outputs are correct.
A typical unit test follows a simple pattern:
- Arrange: Set up the necessary preconditions and inputs. This might involve initializing variables, creating mock objects, or configuring a peripheral.
- Act: Call the function or method being tested with the arranged inputs.
- Assert: Check if the result (the output or a change in system state) matches the expected outcome. If it doesn’t, the test fails.
%%{ init: { 'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans' } } }%% graph TD subgraph Unit Test Execution direction TB A[/"<b>Arrange</b><br>Set up preconditions and inputs<br><i>(e.g., initialize variables, mock objects)</i>"/] B["<b>Act</b><br>Call the function under test"] C{"<b>Assert</b><br>Does the actual outcome<br>match the expected outcome?"} D[("<b>Pass</b><br>The unit behaves as expected")] E[("<b>Fail</b><br>A bug is detected!")] A --> B B --> C C -- Yes --> D C -- No --> E end %% Styling classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6 classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E classDef successNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46 classDef failNode fill:#FEE2E2,stroke:#DC2626,stroke-width:2px,color:#991B1B class A startNode class B processNode class C decisionNode class D successNode class E failNode
The Unity Test Framework
ESP-IDF uses Unity, a popular open-source test framework for C, as its default unit testing engine. Unity is lightweight, highly portable, and designed specifically for testing embedded C code. It provides a simple set of tools to structure your tests and make assertions about your code’s behavior.
Key features of Unity include:
- Test Cases: A mechanism to group related tests using the
TEST_CASE
macro. - Assertions: A rich set of macros (e.g.,
TEST_ASSERT_EQUAL_INT
,TEST_ASSERT_TRUE
) to verify conditions. - Test Runner: ESP-IDF automatically generates a test runner that executes all defined test cases and reports the results over the serial monitor.
Structuring Tests in an ESP-IDF Project
ESP-IDF’s build system is designed to seamlessly integrate testing. The convention is to place test code within a test
subdirectory inside the component you wish to test.
Consider a component named my_component
. The directory structure would look like this:
my_project/
├── main/
│ ├── main.c
│ └── CMakeLists.txt
├── components/
│ └── my_component/
│ ├── include/
│ │ └── my_component.h
│ ├── my_component.c
│ ├── CMakeLists.txt
│ └── test/
│ ├── test_my_component.c
│ └── CMakeLists.txt
└── CMakeLists.txt
my_component/test/test_my_component.c
: This file contains the actual unit tests formy_component
.my_component/test/CMakeLists.txt
: This file tells the build system to compile the test source file as part of the test build.
Writing a Test Case
A test case in Unity is defined using the TEST_CASE
macro. This macro takes two arguments:
- A descriptive name for the test group (e.g., “Math Operations”).
- A descriptive name for the specific test case (e.g., “[Addition]”).
Inside the test case function, you use Unity’s assertion macros to validate your code’s behavior.
Common Unity Assertion Macros:
Macro | Description |
---|---|
TEST_ASSERT_TRUE(condition) |
Asserts that condition is true. |
TEST_ASSERT_FALSE(condition) |
Asserts that condition is false. |
TEST_ASSERT_EQUAL(expected, actual) |
Asserts that actual is equal to expected. (Generic, type-agnostic). |
TEST_ASSERT_EQUAL_INT(expected, actual) |
Asserts two integers are equal. Provides better error messages for integers. |
TEST_ASSERT_EQUAL_UINT8(expected, actual) |
Asserts two 8-bit unsigned integers are equal. |
TEST_ASSERT_EQUAL_STRING(expected, actual) |
Asserts two null-terminated strings are equal. |
TEST_ASSERT_NULL(pointer) |
Asserts that a pointer is NULL. |
TEST_ASSERT_NOT_NULL(pointer) |
Asserts that a pointer is not NULL. |
TEST_FAIL_MESSAGE(message) |
Explicitly fails a test with a given message. |
TEST_IGNORE_MESSAGE(message) |
Skips a test with a given message. |
Tip: Unity provides typed assertion macros like
TEST_ASSERT_EQUAL_UINT8
,TEST_ASSERT_EQUAL_HEX16
, etc. Using the correctly typed macro can prevent compiler warnings and provide more descriptive failure messages.
Practical Example: Testing a math_utils
Component
Let’s create a simple component called math_utils
that contains a function for addition, and then write a unit test to verify its correctness.
Step 1: Create the Component Directory Structure
In your project directory, create the following folder structure inside the components
directory:
components/
└── math_utils/
├── include/
│ └── math_utils.h
├── math_utils.c
├── CMakeLists.txt
└── test/
├── test_math_utils.c
└── CMakeLists.txt
Step 2: Write the Component Source Code
components/math_utils/include/math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
/**
* @brief Adds two integers.
* * @param a The first integer.
* @param b The second integer.
* @return The sum of a and b.
*/
int add_integers(int a, int b);
#endif // MATH_UTILS_H
components/math_utils/math_utils.c
#include "math_utils.h"
int add_integers(int a, int b)
{
return a + b;
}
Step 3: Write the Component’s CMakeLists.txt
components/math_utils/CMakeLists.txt
# This file registers the component source files with the build system.
idf_component_register(SRCS "math_utils.c"
INCLUDE_DIRS "include")
Step 4: Write the Unit Test Code
Now, we’ll write the test file. This code will run on the ESP32, call our add_integers
function, and check if the result is correct.
components/math_utils/test/test_math_utils.c
#include <stdio.h>
#include "unity.h"
#include "math_utils.h" // The header for the component we are testing
// Test case for adding two positive numbers
TEST_CASE("Test addition of positive numbers", "[math_utils]")
{
printf("Running test: addition of positive numbers...\n");
TEST_ASSERT_EQUAL_INT(5, add_integers(2, 3));
TEST_ASSERT_EQUAL_INT(100, add_integers(50, 50));
}
// Test case for adding a positive and a negative number
TEST_CASE("Test addition with negative numbers", "[math_utils]")
{
printf("Running test: addition with negative numbers...\n");
TEST_ASSERT_EQUAL_INT(-5, add_integers(-2, -3));
TEST_ASSERT_EQUAL_INT(0, add_integers(5, -5));
}
// Test case for adding zero
TEST_CASE("Test addition with zero", "[math_utils]")
{
printf("Running test: addition with zero...\n");
TEST_ASSERT_EQUAL_INT(10, add_integers(10, 0));
TEST_ASSERT_EQUAL_INT(0, add_integers(0, 0));
TEST_ASSERT_EQUAL_INT(-5, add_integers(0, -5));
}
Warning: You must include
unity.h
in your test source files. This header provides theTEST_CASE
macro and all the assertion macros.
Step 5: Write the Test’s CMakeLists.txt
This file tells the build system that this directory contains tests for the math_utils
component.
components/math_utils/test/CMakeLists.txt
# This file registers the test source file for the 'math_utils' component.
# The 'TEST_COMPONENTS' variable tells the build system to link this test
# against the 'math_utils' component itself.
idf_component_get_property(test_comp ${COMPONENT_NAME} "NAME")
set(TEST_COMPONENTS ${test_comp})
idf_component_register(SRCS "test_math_utils.c"
INCLUDE_DIRS "."
TEST_COMPONENTS ${TEST_COMPONENTS})
Step 6: Build, Flash, and Run the Tests
Now you can build and run your tests using the idf.py
command-line tool.
- Open a VS Code terminal that is sourced for ESP-IDF.
- Navigate to your project’s root directory.
- Build the tests:
idf.py build
This command compiles your main application code, your component, and the test code. - Flash and run the tests on the target: Connect your ESP32 board and run the following command, replacing
<PORT>
with your device’s serial port (e.g.,COM3
on Windows,/dev/ttyUSB0
on Linux).idf.py -p <PORT> flash monitor
This flashes a special test application onto your device and opens the serial monitor to view the output.
Step 7: Observe the Output
After flashing, the ESP32 will reboot and run the tests. You will see output similar to this in your terminal:
...
I (325) main_task: Started on CPU 0
I (335) main_task: Calling app_main()
I (335) main_task: Starting test execution
================================================================================
Running 3 test cases...
================================================================================
Running test: addition of positive numbers...
Test case: Test addition of positive numbers ([math_utils])
- Test case passed
Running test: addition with negative numbers...
Test case: Test addition with negative numbers ([math_utils])
- Test case passed
Running test: addition with zero...
Test case: Test addition with zero ([math_utils])
- Test case passed
================================================================================
3 Tests 0 Failures 0 Ignored
--------------------------------------------------------------------------------
I (415) main_task: All tests passed
I (415) main_task: Test execution done.
This output clearly shows that all three of our test cases ran and passed successfully. If a test were to fail, Unity would report the file, line number, and the reason for the failure.
Variant Notes
The unit testing methodology described here is consistent across all ESP32 variants, including the ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, and ESP32-H2. The Unity framework and the ESP-IDF build system integration are part of the core framework and are not chip-specific.
However, the content of your tests will naturally be variant-dependent if you are testing hardware peripherals. For example:
- A unit test for a function that uses the USB-OTG peripheral can only be run on variants that have this peripheral, like the ESP32-S2 and ESP32-S3.
- A test for a function interacting with the 802.15.4 radio (for Thread/Zigbee) will only be applicable to the ESP32-H2 and ESP32-C6.
When writing tests for hardware-specific code, it’s good practice to use the preprocessor directives (e.g., #if CONFIG_IDF_TARGET_ESP32S3
) to conditionally compile tests for the appropriate target.
Common Mistakes & Troubleshooting Tips
Mistake / Issue | Symptom(s) | Troubleshooting / Solution |
---|---|---|
Undefined Reference Error | Build fails with linker errors like undefined reference to `my_function` . |
The component being tested was likely not added to the main project’s CMakeLists.txt . Ensure it has a line like set(COMPONENTS main my_component) . |
Test Hangs or Crashes | The serial monitor shows a Guru Meditation Error , a Task Watchdog timeout, or the test runner never completes. |
This often indicates a critical bug in the code under test, such as dereferencing a NULL pointer, an infinite loop, or a deadlock. Check the backtrace provided by the panic handler to locate the faulty code. |
Incorrect Assertion Type | Build fails with type mismatch errors, or a test passes when it should fail because of an implicit type cast. | Always use the correctly typed assertion macro. For example, use TEST_ASSERT_EQUAL_STRING for strings, not TEST_ASSERT_EQUAL . |
Hardware Not Initialized | A test that uses a peripheral (e.g., I2C, SPI) fails or crashes. The function returns an ESP_FAIL or ESP_ERR_INVALID_STATE error. |
The test case must initialize the required hardware before calling the function that uses it. Call the appropriate ..._driver_install() or ..._init() function at the beginning of the test case. |
Header Not Found | Build fails with an error like fatal error: my_component.h: No such file or directory . |
Check the CMakeLists.txt in both the component root and the test subdirectory. Ensure INCLUDE_DIRS is set correctly to expose the public header to the test file. |
Exercises
- Add a
subtract
function: Add asubtract_integers(int a, int b)
function to themath_utils
component. Write at least two newTEST_CASE
s to verify its functionality with positive numbers, negative numbers, and zero. - Create a
string_utils
component: Create a new component namedstring_utils
. Implement a functionvoid reverse_string(char *str)
that reverses a string in-place. Write unit tests to verify its behavior with strings of odd length, even length, and an empty string. - Write a Failing Test: In your
math_utils
component, write a test case that you know will fail (e.g.,TEST_ASSERT_EQUAL_INT(10, add_integers(2, 2));
). Run the tests and observe the failure report in the serial monitor. Note the file, line number, and expected vs. actual values reported by Unity. - Test FreeRTOS Functionality: Create a component that has a function to create a FreeRTOS queue and another to destroy it. Write a test case that calls the create function and asserts that the returned handle is not
NULL
. Then, call the destroy function. This demonstrates how to test code that interacts with the underlying operating system.
Summary
- Unit Testing is crucial for building robust and reliable embedded applications.
- ESP-IDF uses the Unity framework for on-target unit testing.
- Test code for a component should be placed in a
test
subdirectory within that component’s folder. - Test cases are defined with the
TEST_CASE("Group", "[Name]")
macro. - Assertion macros like
TEST_ASSERT_EQUAL_INT
are used to verify expected outcomes. - The
idf.py build
command compiles both the main app and the tests. - The
idf.py -p <PORT> flash monitor
command flashes and runs the test application, displaying results on the serial monitor. - While the testing framework is variant-agnostic, tests for hardware features must target the appropriate ESP32 variant.
Further Reading
- Espressif Official Documentation on Unit Testing: https://docs.espressif.com/projects/esp-idf/en/v5.2.1/esp32/api-guides/unit-tests.html
- Unity Test Framework Documentation: http://www.throwtheswitch.org/unity