Elección de los ejemplos y criterio de finalización

Una de las preguntas más frecuentes cuando empiezas a hacer TDD es cuántos tests tienes que escribir hasta considerar el desarrollo terminado. La respuesta corta es: tendrás que hacer todos los tests que sean necesarios y ni uno más. La respuesta larga es este capítulo.

Checklist driven testing

Una buena técnica puede ser seguir el consejo de Kent Beck y escribir una lista de control o check-list en la que anotamos todos aquellos comportamientos que queremos implementar. Obviamente, a medida que completamos cada comportamiento vamos tachando items en la lista.

También es posible que, durante el trabajo, descubramos que necesitamos testear algún otro comportamiento, que podemos suprimir alguno de los elementos de la lista, o que nos interesa cambiar el orden en que lo hemos planeado. Por supuesto que podemos hacer todo esto según nos convenga.

La lista no es más que una herramienta para no depender de nuestra memoria durante el proceso. Al fin y al cabo, uno de los beneficios de hacer Test Driven Development es reducir la cantidad de información y conocimiento que tenemos que utilizar en cada fase del proceso de desarrollo. Cada ciclo de TDD implica un problema muy pequeño, que podemos resolver con bastante poco esfuerzo. Pequeños pasos que acaban llevándonos muy lejos.

Veamos un ejemplo con la kata Leap Year, en la que tenemos que crear una función para calcular si un año es bisiesto o no. Una posible lista de control sería esta:

1 Checklist para Leap Year
2 
3 * Los años que no pueden dividir por 4 son años normales
4 * Los años divisibles por 4 son bisiestos
5 * Si son divisibles por 100 son bisiestos
6 * Si son divisibles por 400 entonces no son bisiestos

Otro ejemplo para la kata Prime Numbers, en la que el ejercicio consiste en desarrollar una función que obtenga los factores primos de un número:

1 Checklist para Prime Numbers
2 
3 * Números que no tienen factores primos
4 * Números primos (el único factor primo es el mismo número)
5 * Números no primos:
6    * Potencias de un solo factor primo
7    * Producto de distintos factores primos

Selección de ejemplos

Por cada comportamiento que queremos implementar necesitaremos un cierto número de ejemplos con los que escribir los tests. En el capítulo siguiente veremos que TDD tiene dos momentos principales: uno relacionado con el establecimiento de la interfaz de la unidad que estamos creando y otro en el que desarrollamos el comportamiento propiamente dicho. Es en este momento cuando necesitamos ejemplos que cuestionen la implementación existente y nos obliguen a introducir código que produzca el comportamiento deseado.

Una buena idea es, por tanto, anotar varios ejemplos posibles con los que probar cada item de la lista de control.

Pero, ¿cuántos ejemplos son necesarios? En QA tenemos varias técnicas para escoger ejemplos representativos con los que generar los tests, pero tienen el objetivo de optimizar la relación entre el número de tests y su capacidad de cubrir los escenarios posibles.

Podemos utilizar algunas de ellas en TDD, aunque de una forma un poco diferente, como veremos a continuación. Ten en cuenta que en TDD estamos desarrollando un algoritmo y, en muchos casos, lo vamos descubriendo mientras lo escribimos. Para eso necesitaremos varios ejemplos relacionados con el mismo comportamiento, de modo que podamos identificar regularidades y descubrir cómo generalizarlo.

Las técnicas en las que nos vamos a fijar son:

Partición por clase de equivalencia

Esta técnica se basa en que el conjunto de todos los posibles casos concebibles se puede dividir en clases mediante algún criterio. Todos los ejemplos en la misma clase serían equivalentes, por lo que bastaría hacer un test con un ejemplo de cada clase, ya que todos son igualmente representativos de la misma.

Análisis de límites

Esta técnica es similar a la anterior, pero prestando atención a los límites o fronteras entre clases. Se escogen dos ejemplos de cada clase que son justamente los que se encuentran en sus límites. Ambos ejemplos son representativos de la clase, pero nos permiten estudiar qué ocurre en los extremos del intervalo.

Se usa cuando los ejemplos son datos contínuos o nos importa especialmente el cambio que se produce al pasar de una clase a otra. Específicamente es el tipo de situación en la que el resultado depende de si el valor considerado es mayor o estrictamente mayor, etc.

Tabla de decisión

La tabla de decisión no es más que el resultado de combinar los posibles valores, agrupados en clases, de los parámetros que se pasan a la unidad bajo test.

Vamos a ver la elección de ejemplos aplicada al caso de Leap Year. Para eso, empezamos con la lista:

