C++: Adding unit tests to Arduino code

One of the advantages of pure unit testing is validating logic in isolation from external dependencies. In the case of Arduino, it's possible to test an entire sketch without a physical board and (almost) without modifying the original code. In his book "Working effectively with legacy code", Michael Feathers introduces the concept of seams. He defines them as places in the source code where it's possible to swap out an external dependency for a fake object in order to isolate the calling code for testing purposes. In today's post, we'll walk through a concrete example of some techniques that exploit these seams. In particular, we will do this for a simple Arduino project.

As a starting point, let us consider the following Wokwi project. For the sake of context, Wokwi is a website that allows users to create Arduino projects using just a web browser, without a physical board or even installing any software. Wokwi offers a set of virtual componentes that the user can connect to a virtual Arduino board, and then write code to control the board. In this site, the author of this post created the project linked above, in which a virtual Arduino Nano board is connected to an LED through one of its digital outputs, and it is also connected to an LCD monitor through four of its analogical outputs. In each iteration of the control loop, the Arduino code makes the LED blink once, and it also updates the blink count on the LCD monitor. This is a trivial project, but the techniques to apply work for projects of any size.

The first step is copying the entire Arduino code and saving it to a text file. Any name will do for it; in this case, "blinky.ino" was chosen. The code to test will be then:

blinky.ino
#include <LiquidCrystal_I2C.h>
/*
 * IMPORTANT:
 * For the LCD display to work, its SDA port needs to
 * be connected to the Arduino's A4 port. 
 * The same goes for SCL, and A5.
 */

const int LED = 6;

namespace globals{
  LiquidCrystal_I2C lcd(0x27, 20, 4);
}

void setup() {
  setupLED();
  setupLCD();  
}

void setupLED() {
  pinMode(LED, OUTPUT);
}

void setupLCD() {
  LiquidCrystal_I2C &lcd = globals::lcd;
  lcd.init();
  lcd.backlight();
  lcd.setCursor(3, 0);
  lcd.print("I'm Blinky!");
  lcd.setCursor(3, 2);
  lcd.print("0 blink(s)");
}

void loop() {
  static int blinkCounter = 0;
  blinkLED(&blinkCounter);
  updateLCD(globals::lcd, blinkCounter);
}

void blinkLED(int *pBlinkCount) {
  digitalWrite(LED, HIGH);
  delay(1000);
  digitalWrite(LED, LOW);
  delay(1000);
  *pBlinkCount = ++(*pBlinkCount);
}

void updateLCD(LiquidCrystal_I2C &lcd, int blinkCounter) {
  lcd.setCursor(3, 2);
  char buffer[16];
  sprintf(buffer, "%d blink(s)", blinkCounter);
  lcd.print(buffer);
}

The next step will be to create a folder called test at the same level as blinky.ino. Inside it, we will create a file called blinky_test.cc, which is where the tests will be defined and called.

test/blinky_test.cc
#include <iostream>
#include "../blinky.ino"

using namespace std;

void testSetupLED() {
    setupLED();
    cout << "OK" << endl;
}

int main() {
    testSetupLED();
}

The initial goal is to test the setupLED function: we want to validate that the LED is correctly initialized. It's not a very interesting test, but it will be useful for showcasing the techniques to use. So, the next step will be compiling blinky_test.cc. For learning purposes, this will be done manually. The first attempt will be g++ blinky_test.cc -o blinky_test. From this point onwards, we will examine the problems encountered along the way and details on their solutions.

P1: LiquidCrystal_I2C.h: No such file or directory

This error happens because Wokwi, behind the scenes, indicated the C++ compiler the location of the Arduino headers and libraries. This library in particular handles communication with the LCD monitor via an I2C interface. This is an external dependency, and as such, we want to replace it in the tests. Being an external library, its seams are the header and the library binary itself. To deal with the header, since it only contains declarations, it can be included in the test build directly. The definitions, which reside in the library binary, will be replaced later on. At this point, assuming that Arduino IDE was installed in the machine, the Arduino header files must be somewhere in its file system. A search for LiquidCrystal_I2C.h in the file navigator application tells us that the header file resides in /home/user/Arduino/libraries/LiquidCrystal_I2C. Of course, this location can be different for operating systems other than Ubuntu, or different versions of Arduino IDE. It's possible to tell g++ to search for header files in a specific directory using the -I flag, like so:

g++ -I/home/user/Arduino/libraries/LiquidCrystal_I2C blinky_test.cc -o blinky_test

It's important to use an absolute path, and to leave no spaces between -I and the path.

P2: Print.h: No such file or directory

When looking for this file in the file system, it doesn't appear. Arduino IDE probably precompiled it, or it's dynamically generated for each project. Therefore, one solution is to create an empty text file called Print.h at the same level as blinky_test.cc. Also, it will be necessary to indicate g++ to look for headers in the test directory. This makes the updated compilation command the following:

g++ -I/home/user/Arduino/libraries/LiquidCrystal_I2C -I/home/user/Documents/lab/demos/blinky/test blinky_test.cc -o blinky_test

