«Escriba sus pruebas unitarias antes que el código», una verdad que cada desarrollador conoce de memoria, en teoría, pero rara vez se práctica en la realidad.
Test-Driven Development (TDD) o Desarrollo Dirigido por Pruebas, es un concepto novedoso que instruye a los desarrolladores a escribir casos de pruebas unitarios antes de escribir incluso una sola línea de código.
Robert C. Martin, propuso tres leyes de desarrollo basado en pruebas que todo desarrollador que práctique TDD debería seguir.
- Ley 1: No está permitido escribir ningún código de producción a menos que sea para aprobar una unidad de falla.
- Ley 2: No esta permitido escribir más de una prueba unitaria de cada caso de falla, y las fallas de compilación son fallas.
- Ley 3: No se le permite escribir más código de producción del que sea suficiente para pasar la prueba del test unitario.
Estas tres leyes son completas y son una guía saludable para cualquier persona que esté comenzando su viaje con TDD.
Pero la pregunta es, ¿Son realmente importantes las pruebas unitarias? ¿Qué beneficios aportan realmente a nuestro código? ¿TDD es la única forma de escribir pruebas unitarias?
Las pruebas unitarias pueden ayudarlo a lograr una mayor confianza en su código y proporcionarle la información que necesita para realizar cualquier cambio en el código de producción existente (en ejecución) y garantizar que no rompa nada ni introduzca ningún error nuevo sin darse cuenta.
Pero TDD no es la única forma de lograr estos beneficios, el escribir las pruebas unitarias después de escribir el código puede proporcionarle la misma confianza.
Sin embargo, TDD ofrece algo mucho más ventajoso que solo confianza de código; le brinda una forma de diseñar mejor su aplicación.
Veamos un ejemplo para entender cómo funciona.
Supongamos que tenemos el siguiente código para la busqueda binaria.
1 2 3 4 5 6 7 8 9 | def binary_search(numbers, left, right, value): if right >= left: mid = (left + right)/2 if numbers[mid] == value: return mid if numbers[mid] > value: return binary_search(numbers, left, mid - 1, value) return binary_search(numbers, mid + 1, right, value) return -1 |
La búsqueda binaria es un algoritmo de búsqueda simple que tiene una complejidad de tiempo O(n).
El principio básico de la búsqueda binaria es dividir el espacio de búsqueda en mitades hasta el momento en que el espacio de búsqueda está vacío o se encuentra el elemento, y la declaración mid = (left + right) / 2, juega un papel importante en la identificación de la dirección de la búsqueda.
Ahora, si comencé a escribir pruebas unitarias para esta función, al principio, agregaría algunas pruebas genéricas, que tienen una matriz de 10 o 20 elementos y me aseguraría de que mi código funcione como debería. Después de eso, me gustaría probar de extremos y ver si el código se rompe.
El punto que nos preocupa es la declaración: mid = (left + right) / 2.
¿Cuál es el problema con esta declaración? Si los valores de left y right se vuelven lo suficientemente grandes, la suma se desbordaría y se volvería negativa, y la división por 2 nuevamente permanecería negativa, por lo que esta declaración devolvería un número negativo como media de dos números positivos, lo que es matemáticamente imposible. Esta línea fallaría en la intención de encontrar la medía de dos números.
Si esto se envía a producción, podría desatar un caos y luego de identificar el error que acabamos de ver, necesitaríamos matrices muy grandes, matrices que no es factible crear una vez, y mucho menos cada vez que se construye el código. Sin embargo, pruebas como estas pueden ralentizar el desarrollo, entonces, ¿Cómo debemos abordar este tipo de problemas?
La respuesta es TDD (Test-Driven Development).
TDD al rescate
Si siguieramos las leyes del desarrollo diriguido por pruebas, estaríamos en una etapa donde antes de escribir mid = (left + right) / 2, tendríamos que escribir una prueba para calcular el valor medio, pero ¿Cómo se escribe una prueba en el medio de una función? La respuesta es que no; se debe extraer la declaración lógica a una función separada y luego escribir la prueba para ello.
Llamémosla calculate_mid, el código ahora se vería así:
1 2 3 4 5 6 7 8 9 10 11 12 13 | def calculate_mid(first, second): return (first + second)/2 def binary_search(numbers, left, right, value): if right >= left: mid = calculate_mid(left, right) if numbers[mid] == value: return mid if numbers[mid] > value: return binary_search(numbers, left, mid - 1, value) return binary_search(numbers, mid + 1, right, value) return -1 |
Para probar esta función, solo se necesita crear dos números grandes, lo cual es mucho menos costoso que crear dos grandes matrices. Probar la función mencionada anteriormente con dos valores muy grandes daría que el error que estaba misteriosamente oculto fuera claro, y se podría corregir con mayor facilidad.
Despues de eso, el código se vería así:
1 2 3 4 5 6 7 8 9 10 11 12 13 | def calculate_mid(first, second): return first + (second - first)/2 def binary_search(numbers, left, right, value): if right >= left: mid = calculate_mid(left, right) if numbers[mid] == value: return mid if numbers[mid] > value: return binary_search(numbers, left, mid - 1, value) return binary_search(numbers, mid + 1, right, value) return -1 |
Y hecho, se ha resuelto el error no detectado, que por el método convencional de prueba rara vez se detectaría.
Este artículo se encuentra basado en Rearchitect Your Code Using Test-Driven Development.