1 Checklist para Leap Year
2 
3 * Los años que no pueden dividir por 4 son años normales
4 * Los años divisibles por 4 son bisiestos
5 * Si son divisibles por 100 son bisiestos
6 * Si son divisibles por 400 entonces no son bisiestos

Veamos el primer item. Podríamos usar cualquier número que cumpla la condición de no ser divisible por 4:

1 * Los años que no pueden dividir por 4 son años normales
2 
3 Ejemplos: 1997, 2021, 1825

En el segundo item, los ejemplos deben cumplir la condición de ser divisibles por 4:

1 * Los años divisibles por 4 son bisiestos
2 
3 Ejemplos: 1996, 2000, 2020, 1600, 1800, 1900

Prestemos atención al siguiente elemento de la lista. La condición de ser números divisibles por 100 se superpone con la condición anterior. Por tanto, tenemos que eliminar algunos ejemplos del item anterior:

1 * Los años divisibles por 4 son bisiestos
2 
3 Ejemplos: 1996, 2020
4 
5 * Si son divisibles por 100 son bisiestos
6 
7 Ejemplos: 2000, 1600, 1800, 1900

Y ocurre lo mismo con el último de los elementos de la lista. Los ejemplos para este item son los números divisibles por 400. También se superpone con el ejemplo anterior:

1 * Si son divisibles por 100 son bisiestos
2 
3 Ejemplos: 1800, 1900
4 
5 * Si son divisibles por 400 entonces no son bisiestos
6 
7 Ejemplos: 1600, 2000

De este modo, la lista con los ejemplos quedaría así:

 1 * Los años que no pueden dividir por 4 son años normales
 2 
 3 Ejemplos: 1997, 2021, 1825
 4 
 5 * Los años divisibles por 4 son bisiestos
 6 
 7 Ejemplos: 1996, 2020
 8 
 9 * Si son divisibles por 100 son bisiestos
10 
11 Ejemplos: 1800, 1900
12 
13 * Si son divisibles por 400 entonces no son bisiestos
14 
15 Ejemplos: 1600, 2000

Por otro lado, la elección de ejemplos para Prime Factors nos podría dar esto:

 1 Checklist para Prime Numbers
 2 
 3 * Números que no tienen factores primos
 4 
 5 Ejemplos: 1
 6 
 7 * Números primos (el único factor primo es el mismo número)
 8 
 9 Ejemplos: 2, 3, 5...
10 
11 * Números no primos:
12    * Potencias de un solo factor primo
13    
14    Ejemplos: 4, 8, 9, 16, 27...
15    
16    * Producto de distintos factores primos
17    
18    Ejemplos: 6, 10, 12, 15, 18, 20...

Uso de varios ejemplos para generalizar un algoritmo

En ejercicios de código simples como puede ser la kata Leap Year, es relativamente fácil anticipar el algoritmo, de modo que no necesitaríamos usar varios ejemplos para hacerlo evolucionar e implementarlo. En realidad, sería suficiente con un ejemplo de cada clase, como hemos visto al hablar de la partición por clases de equivalencia, y en pocos minutos tendríamos el problema resuelto.

Sin embargo, si estamos empezando con TDD es buena idea ir paso a paso. Lo mismo que si nos enfrentamos a un comportamiento complejo. Es preferible tomar baby steps realmente pequeños, introducir varios ejemplos y esperar a tener suficiente información para generalizar. En este mismo libro puedes encontrar varias aplicaciones de esta técnica. Tener algo de duplicación de código es preferible a escoger la abstracción equivocada y construir sobre ella.

Una heurística que puedes aplicar es la regla de los tres. Esta regla nos dice que no deberíamos intentar generalizar código hasta tener al menos tres repeticiones del mismo. Para ello, tendremos que identificar las partes fijas y las partes que cambian.

Considera este ejemplo, tomado de un ejercicio de la kata Leap Year. En este punto los tests están pasando, pero de momento no hemos generado un algoritmo.

 1 function leapYear(year) {
 2     if (year === 1992) {
 3         return true;
 4     }
 5     if (year === 1996) {
 6         return true;
 7     }
 8     if (year === 2020) {
 9         return true;
10     }
11 
12     return false;
13 }
14 
15 describe('Identify Leap Year', () => {
16     it('should be Leap Year', () => {
17         expect(leapYear(1992)).toBeTruthy();
18         expect(leapYear(1996)).toBeTruthy();
19         expect(leapYear(2020)).toBeTruthy();
20     });
21 })

