Tests unitarios en sistemas embebidos: ¿realidad o ficción?

Me gustaría plantearos dos preguntas: ¿Creéis que hacer TDD es algo común en desarrollo de software embebido? ¿Cómo enfocaríais un proyecto nuevo para maximizar la calidad del mismo?

Tests unitarios en sistemas embebidos: ¿realidad o ficción?

Se han escrito muchas líneas sobre TDD, por lo que a priori otro artículo más sobre el tema parece que no puede contribuir mucho al panorama del software. Sin embargo me gustaría aportar mi granito de arena y transmitiros mi visión haciendo hincapié en los sistemas embebidos, que es a lo que yo me dedico.

También pretendo con esta entrada abrir un pequeño debate para, entre todos aquellos que nos movemos a medio camino entre el hardware y el software, saber cómo podemos mejorar la calidad de nuestros proyectos.

¿Se puede hacer TDD en sofware embebido?

Una de las características que definen la programación de sistemas embebidos es el continuo acceso a periféricos externos. Muchos de estos dispositivos se pasan la mayor parte del tiempo leyendo un acelerómetro por I2C, escribiendo a una memoria externa por SPI, activando y desactivando interruptores usando GPIOs, recibiendo comandos de un PC por UART... En un software para ordenador, esto equivaldría por ejemplo a acceder a bases de datos, archivos o sockets. En definitiva, lo que estamos haciendo es acceder a un recurso externo que no depende de nosotros y que por tanto no sabemos como va a responder.

Cualquiera que sepa lo más mínimo de TDD sabrá que esto es precisamente para lo que existen los dobles (mocks, fakes, stubs...). Para mí esta herramienta es clave y es algo de lo que hago uso contínuamente.

Os pongo un ejemplo práctico: imaginad que queremos conectar un Arduino a un acelerómetro por I2C como podría ser el HMC5983. Esta es la lista de registros internos a los que tenemos acceso:

Registros del HMC5983

Lo primero que se suele hacer para verificar que un chip funciona correctamente es leer ciertos registros cuyo valor es constante y conocido. La mayoría de integrados disponen de esta funcionalidad. En este caso, los registros 0x0A, 0x0B y 0x0C contienen 'H', '4' y '3' respectivamente.

Un test unitario que crearía para validar que sucede si el micro recibe correctamente los bytes de identificación sería algo como lo que podéis ver a continuación (no quería darle especial importancia al framework, solo comentar que he usado Ceedling para este ejemplo).

void test_when_ValidIDRead_should_DetectValidModule(void) {
    // Request register
    char req_expect[] = {0x0A};
    i2c_send_ExpectWithArrayAndReturn(HMC_ADDR, req_expect, sizeof(req_expect), 1, 1);

    // Get response
    i2c_receive_ExpectAndReturn(HMC_ADDR, NULL, 3, 3);
    i2c_receive_IgnoreArg_rx_data(); // Ignore rx_data (NULL) because we will fake it
    // Fake response (3 bytes)
    char expected_id[] = {'H', '4', '3'};
    i2c_receive_ReturnArrayThruPtr_rx_data(expected_id, sizeof(expected_id));

    // Returns true because the ID is correct
    TEST_ASSERT_TRUE(hmc5983_init());
}

Lo que estamos haciendo es básicamente ejecutar hmc5983_init() y asegurarse de que dicha función a su vez llama a dos funciones en el orden indicado:

  1. i2c_send() para solicitar el contenido del registro 0x0A
  2. i2c_receive() para leer la respuesta, que simularemos con i2c_receive_ReturnArrayThruPtr_rx_data para que sea el array {'H', '4' y '3'}

Cabe destacar que este test valida que los valores que devuelve i2c_receive() son interpretados correctamente, pero esto no quiere decir que si ejecutamos hmc5983_init() en nuestro hardware todo vaya a funcionar de perlas. Podría haber un error en i2c_receive() o podríamos no tener configurada bien la UART, lo que impediría que las comunicaciones con el chip no funcionasen correctamente.

Otro test que podría definir sería por ejemplo verificar que se enciende un LED en función de los valores del acelerómetro:

void test_when_MagicAccelValuesDetected_should_TurnLEDOn(void) {
    // Request register
    char req_expect[] = {0x03};
    i2c_send_ExpectWithArrayAndReturn(HMC_ADDR, req_expect, sizeof(req_expect), 1, 1);

    // Get response
    i2c_receive_ExpectAndReturn(HMC_ADDR, NULL, 2, 2);
    i2c_receive_IgnoreArg_rx_data(); // Ignore rx_data (NULL) because we will fake it
    // Fake response (2 bytes)
    char accelx_expect[] = {0x30, 0x21}; // Expected value
    i2c_receive_ReturnArrayThruPtr_rx_data(accelx_expect, sizeof(accelx_expect));

    led_toggle_Expect();

    hmc5983_xaxis_not_zero();
}

Como veis, con estas pruebas estamos validando hasta cierto punto componentes físicos, pero lo bueno es que no dependemos de ellos. Me explico. En los sistemas embebidos los tests se pueden ejecutar en tres entornos diferentes:

  • En el hardware objetivo
  • En un simulador
  • En la máquina de desarrollo

Todas tienen pros y contras, pero para mí lo más cómodo es no depender del hardware en la medida de lo posible. Yo lo uso mucho cuando no me encuentro en la oficina y necesito seguir trabajando, o por ejemplo cuando estoy esperando por una nueva revisón del hardware. Otro punto a favor es que está opción es la más sencilla (aunque no la única) si queremos correrlos en un servidor de integración continua. ¡Se podría decir que es lo más parecido al nomadismo digital a lo que los desarrolladores embebidos podemos llegar!

