Unit Testing with PlatformIO: Part 1. The Basics

Introduction to Unit Testing mechanism used in PlatformIO ecosystem

Valerii Koval
Valerii Koval
Head of System Integration at PlatformIO Labs
Share:

Unit testing isn’t a new concept in the software engineering field, it has been around for at least the past three decades. At the same time, it is still not so widespread in the embedded industry, even though the situation is getting better in recent years.

This article is the beginning of a series that concentrates on creating a convenient environment for test-driven development for embedded devices. For your reference, below is a list of the articles in this series:

Table of Contents

Introduction

In a nutshell, unit testing boils down to splitting the code into small units which can be tested in isolation to verify their behavior in different circumstances. The main benefit of thorough unit testing is looser coupling between software modules which implicitly leads to better software design. A broad set of tests also improves developer confidence in the project codebase by exposing errors very early in the development cycle and preventing regression bugs.

Thanks to proper module isolation, tests can be run directly on the host machine which allows us to start developing software even without real hardware at hand. Besides, well-structured tests represent a form of documentation on the proper use of each tested module.

In embedded software, a “unit” usually represents a single module that consists of a source file and an accompanying header file. This module usually abstracts a part of the system and packs together a group of related functions and data, for example, a peripheral driver, data structure, etc.

In this series, we will implement and test a very useful array-based data structure called circular buffer. In a nutshell, the main advantage of circular buffer is that it does not need to have its elements shifted each time when a new element is added.

Please note, the project in this series has been purposefully made as simple as possible for the sake of clarity. A comprehensive description of all nuances of implementing a proper circular buffer can easily take an entire dedicated article. The goal of this series is to concentrate on testing workflow, so don’t rely on the code presented in this series in real projects.

Prerequisites

Getting started

We will start with testing our module on the host machine. First, we need to install the native development platform. PlatformIO doesn’t install any toolchains automatically for the native dev-platform and uses the toolchain (preferably based on GCC) native for your OS (on Windows it’s recommended to install MinGW and make it available via PATH variable). Let’s install the native dev-platform via PlatformIO Home:

Instead of creating our project from scratch, we can conveniently import the “Hello World” example and use it as a boilerplate:

Let’s replace the contents of the main.c file with an empty main function:

int main ()
{

}

If we try to build the project, we should see the successful result:

Adding first tests

PlatformIO comes with its own tool called Unit Testing Engine to help you start testing as quickly as possible. Out-of-the-box support for a unit testing framework called Unity and a built-in test runner allows us to start writing tests without any preconfiguration. We just need to add new files with test cases to the test folder. For more details on the Unity Test API used in this project, see the overview in PlatformIO Documentation.

Back to the project, let’s create a new file test_circular_buffer.c with the following boilerplate code:

#include <unity.h>

void setUp(void) {
    // set stuff up here
}

void tearDown(void) {
    // clean stuff up here
}

int main( int argc, char **argv) {
    UNITY_BEGIN();
    UNITY_END();
}

Now if we run the Test task in the PlatformIO shortcut menu we should see that PlatformIO successfully detected our new file with no tests inside:

Now it’s time to think through the API for our implementation. We recommend to split any application into isolated modules and place them into the special lib folder in the root of the project. PlatformIO offers a very convenient mechanism called Library Dependency Finder (LDF) that will find these modules and automatically add them to the project build tree based on included header files or explicit dependencies specified in the platformio.ini file.

For the sake of simplicity, our buffer will be fixed-sized with 8-bit bytes as elements. The basic functionality will include the following functions:

  • Initialization of a circular buffer
  • Adding a new element to a circular buffer
  • Getting an element from a circular buffer
  • Reporting whether a circular buffer is empty
  • Cleaning the entire circular buffer

The requirements above lead us to the following header file for our module:

#ifndef CIRCULAR_BUFFER_H
#define CIRCULAR_BUFFER_H

#include <stdint.h>
#include <stdbool.h>

/*
    If the buffer is of a power-of-2 size,
    then a much quicker bitwise-AND instruction can be used instead.
*/

#ifndef BUFFER_SIZE
#define BUFFER_SIZE (32)
#endif

#if (BUFFER_SIZE & (BUFFER_SIZE - 1)) != 0
#error "BUFFER_SIZE must be a power of two"
#endif

#define BUFFER_MASK (BUFFER_SIZE-1)

typedef struct cbuffer_s
{
  uint8_t buffer[BUFFER_SIZE];
  int head;
  int tail;
} cbuffer_t;


void cbuffer_init(cbuffer_t* buf);
void cbuffer_add(cbuffer_t* buf, uint8_t item);
uint8_t cbuffer_get(cbuffer_t* buf);
bool cbuffer_empty(cbuffer_t* buf);
bool cbuffer_full(cbuffer_t* buf);
void cbuffer_clear(cbuffer_t* buf);

#endif // CIRCULAR_BUFFER_H

Let’s create a new folder lib/cbuffer and add two new files: cbuffer.h with the code above and cbuffer.c with empty implementations:

#include "cbuffer.h"

void cbuffer_init(cbuffer_t* buf) {

}

bool cbuffer_empty(cbuffer_t* buf) {
    return true;
}

void cbuffer_add(cbuffer_t* buf, uint8_t item) {

}

uint8_t cbuffer_get(cbuffer_t* buf) {
    return 0;
}


bool cbuffer_full(cbuffer_t* buf) {
    return false;
}


void cbuffer_clear(cbuffer_t* buf) {}

The function stubs above do nothing in terms of functionality, but they are required at this step so we can compile test binary without linker issues.

