Googletest as Unit Test Framework

Motivation

In the past I used to do evolutionary prototyping for my projects. It felt just natural to start writing code without thinking much about architecture or anything. It often happend that I spent hours following a train of thought while programming the according code. But when I compiled I first had to also spend hours to make the code compile just to find the code not working as intended. To find the bug also took me hours of debugging.

When I listened to Robert C. Martin aka Uncle Bob introducing Test Driven Development it was just mind blowing, because all of a sudden I knew that this is the exact technique I needed or my projects. It forces you to have smaller development cycles, increases the quality of the code and saves you a lot of time and trouble. The rules are easy:

  1. First write a text case that fails
  2. When writing the test case just write enough to fail
  3. Then write enough production code to make the test case pass
  4. Then most importantly: Refactor what you just did
When sticking to this small cycle on function level your code style will change: Functions will get smaller and you will find bug in a very early stage.

Overview

I currently use this method on function level for unit testing (like ASPICE SWE.4). To be able to easily write my test cases I installed googletest on my machine. It is noteworthy that I do all my programming in C++. I am pretty sure that googletest also works with C, but I have not tested. It is easiest to not run the test cases on the Raspberry Pico. Instead I run the test cases on my Linux machine, assuming that the behavior of the function will not change when using another compiler or just different compile flags for the Pico. I am using googletest with cmake.
Once everything is set up I am able to compile for testing by setting a build target, like

cmake -DBUILD_TARGET=2

Preparing Your Build Environment

Download or copy the test framework from google's github repository. I usually create a folder named googletest in my projects and copy the content from github there. You should have a project directory that looks like this:

- project root  CMakeLists.txt // to define global settings
 - app // where you put your production code
  - CMakeLists.txt // to compile you app
 - googletest // where the test suite lives
  - CMakeLists.txt // downloaded from github; do not change
 - test // to store your test cases
  - CMakeLists.txt // to compile you test case

Adapting your CMakeLists.txt

The fact that The Raspberry Foundation already is recommending cmake as a build environment comes in handy. Most necessary files already exists. They only have to be adapted.

Switching between Build Targets

We want the possibility to switch between different build targets:

To differentiate between these three targets we will use the variable BUILD_TARGET in the CMakeLists files. The value of BUILD_TARGET will be set by calling cmake from the command line with parameter -DBUILD_TARGET followed by the value to be set, e.g.

cmake . -B build -DBUILD_TARGET=2

The above mentioned command will call cmake in the current command, create a directory "build" and store the generated files in it. While doing so it uses BUILD_TARGET with value 2. I pass the value of BUILD_TARGET into the code files for setting preprocessor directives (see chapter Preparing your Code for more details). This does not work very well with strings, hence I use numerical values. Here is what they mean:

# BUILD_Target == 0 ==> pico
# BUILD_Target == 1 ==> linux
# BUILD_Target == 2 ==> test

The standard Pico cmake file requires to include pico_sdk_import.cmake. If we build for the test framework this include will gives us error messages. So we need to wrap this with an if statement to only include the file if we build for the Pico:

if(BUILD_TARGET EQUAL 0)
include($ENV{PICO_SDK_PATH}/external/pico_sdk_import.cmake)
 add_definitions(-DPICO)
endif()

unction add_definitions() is used to pass variables to the preprocessor. In the above case we pass a parameter called PICO to the make file that is generated by cmake. This parameter can be used during compilation.
Please be aware that googletest requires c++14 as minimum standard for C++.
We need the same if statement for the function initializing the pico sdk.

if(BUILD_TARGET EQUAL 0)
 pico_sdk_init()
endif()

If you want to build for testing you need to include your directory with the test cases, the googletest directory as well as the directory with your productive code.

if(BUILD_TARGET EQUAL 2)
 add_subdirectory(test)
 add_subdirectory(googletest)
 add_definitions(-DTEST)
 add_subdirectory(app)
endif()

In this if statement we also pass a parameter called test to the generated make file.

