Framework de testing mínimo con macros en C

En C, no existe un sistema de pruebas integrado en el lenguaje ni un mecanismo de reflexión que permita inspeccionar funciones o variables en tiempo de ejecución. Para resolver esto, los desarrolladores construimos herramientas de testing basadas en el preprocesador. Un framework de testing es un conjunto de macros y funciones diseñadas para validar que el comportamiento de un programa coincida con el esperado, capturando automáticamente el contexto del error.

Esto funciona gracias a la capacidad del preprocesador para manipular el código antes de la fase de compilación, utilizando operadores especiales como la stringification (#), que convierte una expresión de código en una cadena de texto literal, y el token pasting (##). Esto nos permite inyectar metadatos como __FILE__ (el nombre del archivo fuente) y __LINE__ (el número de línea actual) solo cuando una condición falla. Es una solución ideal cuando trabajas en sistemas embebidos o bibliotecas de bajo nivel donde no puedes permitirte añadir dependencias pesadas de terceros.

Debes usar este enfoque cuando necesites automatizar la validación de tus componentes sin la sobrecarga de un framework genérico. Sin embargo, si implementas mal las macros (por ejemplo, olvidando envolver los argumentos entre paréntesis), podrías introducir errores de precedencia de operadores que sean extremadamente difíciles de depurar. Además, si las macros no están bien protegidas con estructuras do { ... } while(0), podrías romper la lógica de control de flujo (como un if sin llaves) en el código del usuario.

#include <stdio.h>
#include <string.h>
#include <stdbool.h>

/* --- Framework de Testing --- */

int g_total_failures = 0;

#define ASSERT_TRUE(cond) do { \
    if (!(cond)) { \
        printf("  [FAIL] %s:%d: %s es falso\n", __FILE__, __LINE__, #cond); \
        failures++; \
    } \
} while (0)

#define ASSERT_EQ(actual, expected) do { \
    if ((actual) != (expected)) { \
        printf("  [FAIL] %s:%d: %s (%d) != %s (%d)\n", \
                __FILE__, __LINE__, #actual, (int)(actual), #expected, (int)(expected)); \
        failures++; \
    } \
} while (0)

#define ASSERT_STREQ(s1, s2) do { \
    if (strcmp(s1, s2) != 0) { \
        printf("  [FAIL] %s:%d: %s != %s (string mismatch)\n", \
                __FILE__, __LINE__, #s1, #s2); \
        failures++; \
    } \
} while (0)

#define ASSERT_NULL(ptr) do { \
    if ((ptr) != NULL) { \
        printf("  [FAIL] %s:%d: %s no es NULL\n", __FILE__, __LINE__, #ptr); \
        failures++; \
    } \
} while (0)

typedef struct {
    const char *name;
    int (*func)(void);
} test_case_t;

// Macro para facilitar la definición de tests
#define TEST(name) int name(void)

void run_test(test_case_t test) {
    printf("Running test: %s... ", test.name);
    int failures_in_test = 0;
    
    // Usamos una variable local para que el test sea aislado
    // pero el macro la usará para acumular sus propios errores.
    // En este diseño simple, el test debe declarar su propia variable 'failures'.
    // Por simplicidad, pasamos el control al test.
    int result = test.func();
    
    if (result == 0) {
        printf("PASSED\n");
    } else {
        printf("FAILED (%d errors)\n", result);
        g_total_failures += result;
    }
}

/* --- Código a testear (Lógica de negocio) --- */

int safe_add(int a, int b) {
    return a + b;
}

char* get_version() {
    return "1.2.3-release";
}

/* --- Implementación de los Tests --- */

TEST(test_addition) {
    int failures = 0;
    ASSERT_EQ(safe_add(2, 2), 4);
    ASSERT_EQ(safe_add(-1, 1), 0);
    ASSERT_EQ(safe_add(10, 20), 30);
    return failures;
}

TEST(test_string_version) {
    int failures = 0;
    ASSERT_STREQ(get_version(), "1.2.3-release");
    return failures;
}

TEST(test_null_check) {
    int failures = 0;
    int *ptr = NULL;
    ASSERT_NULL(ptr);
    return failures;
}

TEST(test_failure_example) {
    int failures = 0;
    ASSERT_TRUE(1 == 1);
    ASSERT_EQ(safe_add(1, 1), 3); // Esto fallará
    return failures;
}

/* --- Driver de ejecución --- */

static const test_case_t tests[] = {
    {"Addition", test_addition},
    {"Version String", test_string_version},
    {"Null Pointer", test_null_check},
    {"Intentional Failure", test_failure_example},
};

int main(void) {
    int num_tests = sizeof(tests) / sizeof(tests[0]);
    
    printf("Starting Test Suite...\n");
    printf("--------------------------------------\n");
    
    for (int i = 0; i < num_tests; i++) {
        run_test(tests[i]);
    }
    
    printf("--------------------------------------\n");
    printf("Tests run: %d\n", num_tests);
    printf("Total failures: %d\n", g_total_failures);
    
    return (g_total_failures == 0) ? 0 : 1;
}

Análisis del código

El corazón del framework reside en la técnica do { ... } while (0). Si intentaras usar una macro simple como #define ASSERT_EQ(a, b) if (a != b) failures++;, y luego escribieras en tu test:
if (condicion) ASSERT_EQ(a, b); else ..., el compilador insertaría el if de la macro dentro del if principal, rompiendo la lógica de la sentencia else y causando un error de sintaxis o un comportamiento errático. Al usar do { ... } while (0), forzamos a que la macro se comporte como una sentencia única y completa que requiere un punto y coma.

En ASSERT_EQ(actual, expected), el uso de #actual y #expected es crucial. Si pasas safe_add(2, 2), el preprocesador sustituye #actual por el texto literal "safe_add(2, 2)". Esto permite que, cuando el test falla, el reporte de error te diga exactamente qué expresión falló sin que tú tengas que escribirla manualmente en un printf.

En el main, implementamos un patrón común en sistemas robustos: la tabla de pruebas (test_case_t tests[]). Esto permite iterar sobre una lista de funciones de prueba de forma genérica. El main no solo reporta el error, sino que devuelve un código de salida (0 para éxito, 1 para fallo), lo cual es vital para la integración con herramientas de CI/CD como make o scripts de automatización.

El uso de (int)(actual) dentro de la macro de aserción es una medida de seguridad para asegurar que, al imprimir, el valor se trate como un entero, evitando problemas si se pasan tipos distintos, aunque en un framework de producción esto se gestionaría con tipos genéricos o _Generic [disponible desde C11].

El error frecuente

Un error clásico al intentar usar macros de comparación es olvidar la diferencia entre comparar valores y comparar punteros.

// ERROR CRÍTICO
ASSERT_EQ(get_version(), "1.2.3-release");

Si get_version() devuelve un char* (un puntero), ASSERT_EQ intentará comparar las direcciones de memoria y no el contenido de la cadena. Como la cadena literal "1.2.3-release" reside en una sección de memoria (el segmento de lectura) y el puntero devuelto por la función podría apuntar a una zona de datos o heap, la comparación fallará aunque el texto sea idéntico. Para strings, siempre se debe usar ASSERT_STREQ que utiliza strcmp.

Un linter o AddressSanitizer (-fsanitize=address) no detectará esto como un error de memoria, pero un analizador estático de código te advertirá que estás comparando punteros de forma sospechosa.

118

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio