Maker.io main logo

Writing C/C++ Unit Tests with CppUTest

2023-02-20 | By ShawnHymel

License: Attribution Microcontrollers

Unit tests are small programs written to test the functionality of individual components (modules, submodules, libraries, etc.) in a larger software project. Unit testing is a critical part of the continuous integration and continuous deployment (CI/CD) pipeline, as automated testing helps ensure code quality in software projects. You will often find automated unit tests employed in medium- to large-scale projects, as the setup involved in building or using a test framework can take some time. 

In the previous tutorials, we looked at how to use Docker and GitHub Actions. In this tutorial, we will use both Docker and GitHub Actions along with a new framework, CppUTest, to build a unit test demo for C/C . You can watch this tutorial in video form here:

 

Unit Tests

Unit testing work best when you can divide a larger software project into smaller components that can be tested separately. However, it can sometimes be difficult to divide your project into smaller components that operate independently of each other, as parts will often depend on other parts.

When planning out your project, it can be helpful to divide the components into independent parts as much as possible. If possible, try to use separate files (e.g. .h, .c, .hpp, .cpp) to keep functions, classes, etc. outside of your main application file (e.g. main.c). This means breaking your project down as much as possible into functions that can be tested separately.

Block diagram showing application vs unit test code

You will also often find that your components will depend on other parts of your system, including hardware drivers. When programming for desktop or mobile applications, these drivers are usually abstracted, meaning you do not need to worry about these dependencies as much. However, in embedded systems, many applications depend heavily on drivers specifically written for one microcontroller. In smaller projects, you may even write these drivers directly into your application.

When it comes to unit testing, you will usually not be able to use the drivers directly, which means your code will fail to run on virtualized hardware (e.g. in a Docker container). One way to get around this is to use a hardware-in-the-loop (HIL) setup, where your automation framework (e.g. GitHub Actions) talks to real hardware (e.g. a Raspberry Pi) that can program and run tests on your target system (e.g. a microcontroller). We won’t get into HIL setups here, but you can read more about them in this blog post series by Golioth.

Another option is to create a set of “test doubles” that mimic (to varying degrees) the interfaces of your drivers and other dependencies. You will run across the following terms (note that the definitions can vary some, depending on what you might be reading):

  • Stub: basic interface implementation that returns predefined values (e.g. always return 0)
  • Mock: basic interface implementation that performs predefined behavior depending on the input values (e.g. simple if/else statements that give different return values for different inputs)
  • Fake: limited working implementation that pretends to be a dependency but might not rely on actual underlying hardware or frameworks (e.g. I2C driver that pretends to be a particular temperature sensor).

The application is written to combine all of the components into a single, usable program that solves a user’s needs. An automated integration test might find ways to compile and/or test the full application. However, unit tests are simply concerned with testing the individual components that provide underlying tooling for the application. As such, unit tests are often their own programs (with their own main() entrypoints) built to test only one component (e.g. a single library or group of related functions).

CppUTest

For smaller projects, you can likely write your own unit tests by creating separate main() entrypoint and then scripting the compilation and running of that test (e.g. using Python or Bash). However, writing multiple entrypoint files and dealing with interconnected dependencies becomes cumbersome in larger projects. To remedy this situation, myriad testing frameworks exist for most popular programming languages.

Some of the popular testing frameworks for C/C are Google Test, Catch, Cppunit, and CppUTest. We will use CppUTest because it is easy to set up, portable, and actively maintained (at the time of writing).

For using CppUTest on your local machine, I recommend checking out this blog post for building it and the follow-on blog post for running it against a simple test.

Directory Structure

Rather than building CppUTest locally, we will use a Docker image to test its functionality. Clone or download the repository here: https://github.com/ShawnHymel/c-unit-test

Note the directory structure of the repository. It is based on the Pitchfork Layout, which is a popular suggested directory structure for C/C projects (it is not a standard, so take it as just that: a suggestion).

Copy Code
c-unit-tests/
|-- .github/
|   |-- workflows/
|       |-- unit-tests.yml
|-- src/
|   |-- average/
|   |   |-- average.c
|   |   |-- average.h
|   |-- main.c
|-- tests/
|   |-- average/
|   |   |-- Makefile
|   |   |-- main.c
|   |   |-- test.cpp
|   |-- Makefile
|-- .gitignore
|-- Dockerfile
|-- Makefile

The .yml file under .github/workflows is used to define the GitHub Actions workflow, which we will explore in the next section. .gitignore lists the files that we should not check into our repository, including object files (.o, .obj) and executables (.elf).

