In my previous article, we learned how to build a stand-alone library for esp-idf. In this article we are going to learn how to write unit tests for our code so we can have confidence it does what we want it to do.

There are a few ways we can go about unit testing code written for ESP32:

  • Run tests directly on ESP32 board
  • Run tests using an emulator
  • Run tests on Linux host using mocks

We are going to learn how to write tests that can run on a Linux host, so it’s easy to plug them to a CI system.

Testing framework

Since we are going to be running tests in a Linux host, we will need a whole different build for our test. We will use this folder structure:

1
2
3
4
5
6
7
8
9
10
library-root/
├── CMakeLists.txt
├── example
│   └── ...
├── include
│   └── ...
├── src
│   └── ...
└── test
    └── ...

We are going to use Catch2 to run our unit tests because we just need to download a single file from Github. Copy the contents of catch.hpp into library-root/test/external/catch2/catch.hpp.

We can create library-root/test/CMakeLists.txt to run our tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cmake_minimum_required(VERSION 3.27.4)
project(LibraryTest
  VERSION 0.1.0
  LANGUAGES CXX)

include_directories(external/catch2)

set(TEST_TARGET_SRCS
  src/test-main.cpp
)

add_compile_options(-Wall -Wextra -Wpedantic -Werror)

add_executable(
  test
  ${TEST_TARGET_SRCS}
)

Notice that this file references src/test-main.cpp, so let’s create it:

1
2
3
4
5
6
7
8
#define CATCH_CONFIG_MAIN
#include <catch.hpp>

TEST_CASE("sample") {
  SECTION("test") {
    REQUIRE(1 == 1);
  }
}

We can verify that the test build is correctly configured like so:

1
2
3
4
5
mkdir -p library-root/test/build
cd library-root/test/build
cmake ..
make
./test

We should see a message similar to this one:

1
2
===============================================================================
All tests passed (1 assertion in 1 test case)

Writing tests

Let’s say we want to build a function that can parse a query string into a map.

First, we need library-root/test/CMakeLists.txt to include our files under test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
cmake_minimum_required(VERSION 3.27.4)
project(LibraryTest
  VERSION 0.1.0
  LANGUAGES CXX)

include_directories(external/catch2)
include_directories(../include)

set(TESTING_SRCS
  ../src/library.cpp
)

set(TEST_TARGET_SRCS
  src/test-main.cpp
)

add_compile_options(-Wall -Wextra -Wpedantic -Werror)

add_executable(
  test
  ${TEST_TARGET_SRCS}
  ${TESTING_SRCS}
)

Here is an example test of the desired functionality:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define CATCH_CONFIG_MAIN
#include <catch.hpp>

TEST_CASE("parseQueryString") {
  SECTION("Multiple key values") {
    std::unordered_map<std::string, std::string> actual =
        parse_query_string("hello?abc=1&qwer=world&onemore=yesyes");
    REQUIRE(actual.size() == 3);
    REQUIRE(actual.at("abc") == "1");
    REQUIRE(actual.at("qwer") == "world");
    REQUIRE(actual.at("onemore") == "yesyes");
  }
}

If we compile and run our tests now, we will get an error, because we haven’t defined parse_query_string:

1
2
3
4
/library-root/test/src/test-main.cpp: In function 'void C_A_T_C_H_T_E_S_T_0()':
/library-root/test/src/test-main.cpp:7:9: error: 'parse_query_string' was not declared in this scope
    7 |         parse_query_string("hello?abc=1&qwer=world&onemore=yesyes");
      |         ^~~~~~~~~~~~~~~~~~

We need to write our implementation. Let’s start with library-root/include/library.hpp:

1
2
3
4
5
6
#pragma once

#include <unordered_map>
#include <string>

std::unordered_map<std::string, std::string> parse_query_string(const std::string&);

Then, we have library-root/src/library.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include "library.hpp"

#include <iostream>

void replace(std::string &in, const char f, const char r) {
  for (long unsigned int i = 0; i < in.length(); i++) {
    if (in[i] == f) {
      in[i] = r;
    }
  }
}

