Arquitectura e Implementación de un Framework de Pruebas Unitarias Ligero para C Embebido

Introducción al Framework

Unity es un framework de pruebas unitarias minimalista diseñado específicamente para el lenguaje C. Su objetivo principal es permitir la escritura y ejecución de pruebas en cualquier compilador de C y en cualquier cadena de herramientas para sistemas embebidos, sin importar las restricciones del entorno.

  • Ausencia de frameworks estándar: Las toolchains de microcontroladores (MCU) rara vez incluyen soporte para frameworks modernos de C++ como GoogleTest o Catch2, y añadir dependencias complejas suele ser inviable.
  • Recursos limitados: La memoria Flash y RAM son escasas, por lo que el framework debe tener una huella mínima y ser altamente configurable para eliminar funciones no utilizadas.
  • Integración con sistemas de compilación: Debe adaptarse sin fricciones a Make, CMake, Meson, PlatformIO o scripts personalizados.

La filosofía de diseño se basa en principios estrictos:

  • El núcleo consta de un único archivo fuente (unity.c) y un par de cabeceras (unity.h y unity_internals.h).
  • La configuración se realiza exclusivamente mediante macros y opciones de compilación, garantizando cero asignación dinámica de memoria en tiempo de ejecución.
  • La salida de texto es simple y redirigible, facilitando su análisis por puertos serie, parsers de logs o pipelines de CI/CD.

Arquitectura y Mecanismos Internos

1. Estructura del Proyecto

Para comprender cómo se integra Unity, es útil observar su organización interna.

Unity/
├── src/
│   ├── unity.c                  # Implementación central: aserciones, salida y control de ejecución
│   ├── unity.h                  # API pública y macros de aserción (TEST_ASSERT_*)
│   └── unity_internals.h        # Estructuras de datos internas e interfaces privadas
├── extras/
│   ├── fixture/                 # Extensión para agrupar pruebas (test suites)
│   ├── memory/                  # Rastreo de asignaciones de memoria (detección de fugas)
│   ├── bdd/                     # Soporte para estilo BDD (Behavior-Driven Development)
│   └── eclipse/                 # Integración con IDEs como Eclipse
├── auto/
│   ├── generate_test_runner.rb  # Generación automática del ejecutor de pruebas
│   ├── parse_output.rb          # Análisis de la salida de las pruebas
│   └── stylize_as_junit.py      # Conversión de resultados a formato JUnit (Python)
├── examples/                    # Proyectos de demostración con diversas configuraciones
└── test/                        # Pruebas de autovalidación del propio framework

Para integrar este framework en un proyecto embebido, generalmente solo se requiere incorporra el directorio src/, compilar unity.c junto con el código de la aplicación y, opcionalmente, utilizar los scripts de auto/ para generar los archivos runner.

2. Desacoplamiento de Macros de Aserción

La API pública expuesta en unity.h utiliza macros para las aserciones:

TEST_ASSERT_EQUAL_INT(expected_val, actual_val);
TEST_ASSERT_FLOAT_WITHIN(tolerance, expected_val, actual_val);
TEST_ASSERT_NOT_NULL(pointer_val);

Estas macros no ejecutan la lógica de comparación directamente, sino que actúan como envoltorios que realizan tres tareas fundamentales:

  1. Captura del contexto: Utilizan __LINE__ y __FILE__ para registrar la ubicación exacta de la llamada.
  2. Definición del formato de salida: Especifican cómo se deben imprimir los valores (por ejemplo, como enteros con signo o en formato hexadecimal).
  3. Delegación a funciones internas: Redirigen la ejecución a funciones como UnityAssertEqualIntNumber o UnityAssertFloatsWithin.

3. Estado Global y Control de Ejecución

El ciclo de vida de las pruebas es gestionado por una estructura global UNITY_STORAGE_T Unity, que almacena:

  • El nombre, archivo y línea de la prueba en ejecución.
  • Contadores globales de pruebas totales, fallidas e ignoradas.
  • Banderas de estado para la prueba actual (fallo o ignorada).
  • UnityBegin: Inicializa las variables globales y prepara la salida.
  • UnityDefaultTestRun: Ejecuta la prueba individual, invocando setUp, la función de prueba y tearDown, protegidas por TEST_PROTECT().
  • UnityConcludeTest: Evalúa las banderas de estado, actualiza los contadores y emite el resultado de la prueba actual.
  • UnityEnd: Imprime el resumen final y retorna el número de fallos, útil para determinar el código de salida del proceso.

En entornos embebidos, la salida se controla redefiniendo la macro UNITY_OUTPUT_CHAR, permitiendo redirigir los caracteres a una UART, un puerto SWO o un buffer circular en memoria.

4. Configuración y Adaptación de Tipos

El framwork ofrece una amplia gama de directivas #define para adaptar su comportamiento a las limitaciones del hardware:

  • Arquitectura de enteros: Macros como UNITY_SUPPORT_64, UNITY_INT_WIDTH y UNITY_POINTER_WIDTH ajustan el tamaño de los tipos base.
  • Soporte de punto flotente: Se pueden excluir las operaciones con float o double mediante UNITY_EXCLUDE_FLOAT y UNITY_EXCLUDE_DOUBLE para ahorrar espacio en ROM.
  • Tipos de contadores: UNITY_COUNTER_TYPE permite usar tipos de datos más pequeños (como uint8_t) si el número de pruebas es reducido.