Ya tenemos tres repeticiones. ¿Qué tienen en común aparte de la estructura if/then?. Forcemos un pequeño cambio:

 1 function leapYear(year) {
 2     if (year === 498 * 4) {
 3         return true;
 4     }
 5     if (year === 499 * 4) {
 6         return true;
 7     }
 8     if (year === 505 * 4) {
 9         return true;
10     }
11 
12     return false;
13 }
14 
15 describe('Identify Leap Year', () => {
16     it ('should be Common Year', () => {
17         expect(leapYear(1997)).toBeFalsy();
18         expect(leapYear(1998)).toBeFalsy();
19         expect(leapYear(2021)).toBeFalsy();
20     });
21 	
22     it('should be Leap Year', () => {
23         expect(leapYear(1992)).toBeTruthy();
24         expect(leapYear(1996)).toBeTruthy();
25         expect(leapYear(2020)).toBeTruthy();
26     });
27 })

Claramente, los tres años son divisibles por 4. Así que podríamos expresarlo de otra manera:

 1 function leapYear(year) {
 2     if (year % 4 === 0) {
 3         return true;
 4     }
 5     if (year % 4 === 0) {
 6         return true;
 7     }
 8     if (year % 4 === 0) {
 9         return true;
10     }
11 
12     return false;
13 }
14 
15 describe('Identify Leap Year', () => {
16     it ('should be Common Year', () => {
17         expect(leapYear(1997)).toBeFalsy();
18         expect(leapYear(1998)).toBeFalsy();
19         expect(leapYear(2021)).toBeFalsy();
20     });
21 	
22     it('should be Leap Year', () => {
23         expect(leapYear(1992)).toBeTruthy();
24         expect(leapYear(1996)).toBeTruthy();
25         expect(leapYear(2020)).toBeTruthy();
26     });
27 })

Que ahora es una repetición obvia y se puede eliminar:

 1 function leapYear(year) {
 2     if (year % 4 === 0) {
 3         return true;
 4     }
 5 
 6     return false;
 7 }
 8 
 9 describe('Identify Leap Year', () => {
10     it ('should be Common Year', () => {
11         expect(leapYear(1997)).toBeFalsy();
12         expect(leapYear(1998)).toBeFalsy();
13         expect(leapYear(2021)).toBeFalsy();
14     });
15 	
16     it('should be Leap Year', () => {
17         expect(leapYear(1992)).toBeTruthy();
18         expect(leapYear(1996)).toBeTruthy();
19         expect(leapYear(2020)).toBeTruthy();
20     });
21 })

Esto ha sido muy obvio, por supuesto. Sin embargo, no siempre tendremos las cosas tan fáciles.

En resumen, si no conocemos muy bien el problema, puede ser útil esperar a que se cumpla la regla de los tres para empezar a pensar en generalizaciones del código. Esto implica que, como mínimo, introduciremos tres ejemplos que representen la misma clase antes de refactorizar la solución a una más general.

Veamos otro ejemplo en la misma kata:

 1 function leapYear(year) {
 2     if (year % 100 === 0) {
 3         return false;
 4     }
 5 
 6     if (year % 4 === 0) {
 7         return true;
 8     }
 9 
10     return false;
11 }
12 
13 describe('Identify Leap Year', () => {
14     it ('should be Common Year', () => {
15         expect(leapYear(1997)).toBeFalsy();
16         expect(leapYear(1998)).toBeFalsy();
17         expect(leapYear(2021)).toBeFalsy();
18     });
19 	
20     it('should be Leap Year', () => {
21         expect(leapYear(1992)).toBeTruthy();
22         expect(leapYear(1996)).toBeTruthy();
23         expect(leapYear(2020)).toBeTruthy();
24     });
25 
26     it('should be exceptional common year', function () {
27         expect(leapYear(1700)).toBeFalsy();
28         expect(leapYear(1800)).toBeFalsy();
29         expect(leapYear(1900)).toBeFalsy();
30     });
31 })

La duplicación que no lo es

El concepto divisible es evidente en esta ocasión y realmente no necesitamos un tercer caso para valorar la posibilidad de extraerlo. Pero aquí lo importante no es la duplicación. En realidad nos hubiese bastado con un ejemplo. Este refactor lo hacemos porque hace explícita la idea de que la condición que se evalúa es el hecho de que el número del año sea divisible por un cierto factor para que se aplique la regla.

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(100)) {
 7         return false;
 8     }
 9 
10     if (divisibleBy(4)) {
11         return true;
12     }
13 
14     return false;
15 }
16 
17 describe('Identify Leap Year', () => {
18     it ('should be Common Year', () => {
19         expect(leapYear(1997)).toBeFalsy();
20         expect(leapYear(1998)).toBeFalsy();
21         expect(leapYear(2021)).toBeFalsy();
22     });
23 	
24     it('should be Leap Year', () => {
25         expect(leapYear(1992)).toBeTruthy();
26         expect(leapYear(1996)).toBeTruthy();
27         expect(leapYear(2020)).toBeTruthy();
28     });
29 
30     it('should be exceptional common year', function () {
31         expect(leapYear(1700)).toBeFalsy();
32         expect(leapYear(1800)).toBeFalsy();
33         expect(leapYear(1900)).toBeFalsy();
34     });
35 })

