Una visión general del desarrollo dirigido por pruebas.
El desarrollo guiado por pruebas (TDD) es 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 varias pruebas (en particular, pruebas unitarias) antes de escribir el código. Las pruebas pretenden 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 su arquitectura general en un diseño específico para la implementación. Se ejecutan las pruebas y deberían fallar, porque el código aún no se ha implementado. Se implementa el código y se vuelven a ejecutar las pruebas. Si las pruebas pasan, o bien ya se ha terminado de implementar ese comportamiento, o bien puede que aún queden por crear más pruebas necesarias. Si las pruebas no pasan, se depura el código y se vuelven a ejecutar las pruebas. El ciclo de probar y programar se repite hasta que pasan todas las pruebas necesarias; en ese momento, la implementación de ese aspecto del comportamiento del programa está terminada... 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 llama «refactorizar» a modificar el código para conseguir que pase las pruebas. Aunque modificar el código pueda incluir mejorar el diseño como forma de hacer que pase las pruebas, el mero hecho de depurar no mejora necesariamente el diseño del código y, por tanto, no es necesariamente refactorizar.
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, después, 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 consultar las pruebas para hacerse una idea más detallada de qué se necesita para que una solución pase. Puede que se le proporcione al estudiante un esqueleto de solución.
Cuando fallan una o varias pruebas de una solución de Python, la tarea o tareas correspondientes no tendrán el fondo verde. La primera área de tarea que falla aparecerá expandida y su encabezado tendrá un aspecto parecido a este:
Task 1 Extract coordinates -
Si haces clic en el signo menos, la tarea se contraerá, así podremos fijarnos en otras tareas, pero por ahora nos quedaremos con esta.
Debajo habrá un área de pruebas expandida que tendrá un aspecto parecido a este:
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.
Normalmente, Test 1 será una especie de plantilla con una sección de código para preparar las pruebas.
No contiene información sobre la prueba o pruebas concretas que fallaron.
Hacia el final dirá algo así:
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 con un aspecto parecido a este:
Test 2 >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
("Scrimshaw Whale's Tooth", '2A'), result='2A')
Su aspecto variará según el ancho que tenga el panel derecho.
Si haces clic en >, la prueba se expandirá.
Los datos de entrada y los datos del resultado esperado probablemente se muestren en una sección de código.
Los datos pueden corresponder a todas las pruebas de esta tarea.
Al final, en la sección Test Failure, está el motivo concreto por el que falló esta prueba.
Tendrá un aspecto parecido a este:
AssertionError: ['2A'] != '2A'
En este caso concreto, indica que el valor devuelto ['2A'] no era igual al valor esperado '2A'.
Si nos fijamos en el código de get_coordinate, vemos que está implementado así:
def get_coordinate(record):
return [record[1]]
Si quitamos los corchetes del array (por ejemplo, return record[1]) y volvemos a ejecutar las pruebas, las pruebas de la tarea 1 pasarán.
Si una o varias tareas siguen fallando, se repite el proceso anterior con cada una hasta que pasen todas las pruebas.
A veces, los datos esperados y los datos devueltos son demasiado grandes para caber todos en la sección Test Failure.
Tendrá un aspecto parecido a este:
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.
Puede que aún haya datos suficientes 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).
¡Enhorabuena! Ya han pasado todas las pruebas. ¿Y ahora qué? La solución podría publicarse directamente. O, ahora que el código funciona, si quieres refactorizarlo por algún motivo, 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 que se ponga en contacto contigo con ideas para otros enfoques de la solución. Cuando publiques tu solución, puedes permitir comentarios y puede que otros estudiantes aprovechen 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 el que, aunque la solución funcione, se quiere mejorar su rendimiento.
Uno de esos momentos puede ser cuando la solución pasa algunas pruebas, pero se le 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 aceptar 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 cuya ejecución se va a medir.
El parámetro number determina cuántas veces se ejecutará el código de stmt.
El parámetro setup define el código que se ejecuta solo una vez para preparar la ejecución del código de stmt.
El tiempo que tarda en ejecutarse el código de setup se incluye en el tiempo total.
Cuantas más veces se ejecute el código de stmt, menos contará el tiempo de setup en cada iteración.
El parámetro timer permite pasar un Timer distinto del predeterminado.
El argumento predeterminado del parámetro timer es perf_counter, que debería bastar para la mayoría de los casos.
El argumento predeterminado del 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 del parámetro globals es None.
El siguiente es un ejemplo del uso de timeit para ver cuánto tarda en determinarse si una frase contiene todas las vocales inglesas:
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 tardó una media de 4.965089999896008e-07 segundos por llamada (unos 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 tardó una media de 4.923898000270128e-07 segundos por llamada (unos 492 nanosegundos por llamada).
Así que sacar casefold de la comprensión de listas ahorró unos 5 nanosegundos por llamada, o unos 5 milisegundos en total para un millón de llamadas.
cProfile también se puede usar para analizar el rendimiento del código; sin embargo, no es tan granular, ya que solo llega hasta duraciones de milisegundos.