It’s time to add the first test case. It’s logical to assume that after initialization a circular buffer shouldn’t contain any elements, let’s test this behavior by adding the first test case to the test_circular_buffer.c file:

#include <unity.h>
#include "cbuffer.h"

void setUp(void) {
    // set stuff up here
}

void tearDown(void) {
    // clean stuff up here
}

void test_circular_buffer_empty_after_init() {
    cbuffer_t buff;

    cbuffer_init(&buff);

    TEST_ASSERT_TRUE(cbuffer_empty(&buff));
}


int main( int argc, char **argv) {
    UNITY_BEGIN();

    RUN_TEST(test_circular_buffer_empty_after_init);

    UNITY_END();
}

The body of the test function is pretty simple. We create a new buffer and initialize it in the default state. By using the TEST_ASSERT_TRUE statement we check that the return value from the cbuffer_empty function is exactly what we expect. If we run the Test task again, PlatformIO will report that everything worked as expected, and that seems correct since we hardcoded the return value of the cbuffer_empty to be true. Let’s add another test that adds a new element to the buffer and checks that it’s not empty:

void test_circular_buffer_not_empty_after_new_element_added() {
    cbuffer_t buff;
    cbuffer_init(&buff);

    cbuffer_add(&buff, 100);

    TEST_ASSERT_FALSE(cbuffer_empty(&buff));
}

This time PlatformIO complains that our new test failed:

This means that it’s time to implement some missing parts of our module in the lib/cbuffer/cbuffer.c file:

void cbuffer_init(cbuffer_t* buf) {
    buf->head = buf->tail = 0;
}

bool cbuffer_empty(cbuffer_t* buf) {
    return buf->head == buf->tail;
}

void cbuffer_add(cbuffer_t* buf, uint8_t item) {
  if(cbuffer_full(buf)) {
    buf->tail = ((buf->tail + 1) & BUFFER_MASK);
  }

  buf->buffer[buf->head] = item;
  buf->head = ((buf->head + 1) & BUFFER_MASK);
}

Now, if we run our tests, we see that all of them passed:

In the same way, we can add a new test test_circular_buffer_reports_full_correctly:

void test_circular_buffer_reports_full_correctly() {
    cbuffer_t buff;
    cbuffer_init(&buff);
    for (uint8_t i = 0; i < BUFFER_SIZE; i++) {
        cbuffer_add(&buff, i);
    }

    TEST_ASSERT_TRUE(cbuffer_full(&buff));
}

Now we can implement the cbuffer_full function and ensure that it works as expected:

bool cbuffer_full(cbuffer_t* buf) {
    return ((buf->head - buf->tail) & BUFFER_MASK) == BUFFER_MASK;
}

The next step is to implement reading from the buffer. Let’s write a new test case for this functionality and run the tests:

void test_circular_buffer_read_element_succesful() {
    cbuffer_t buff;
    cbuffer_init(&buff);

    uint8_t value = 55;
    cbuffer_add(&buff, value);

    TEST_ASSERT_EQUAL(value, cbuffer_get(&buff));
}

Predictably, the new test failed because the cbuffer_get function is not implemented yet. Let’s fix that by adding the following code to our module:

uint8_t cbuffer_get(cbuffer_t* buf) {
    if (cbuffer_empty(buf))
        return 0;
    return buf->buffer[buf->tail++];
}

The last requirement states that we have to provide a function that clears the entire buffer. Let’s start by writing a simple test case which uses this feature and fails if we run the tests:

void test_circular_buffer_cleaned_succesfully() {
    cbuffer_t buff;
    cbuffer_init(&buff);

    for (uint8_t i = 0; i < BUFFER_SIZE/2; i++) {
        cbuffer_add(&buff, i);
    }
    cbuffer_clear(&buff);

    TEST_ASSERT_TRUE(cbuffer_empty(&buff));
}

We can implement the cbuffer_clear function in the following way:

void cbuffer_clear(cbuffer_t* buf) {
    buf->head = buf->tail;
}

Unit testing often deals with a lot of repeated code. Usually, we need to prepare some context before a test can be run and we also might have some finishing work after. Fortunately, testing frameworks like Unity have helper functions which can help us get rid of that code duplication:

  • setUp function is called before the invocation of each test function
  • tearDown function is called after the invocation of each test function

If we take a look at the contents of the test_circular_buffer.c file, we can notice that all tests contain code where we initialize a buffer before validating behavior. Let’s extract that initialization step into the setUp function and clean the buffer in the tearDown function accordingly:

void setUp(void) {
    cbuffer_init(&buff);
}

void tearDown(void) {
    cbuffer_clear(&buff);
}

Now the tests look much better and if we run our tests again, PlatformIO will report that all tests are successful.

At his point we’ve satisfied all the requirements specified in the beginning of this post. There are plenty of features that haven’t been implemented and probably even more edge cases that haven’t been tested properly. All of them could greatly improve our implementation, but they are out of the scope of this post since the main goal of this series is to introduce you to the unit testing mechanism used in PlatformIO.

Summary

Unit testing may look like an exhausting and boring process that introduces additional challenges, but in the long run, there are significant benefits. Unit testing forces the developers to write testable code which implicitly leads to better modular and loosely-coupled design, fewer bugs and less debug time. The more checks are covered in unit tests, the higher the quality of the final code.

In the next post, we will try to run our tests on real hardware and explore the subtleties of setting up a communication channel for getting results from an embedded device.

Stay in touch with us

Stay tuned to this blog or follow us on LinkedIn and Twitter @PlatformIO_Org to keep up to date with the latest news, articles and tips!

Valerii Koval
Valerii Koval
Share:

Have questions?

Join the discussion on our forum