C++: Agregando tests unitarios a código Arduino

Una gran ventaja de tener tests unitarios puros es poder validar la lógica del código de manera aislada de las dependencias externas. En el caso de Arduino, es posible probar el sketch completo sin una placa física, y además, sin modificar el código original. En el libro "Working effectively with legacy code", Michael Feathers habla del concepto de seam (costura). Para Feathers, un seam es un punto del código donde se puede reemplazar una dependencia externa con un objeto falso a fin de poder ejecutar el código en un ambiente de test. En el post de hoy, se verá un ejemplo concreto de técnicas para aprovechar estos seams. En particular, para un proyecto Arduino sencillo.

Como punto de partida, considérese el siguiente proyecto Wokwi. Contexto: Wokwi es un sitio que permite crear proyectos Arduino en un navegador web, sin necesidad de placas físicas. Permite insertar y conectar componentes virtuales, y escribir código Arduino para controlarlos. En dicho sitio, el autor creó el proyecto antes enlazado, en el cual una placa Arduino Nano está conectada a un LED a través de una de sus salidas digitales, y a un monitor LCD a través de 4 de sus salidas analógicas. En cada iteración del bucle de control, el código Arduino hace parpadear una vez al LED, y actualiza la cantidad de parpadeos en el monitor LCD. Como se dijo, este es un proyecto trivial, pero las técnicas a aplicar son aplicables a proyectos de mayor envergadura.

El primer paso es copiar el código Arduino y guardarlo en un archivo de texto. Cualquier nombre sirve: en este caso, se eligió "blinky.ino". El código resulta entonces:

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);
}

El primer paso será crear una carpeta llamada test al mismo nivel que blinky.ino, y dentro de ella, un archivo blinky_test.cc, donde se definirá una función main que correrá los tests. Verbigracia:

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

using namespace std;

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

int main() {
    testSetupLED();
}

El objetivo inicial es probar la función setupLED: se desea validar que el LED sea inicializado correctamente. No es una prueba muy interesante, pero servirá para demostrar las técnicas a utilizar. El primer paso será intentar compilar blinky_test.cc. Por fines de aprendizaje, ello se hará manualmente. El primer intento será g++ blinky_test.cc -o blinky_test. A continuación, se detallarán los problemas que van surgiendo y cómo resolverlos.

P1: LiquidCrystal_I2C.h: No such file or directory

Este error ocurre porque Wokwi se encargó, detrás de escenas, de indicar al compilador C++ la ubicación de los headers de Arduino. Esta biblioteca es la encargada de la comunicación con el monitor LCD a través de una interfaz I2C. Esto es una dependencia externa, y por lo tanto es deseable reemplazarla en los tests. Siendo una biblioteca externa, sus seams son el header y el binario de la biblioteca. Para lidiar con el header, dado que sólo contiene declaraciones, se puede incluir en el test sin problemas. Las definiciones, que están en el binario de la biblioteca, serán reemplazadas más adelante. Por lo pronto, asumiendo que se ha instalado Arduino IDE en la máquina donde se está compilando, los headers de Arduino deben estar en algún lugar del sistema de archivos. Buscando LiquidCrystal_I2C.h con el navegador de archivos del sistema operativo, resulta que se halla en /home/user/Arduino/libraries/LiquidCrystal_I2C. Dicha ubicación puede naturalmente ser diferente en otro sistema operativo o versión de Arduino IDE. Es posible indicarle a g++ que busque headers en ese directorio, sea cual sea:

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

Es importante utilizar un path absoluto, y no poner ningún espacio entre -I y el path.

P2: Print.h: No such file or directory

Al buscar este archivo en el sistema, no aparece. Lo que ocurre es que probablemente Arduino IDE lo tenga precompilado, o lo genere dinámicamente para cada proyecto. Por lo tanto, la solución será crear un archivo Print.h al mismo nivel que blinky_test.cc, inicialmente vacío. Además, será necesario indicarle a g++ que busque headers en el directorio de test. Con ello, la línea de compilación resulta:

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

Este archivo tampoco existe en el sistema de archivos, por lo que se procede igual que en P2: se crea un archivo de texto vacío llamado Wire.h en la carpeta de test. No es necesario modificar la llamada a g++ porque el directorio ya está especificado.

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

Este error ocurre en el header de LiquidCrystal_I2C porque la clase LiquidCrystal_I2C hereda de la clase Print. Como se introdujo un seam vacío a ese nivel, la clase Print no está definida. Por lo tanto, para avanzar, es preciso definir una clase vacía en 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’?