In the CMakeLists.txt file of my application I put a if statement for every build target. For Pico and Linux I could have made things a little simpler, but I wanted to have everything separated. To be able to use productive source code like classes in the test environment, I needed to build a library out of the relevant productive source files. This is done in the if BUILD_TARGET EQUAL 2 statement by using the add_library() function. Please note that the binary running your productive code either on Pico or on Linux will be called App.

if(BUILD_TARGET EQUAL 0)
 set(This "App")
 add_executable(${This}
  main.cpp
  # put additional source files here
 )

 target_include_directories(
  ${This} PUBLIC "include"
  ${PROJECT_BINARY_DIR}
  # put your include directories here
 )

 # pull in common dependencies
 target_link_libraries(
  ${This}
  pico_stdlib
  # put your libraries to include here
 )

 # enable usb output, disable uart output
 pico_enable_stdio_usb(${This} 0)
 pico_enable_stdio_uart(${This} 1)

 # create map/bin/hex/uf2 file etc.
 pico_add_extra_outputs(${This})
endif()

if(BUILD_TARGET EQUAL 1)
 set(This "App")
 add_executable(${This}
  main.cpp
  # put your source files here
 )

 target_include_directories(
  ${This} PUBLIC "include"
  ${PROJECT_BINARY_DIR}
  # put your include directories here
 )

 # pull in common dependencies
 target_link_libraries(
  ${This}
  pico_stdlib
  # put your libraries to include here
 )
endif()

if(BUILD_TARGET EQUAL 2)
 set(This "libApp")

 set(Headers
  # put the header files you need for testing here
 )
 set(Sources
  # put the source files of your productive code here
 )

 add_library(${This} STATIC ${Sources}) # this is to build a library for testing
 target_include_directories(${This} PUBLIC "include")

 target_link_libraries(
  ${This} PUBLIC
  # put additional libraries here
 )

endif()

In the test directory's CMakeList.txt file put below code. Please note that the library made of the productive code is includes as libApp in the target_link_libraries() function. Please note that the binary for running the tests cases will be called Test

set(This "Test")

add_executable(
 ${This}
  test_ClassXYZ.cpp # test cases for class XYZ
  # additional test files
)

target_include_directories(
 ${This} PUBLIC
 # put additional include directories here
 "${PROJECT_SOURCE_DIR}/app/include"
)

target_link_libraries(
 ${This} PRIVATE
 gtest_main
)

target_link_libraries(
 ${This} PUBLIC
 # put additional libraries to include here
 libApp
)

Preparing Your Code

The test cases are run on a Linux machine. This means, if you would try to execute the productive code without any preparation, you would get errors every time you try to execute Pico-specific code, like IO functions. To blank out Pico includes we use the parameter we passed from cmake to make with function add_definitions(), e.g. -DPICO.
You can check for the existence of those parameters in your preprocessor directives

#ifdef PICO
#include "hardware/pio.h"
#include "HCSR04.pio.h"
#include "pico/stdlib.h"
#endif

Depending on your test strategy you can either use the PICO parameter or the TEST parameter to enable or disable parts of your code.

#ifdef PICO
gpio_init(m_pin_led);
gpio_set_dir(m_pin_led, GPIO_OUT);

gpio_init(m_pin_int);
gpio_set_dir(m_pin_int, GPIO_IN);
#endif

Writing Test Cases with googletest

You need to include gtest.h in all of your test files as well as all the productive code that should be tested.

#include
#include "MyClass.hpp"

Every test case is defined by a macro called

TEST(TestSuiteName, TestName)

within the macro you use assertions to perform the test.
If you would like to test a getter function of lass MyClass, you would define a test case like

TEST(MyClass, getWidth) {
 MyClass m = MyClass(5);
 EXPECT_EQ(3, m.getWidth());
}

Compiling and running the tests

I usually generate the make files for testing in a separate directory, e.g. "tst". To generate the make files type in your bash, navigate to the root folder of your project and run

cmake . -B tst -DBUILD_TARGET=2

This will generate a lot of files in directory tst. No go there and run make

cd tst && make -j 4

To execute the tests

./test/Test

Last edit: 2023-04-13

Content