P3: Wire.h: No such file or directory

This file doesn't exist on the file system either, so the solution is like in P2: create an empty text file called Wire.h inside the test folder. It's not necessary to update the g++ call, since the directory is already specified.

P4: expected class-name before ‘{’ token 55 | class LiquidCrystal_I2C : public Print {

This error happens in LiquidCrystal_I2C.h, because the LiquidCrystal_I2C class inherits from the Print class. Since an empty seam was introduced at that level, the Print class is not defined. Therefore, to move on, we define an empty class inside Print.h:

test/Print.h
#ifndef __PRINT_H__
#define __PRINT_H__

class Print{};

#endif // __PRINT_H__

P5: ‘setupLED’ was not declared in this scope; did you mean ‘setup’?

It's important to remember that Arduino is a C++ dialect, and here, a key difference between the two arises. In Arduino, it's valid to call a function before it is defined; in C++, it's not. To solve this error, it's necessary to modify blinky.ino, moving the setupLED definition above its usage. This is why earlier we said that it's almost not necessary to modify the original code. However, this is an inocuous change.

P6: ‘OUTPUT’ was not declared in this scope

According to the Arduino documentation, OUTPUT is a constant. Since mor of these will pop up later, the solution will be to create a file in the test directory that defines these constants, and include it in blinky_test.cc.

test/arduino_fake_constants.hpp
#ifndef __ARDUINO_FAKE_CONSTANTS_HPP__
#define __ARDUINO_FAKE_CONSTANTS_HPP__

const int OUTPUT = 1;

#endif //__ARDUINO_FAKE_CONSTANTS_HPP__
test/blinky_test.cc

#include <iostream<
#include "arduino_fake_constants.hpp"
#include "../blinky.ino"

using namespace std;

void testSetupLED() {
    setupLED();
    cout << "OK" << endl;
}

int main() {
    testSetupLED();
}

It's vital to include arduino_fake_constants.hpp before blinky.ino, so that the definition of OUTPUT comes before its usage. Otherwise, we'll still get an error about OUTPUT not being defined.

P7: error: ‘pinMode’ was not declared in this scope

This is the most important seam for this test case. This is an Arduino library function, and its definition is injected automatically by both Wokwi and Arduino IDE. It's not associated with any explicit #include directive. This makes it easy to replace with a stub, because when building blinky_test, it's possible to define pinMode as needed. Recalling the difference between a mock and a stub: both aim to replace a real function or object by a fake one, but a mock only verifies calls, while a stub has hand coded logic that simulates the real object. With this in mind, in this post, for learning purposes, manual stubs will be the chosen way. Initially, they will be defined as empty functions. Later on, some logic will be added for testing. At this point, we shall create a new file, arduino_fake.hpp, define the stub there, and include it in blinky_test.

test/arduino_fake.hpp
#ifndef __ARDUINO_FAKE_HPP__
#define __ARDUINO_FAKE_HPP__

void pinMode(int pin, int mode) {}

#endif // __ARDUINO_FAKE_HPP__
test/blinky_test.cc
#include <iostream<
#include "arduino_fake_constants.hpp"
#include "arduino_fake.hpp"
#include "../blinky.ino"

using namespace std;

void testSetupLED() {
    setupLED();
    cout << "OK" << endl;
}

int main() {
    testSetupLED();
}

P8: error: ‘class LiquidCrystal_I2C’ has no member named ‘print’; did you mean ‘Print’?

This happens because we are not using the real Print.h file. Apparently, the LiquidCrystal_I2C class inherits its print method from Print. As a consequence, this method must be define for the Print class.

test/Print.h
#ifndef __PRINT_H__
#define __PRINT_H__

#include <string>

class Print{
public:
    void print(const std::string&){}
};

#endif // __PRINT_H__

P9: error: ‘HIGH’ was not declared in this scope

Another constant just like OUTPUT. This case is handled just like P6, adding the constant definition to arduino_fake_constants.hpp.

P10: error: ‘digitalWrite’ was not declared in this scope

P11: error: ‘delay’ was not declared in this scope

Both of these are Arduino functions, so we define them as empty functions in arduino_fake.hpp.

P12: error: ‘LOW’ was not declared in this scope

Another constant just like OUTPUT. This case is handled just like P6, adding the constant definition to arduino_fake_constants.hpp.

P13: Linking errors

In the previous step, the last compilation error was solved. But linking errors remain, since the compiler cannot find defintions for the Liquid Crystal library methods being called in blinky_ino. These errors look like this:

/usr/bin/ld: /tmp/cc3b51wd.o: in function `setupLCD()':
blinky_test.cc:(.text+0x79): undefined reference to `LiquidCrystal_I2C::init()'
/usr/bin/ld: blinky_test.cc:(.text+0x85): undefined reference to `LiquidCrystal_I2C::backlight()'
/usr/bin/ld: blinky_test.cc:(.text+0x9b): undefined reference to `LiquidCrystal_I2C::setCursor(unsigned char, unsigned char)'
/usr/bin/ld: blinky_test.cc:(.text+0x102): undefined reference to `LiquidCrystal_I2C::setCursor(unsigned char, unsigned char)'
/usr/bin/ld: /tmp/cc3b51wd.o: in function `updateLCD(LiquidCrystal_I2C&, int)':
blinky_test.cc:(.text+0x25a): undefined reference to `LiquidCrystal_I2C::setCursor(unsigned char, unsigned char)'
/usr/bin/ld: /tmp/cc3b51wd.o: in function `__static_initialization_and_destruction_0(int, int)':
blinky_test.cc:(.text+0x3f2): undefined reference to `LiquidCrystal_I2C::LiquidCrystal_I2C(unsigned char, unsigned char, unsigned char)'
collect2: error: ld returned 1 exit status

What indicates these are linking errors is the last line: ld returned 1 exit status. The ld program is the linker: it combines the different compiled binaries in order to generate the final program. To solve this errors, the reported functions will be stubbed in a new file called liquid_crystal_i2c_fake.hpp.

test/liquid_crystal_i2c_fake.hpp
#ifndef __LIQUID_CRYSTAL_I2C_FAKE_HPP__
#define __LIQUID_CRYSTAL_I2C_FAKE_HPP__

LiquidCrystal_I2C::LiquidCrystal_I2C(unsigned char, unsigned char, unsigned char) {}

void LiquidCrystal_I2C::init() {}

void LiquidCrystal_I2C::backlight() {}

void LiquidCrystal_I2C::setCursor(unsigned char, unsigned char) {}

#endif // __LIQUID_CRYSTAL_I2C_FAKE_HPP__

P14: Cryptic linking error

This error message says something like undefined reference to `vtable for LiquidCrystal_I2C'. This implies that there is an undefined virtual method in the LiquidCrystal_I2C class. Observing the contents of Liquid_Crystal_I2C.h, the only method that is declared as virtual is write. Adding a definition for it inside liquid_crystal_i2c_fake.hpp, the error will be fixed.

void LiquidCrystal_I2C::write(uint8_t) {}

P15: Adding testing logic to stubs

At this point, the test compiles, links and runs, but it's not testing anything other than setupLED being able to run without crashing. As a function, all that setupLED does is setting the pin mode to OUTPUT for the digital output connected to the LED. To verify the code is doing that, there are many approaches. The most direct one would be to use a mocking library, and verify that pinMode is called by setupLED a certain number of times with certain parameters. For deeper learning, a manual stub will be coded. For this, an initial approach would involve using a global variable that stores the state for each pin. This variable could be an std::map<int,int>, where the key would be the pin number, and the value its mode. The pinMode stub could set this variable, and its state could be checked after calling setupLED. This would work, but when adding more tests later on, they would share the global variable with the first test, and tests cannot share state between them. Unit tests are not only isolated from external dependencies: they must also be isolated from other tests!

Since the stub functions are free functions, not methods of a class, they must irrevocably access some shared state. A better solution is having that state be a pointer to an object, and have each test have its own stub object and set the pointer to it on start. This way, each test will have its own separate state. As a downside, tests won't be able to run in parallel, because if one modifies the pointer, it will corrupt any other test that runs at the same time. Applying these ideas, we get:

test/arduino_fake.hpp
#ifndef __ARDUINO_FAKE_HPP__
#define __ARDUINO_FAKE_HPP__

#include <map>

class ArduinoStub {
    std::map<int,int> pins;
    static ArduinoStub *pCurrentStub;
public:
    void setPinMode(int pin, int mode) {
        pins[pin] = mode;
    }

    int getPinMode(int pin) const {
        return pins.find(pin)->second;
    }

    static ArduinoStub* getCurrentStub() {
        return pCurrentStub;
    }

    static void setCurrentStub(ArduinoStub *pStub) {
        pCurrentStub = pStub;
    }
};

ArduinoStub* ArduinoStub::pCurrentStub = nullptr;

// Stubs

void delay(int millis) {}
void digitalWrite(int pin, int value) {}

void pinMode(int pin, int mode) {
    ArduinoStub::getCurrentStub()->setPinMode(pin, mode);
}

// Test auxiliaries

int getPinMode(int pin) {
    return ArduinoStub::getCurrentStub()->getPinMode(pin);
}

#endif // __ARDUINO_FAKE_HPP__
test/blinky_test.cc
#include <iostream>
#include "arduino_fake_constants.hpp"
#include "arduino_fake.hpp"
#include "../blinky.ino"
#include "liquid_crystal_i2c_fake.hpp"

using namespace std;

void testSetupLED() {
    ArduinoStub arduinoStub;
    ArduinoStub::setCurrentStub(&arduinoStub);
    setupLED();
    if (getPinMode(LED) != OUTPUT) {
        cerr << "FAIL: Expected LED pin to be in OUTPUT mode" << endl;
    } else {
        cerr << "testSetupLED: OK" << endl;
    }
}

int main() {
    testSetupLED();
}

Comments

Popular posts from this blog

VB.NET: Raise base class events from a derived class

Apache Kafka - I - High level architecture and concepts

Upgrading Lodash from 3.x to 4.x