7 tips for adding unit tests to existing firmware

I've written before about the how to configure Ceedling to run unit tests with an existing project. But after you have your unit test tools set up, it can still be difficult to figure out how to start writing tests. Code that hasn't been designed to be unit tested can be especially difficult. Here a few tips to help you out.

1. Test on the host

Your first goal should be (in the words of James Grenning) to "get your code off the target." You do this by taking one source file at a time, mocking its dependencies (including any hardware dependencies) so that you can compile it by itself with a native compiler.

It's a lot easier to work with the code on your host PC because it will build and run a lot faster. This exercise of mocking dependencies will also help you understand the design of your firmware. As you try to mock each one you'll really feel the pain of dependencies, hopefully encouraging you create more loosely coupled code in the future.

2. Start with the right module

Since mocking dependencies is a prerequisite for unit testing existing code, you'll want to start by choosing a module that doesn't have a lot of dependencies -- and you'll especally want to avoid modules that directly access hardware registers.

You can look at the #include-ed header files for help with this, but really what you want to know how many different functions are being called in other modules. In this case you should prefer a module that has fewer external function calls and has those function calls spread across fewer different modules.

This may not be the most interesting -- or important -- part of your application, but you want to start with some easier wins that get you some momentum. Once you understand the easier stuff, you can move on more confidently to modules with more dependencies.

3. Create a hardware abstraction layer (HAL)

If you don't have any modules without hardware dependencies, then you'll need to start building a HAL to get your code off the target for testing. This actually doesn't have to be that complicated. Just replace your register accesses with function calls that describe what you are doing. Consider a function that looks like this:

void InitLEDs(void)
{
    //The LED is on PORTE bit 4.
    //Assign port E bit 4 as an I/O, not the ECLK.
    PEAR |= bit(4);

    //Set the LED value to on.
    PORTE |= bit(4);

    //Set the LED control pin as an output.
    DDRE |= bit(4);
}

We could take those register accesses and move them to a new hal_led module where hal_led.c looks like this:

void set_led_as_io_not_eclock()
{
  PEAR |= bit(4);
}

void set_led_on()
{
  PORTE |= bit(4);
}

void set_led_as_output()
{
  DDRE |= bit(4);
}

Then our InitLEDs function would change to this:

#include "hal_led.h"

void InitLEDs(void)
{
  set_led_as_io_not_eclock();

  set_led_on();

  set_led_as_output();
}

Now we can mock hal_led.h for running our tests off target. As a bonus, this code is a lot easier to read and we don't even need those old comments any more.

4. Abstract that pesky driver library

Just like hardware accesses can be a problem, driver libraries can also be a pain, mostly for the way that they set off a chain of included header files that can be difficult to mock.

I've had success mocking some of these files in the past (e.g. this example mocks TI's TivaWare drivers), but this isn't always going to work. In particular I've had some trouble with the way that the STM32 drivers are structured.

In more difficult cases, you can create your own abstraction layer to the hardware drivers -- similar to the way you create a HAL. Instead of directly including driver header files and calling their functions, you can create your own driver abstraction module that sits in between.

This module only needs to include the functions and definitions that you need for your application. It can also hide that complicated chain of hardware-specific include files, making it much easier to mock and helping you get your code off the target.

5. Use Ceedling with the fff plugin

By default, Ceedling uses CMock for mocking. CMock is some pretty good stuff, but it's not the best option for adding tests to existing code. As we've already discussed, with existing code you're probably going to be doing a lot of mocking.

When it comes time to write your tests, with CMock you're going to need to explicitly expect all the function calls that your code under test is making. This can be hard.

Instead, I recommend using the fake function framework (fff) with Ceedling because it doesn't require strict expectations of all mocks before calling a function under test. You just use a header file to create mocks for all of a module's functions, and then you have complete control over whether or not you care that specific functions where called.

And fff is pretty easy to use with Ceedling, since I created a plugin for it (this article also explains the differences between CMock and fff in more detail).

6. Refactor infinite loops

Infinite loops are pretty common in our firmware, especially when implementing RTOS tasks. Code isn't testable in this form -- because functions with infinte loops don't return -- but we can fix this with a really simple refactoring. Consider a task function that looks like this:

void LEDTask (void* pvParameters)
{
  InitLEDs();

  for (;;)
  {
    led_on();
    vTaskDelay(LED_DELAY);
    led_off();
    vTaskDelay(LED_DELAY);
  }
}

If we wanted to test the main loop of this task, we could simply refactor the loop contents into its own testable function:

void LEDTask (void* pvParameters)
{
  InitLEDs();

  for (;;)
  {
    LEDBlink();
  }
}

void LEDBlink()
{
  led_on();
  vTaskDelay(LED_DELAY);
  led_off();
  vTaskDelay(LED_DELAY);
}

Now we can call LEDBlink from a unit test.

7. Use special test code as a last resort

Consider some weirdo, hardware-specific code like this:

void interrupt TimerOneSecondISR (void);

Here interrupt isn't standard C syntax, it's some custom function decorator used by this particular compiler vendor (this is a CodeWarrior HC12 compiler if I remember correctly). This won't compile when trying to use Ceedling to run tests on the host.

Fortunately, Ceedling provides a TEST macro that is only defined when compiling unit tests, and won't affect your code when compiling for the target.

In this example, you can redefine interrupt to be an empty macro which will essentially remove it when we are compiling unit tests:

#if TEST
#define interrupt
#endif

void interrupt TimerOneSecondISR (void);

This code will now compile on the host with Ceedling, allowing us to write tests for this timer module interrupt.

Tip: TEST is a pretty common name, which can lead to strange build problems. If a TEST definition is already used in your application somewhere, you can change the name of the Ceedling definition in the project.yml configuration file.