Chapter 249: ESP-IDF Component Testing Framework

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the principles and importance of unit testing in embedded firmware development.
  • Describe the architecture of the ESP-IDF testing framework, which is based on Unity.
  • Write, register, and organize test cases for custom components.
  • Use the idf.py command-line tool to build, flash, and run tests on a target device.
  • Interpret test results from the serial monitor output.
  • Recognize and troubleshoot common issues encountered when writing and running tests.

Introduction

As our projects grow in complexity, so does the risk of introducing bugs. A small change in one part of the code can have unintended and disastrous consequences elsewhere. In an embedded system, where software is intimately tied to hardware, these bugs can be particularly difficult to find and fix. Manually re-testing every feature after every change is tedious, time-consuming, and prone to human error.

To solve this, professional software development relies on automated testing. The ESP-IDF provides a powerful, integrated testing framework that allows you to write formal, repeatable tests for your code components. By building a suite of tests, you create a safety net that verifies your code’s correctness automatically. This chapter will teach you how to leverage this framework to build more reliable, maintainable, and robust ESP32 applications.

Theory

The ESP-IDF testing framework is built upon a popular open-source testing framework for C called Unity. It enables a practice known as unit testing, where individual “units” of code (typically functions or modules) are tested in isolation to verify they behave as expected.

1. What is a Test Case?

A test case is a C function designed to test a single, specific aspect of a unit’s behavior. For example, if you have a function int add(int a, int b), you might have several test cases:

  • A test for adding two positive numbers.
  • A test for adding a positive and a negative number.
  • A test for adding zero.
  • A test for potential overflow conditions.

Inside a test case, you use special macros called assertions to check for expected outcomes.

2. The Unity Framework and Assertions

Unity provides a rich set of assertion macros to validate conditions. If an assertion passes, the test continues. If it fails, the test case stops immediately, is marked as failed, and the framework moves on to the next test.

All test cases are defined using the TEST_CASE macro. Here are some of the most common assertion macros:

Assertion Macro Checks If… Example
TEST_ASSERT_EQUAL(a, b) The two values are equal. Works for integers, pointers, etc. TEST_ASSERT_EQUAL(5, add(2, 3));
TEST_ASSERT_EQUAL_STRING(a, b) The two null-terminated strings are identical. TEST_ASSERT_EQUAL_STRING(“hi”, my_str);
TEST_ASSERT_TRUE(cond) The condition cond evaluates to true. TEST_ASSERT_TRUE(x > 0);
TEST_ASSERT_NOT_NULL(ptr) The pointer ptr is not NULL. char* m = malloc(10);
TEST_ASSERT_NOT_NULL(m);
TEST_ASSERT_NULL(ptr) The pointer ptr is NULL. char* d = duplicate(NULL);
TEST_ASSERT_NULL(d);
TEST_FAIL_MESSAGE(msg) Always fails the test, printing a custom message. TEST_FAIL_MESSAGE(“This should not happen.”);

A simple test case function would look like this:

C
#include "unity.h"

// The function we want to test
int add(int a, int b) {
    return a + b;
}

// The test case
TEST_CASE("addition of two positive numbers", "[math]")
{
    TEST_ASSERT_EQUAL(5, add(2, 3));
    TEST_ASSERT_EQUAL(100, add(50, 50));
}

Notice the two arguments to TEST_CASE:

  1. Description: A string describing what the test does.
  2. Tags: A string in brackets ([tag]) used to group related tests. You can run tests based on these tags.

3. Test Component Structure

In ESP-IDF, tests are organized within the component they are testing. You create a special test subdirectory inside your component’s folder. The build system recognizes this convention and knows to build the contents of this folder only when running tests.

my_project/
├── main/
│   └── main.c
├── components/
│   └── math_utils/
│       ├── include/
│       │   └── math_utils.h
│       ├── math_utils.c
│       ├── CMakeLists.txt
│       └── test/              <-- Test subdirectory
│           └── test_math_utils.c  <-- Test source file
└── CMakeLists.txt
%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': '"Open Sans", sans-serif'}}}%%
graph TD
    subgraph "Project Directory"
        direction LR
        subgraph "components/"
            direction TB
            subgraph "my_component (e.g., math_utils)"
                direction LR
                subgraph "Source"
                    A["math_utils.c<br>math_utils.h<br>CMakeLists.txt"]
                end
                subgraph "Tests"
                    B["test/test_math_utils.c"]
                end
            end
        end
    end

    B -- "Includes & Links To" --> A
    
    %% Styling
    classDef component fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    classDef test fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E

    class A component
    class B test