Esto se ve más claro si avanzamos un poco más.

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(400)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(100)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

Tenemos una misma estructura repetida tres veces, pero no podemos extraer un concepto común de aquí. Dos de las repeticiones representan el mismo concepto (año bisiesto), pero la tercera representa años de duración normal excepcionales.

En búsqueda de la abstracción incorrecta

Intentemos otra aproximación:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(4 * 4 * 5 * 5)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(4 * 5 * 5)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

Si dividimos el año entre cuatro podríamos plantear otra idea, ya que eso nos podría ayudar a identificar mejor las partes comunes y diferentes:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5     year /= 4
 6 
 7     if (divisibleBy(4 * 5 * 5)) {
 8         return true;
 9     }
10 
11     if (divisibleBy(5 * 5)) {
12         return false;
13     }
14 
15     if (divisibleBy(1)) {
16         return true;
17     }
18 
19     return false;
20 }

Es extraño, pero funciona. Más simplificado:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5     year /= 4
 6 
 7     if (divisibleBy(100)) {
 8         return true;
 9     }
10 
11     if (divisibleBy(25)) {
12         return false;
13     }
14 
15     if (divisibleBy(1)) {
16         return true;
17     }
18 
19     return false;
20 }

Sigue funcionando. Pero, ¿de qué nos sirve?

  • Por un lado, seguimos sin encontrar una manera de reconciliar las tres estructuras if/then.
  • Por otro, hemos hecho que las reglas de dominio sean irreconocibles.

En otras palabras: intentar encontrar una abstracción basándonos solo en la existencia de repetición en el código puede ser un camino sin salida.

La abstracción correcta

Como hemos señalado antes, el concepto que nos interesa es el de año bisiesto y las reglas que lo determinan. ¿Podemos hacer el código menos repetitivo? Puede ser. Volvamos al principio:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(400)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(100)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

La cuestión es que la regla de divisible por 400 es una excepción a la regla de divisible por 100:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(100) && !divisibleBy(400)) {
 7         return false;
 8     }
 9 
10     if (divisibleBy(4)) {
11         return true;
12     }
13 
14     return false;
15 }

Lo que nos permite hacer esto y compactar un poco la solución:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(100) && !divisibleBy(400)) {
 7         return false;
 8     }
 9 
10     return divisibleBy(4);
11 }

Quizá podamos hacerla un poco más explícita:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     function isCommonYearExceptionally() {
 7         return divisibleBy(100) && !divisibleBy(400);
 8     }
 9 
10     if (isCommonYearExceptionally()) {
11         return false;
12     }
13 
14     return divisibleBy(4);
15 }

Pero ahora queda un poco raro, necesitamos ser más explícitas aquí:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     function isCommonYearExceptionally() {
 7         return divisibleBy(100) && !divisibleBy(400);
 8     }
 9 
10     function isLeapYear() {
11         return divisibleBy(4);
12     }
13 
14     if (isCommonYearExceptionally()) {
15         return false;
16     }
17 
18     return isLeapYear();
19 }

En este punto, me pregunto si esto no es demasiado poco natural. Por un lado, la abstracción es correcta, pero a fuerza de llevarla tan lejos posiblemente estamos pecando de cierta sobre-ingeniería. El dominio del problema es muy pequeño y las reglas muy sencillas y claras. Si comparas esto:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     if (divisibleBy(400)) {
 7         return true;
 8     }
 9 
10     if (divisibleBy(100)) {
11         return false;
12     }
13 
14     if (divisibleBy(4)) {
15         return true;
16     }
17 
18     return false;
19 }

Con esto:

 1 function leapYear(year) {
 2     function divisibleBy(divisor) {
 3         return year % divisor === 0;
 4     }
 5 
 6     function isCommonYearExceptionally() {
 7         return divisibleBy(100) && !divisibleBy(400);
 8     }
 9 
10     function isLeapYear() {
11         return divisibleBy(4);
12     }
13 
14     if (isCommonYearExceptionally()) {
15         return false;
16     }
17 
18     return isLeapYear();
19 }

Creo que me quedaría con la primera solución. Ahora bien, en un problema más complejo y más complicado de entender, puede que la segunda solución sea mucho más adecuada, precisamente porque nos ayudaría a hacer explícitos los conceptos implicados.

La moraleja es que no hay que empeñarse en buscar la abstracción perfecta, sino la suficiente para este momento.