Todas estas decisiones se resuelven en tiempo de compilación, eliminando la sobrecarga de las evaluaciones condicionales en tiempo de ejecución.

Implementación Práctica

A continuación, se demuestra la integración del framework mediante un módulo matemático simple. Supongamos que tenemos una función de multiplicación en math_ops.c:

// math_ops.c
int multiply_values(int factor1, int factor2) {
    return factor1 * factor2;
}

1. Integración del Núcleo

Se deben añadir unity.c, unity.h y unity_internals.h al árbol de fuentes del proyecto y asegurar que el archivo fuente sea compilado por el sistema de build (CMake, Make, etc.).

2. Escritura de Casos de Prueba

// test_math_ops.c
#include "unity.h"
#include "math_ops.h"

void setUp(void) {
    // Inicialización previa a cada prueba
}

void tearDown(void) {
    // Limpieza posterior a cada prueba
}

void test_multiply_should_compute_positive_product(void) {
    TEST_ASSERT_EQUAL_INT(20, multiply_values(4, 5));
}

void test_multiply_should_handle_negative_factors(void) {
    TEST_ASSERT_EQUAL_INT(-15, multiply_values(3, -5));
}

int main(void) {
    UnityBegin("test_math_ops.c");

    UnityDefaultTestRun(test_multiply_should_compute_positive_product,
                        "test_multiply_should_compute_positive_product", __LINE__);

    UnityDefaultTestRun(test_multiply_should_handle_negative_factors,
                        "test_multiply_should_handle_negative_factors", __LINE__);

    return UnityEnd();
}

3. Manejo de Fallos en las Aserciones

Si una aserción falla, por ejemplo, al evaluar incorrectamente un producto:

TEST_ASSERT_EQUAL_INT(10, multiply_values(3, -5)); // El resultado real es -15

El mecanismo interno procede de la siguiente manera:

  1. La macro captura la línea y los valores, pasándolos a UnityAssertEqualIntNumber.
  2. Al detectar la discrepancia, se invoca UnityTestResultsFailBegin para imprimir la cabecera del error (archivo, línea, nombre de la prueba).
  3. Se imprime el valor esperado frente al valor obtenido.
  4. Se ejecuta UNITY_FAIL_AND_BAIL, que marca la prueba como fallida y utiliza TEST_ABORT() (generalmente implementado con setjmp/longjmp) para interrumpir la ejecución de la prueba actual.

Este comportamiento de "abortar tras el primer fallo" es crucial en sistemas embebidos para evitar que el hardware entre en estados indefinidos o ejecute código inválido tras un error crítico, garantizando además que tearDown se ejecute para liberar recursos.

4. Agrupación de Pruebas con Fixtures

Para proyectos más extensos, la extensión fixture permite organizar las pruebas en suites, ofreciendo una estructura similar a xUnit:

#include "unity.h"
#include "unity_fixture.h"
#include "math_ops.h"

/* Suite 1: Operaciones básicas */
TEST_GROUP(MathBasic);

TEST_SETUP(MathBasic) {}
TEST_TEAR_DOWN(MathBasic) {}

TEST(MathBasic, MultiplyTwoPositives)
{
    TEST_ASSERT_EQUAL_INT(42, multiply_values(6, 7));
}

TEST(MathBasic, MultiplyByZero)
{
    TEST_ASSERT_EQUAL_INT(0, multiply_values(99, 0));
}

TEST_GROUP_RUNNER(MathBasic)
{
    RUN_TEST_CASE(MathBasic, MultiplyTwoPositives);
    RUN_TEST_CASE(MathBasic, MultiplyByZero);
}

/* Suite 2: Casos límite y desbordamiento */
TEST_GROUP(MathEdgeCases);

TEST_SETUP(MathEdgeCases) {}
TEST_TEAR_DOWN(MathEdgeCases) {}

TEST(MathEdgeCases, MultiplyNegativeNumbers)
{
    TEST_ASSERT_EQUAL_INT(25, multiply_values(-5, -5));
}

TEST(MathEdgeCases, MultiplyLargeIntegers)
{
    TEST_ASSERT_EQUAL_INT(1000000, multiply_values(1000, 1000));
}

TEST_GROUP_RUNNER(MathEdgeCases)
{
    RUN_TEST_CASE(MathEdgeCases, MultiplyNegativeNumbers);
    RUN_TEST_CASE(MathEdgeCases, MultiplyLargeIntegers);
}

static void ExecuteAllSuites(void)
{
    RUN_TEST_GROUP(MathBasic);
    RUN_TEST_GROUP(MathEdgeCases);
}

int main(int argc, const char * argv[])
{
    return UnityMain(argc, argv, ExecuteAllSuites);
}

Etiquetas: C Embedded Systems Unit Testing Unity Framework Firmware Testing

Publicado el 8-30 18:17