4. Running Tests on Target

A key philosophy of the ESP-IDF framework is testing on the target hardware. This is crucial because code that works perfectly on a PC (the “host”) may fail on the ESP32 due to hardware differences, memory constraints, or peripheral interactions.

When you initiate a test build, the ESP-IDF creates a special, temporary application. This “test app” includes your component, the Unity framework, and the specific test code. It does not include the main function from your main project. Instead, it has its own main that acts as a test runner. This test app is then flashed to the ESP32, runs the tests, and prints the results to the serial monitor.

Practical Examples

Let’s create a simple component and then write a unit test for it.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': '"Open Sans", sans-serif'}}}%%
graph TD
    A(Start: Identify a function<br>or feature to test) --> B[1- Create test/test_*.c file<br>inside the component directory];
    B --> C[2- Write a TEST_CASE<br> - Give it a description and tag<br> - Call the function to be tested];
    C --> D[3- Add TEST_ASSERT... macros<br>to validate the results];
    D --> E{Does the test<br>allocate memory?};
    E -- Yes --> F["Add free() or use<br> tearDown() to prevent leaks"];
    E -- No --> G;
    F --> G[4- Register the test source file<br>in the component's CMakeLists.txt];
    G --> H[5- Run idf.py test command<br>to build, flash, and monitor];
    H --> I{Test Passed?};
    I -- No --> J[Debug the component code<br>or the test logic];
    J --> C;
    I -- Yes --> K(End: Test successful);

    %% Styling
    classDef start-node fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef process-node fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decision-node fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef check-node fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;
    classDef endo-node fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;

    class A,K start-node;
    class B,C,D,F,G,H process-node;
    class E,I decision-node;
    class J check-node;

1. Create a Custom Component

First, we’ll create a component named string_utils with a function that duplicates a string.

  • Inside your project, create the directory components/string_utils/include.
  • Create the header file components/string_utils/include/string_utils.h:
C
#pragma once
#include <stddef.h>

/**
 * @brief Duplicates a string using heap memory.
 * * @param str_in The input string to duplicate.
 * @return A pointer to the newly allocated string, or NULL on failure.
 * The caller is responsible for freeing this memory.
 */
char* string_utils_duplicate(const char* str_in);
  • Create the source file components/string_utils/string_utils.c:
C
#include <string.h>
#include <stdlib.h>
#include "string_utils.h"

char* string_utils_duplicate(const char* str_in)
{
    if (str_in == NULL) {
        return NULL;
    }
    size_t len = strlen(str_in) + 1; // +1 for null terminator
    char* str_out = malloc(len);
    if (str_out == NULL) {
        return NULL;
    }
    memcpy(str_out, str_in, len);
    return str_out;
}
  • Create the component’s build file components/string_utils/CMakeLists.txt:
    idf_component_register(SRCS "string_utils.c" INCLUDE_DIRS "include")

2. Create the Test Case

Now, let’s add the test code.

  1. Create the test subdirectory: components/string_utils/test.
  2. Create the test source file components/string_utils/test/test_string_utils.c:
C
#include <string.h>
#include "unity.h"
#include "string_utils.h" // The header for the code we are testing

TEST_CASE("duplicate a valid string", "[string_utils]")
{
    const char* source = "Hello, Unity!";
    char* duplicated_str = string_utils_duplicate(source);

    // Check that the duplication was successful
    TEST_ASSERT_NOT_NULL(duplicated_str);

    // Check that the content is identical
    TEST_ASSERT_EQUAL_STRING(source, duplicated_str);

    // Check that it's a new memory location
    TEST_ASSERT_NOT_EQUAL(source, duplicated_str);

    // IMPORTANT: Clean up allocated memory
    free(duplicated_str);
}

TEST_CASE("duplicate a NULL string", "[string_utils]")
{
    // The function should gracefully handle NULL input and return NULL
    char* duplicated_str = string_utils_duplicate(NULL);
    TEST_ASSERT_NULL(duplicated_str);
}

Tip: Always free any memory your test case allocates. A memory leak in a test is still a memory leak!

3. Register the Test with CMake

We need to tell the build system about our new test file.

  1. Open components/string_utils/CMakeLists.txt and modify it:
C
idf_component_register(SRCS "string_utils.c"
                       INCLUDE_DIRS "include")

# --- Add this part for testing ---
# List all your test source files here.
set(TEST_SRCS test/test_string_utils.c)

# Register the test with the build system.
# The first argument is the component being tested.
idf_component_add_unit_test(string_utils "${TEST_SRCS}")

4. Build, Flash, and Run the Test

Now for the final step. We will use the idf.py command line.

  1. Open a new terminal in VS Code (Terminal -> New Terminal).
  2. Run the test command. Replace (PORT) with your device’s serial port (e.g., COM3 or /dev/ttyUSB0).idf.py -p (PORT) test string_utils flash monitor
    Let’s break down this command:
    • idf.py test: The main command to initiate a test build.
    • string_utils: The specific component we want to test.
    • flash: Instructs the tool to flash the resulting test app to the target.
    • monitor: Opens the serial monitor to view the results.

After flashing, the test app will run, and you will see the Unity test report in the monitor:

Plaintext
...
Running test_string_utils...
I (314) test_string_utils: (1) "duplicate a valid string"
I (324) test_string_utils: (2) "duplicate a NULL string"
2 Tests 0 Failures 0 Ignored
OK

Variant Notes

The ESP-IDF Component Testing Framework itself is identical across all ESP32 variants (ESP32, S2, S3, C3, C6, H2). The idf.py test ... command works the same way for all of them.

The crucial difference lies in the behavior of the code being tested, especially when it interacts with hardware peripherals.

  • A GPIO test written for an ESP32 may not work on an ESP32-C3 without modification, as the valid GPIO numbers differ.
  • A TWAI (CAN) peripheral test will only run on variants that have that peripheral, like the ESP32. It will fail to build or run on an ESP32-C3.
  • Timing-sensitive tests might behave differently due to the core architecture (Xtensa vs. RISC-V) or different clock speeds.

Best Practice: When writing tests for hardware-interacting components, use the test tags to specify the intended hardware. For example: TEST_CASE("Test TWAI feature", "[twai][esp32]"). This allows you to selectively run tests relevant to the connected chip.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Forgetting to Register Test in CMake The build completes, but the test command reports: No tests for component ‘my_component’ found. Solution: Ensure the component’s CMakeLists.txt contains the idf_component_add_unit_test() function call pointing to your test source file(s).
Test Case Always Passes A test runs and is marked “OK” even when the underlying function is clearly broken or returns a wrong value. Solution: The TEST_CASE function is missing an assertion. Every test case must contain at least one TEST_ASSERT…() macro to actually check a condition.
Test Causes a Reboot (WDT) The device suddenly reboots in the middle of a test run, and the log shows a “Watchdog timer expired” error. Solution: The code being tested has a long-running loop or has crashed. Insert a vTaskDelay(1) inside long loops to yield to the scheduler and reset the watchdog. Debug to find potential crashes.
“Flaky” or Inconsistent Tests A test passes on one run but fails on the next without any code changes. Often related to external factors. Solution: Make tests self-contained. Do not rely on a Wi-Fi connection or an external sensor being present unless you explicitly configure it in a setUp() function and clean it up in a tearDown() function.

Exercises

  1. Test for Failure: Add a new function to string_utils called char* string_utils_copy_safe(const char* src, char* dst, size_t dst_size). This function should copy a string but return NULL if the destination buffer is too small. Write a new test case that asserts the function correctly returns NULL when it’s supposed to fail.
  2. Group with Tags: Create a new component called calculator with add and subtract functions. Write four test cases: test_addtest_subtracttest_add_negativetest_subtract_negative. Tag the addition tests with [add] and the subtraction tests with [subtract]. Run only the [add] tests using the command idf.py -p (PORT) test calculator -T "[add]" flash monitor.
  3. Refactor with Setup/Teardown: In the string_utils test, notice that you might allocate and free memory in multiple tests. Refactor the tests to use setUp() to allocate a resource before each test and tearDown() to free it afterward, reducing code duplication.

Summary

  • Automated testing is vital for creating reliable and maintainable firmware.
  • ESP-IDF uses the Unity framework for on-target unit testing.
  • Tests are organized in a test sub-directory within each component.
  • Test cases are C functions that use assertion macros (TEST_ASSERT...) to validate code behavior.
  • Tests are registered in the component’s CMakeLists.txt using idf_component_add_unit_test.
  • The idf.py test command builds and runs a dedicated test application on the target hardware, providing results over serial.
  • While the framework is variant-agnostic, the tests you write must account for hardware differences.

Further Reading

Leave a Comment

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

Scroll to Top