std::unordered_map<std::string, std::string>
parse_query_string(const std::string &line) {
  std::unordered_map<std::string, std::string> dictionary;

  int start = line.find_first_of("?") + 1;
  int end = line.find_first_of(" ", start);
  std::string query_string = line.substr(start, end);
  replace(query_string, '+', ' ');

  long unsigned int current_start = 0;
  while (current_start < query_string.length()) {
    int current_end = query_string.find_first_of("&", current_start);
    if (current_end == -1) {
      current_end = query_string.length();
    }

    std::string current_pair =
        query_string.substr(current_start, current_end - current_start);
    int equalPos = current_pair.find_first_of("=");
    if (equalPos != -1) {
      // If there is no equal sign, we skip adding it to the result
      std::string key = current_pair.substr(0, equalPos);
      std::string value =
          current_pair.substr(equalPos + 1, current_pair.length());
      dictionary[key] = value;
    }

    current_start = current_end + 1;
  }

  return dictionary;
}

At this point, our library doesn’t use any esp-idf specific functionality, but it can be used by esp-idf projects, and we already wrote a test for it.

Mocking esp-idf

When our code depends on esp-idf, we will need to mock the functionality in order to test it.

Let’s say we have this code in library-root/src/library.hpp:

1
2
3
4
5
#pragma once

#include <esp_err.h>

esp_err_t event_loop();

And this in library-root/src/library.cpp:

1
2
3
4
5
6
7
#include "library.hpp"

#include <esp_event.h>

esp_err_t event_loop() {
  return esp_event_loop_create_default();
}

We have introduced 2 ESP-IDF dependencies: esp_err.h and esp_event.h. To be able to write tests for this code, we will need to mock those dependencies.

We’ll start by creating library-root/test/mock/ folder. This will be the place where we’ll add our mocks. We have the freedom to make our mocks do whatever we desire.

For this example, we’ll keep them very simple.

library-root/test/mock/esp_err.h:

1
2
3
4
5
#pragma once

#define ESP_OK 0

typedef int esp_err_t;

library-root/test/mock/esp_event.h:

1
2
3
4
5
#pragma once

#include "esp_err.h"

esp_err_t esp_event_loop_create_default();

library-root/test/mock/esp_event.cpp:

1
2
3
4
5
#include "esp_event.h"

esp_err_t esp_event_loop_create_default() {
  return ESP_OK;
}

We also need to update library-root/test/CMakeLists.txt, so it knows where to find the mocks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
cmake_minimum_required(VERSION 3.27.4)
project(LibraryTest
  VERSION 0.1.0
  LANGUAGES CXX)

include_directories(external/catch2)
include_directories(../include)
include_directories(mock)

FILE(GLOB MOCK_SRCS mock/*.cpp)

set(TESTING_SRCS
  ../src/library.cpp
)

set(TEST_TARGET_SRCS
  src/test-main.cpp
)

add_compile_options(-Wall -Wextra -Wpedantic -Werror)

add_executable(
  test
  ${MOCK_SRCS}
  ${TEST_TARGET_SRCS}
  ${TESTING_SRCS}
)

Now, we can write a test:

1
2
3
4
5
6
7
8
9
10
#define CATCH_CONFIG_MAIN
#include <catch.hpp>

#include "library.hpp"

TEST_CASE("event_loop") {
  SECTION("Returns ESP_OK") {
    REQUIRE(event_loop() == ESP_OK);
  }
}

Conclusion

Writing tests for ESP-IDF is not very complicated once we know what to do. The biggest hurdle is probably writing the tests, which, depending on the amount of dependencies, might be a lot of work.

As usual, you can find a working example in my examples’ repo.

[ esp32  programming  testing  ]
Handling Interrupts With ESP-IDF
Building a Stand-Alone Library for ESP-IDF
Configuring ESP32 to act as Access Point
ESP32 Non-Volatile Storage (NVS)
Making HTTP / HTTPS requests with ESP32