Test Driven Development

TDD Cycle
Ciclo del desarrollo guiado por pruebas (TDD).

Desarrollo guiado por pruebas, TDD por sus siglas en inglés (Test Driven Development), es una técnica usada en el desarrollo de software. Lo que esta técnica propone es que se escriban unit tests antes de escribir nuevo código para una clase, es decir, escribir tests para un código que aun no existe. Al no haber programado aun la lógica de los métodos que se quieren probar, como es de esperarse los tests no van a pasar. El objetivo será entonces escribir el código que hará que los tests pasen. La propuesta de TDD es realmente interesante, aunque en principio pueda sonar algo rara, por no ser la forma habitual como se ha desarrollado software históricamente.

Según el artículo en español de Wikipedia, el ciclo de TDD es el siguiente:

  1. Elegir un requisito
  2. Escribir una prueba
  3. Verificar que la prueba falla
  4. Escribir la implementación
  5. Ejecutar las pruebas automatizadas
  6. Eliminación de duplicación
  7. Actualización de la lista de requisitos

Ejemplo

Escribir un programa para determinar si un año es bisiesto, de acuerdo a los siguientes criterios:

  • Es bisiesto si es divisible entre 4.
  • Pero no es bisiesto si es divisible entre 100.
  • Pero sí es bisiesto si es divisible entre 400. (2000 y 2400 sí son bisiestos son divisibles entre 100 pero también entre 400. 1900, 2100, 2200 y 2300 no lo son porque solo son divisibles entre 100).

Tomando el primer criterio se escribe el siguiente test:

TEST(isLeapYear, IfDivisibleBy4)
{
    LeapYear leap;
   
    EXPECT_TRUE(leap.isLeapYear(4));
}

y el correspondiente código para que el test pase es:

bool LeapYear::isLeapYear(const int year) const
{
    if (!(year % 4))
    {  
        return true;
    }
    return false;
}

Tomando el segundo criterio se escribe el siguiente test:

TEST(isNotLeapYear, IfDivisibleBy100)
{
    LeapYear leap;
   
    EXPECT_FALSE(leap.isLeapYear(100));
}

y se complementa el código de la siguiente manera para que el test pase:

bool LeapYear::isLeapYear(const int year) const
{
    if (!(year % 4))
    {
        if(!(year % 100))
        {
            return false;
        }
       
        return true;
    }
    return false;
}

Tomando el tercer criterio se escribe el siguiente test:

TEST(isLeapYear, IfDivisibleBy100And400)
{
    LeapYear leap;
   
    EXPECT_TRUE(leap.isLeapYear(400));
}

y se complementa el código de la siguiente manera para que el test pase:

bool LeapYear::isLeapYear(const int year) const
{
    if (!(year % 4))
    {
        if(!(year % 100))
        {
            if(!(year % 400))
            {
                return true;
            }
            return false;
        }
       
        return true;
    }
    return false;
}

De esta forma se ha ido tomando criterio por criterio para escribir test cases. Después de escribir cada test case se ha escrito el código correspondiente para cumplir con el criterio que está siendo probado.

Link al ejercicio.

Conclusión

Lo más valioso que yo he sacado de esta técnica no es precisamente la idea de escribir primero la prueba y después la implementación. Para ser sincero, no estoy convencido que eso sea lo mejor, por las siguientes razones:

Al leer las especificaciones puedo entender el problema que se requiere solucionar, pero no necesariamente sabré cual es la solución, y mucho menos como implementarla a detalle, es decir, no sé que nuevos métodos declararé, y tampoco que tipos de datos tomarán y regresarán esos métodos. En mi experiencia esto se va definiendo conforme uno va plasmando sus ideas a través del código.

Para ejecutar el paso 3 (Verificar que la prueba falla) de manera correcta, se tiene que escribir además del test una parte del código también, de otra forma el test no compilará porque se estaría llamando en el test a algún método que no existe aun. En algunos casos, escribir el “esqueleto” del método de tal forma que este compile y pueda ser llamado, es casi equivalente a implementar el método de forma completa.

Lo más valioso que aprendí a través del estudio de esta técnica es lo que los pasos 1 y 2 implican.

Los pasos 1 (elegir un requisito) y 2 (escribir una prueba) implican que los unit tests que se escriban para un código actuarán tanto como especificación como confirmación. Es decir, leer los unit tests sería como leer las especificaciones, e idealmente todos los detalles e intenciones de las especificaciones quedarían explícitamente escritos a través de los tests, que al pasar confirmarían que el código cumple con las especificaciones.

Autor: Arturo González

mexicano, ingeniero, programador

Deja un comentario