Si tenemos el entorno de pruebas adecuado podemos validar software sin necesidad de disponer del hardware para el que ha sido diseñado.

Entonces, ¿dónde está el problema?

Prácticamente cualquier programador de software de más alto nivel no habrá encontrado nada novedoso en lo que acabo de contar, y personalmente me parece que así debería ser también para nosotros, los desarrolladores embebidos. Disponer de tests unitarios que se ejecuten periódica y automáticamente me parece algo imprescindible al menos como primer cortafuegos. ¡Sobre todo si la alternativa son los tests manuales!

Sin embargo, a pesar de ser una práctica bastante extendida y diría que generalizada para nuestros colegas, esto no es lo que yo me he encontrado en equipos que desarrollan sistemas embebidos. Cierto es que el contexto importa, hay situaciones en las que no es necesario pasar por todo el trabajo de implementar TDD. Yo por ejemplo no lo veo necesario para códigos pequeños y sencillos o en pruebas de concepto, pero poco más.

El motivo de que esta técnica no esté tan extendida en el desarrollo de sistemas embebidos no lo tengo claro, aunque me voy a atrever a hacer algunas suposiciones:

  • La principal razón por la cual creo que TDD no ha tenido el mismo impacto en este sector es la complejidad de los toolchains. Lo ideal sería disponer de un entorno común que nos permita de una manera sencilla ejecutar los tests en nuestra máquina, en un simulador y probarlos en el hardware. Estaría bien también que eso sucediese siempre que alguién hace un commit, pero habría que enlazar el servidor de control de versiones a nuestra placa. Se puede hacer pero no hay soluciones plug and play para ello. El hecho de depender de hardware específico y muchas veces herramientas propias de cada fabricante de chip complica bastante las cosas. Esto no es una tarea sencilla tampoco en el caso del software de alto nivel, y de hecho los trabajos de devops han nacido -en parte- para hacerse cargo de este problema.
  • Existen también motivos históricos, y es que como explican en este artículo los desarrolladores de sistemas embebidos no solemos tener una formación en software, sino que es algo que aprendemos por necesidad, para hacer funcionar nuestro hardware. Es entendible en este caso que la evolución sea más lenta.
  • Que C siga siendo el lenguaje más habitual en este campo pienso que tampoco ayuda. La programación procedimental hace más complicado crear herramientas de desarrollo que ayuden a crear y ejecutar estos tests. No es imposible (ya lo he demostrado en el ejemplo con Ceedling), pero sí es más complicado que hacerlo para un lenguaje orientado a objetos.

¿Cómo enfocaría un nuevo proyecto?

Vistos los problemas, ¿qué es lo que propongo para mejorar la calidad de nuestro código? ¿Por dónde comenzaría a trabajar a la hora de arrancar un nuevo proyecto?

Lo primero que haría sería demostrar la viabilidad del proyecto. Compraría una placa de desarrollo y crearía código de usar y tirar. Esta fase debe durar lo menos posible y por norma general me olvidaría de hacer TDD en este momento.

Una vez que sepa que es factible alcanzar el objetivo invertiría una semana en crear un toolchain eficaz, un entorno de trabajo que me permita hacer más en menos tiempo. Podría parecer que estoy tirando unas preciosas horas de trabajo a la basura (sobre todo al estar en una fase tan temprana del proyecto), pero dad por sentado que esta labor se pagará con creces en el futuro. Si gracias a esto consigo compilar, correr los tests, y programar el hardware en 30 segundos ejecutando un único comando, frente a usar 3 comandos diferentes y perder 3 minutos, echad vosotros cuentas de cuánto ahorramos en un mes de trabajo.

Mi siguiente tarea no tendría tanto que ver con TDD sino con estrategia de producto. Siempre se habla de los beneficios de las lean startups y los MVPs, pero normalmente se hace en el contexto del mundo software. Para aquellos que creamos productos físicos es algo más complicado, ya que el hardware no es fácilmente actualizable. Por ello, los desarrolladores de sistemas embebidos debemos hacer lo posible para compensarlo y crear un sistema de actualización remota desde una etapa muy temprana. Cuanto antes dispongamos de esta funcionalidad antes podremos entregarles el producto a los usuarios y mejorarlo en base a su opinión.

Ya para acabar, y este paso quizás sea el más importante, es necesario que todo el equipo sea consciente de la importancia y de los objetivos de hacer TDD. Con que un único desarrollador no siga esta filosofía (alguien que suba código que rompa tests o que simplemente no esté validado por pruebas unitarias), va a echar abajo el esfuerzo del resto de compañeros.

Puede que no sea el flujo de trabajo ideal para todos los casos, pero tras tropezar un par de veces con la misma piedra, es el que yo escogería en caso de querer lanzar un producto al mercado y no morir en el intento.

¿A vosotros qué os parece? ¿Seguiríais un esquema similar al que propongo en vuestros proyectos o haríais algo diferente? ¿Habéis detectados también vosotros que en sistemas embebidos no se le dá la suficiente importancia al testing automático o simplemente no me he encontrado con los equipos adecuados?


Crédito de la imagen principal a Jonas Svidras.

Esta obra tiene una licencia Creative Commons Atribución-Compartir igual 4.0 Internacional
Licencia Creative Commons