Una visión general del desarrollo guiado por pruebas.
El desarrollo guiado por pruebas (TDD) se refiere a un estilo de programación en el que se escriben pruebas para guiar la implementación del diseño del programa en el código.
Se escriben una o más pruebas (en particular, pruebas unitarias) antes de escribir el código. Las pruebas tienen como objetivo cubrir un aspecto del comportamiento del programa, que puede centrarse en una sola función o método. Escribir las pruebas es una forma de transformar los requisitos del programa y la arquitectura general en un diseño específico de la implementación. Las pruebas se ejecutan y deben fallar, porque el código aún no se ha implementado. Se implementa el código y las pruebas se ejecutan de nuevo. Si las pruebas pasan, o bien ya se terminó de implementar el comportamiento, o quizás aún falten más pruebas necesarias por crear. Si las pruebas no pasan, se depura el código y las pruebas se ejecutan de nuevo. El ciclo de escribir pruebas y código se repite hasta que pasen todas las pruebas necesarias; en ese momento, la implementación de ese aspecto del comportamiento del programa está lista... por ahora.
La refactorización es la reescritura de código para mejorar su diseño. No es simplemente reescribir código para corregir bugs. A veces se le llama «refactorización» a modificar el código para hacer que pase las pruebas. Aunque modificar el código puede incluir mejorar el diseño como una forma de hacer que pase las pruebas, simplemente depurar no necesariamente mejora el diseño del código y, por lo tanto, no necesariamente es refactorización.
El siguiente es un ejemplo de depuración sin refactorización:
# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
# used multiply operator by mistake. It fails the tests.
return x * y
# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
return x + y
El siguiente es un ejemplo de refactorización y luego de depuración:
# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
# Introduced multiply operator by mistake. It fails the tests. This is why we test.
return old_cars * new_cars
# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
return old_cars + new_cars
El track de Python de Exercism utiliza la metodología TDD en sus ejercicios. Las pruebas unitarias ya están escritas. El estudiante puede ver las pruebas para comprender con más detalle qué se requiere para que una solución pase. Se le puede proporcionar al estudiante un stub de solución.
Cuando una o más pruebas fallan en una solución de Python, las tareas correspondientes no tendrán un fondo verde. El área de la primera tarea fallida se expandirá y su encabezado se verá más o menos así
Task 1 Extract coordinates -
Si haces clic en el signo menos, la tarea se contraerá, así podemos ver otras tareas, pero por ahora nos quedaremos con esta tarea.
Debajo habrá un área de Test expandida que se verá más o menos así
Test 1 ⌄
FAILED TisburyTreasure > get coordinate
donde Tisbury Treasure indica el ejercicio y get_coordinate indica la función o el método que falla.
Test 1 generalmente será una especie de plantilla con una sección de código para configurar las pruebas.
No tiene información sobre la prueba o pruebas específicas que fallaron.
Hacia la parte inferior dirá que
One or more variations of this test failed. Details can be found under each [variant#].
Si haces clic en ⌄, la prueba se contraerá.
Debajo habrá una prueba contraída que se verá más o menos así:
Test 2 >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
("Scrimshaw Whale's Tooth", '2A'), result='2A')
Su aspecto variará según el ancho configurado para el panel derecho.
Si haces clic en >, la prueba se expandirá.
Los datos de los argumentos y los datos del resultado esperado probablemente se mostrarán en una sección de código.
Los datos pueden ser de todas las pruebas de esta tarea.
En la parte inferior, en la sección Test Failure, está la razón específica por la que falló esta prueba.
Puede verse más o menos así:
AssertionError: ['2A'] != '2A'
En este caso particular, indica que el valor devuelto de ['2A'] no era igual al valor esperado de '2A'.
Si miramos el código de get_coordinate, vemos que está implementado así
def get_coordinate(record):
return [record[1]]
Si quitamos los corchetes de la lista (por ejemplo, return record[1]) y ejecutamos las pruebas de nuevo, las pruebas de la tarea 1 pasarán.
Si una o más tareas siguen fallando, se repite el proceso anterior con cada una hasta que todas las pruebas pasen.
A veces, los datos esperados y los datos devueltos son demasiado grandes para caber todos en la sección Test Failure.
Puede verse más o menos así:
AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.
Es posible que aún haya suficientes datos para ver cuál es el problema.
En el caso anterior, se devuelven dos saltos de línea (por ejemplo, \n\n(\'Brass Spyglass) cuando solo se espera uno (por ejemplo, \n(\'Brass Spyglass).
¡Felicidades! Todas las pruebas han pasado. ¿Y ahora qué? La solución se puede publicar de inmediato. O, ahora que el código funciona, si quieres refactorizarlo por alguna razón, puedes modificar el código y enviar otra iteración. Si crees que el código podría ser mejor, pero no sabes cómo, puedes solicitar mentoría para la solución. Si hay un mentor disponible, puede contactarte con ideas para otros enfoques de una solución. Cuando publiques tu solución, puedes permitir comentarios y otros estudiantes pueden aprovechar para publicar comentarios o hacer preguntas.
Aunque «la optimización prematura es la raíz de todos los males» (un dicho atribuido tanto a Tony Hoare como a Donald Knuth), llega un momento en que, aunque la solución funcione, se desea mejorar su rendimiento.
Una de esas ocasiones puede ser cuando la solución pasa algunas pruebas pero se agota el tiempo en otras.
Puede ser útil saber exactamente cuánto tiempo tarda un fragmento de código.
El módulo timeit se puede usar para medir el tiempo de ejecución de código hasta duraciones muy pequeñas.
La función timeit puede tomar hasta cinco argumentos: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None).
El parámetro stmt define el código real que se va a ejecutar y a medir.
El parámetro number determina cuántas veces se ejecutará el código stmt.
El parámetro setup define el código que se ejecuta una sola vez para preparar la ejecución del código stmt.
El tiempo de ejecución del código setup se incluye en el tiempo total.
Cuantas más iteraciones se ejecute el código stmt, menos contará el tiempo de setup por iteración.
El parámetro timer permite pasar un Timer diferente al predeterminado.
El argumento predeterminado para el parámetro timer es perf_counter, que debería ser suficiente para la mayoría de los casos.
El argumento predeterminado para el parámetro number es 1_000_000.
El parámetro globals especifica un espacio de nombres en el que ejecutar el código.
El argumento predeterminado para el parámetro globals es None.
El siguiente es un ejemplo del uso de timeit para ver cuánto tarda en determinar si una oración contiene todas las vocales en inglés:
import timeit
# run one million times
loops = 1_000_000
# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)
Ejecutar el código un millón de veces tomó un promedio de 4.965089999896008e-07 segundos por llamada (alrededor de 497 nanosegundos por llamada).
El siguiente ejemplo sirve para ver si sacar la llamada a casefold de la comprensión de listas ahorra algo de tiempo:
import timeit
loops = 1_000_000
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
"""
VOWELS = "AEIOU"
def has_all_vowels(sentence):
sentence = sentence.casefold()
return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)
Ejecutar el código un millón de veces tomó un promedio de 4.923898000270128e-07 segundos por llamada (alrededor de 492 nanosegundos por llamada).
Así que sacar casefold de la comprensión de listas ahorró alrededor de 5 nanosegundos por llamada, o alrededor de 5 milisegundos en total para un millón de llamadas.
cProfile también se puede usar para perfilar código; sin embargo, no es tan granular, ya que solo llega hasta duraciones de milisegundos.