The src/ directory is what you need to build the full application. It contains our entrypoint, main.c, for the application and one dependent component: the average function. The average function will be our component under test: it takes in an array and array length, computes the mean of all the values in the array, and returns that average value.

The top-level Makefile defines how we build the application as well as sets the target “test” to call the Makefile in tests/. By running “make” from this top-level directory, you should be able to compile the application into app.elf on your machine (assuming you have make and gcc installed). Run the application with “./app.elf” (on Linux or Mac machines). If you are on Windows, you will want to use WSL.

We recommend checking out this documentation to learn more about automatic variables in make.

In the tests/ directory, we define our unit tests. Notice the Makefile here: it looks for any subdirectories in tests/ and calls “make” from within those directories. So, you just need to copy the unit test directory (average/ that is in tests/) to create a new unit test. The Makefile in this unit test directory sets up a number of variables (including COMPONENT_NAME, which you will want to change for your test) before running the MakefileWorker.mk file inside CppUTest.

MakefileWorker.mk will take care of running the tests by using the CppUTest framework, the variables you have set, and the macros you define in test.cpp. Note that you must define main.c under this unit test directory so that CppUTest has an entrypoint. That main.c can likely stay the same for most of your unit tests, as it just calls the RunAllTests() function in CppUTest.

We will use the Dockerfile to create a simple Ubuntu image that copies in our source code, downloads and builds CppUTest, and runs all of the unit tests.

Using Docker to Run the Tests

Make sure that you have Docker Desktop installed and running on your computer. From a terminal, navigate to this project directory and run:

Copy Code
docker build -t unit-tests-image -f Dockerfile .

This will build a Docker image using the Dockerfile provided to the -f flag within the context of the current directory (e.g. c-unit-test/). Note that if you make any changes to the source code (src/ or tests/), you will need to run this command again to rebuild the image.

Once that completes, run the image with:

Copy Code
docker run --rm unit-tests-image

This should build and run a container using your image, which will run your unit tests. You should see the output logs from CppUTest showing that the 2 tests passed.

Unit test output log from CppUTest

From this, we can construct a GitHub Actions workflow that will build and run the unit tests Docker image every time new code is pushed to the repository.

Using GitHub Actions

Take a look at the script in .github/workflows/unit-tests.yml:

Copy Code
name: Unit Tests

on: [push]

jobs:
docker-unit-tests:
runs-on: ubuntu-latest
name: Build and run unit tests Docker image
steps:

- name: Check out this repo
uses: actions/checkout@v3

- name: Build Docker image
run: docker build -t unit-tests-image -f Dockerfile .

- name: Run image
run: docker run --rm unit-tests-image

This simple workflow activates every time a new commit is pushed to the repository. It then checks out the repository and runs the two Docker commands above to build and run the test Docker image.

If you fork the repository and commit/push new code, this workflow should run automatically. In the repository page on GitHub, go to Actions > All workflows and click on the latest workflow run. Click on the jobs to see the output. 

Expand the “Run image” section and scroll down to see the output of the Docker container. You should see the same output from the previous section: both tests ran and passed.

CppUTest unit tests running in GitHub Actions workflow

At this point, you should have a simple unit test template set up that runs inside a Docker container inside GitHub Actions. The template is extremely portable: you can build the application on your local machine and run unit tests inside a Docker image. You do not need to install additional dependencies (e.g. CppUTest), and the GitHub Actions workflow runs the tests automatically whenever new code is checked in.

Creating a New Component and Unit Test

To create a new component, just create a new directory under /src. You will need to update the top-level Makefile to tell make where to find the new header and source files (see the “# Search path for header files” and “# List module source files” sections). 

To include a new unit test, copy and rename the average/ directory under tests/. Rename COMPONENT_NAME in Makefile to the name of your new directory. Update the macros in test.cpp to fully test your new component. That’s it! Just recreate and run the Docker image to run all of the tests.

Notice the top of the average.c unit test:

Copy Code
extern "C" {
    #include "average.h"
}

Because CppUTest is written in C and built for C testing, you must use the ‘extern “C”’ keyword to include pure C code in your unit tests. If you are writing C , you can leave this keyword out and just include your header. See here to learn more about using C with CppUTest.

Recommended Reading

Here is some recommended reading if you would like to dig deeper into unit testing (with a focus on embedded systems):

TechForum

Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.

Visit TechForum