Recordando que Arduino es un dialecto de C++, aquí se observa una diferencia entre ambos. En Arduino es válido llamar a una función antes de definirla; en C++, no. Por lo tanto, para resolver este error es necesario modificar blinky.ino, moviendo la definición de setupLED arriba de la de setup. Inicialmente, se dijo que no sería necesario modificar el código del sketch, pero este cambio en particular es inocuo.

P6: ‘OUTPUT’ was not declared in this scope

Según la documentación de Arduino, OUTPUT es una constante. Dado que se utilizan otras constantes de este tipo en el código, la solución será crear un archivo dentro del directorio de test que defina dichas constantes, e incluirlo dentro de 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();
}

Es importante incluir arduino_fake_constants.hpp antes de blinky.ino, para que la declaración de OUTPUT sea procesada antes de su uso. Si no, seguirá apareciendo el error de que OUTPUT no está declarada.

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

Éste es el seam más importante para este caso de prueba. Es una función de la biblioteca de Arduino, que claramente es inyectada por Wokwi y también por Arduino IDE, ya que no está asociada a ningún #include existente. Esto facilita reemplazarla por un stub, ya que al compilar blinky_test, es posible definir pinMode como sea necesario. Recuérdese la diferencia entre mock y stub: ambos reemplazan una función u objeto real por uno de prueba, pero un mock sólo verifica llamadas, en tanto un stub tiene lógica programada a mano para simular el objeto real. Con eso en mente, lo que se hará en este post, para fines de aprendizaje, serán stubs. Inicialmente, se definirá como una función vacía. Luego se verá que se le puede poner lógica para ayudar a las pruebas. Por ahora, se definirá en un archivo aparte, arduino_fake.hpp, que será incluido en 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’?

Esto ocurre porque no se está usando el verdadero archivo Print.h. Aparentemente, la clase LiquidCrystal_I2C hereda su método print de su clase base Print. Por lo tanto, es necesario definir dicho método.

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

Otra constante como OUTPUT; se maneja igual que P6, agregando su definición a arduino_fake_constants.hpp.

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

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

Ambas son funciones de Arduino: definirlas vacías en arduino_fake.hpp.

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

Otra constante como OUTPUT; se maneja igual que P6, agregando su definición a arduino_fake_constants.hpp.

P13: Errores de linking (enlazado)

A esta altura, no hay más errores de compilación. Pero sí quedan errores de linking, ya que el compilador no encuentra definiciones de métodos para la biblioteca Liquid Crystal. Estos errores se ven así:

/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

Lo que nos indica que estos son errores de linking es la última línea: ld returned 1 exit status. El programa ld es el linker o enlazador: es el que combina los distintos binarios compilados para generar el binario final. Para resolver estos errores, se definirán las funciones que se indican en el mensaje de error, en un archivo aparte que se incluirá en blinky_test.cc.

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: Error de linking críptico

El mensaje de error es algo como undefined reference to `vtable for LiquidCrystal_I2C'. Lo que esto implica es que hay un método virtual en la clase LiquidCrystal_I2C que está sin definir. Si se observa el contenido de Liquid_Crystal_I2C.h, el único método virtual declarado es write. Agregando su definición a liquid_crystal_i2c_fake.hpp, se resuelve el error.

void LiquidCrystal_I2C::write(uint8_t) {}

P15: Lógica en los stubs

Llegado este punto, el test compila, enlaza y corre, pero no está probando nada. Sólo que setupLED corra sin arrojar errores fatales. Lo único que hace es setear el pinMode a OUTPUT para la salida del LED. Por lo tanto, para verificar que setupLED efectivamente haga eso, hay varios enfoques posibles. La más directa sería utilizar una biblioteca de mocking, y sólo verificar que pinMode sea invocada cierta cantidad de veces y con ciertos parámetros. Pero para un mejor aprendizaje, se hará un stub manual. Un enfoque inicial sería utilizar una variable global que guarde el estado de los pins; dicha variable puede ser un std::map<int,int>, donde la clave sea el número de pin y el valor su modo. La implementación de pinMode podría setear esta variable, y luego de correr el test alcanza con verificar su estado. Esto funcionaría, pero al agregar tests, compartirían ese estado, y los tests tienen que estar aislados entre sí, por ende no pueden compartir estado.

Dado que las funciones stub son funciones libres, vale decir, no son métodos de una clase, invariablemente deben acceder a un estado compartido. Lo que se puede hacer es que ese estado sea un puntero a un objeto, y que cada test tenga su objeto local y setee el puntero al objeto al iniciar. De esa manera, cada test tendrá su propio estado. Como contra, los tests no podrán correr en paralelo, ya que si uno modifica el puntero, corromperá a los demás. Aplicando estas ideas, resulta finalmente:

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