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:

  1. Arrange: Set up the necessary preconditions and inputs. This might involve initializing variables, creating mock objects, or configuring a peripheral.
  2. Act: Call the function or method being tested with the arranged inputs.
  3. 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_INTTEST_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 for my_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:

  1. A descriptive name for the test group (e.g., “Math Operations”).
  2. 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_UINT8TEST_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

C
#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

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

Plaintext
# 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

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 the TEST_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

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

  1. Open a VS Code terminal that is sourced for ESP-IDF.
  2. Navigate to your project’s root directory.
  3. Build the tests:idf.py build
    This command compiles your main application code, your component, and the test code.
  4. 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:

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

  1. Add a subtract function: Add a subtract_integers(int a, int b) function to the math_utils component. Write at least two new TEST_CASEs to verify its functionality with positive numbers, negative numbers, and zero.
  2. Create a string_utils component: Create a new component named string_utils. Implement a function void 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.
  3. 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.
  4. 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

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top