Antes de escribir un test: lo que realmente necesitas
El razonamiento previo a cualquier código de prueba: transformar un requisito vago en un contrato preciso, escribir código fácil de probar, elegir casos con valores límite, conocer la respuesta esperada sin ejecutar el código y decidir qué no probar, todo con Python básico y assert.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes una función que calcula el promedio de una lista de calificaciones de exámenes, y la forma en que la mayoría de nosotros prueba una función así: llamarla un par de veces y mirar el resultado:
def average(scores):
return sum(scores) / len(scores)
print(average([80, 90, 70]))
print(average([100]))80.0
100.0Ambas respuestas son correctas. La función «funciona» y pasa a formar parte del programa. Una semana después, una clase que todavía no tiene calificaciones llega a la misma línea:
def average(scores):
return sum(scores) / len(scores)
print(average([]))Traceback (most recent call last):
File "/home/you/school/report.py", line 5, in <module>
print(average([]))
^^^^^^^^^^^
File "/home/you/school/report.py", line 2, in average
return sum(scores) / len(scores)
~~~~~~~~~~~~^~~~~~~~~~~~~
ZeroDivisionError: division by zero«Funcionó cuando lo probé» era verdad. Pero solo significaba: «funciona para las dos entradas que se me ocurrió probar». Las comprobaciones manuales tienen dos debilidades que esto deja al descubierto.
La primera es que solo cubren aquello en lo que piensas en el momento, y una lista vacía es exactamente el tipo de cosas en las que nadie piensa en ese instante.
La segunda es que no se repiten. Las dos llamadas a print se ejecutaron una vez, se leyeron una vez y se borraron. Cuando la función cambie el próximo mes, nadie volverá a ejecutarlas.
Los tests automatizados resuelven la segunda debilidad, y el resto de este curso trata sobre cómo escribirlos con pytest. Pero observa con más atención la primera debilidad, porque ninguna herramienta la soluciona por ti. Supón que quisieras escribir una comprobación para la lista vacía ahora mismo. ¿Qué debería decir? ¿average([]) debería devolver 0? ¿Devolver None? ¿Lanzar un error? Nadie lo ha decidido. No puedes escribir un test para un comportamiento que nadie ha definido, y no puedes verificar una respuesta que no conoces de antemano.
De eso trata este capítulo: del razonamiento que debe ocurrir antes de la primera línea de código de un test. Si lo haces bien, los tests casi se escriben solos. Si lo pasas por alto, terminarás con tests que pasan pero que aun así pasan por alto el error.
Al terminar este capítulo podrás
- Decir qué es un test en una sola frase: una afirmación ejecutable sobre el comportamiento
- Convertir un requisito vago en un contrato preciso: entradas, salida, errores y efectos secundarios
- Reconocer código difícil de probar y reestructurarlo en un núcleo puro con una capa delgada
- Planificar casos con clases de equivalencia y valores límite en una tabla
- Calcular respuestas esperadas de forma independiente al código: el oráculo
- Decidir qué no probar y cuándo tienes suficientes tests
- Escribir el plan como comprobaciones simples con
assert, ejecutarlas y explicar por qué todavía no son suficientes
Prerrequisitos: ninguno; aquí es donde empieza el curso. Necesitas Python 3 y saber cómo escribir una función. Todavía no necesitas pytest; todo en este capítulo se ejecuta con Python básico.
Antes de escribir el test
Cada capítulo de este curso incluye una sección con este nombre, y cada una de ellas responde las mismas seis preguntas para su propio tema. Este capítulo es de donde provienen esas preguntas, así que aquí están reunidas en un solo lugar. Consérvalas; el resto del curso las utilizará como lista de verificación.
- ¿Qué se promete exactamente? El contrato: para estas entradas, esta salida; para estas entradas, este error; y estos efectos secundarios, o ninguno.
- ¿Puede un test llamar al código? ¿Recibe sus entradas como argumentos y devuelve un resultado, o lee del teclado, imprime y consulta el reloj?
- ¿Qué casos? Uno de cada tipo de entrada, ambos lados de cada límite, las entradas inválidas, las vacías y las muy grandes.
- ¿Cómo sé cuál es la respuesta correcta? Calculada a mano o a partir de la especificación; nunca ejecutando el código bajo prueba.
- ¿Qué debe estar preparado? La versión de Python, un entorno, dónde vivirán los archivos de prueba y, en capítulos posteriores, los archivos, configuraciones y herramientas que el tema necesite.
- ¿Qué no voy a probar? El propio Python, las bibliotecas de terceros, código demasiado simple para tener errores y comportamientos que nadie prometió.
Aquí las aplicamos a la función average de arriba, que es lo bastante pequeña para analizarla por completo.
El contrato tiene un vacío, así que el primer paso es cerrarlo. Supón que preguntas y la respuesta es: «el promedio de una lista vacía no tiene sentido; lanza un ValueError». La función ya es fácil de probar: recibe una lista y devuelve un número. Para este capítulo, todo lo que debe estar listo es Python 3. Y los casos:
| Caso | Entrada | Esperado | Por qué | | --- | --- | --- | --- | | Típico | [80, 90, 70] | 80.0 | el uso habitual: (80 + 90 + 70) / 3 | | Un solo valor | [100] | 100.0 | la lista más pequeña que tiene un promedio | | Vacía | [] | ValueError | el caso que falló; ahora está decidido |
Lo que no está en la lista: si sum suma correctamente o si / divide bien; ese es trabajo de Python, y está probado mucho más a fondo de lo que tú o yo jamás escribiremos.
El resto del capítulo aborda cada una de las seis preguntas por turno y concluye ejecutando todo el proceso sobre una función realista.
Qué es realmente un test
Un test es una afirmación ejecutable sobre el comportamiento: dada esta entrada, se espera este resultado. El «resultado» se presenta exactamente en tres formas:
- un valor de retorno:
average([80, 90, 70])da80.0 - un error:
average([])lanzaValueError - un efecto secundario: tras
add_score(scores, 70), la listascorestiene70al final
Python ya tiene una instrucción para plasmar una afirmación por escrito: assert. No hace nada cuando la condición es verdadera y lanza AssertionError cuando es falsa. Aquí tienes una afirmación de cada tipo, con average siguiendo ahora su contrato ya definido:
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
def add_score(scores, new_score):
scores.append(new_score)
# 1. A return value: given this input, expect this output.
assert average([80, 90, 70]) == 80.0
# 2. An error: given this input, expect this exception.
try:
average([])
except ValueError:
pass
else:
raise AssertionError("average([]) should raise ValueError")
# 3. A side effect: given this call, expect this change to the world.
scores = [80, 90]
add_score(scores, 70)
assert scores == [80, 90, 70]
print("3 claims checked")3 claims checkedObserva lo que necesita cada afirmación: una entrada que tú eliges, un resultado que ya conoces y una forma de llamar al código y examinar lo que hizo. Las seis preguntas existen para garantizar que tengas las tres cosas antes de comenzar. Nota también lo engorrosa que resulta la comprobación del error: cinco líneas de try/except/else para decir «esto debería lanzar una excepción». pytest convierte eso en una sola línea en el capítulo cuatro.
1. El contrato: de un requisito vago a una promesa precisa
Los requisitos suelen llegar como una sola frase: «Los miembros obtienen un 10% de descuento en pedidos grandes». Parece completa. Dásela a dos desarrolladores minuciosos y observa:
# "Members get 10% off big orders." Two honest readings of the same sentence.
def discount_by_asha(total, is_member):
if is_member and total > 1000:
return total * 0.10
return 0
def discount_by_ravi(total, is_member):
if is_member and total >= 1000:
return round(total * 0.10)
return 0
for total in [500, 1000, 1234.56]:
print(total, discount_by_asha(total, True), discount_by_ravi(total, True))500 0 0
1000 0 100
1234.56 123.456 123Ninguno de los dos cometió un error. La frase no decía si exactamente 1000 cuenta como «grande», ni cómo redondear. Cada quien llenó el vacío de forma diferente, y un pedido de 1000 recibe un descuento de 0 con uno y de 100 con el otro. Ningún test puede decir cuál de los dos tiene razón, porque lo que era «correcto» nunca se definió.
Un contrato es el requisito formulado con la precisión suficiente para poder probarlo. Para pasar de la frase al contrato, haz preguntas; las mismas cada vez:
- Entradas. ¿Qué tipos? ¿Qué rango? ¿Existe un límite, y el límite en sí está incluido? ¿Qué ocurre con cero, números negativos, valores vacíos o
None? - Salida. ¿Qué se devuelve exactamente: el descuento o el nuevo total? ¿De qué tipo? ¿Redondeado a qué?
- Errores. ¿Qué entradas se rechazan y cómo: con qué excepción?
- Efectos secundarios. ¿Modifica algo (una lista pasada como argumento, un archivo, una base de datos) o imprime algo? ¿O absolutamente nada?
Al aplicarlas al descuento, esas preguntas producen respuestas, y las respuestas van donde está el código: en el docstring:
def member_discount(total, is_member):
"""Return the discount on an order, in currency units.
- total: the order total before discount, a number >= 0.
A negative total raises ValueError.
- is_member: True or False.
- Members get 10% of the total when the total is 1000 or more
(1000 itself qualifies). Everyone else gets 0.
- The result is rounded to 2 decimal places.
- Returns the discount amount, not the new total.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.10, 2)
return 0
print(member_discount(999.99, True))
print(member_discount(1000, True))
print(member_discount(1234.56, True))
print(member_discount(5000, False))
try:
member_discount(-5, True)
except ValueError as error:
print("ValueError:", error)0
100.0
123.46
0
ValueError: total must not be negative, got -5Ahora cada línea del docstring es algo que un test puede comprobar, y cada comprobación tiene un valor esperado evidente. Esa es la prueba de fuego de un buen contrato: ¿podría una persona externa escribir el valor esperado para cualquier entrada, sin leer el código? Si no es así, todavía falta hacer alguna pregunta.
¿Quién responde las preguntas? Quien sea el dueño del requisito: un product owner, un profesor, un cliente o tú. Cuando no haya nadie a quien preguntar, decide tú, escribe la decisión en el contrato y sigue adelante. Una decisión explícita se puede revisar y cambiar; una decisión silenciosa se queda viviendo en el código hasta que toma a alguien por sorpresa.
2. Código fácil de probar: un núcleo puro y una capa delgada
Un test llama a una función con entradas elegidas y compara lo que esta devuelve. Algunas funciones hacen que eso sea imposible. Aquí tienes una, para la hora feliz de una cafetería: 20% de descuento en bebidas entre las 17:00 y las 19:00:
from datetime import datetime
def drink_price():
price = float(input("Price: "))
hour = datetime.now().hour
if 17 <= hour < 19:
price = price * 0.8
print(f"You pay {price:.2f}")Funciona cuando la ejecutas y escribes un precio. Ahora intenta probarla desde otro archivo, check_drinks.py:
from drinks import drink_price
result = drink_price()
print("returned:", result)Una comprobación automatizada se ejecuta sin nadie ante el teclado. < /dev/null le da al programa exactamente eso: una entrada en la que no hay nada:
$ python check_drinks.py < /dev/null
Price: Traceback (most recent call last):
File "/home/you/cafe/check_drinks.py", line 3, in <module>
result = drink_price()
^^^^^^^^^^^^^
File "/home/you/cafe/drinks.py", line 5, in drink_price
price = float(input("Price: "))
^^^^^^^^^^^^^^^^
EOFError: EOF when reading a linePásale un precio y se ejecuta, pero mira lo que devuelve:
$ echo 10 | python check_drinks.py
Price: You pay 10.00
returned: NoneTres problemas distintos, y cada uno de ellos es muy común:
- Lee su entrada con
input()en lugar de recibirla como argumento, por lo que un test no puede elegir la entrada. - Imprime su resultado en lugar de devolverlo, por lo que un test recibe
Noney no tiene nada que comparar. - Consulta el reloj por sí misma. Esa ejecución fue justo antes de las siete de la mañana, por lo que la respuesta fue
10.00; el mismo comando a las cinco y media de la tarde da8.00. Una comprobación sobre ella pasaría o fallaría según la hora en que se ejecutara: un test inestable (flaky), lo cual es peor que no tener ningún test, porque la gente aprende a ignorarlo.
La solución no es ningún truco de testing. Es un cambio de diseño: separar la decisión de la entrada y la salida. La decisión pasa a ser una función pura: todo lo que necesita le llega como argumentos, la respuesta sale como valor de retorno y no toca nada más. El teclado, la pantalla y el reloj pasan a una capa delgada a su alrededor:
from datetime import datetime
def drink_price(price, hour):
"""Return the price to pay for a drink.
20% off from 17:00 up to, but not including, 19:00.
"""
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def main():
# The thin shell: all the input, output and clock reading lives here.
price = float(input("Price: "))
print(f"You pay {drink_price(price, datetime.now().hour):.2f}")
if __name__ == "__main__":
main()Ahora las comprobaciones eligen la hora que quieran, en cualquier momento del día:
from drinks import drink_price
assert drink_price(10, 16) == 10
assert drink_price(10, 17) == 8.0
assert drink_price(10, 18) == 8.0
assert drink_price(10, 19) == 10
print("all checks passed")all checks passedEl programa se comporta exactamente igual que antes para la persona ante el teclado. Lo que cambió es que la parte que vale la pena probar (la regla) ahora se puede probar de forma aislada, y la capa externa es tan delgada que casi no tiene nada en lo que equivocarse.
Las señales de advertencia del código difícil de probar, para que puedas identificarlas antes de escribir un test:
| Señal | Por qué perjudica a un test | La solución habitual | | --- | --- | --- | | input() dentro de la lógica | el test no puede elegir la entrada | recibirlo como argumento | | print() como único resultado | no se devuelve nada para comparar | devolver el valor; imprimir en la capa externa | | datetime.now(), random por dentro | la respuesta cambia entre ejecuciones | pasar la hora o el valor aleatorio como argumento | | lee o modifica una variable global | los tests interfieren entre sí | pasarlo como argumento, devolver el nuevo valor | | abre un archivo fijo o URL por dentro | el test necesita ese archivo o la red | pasar los datos, o la ruta, como argumento |
No siempre puedes reestructurar el código; a veces pertenece a otra persona o la E/S es el comportamiento. pytest tiene herramientas para esos casos: capsys captura la salida impresa (capítulo nueve), tmp_path proporciona una carpeta descartable (capítulo nueve), y monkeypatch y los mocks reemplazan el reloj, la red y el entorno (capítulos diez y once). Pero reestructurar va primero. El código que es fácil de probar suele ser, simplemente, más fácil de entender.
3. Elegir los casos
No puedes probar todas las entradas posibles; drink_price por sí sola acepta cualquier precio a cualquier hora. El trabajo consiste en elegir un conjunto pequeño donde cada caso pueda detectar un error que los demás pasarían por alto. Dos conceptos hacen la mayor parte de ese trabajo.
Clases de equivalencia. Agrupa las entradas que el contrato trata de la misma manera. Cualquier miembro de un grupo representa a los demás: si drink_price(10, 12) es correcta, es casi seguro que drink_price(10, 11) también lo sea, porque la misma línea de código procesa ambas. Supón que se ha ajustado el contrato para indicar que una hora fuera de 0–23, o un precio negativo, lanza ValueError. Entonces las horas se dividen en cinco clases:
hour: ... -2 -1 | 0 1 ... 15 16 | 17 18 | 19 20 ... 23 | 24 25 ...
invalid | full price | 20% off | full price | invalidValores límite. Los errores no se distribuyen de forma uniforme a lo largo de una clase. Se concentran en sus bordes, porque ahí es donde se eligen < y <=, y donde «hasta las 19:00» se traduce en código. Por lo tanto, para cada borde, prueba el valor a cada lado de él: el último de una clase y el primero de la siguiente. Para un límite L, eso significa examinar L - 1, L y L + 1; para recuentos y longitudes, también 0 y 1.
He aquí por qué «un valor típico por clase» no es suficiente. A continuación, el test de la hora feliz se escribió con un desliz común: 17 < hour en lugar de 17 <= hour:
def drink_price(price, hour):
# A slip: 17 < hour instead of 17 <= hour.
if 17 < hour < 19:
return round(price * 0.8, 2)
return price
# Three "typical" cases, one from each valid class:
assert drink_price(10, 12) == 10
assert drink_price(10, 18) == 8.0
assert drink_price(10, 21) == 10
print("typical cases passed")
# The boundary:
assert drink_price(10, 17) == 8.0
print("boundary passed")typical cases passed
Traceback (most recent call last):
File "/home/you/cafe/boundary.py", line 15, in <module>
assert drink_price(10, 17) == 8.0
^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorUn caso de cada clase: todos pasan, y los clientes que llegan a las cinco en punto pagan el precio completo. El caso del valor límite lo detecta de inmediato.
Las clases y los límites cubren la estructura de la entrada. Luego, repasa estas preguntas, cada una de las cuales ha descubierto errores reales:
- Entrada inválida: lo que el contrato indica rechazar: un precio negativo, la hora
24. - Vacío y cero:
[],"",0, un precio de0. ¿Se comporta bien la «nada»? None: solo si el contrato lo menciona. Si no lo hace, no es un caso (consulta la pregunta seis).- Valores grandes: una lista muy larga, un número muy grande. Cualquier cosa con un límite, pruébala en el límite.
- Redondeo: donde un cálculo produce más decimales de los que conserva el resultado.
Todo junto conforma un plan de pruebas: una fila por caso, elaborado antes de escribir nada de código de prueba. La última columna es la más importante: una fila que no puedes justificar es una fila que no necesitas.
| Caso | Entrada (price, hour) | Esperado | Por qué | | --- | --- | --- | --- | | Hora válida más baja | (10, 0) | 10 | extremo del rango válido | | Última hora a precio completo | (10, 16) | 10 | por debajo del límite de las 17:00 | | Primera hora con descuento | (10, 17) | 8.0 | en el límite de las 17:00 | | Última hora con descuento | (10, 18) | 8.0 | por debajo del límite de las 19:00 | | Primera hora a precio completo otra vez | (10, 19) | 10 | en el límite de las 19:00: «sin incluir» | | Hora válida más alta | (10, 23) | 10 | extremo del rango válido | | Bebida gratis | (0, 17) | 0 | precio cero | | Redondeo | (2.49, 17) | 1.99 | 2.49 × 0.8 = 1.992, redondeado a 2 decimales | | Hora demasiado baja | (10, -1) | ValueError | justo fuera del rango | | Hora demasiado alta | (10, 24) | ValueError | justo fuera del rango | | Precio negativo | (-1, 12) | ValueError | entrada inválida |
Y el plan transformado en comprobaciones, frente a la función con el contrato más estricto:
def drink_price(price, hour):
"""Return the price to pay for a drink.
- price: a number >= 0. hour: a whole number 0-23.
- 20% off from 17:00 up to, but not including, 19:00.
- The result is rounded to 2 decimal places.
- An hour outside 0-23 or a negative price raises ValueError.
"""
if not 0 <= hour <= 23:
raise ValueError(f"hour must be 0-23, got {hour}")
if price < 0:
raise ValueError(f"price must not be negative, got {price}")
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
def raises_value_error(price, hour):
try:
drink_price(price, hour)
except ValueError:
return True
return False
assert drink_price(10, 0) == 10 # lowest valid hour
assert drink_price(10, 16) == 10 # last full-price hour before
assert drink_price(10, 17) == 8.0 # first discounted hour
assert drink_price(10, 18) == 8.0 # last discounted hour
assert drink_price(10, 19) == 10 # first full-price hour after
assert drink_price(10, 23) == 10 # highest valid hour
assert drink_price(0, 17) == 0 # free drink stays free
assert drink_price(2.49, 17) == 1.99 # rounding: 1.992 -> 1.99
assert raises_value_error(10, -1) # just below the valid range
assert raises_value_error(10, 24) # just above the valid range
assert raises_value_error(-1, 12) # negative price
print("11 checks passed")11 checks passedOnce filas, y cada una está ahí por una razón que puedes explicar en voz alta.
4. El oráculo: conocer la respuesta sin el código
Cada comprobación compara la respuesta del código con una respuesta esperada. La fuente de esa respuesta esperada se llama oráculo, y tiene una sola regla: no debe ser el código bajo prueba.
Esto suena tan obvio que parecería innecesario decirlo, hasta que ves lo fácil que es cometer el error. Aquí member_discount tiene un fallo (1% en lugar de 10%) y dos comprobaciones:
def member_discount(total, is_member):
if total < 0:
raise ValueError(f"total must not be negative, got {total}")
if is_member and total >= 1000:
return round(total * 0.01, 2) # bug: 1%, not 10%
return 0
# Wrong: the expected value comes from the code under test.
expected = member_discount(2000, True)
assert member_discount(2000, True) == expected
print("self-check passed, expected was", expected)
# Right: the expected value was worked out by hand. 10% of 2000 is 200.
assert member_discount(2000, True) == 200
print("hand-check passed")self-check passed, expected was 20.0
Traceback (most recent call last):
File "/home/you/shop/oracle.py", line 15, in <module>
assert member_discount(2000, True) == 200
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorLa primera comprobación compara la función consigo misma. Pasa con la función correcta, pasa con esta que está rota y pasa con cualquier otra versión que cualquiera pudiera escribir: no puede fallar, así que no te dice nada.
Nadie la escribe de forma tan descarada. El mismo error suele presentarse bajo dos disfraces:
- Copiar la salida. Ejecutas la función, imprime
20.0y pegas20.0en la comprobación. Si el código ya estaba mal, la comprobación ahora protege el error. - Repetir la fórmula. Escribes el valor esperado como
2000 * 0.01, la misma operación que hace el código. Una copia de la lógica del código comparte los errores del código. (La sección «Cuando algo falla», más adelante, muestra este caso en acción).
Buenos oráculos, aproximadamente del más común al menos común:
- Calcularlo a mano a partir del contrato. Elige entradas que faciliten esto: 2000 en lugar de 1873.41, de modo que calcular «el 10% de eso» sea algo que puedas hacer mentalmente.
- Ejemplos en la especificación. Si el requisito dice: «un pedido de 1500 tiene un descuento de 150», esa fila se convierte directamente en un test.
- Hechos conocidos. El agua hierve a 100 °C y 212 °F; una lista vacía tiene longitud 0.
- Un método diferente y de confianza. Un cálculo más simple y lento, o una función de la biblioteca estándar que haga el mismo trabajo de otra manera:
import statistics
def average(scores):
if not scores:
raise ValueError("average() of an empty list")
return sum(scores) / len(scores)
samples = [[80, 90, 70], [1, 2], [5], [10, 0, 0, 0]]
for scores in samples:
assert average(scores) == statistics.mean(scores), scores
print("agrees with statistics.mean on", len(samples), "samples")agrees with statistics.mean on 4 samplesstatistics.mean fue escrita por otras personas, de una forma diferente, por lo que coincidir con ella tiene verdadero valor. (Comparar resultados decimales con == puede jugarte una mala pasada: 0.1 + 0.2 == 0.3 es False en Python. Los valores aquí son seguros; el capítulo cinco muestra cómo comparar floats adecuadamente).
Si no puedes deducir la respuesta esperada por ninguno de estos medios, no es un problema de pruebas. Significa que todavía no sabes qué debería hacer el código: vuelve al contrato.
5. Lo que debe estar listo
Para este capítulo, solo Python 3. Comprueba cuál tienes:
$ python3 --version
Python 3.12.3Este curso utiliza Python 3.12, y casi todo funcionará con cualquier versión a partir de la 3.10. En Windows, el comando suele ser py --version.
A partir del próximo capítulo, tres cosas más deben estar preparadas antes de que un test pueda ejecutarse, y el capítulo uno configura cada una de ellas:
- Un entorno virtual para el proyecto, de modo que la herramienta de pruebas esté instalada en el mismo Python que ejecuta tu código.
- pytest instalado en él.
- Un lugar para los tests. Los archivos de test van junto al código, o en una carpeta
tests/, con nombres que comienzan contest_. El capítulo uno empieza con la primera opción; el capítulo tres pasa a la segunda y explica el motivo.
Los capítulos posteriores añaden sus propias necesidades (un archivo de configuración, un plugin, datos de muestra en disco) y cada uno las enumera en su propia sección «Antes de escribir el test».
6. Qué no probar y cuándo detenerse
Un test que no puede detectar un error en tu código cuesta tiempo de escribir, tiempo de ejecutar y tiempo de mantener, sin aportar nada a cambio. Deja fuera lo siguiente:
- El propio Python. No si
sortedordena o siroundredondea. Prueba que tu función ordene lo correcto en el orden correcto. - Las bibliotecas de terceros. No si
requestsenvía una petición o sipandaslee un CSV. Prueba lo que tu código hace con el resultado; y si necesitas asegurarte del comportamiento de una biblioteca, una única comprobación pequeña que documente tu suposición es suficiente. - Código demasiado simple para tener errores. Una función que devuelve una constante, o una clase que solo almacena sus argumentos. Si no puedes concebir el error, el test no puede atraparlo.
- Comportamiento que nadie prometió. Si el contrato no dice nada acerca de pasarle una cadena a
drink_price, un test solo congelaría lo que sea que haga hoy por casualidad. O bien lo añades al contrato (y entonces lo pruebas), o lo dejas fuera. - El cómo, en lugar del qué. No qué función auxiliar llamó internamente, ni en qué orden realizó sus pasos. Prueba lo que entra y lo que sale; así el interior se podrá reescribir con total libertad y los tests demostrarán que sigue funcionando.
¿Y cuántos tests son suficientes? No hay un número fijo, pero sí una regla. Tienes suficientes cuando:
- cada clase de equivalencia tiene al menos un caso,
- cada límite tiene un caso a cada lado,
- cada error que promete el contrato tiene un caso,
- y cada error que hayas corregido en el pasado tiene un caso propio, para que no pueda reaparecer en silencio.
Entonces detente. Por cada nuevo caso que sientas la tentación de añadir, pregúntate: ¿qué error detectaría este que los demás no detecten ya? Si no puedes nombrar ninguno, es un duplicado. drink_price(10, 11) junto a drink_price(10, 12) no detecta nada nuevo.
Un ejemplo completo
Ahora veamos todo el proceso, de principio a fin, sobre una función realista.
El requisito, tal como llegó: «El envío cuesta 60 para paquetes locales y 120 para los nacionales. Los paquetes más pesados cuestan más: 20 por kilo extra en el ámbito local y 30 en el nacional. No aceptamos nada que supere los 30 kg».
Las preguntas, y las respuestas que obtuvieron:
| Pregunta | Respuesta | | --- | --- | | ¿Qué cubre el precio base? | El primer kilo: 1 kg exacto sigue teniendo el precio base. | | ¿1.2 kg se cobra como 1 kg o como 2? | Cada kilo iniciado cuenta: 1.2 kg se cobra como 2 kg. | | ¿Se aceptan exactamente 30 kg? | Sí. 30 está permitido; todo lo que supere eso se rechaza. | | ¿Qué ocurre con 0 kg o un peso negativo? | Se rechaza: eso es un error al ingresar los datos. | | ¿Cómo se escriben las zonas? | Exactamente "local" o "national". Cualquier otra cosa se rechaza, incluido "Local". | | ¿Qué devuelve? | El costo como número entero. No se imprime ni se guarda nada. |
El contrato y la función fácil de probar, shipping.py. Recibe todo como argumentos y devuelve un número; no hay ninguna capa externa que separar, porque no tiene entrada ni salida en absoluto:
import math
BASE = {"local": 60, "national": 120} # covers the first kilogram
PER_EXTRA_KG = {"local": 20, "national": 30}
MAX_WEIGHT_KG = 30
def shipping_cost(weight_kg, zone):
"""Return the shipping cost of one parcel, as a whole number.
- weight_kg: a number. At or below 0, or above 30: ValueError.
- zone: "local" or "national", exactly. Anything else: ValueError.
- The base price covers the first kilogram (1 kg included).
- Every started kilogram after that costs extra: 1.2 kg is charged as 2 kg.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if zone not in BASE:
raise ValueError(f"unknown zone: {zone!r}")
if not 0 < weight_kg <= MAX_WEIGHT_KG:
raise ValueError(f"weight must be more than 0 and at most 30 kg, got {weight_kg}")
extra_kg = math.ceil(weight_kg) - 1
return BASE[zone] + extra_kg * PER_EXTRA_KG[zone]math.ceil redondea hacia arriba al siguiente número entero (math.ceil(1.2) es 2), lo cual coincide exactamente con «cada kilo iniciado cuenta».
La tabla de casos. Las clases para el peso son: inválido (0 y menores), el kilo base (mayor que 0, hasta 1), kilos adicionales (mayor que 1, hasta 30), inválido (mayor que 30). Las zonas son "local", "national" y cualquier otra cosa. Cada valor esperado se calcula a mano a partir del contrato, no del código:
| Caso | Entrada | Esperado | Por qué | | --- | --- | --- | --- | | Paquete ligero | (0.5, "local") | 60 | dentro del kilo base | | Exactamente 1 kg | (1, "local") | 60 | límite superior de la base: 1 kg está incluido | | Apenas más de 1 kg | (1.2, "local") | 80 | el kilo iniciado cuenta: 60 + 1 × 20 | | Kilos enteros, local | (3, "local") | 100 | 60 + 2 × 20 | | Kilos enteros, nacional | (3, "national") | 180 | 120 + 2 × 30: la otra zona | | En el límite | (30, "national") | 990 | 120 + 29 × 30; 30 está permitido | | Cero | (0, "local") | ValueError | límite inferior: 0 se rechaza | | Negativo | (-2, "local") | ValueError | entrada inválida | | Supera el límite | (30.5, "local") | ValueError | apenas por encima de 30 | | Zona incorrecta | (2, "Local") | ValueError | no es exactamente "local" |
Lo que deliberadamente no está en la tabla: un peso de None o "2". El contrato dice que el peso es un número, así que esos valores quedan fuera de él: Python lanzará su propio TypeError al compararlos con 0, y nosotros no hacemos ninguna promesa más allá de eso. Tampoco math.ceil: eso es de Python, no nuestro. Y no (2, "local") junto a (3, "local"): misma clase, misma línea de código, nada nuevo que detectar.
Las comprobaciones, check_shipping.py: la tabla, fila por fila:
from shipping import shipping_cost
def raises_value_error(weight_kg, zone):
"""True if shipping_cost raises ValueError for these arguments."""
try:
shipping_cost(weight_kg, zone)
except ValueError:
return True
return False
# Valid weights: one case per class, and both sides of every edge.
assert shipping_cost(0.5, "local") == 60
assert shipping_cost(1, "local") == 60
assert shipping_cost(1.2, "local") == 80
assert shipping_cost(3, "local") == 100
assert shipping_cost(3, "national") == 180
assert shipping_cost(30, "national") == 990
# Invalid input: the contract promises a ValueError for each.
assert raises_value_error(0, "local")
assert raises_value_error(-2, "local")
assert raises_value_error(30.5, "local")
assert raises_value_error(2, "Local")
print("all 10 checks passed")$ python check_shipping.py
all 10 checks passedAhora rompámosla. Unos meses después, alguien limpia shipping.py, elimina import math y escribe int(weight_kg) en lugar de math.ceil(weight_kg). Se lee bien y se ejecuta sin errores. Vuelve a ejecutar las comprobaciones:
$ python check_shipping.py
Traceback (most recent call last):
File "/home/you/shop/check_shipping.py", line 14, in <module>
assert shipping_cost(0.5, "local") == 60
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorEl plan cumplió su objetivo: el cambio se detectó en el mismo instante en que se ejecutaron las comprobaciones. int corta los decimales en lugar de redondear hacia arriba, por lo que 0.5 kg se convirtió en int(0.5) - 1, lo que da -1 kilos extra.
Pero lee el informe como si no supieras esto de antemano y observa lo poco que dice:
- ¿Cuál fue el valor real? El informe muestra la línea, no el número. Tienes que ejecutar
shipping_cost(0.5, "local")por tu cuenta para enterarte de que dio40. - ¿Qué más se rompió? El script se detuvo en el primer fallo. El caso de 1.2 kg también es incorrecto (ahora da
60en lugar de80), pero esa comprobación nunca llegó a ejecutarse. - ¿Qué promesas se mantuvieron? Nada te dice si los casos de error todavía pasan.
- Y las comprobaciones de error necesitaron una función auxiliar con
try/exceptsolo para decir «esto debería lanzar una excepción».
La parte difícil era el razonamiento, y ya está hecha: el contrato, la tabla y el oráculo. Lo que falta es un mejor ejecutor de pruebas: uno que encuentre las comprobaciones por sí mismo, que ejecute cada una incluso después de un fallo, que muestre el valor real junto al esperado y que verifique errores en una sola línea. Eso es pytest, e instalarlo es el tema del próximo capítulo.
Cuando algo falla
Estos son errores de planificación más que fallos de sintaxis de Python, por lo que los síntomas son más discretos: por lo general, una comprobación que pasa cuando no debería hacerlo.
El valor esperado repite la propia fórmula del código La comprobación se escribe haciendo la misma operación que hace la función. Aquí la función tiene el error de int de antes, y la comprobación también:
def shipping_cost(weight_kg, zone):
# Local zone only, to keep the example short. Bug: int() instead of math.ceil().
return 60 + (int(weight_kg) - 1) * 20
# The expected value repeats the code's own sum, so it repeats the code's own bug.
assert shipping_cost(1.2, "local") == 60 + (int(1.2) - 1) * 20
print("passed")
# Worked out by hand from the contract: 1.2 kg is charged as 2 kg, so 60 + 20.
assert shipping_cost(1.2, "local") == 80passed
Traceback (most recent call last):
File "/home/you/shop/rederive.py", line 11, in <module>
assert shipping_cost(1.2, "local") == 80
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorAsí es también como se ve «probar la implementación»: la comprobación refleja cómo funciona el código en lugar de establecer qué promete. Escribe el valor esperado como un simple número deducido del contrato.
El valor esperado se copió de la salida Ejecutaste la función, viste 20.0 y lo pegaste en la comprobación. Pasa; y si 20.0 era incorrecto, ahora estás protegiendo el error. Deduce cada valor esperado antes de ejecutar el código; si tu número y el del código no coinciden, averigua cuál de los dos está mal antes de escribir nada.
El plan no tiene casos límite Todas las comprobaciones pasan, y el error se esconde justo en el límite: las cinco en punto, exactamente 1 kg, la edad de 12 años. Una tabla que solo tiene filas «típicas» es la causa habitual. Por cada <, <=, «hasta», «a partir de» y «al menos» que haya en el contrato, debe existir un caso a cada lado.
La función no puede ser llamada por una comprobación EOFError: EOF when reading a line significa que la función espera datos del teclado; returned: None significa que imprimió su respuesta en lugar de devolverla; una comprobación que pasa por la mañana y falla por la tarde significa que consulta el reloj. Los tres son problemas de diseño, que se solucionan con la reestructuración de núcleo puro y capa delgada de la sección dos.
SyntaxWarning: assertion is always true, perhaps remove parentheses? Los paréntesis alrededor de un assert y su mensaje los convierten en una sola tupla, y una tupla no vacía siempre se evalúa como verdadera:
def drink_price(price, hour):
if 17 <= hour < 19:
return round(price * 0.8, 2)
return price
assert (drink_price(10, 17) == 999, "happy hour price")
print("passed?!")/home/you/cafe/tuple.py:7: SyntaxWarning: assertion is always true, perhaps remove parentheses?
assert (drink_price(10, 17) == 999, "happy hour price")
passed?!999 es claramente erróneo, y aun así la comprobación pasó. Escribe assert drink_price(10, 17) == 999, "happy hour price" —sin paréntesis— y fallará como corresponde. Aquí Python te avisa; lee la advertencia en lugar de pasar de largo.
Una comprobación que compara resultados decimales con == falla ante una respuesta correcta 0.1 + 0.2 == 0.3 es False en Python, porque 0.1 + 0.2 es 0.30000000000000004. La lección de planificación consiste en indicar en el contrato cómo se redondean los resultados, tal como hacen member_discount y drink_price. La herramienta de pytest para comparar floats es el tema del capítulo cinco.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
El requisito: «pedidos de 500 o más tienen envío gratis». ¿Qué sucede cuando se ejecuta este script?
def free_shipping(total):
return total > 500
assert free_shipping(800) is True
assert free_shipping(100) is False
print("checks passed")
assert free_shipping(500) is True
print("all done")- ASe imprime `checks passed`, y luego un `AssertionError` en el último assert
- BUn `AssertionError` en el primer assert, y no se imprime nada
- CNo ocurre nada: los asserts se ignoran en un script
- DSe imprimen `checks passed` y luego `all done`
¿Qué función se puede probar de forma fiable, tal como está, con una sola línea de assert?
- Adef is_weekend(weekday): return weekday >= 5
- Bdef greet(): print('Hi', input())
- Cdef log_visit(): VISITS.append(1)
- Ddef is_weekend(): return datetime.now().weekday() >= 5
Esta comprobación debe vigilar la regla «1.2 kg se cobra como 2 kg». ¿Qué tiene de malo?
from shipping import shipping_cost
expected = shipping_cost(1.2, "local")
assert shipping_cost(1.2, "local") == expected- ADebería usar `is` en lugar de `==`
- BLa función se debe llamar dentro de `try`/`except`
- CEl valor esperado proviene del código bajo prueba, por lo que la comprobación nunca puede fallar
- D`1.2` no es un valor límite, por lo que no vale la pena probarlo
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Un cine te envía este requisito:
«Los niños pagan menos, los jubilados tienen descuento y los bebés entran gratis. Los adultos pagan tarifa completa, 300».
Haz todo lo explicado en este capítulo, en orden, antes de escribir una sola comprobación:
- Haz las preguntas. Anota al menos cinco aspectos que la frase deja sin definir: qué significa «niños», dónde empieza «jubilado», si los límites están incluidos, cuáles son los precios, qué sucede con una edad imposible. Luego decide las respuestas, tal como lo haría el cine: menores de 3 entran gratis; de 3 a 12 pagan 150; de 13 a 64 pagan 300; 65 o más pagan 200; las edades van de 0 a 120, y cualquier valor fuera de eso lanza
ValueError. - Escribe el contrato como el docstring de una función
ticket_price(age)encinema.py. - Hazla fácil de probar: argumentos de entrada, un valor de retorno de salida, sin
input()y sinprint(). - Escribe la tabla de casos: caso, entrada, esperado, por qué. Encuentra primero las clases de equivalencia y luego coloca un caso a cada lado de cada límite. Deduce cada valor esperado a partir del contrato.
- Decide qué no vas a probar y explica por qué.
- Escribe
check_cinema.pycon un únicoassertdirecto por fila y ejecútalo.
Luego rompe la función a propósito: cambia la regla para niños de «12 o menos» a «menos de 12» (de <= a <). Antes de ejecutar, predice qué comprobación fallará. Ejecútala y compara.
Solución
1. Las preguntas, y las respuestas decididas:
| Pregunta | Respuesta | | --- | --- | | ¿Hasta qué edad entra gratis un bebé? | Edades 0, 1 y 2. A partir de los 3 años la entrada cuesta dinero. | | ¿Qué es un «niño»? | De 3 a 12 años, ambos inclusive. Precio 150. | | ¿Una persona de 13 años cuenta como niño? | No: a los 13 paga precio de adulto, 300. | | ¿Dónde empiezan los jubilados? ¿Se incluye a los 65 en sí? | A partir de 65 años. Precio 200. | | ¿Qué es una edad imposible? | Menor que 0 o mayor que 120: ValueError. | | ¿Qué devuelve? | El precio como número entero; no se imprime nada. |
2 y 3. El contrato y la función, cinema.py. Los límites son constantes con nombre, de modo que el código se lee igual que la tabla:
FREE_UNDER = 3 # ages 0-2 go free
CHILD_UP_TO = 12 # ages 3-12 pay the child price
SENIOR_FROM = 65 # ages 65 and over pay the senior price
MAX_AGE = 120
def ticket_price(age):
"""Return the price of one cinema ticket, as a whole number.
- age: a whole number of years, 0 to 120. Outside that range: ValueError.
- 0-2: 0 (free), 3-12: 150, 13-64: 300, 65 and over: 200.
- No side effects: reads nothing, prints nothing, changes nothing.
"""
if not 0 <= age <= MAX_AGE:
raise ValueError(f"age must be 0-{MAX_AGE}, got {age}")
if age < FREE_UNDER:
return 0
if age <= CHILD_UP_TO:
return 150
if age < SENIOR_FROM:
return 300
return 2004. La tabla de casos. Las clases son: inválida (menor que 0), gratis (0–2), niño (3–12), adulto (13–64), jubilado (65–120), inválida (mayor que 120). Eso da cuatro casos típicos, ocho casos límite y dos casos inválidos:
| Caso | Entrada | Esperado | Por qué | | --- | --- | --- | --- | | Bebé típico | 1 | 0 | clase gratis | | Niño típico | 8 | 150 | clase niño | | Adulto típico | 30 | 300 | clase adulto | | Jubilado típico | 80 | 200 | clase jubilado | | Recién nacido | 0 | 0 | edad válida más baja | | Última edad gratis | 2 | 0 | por debajo del límite entre gratis y niño | | Primera edad de niño | 3 | 150 | en el límite entre gratis y niño | | Última edad de niño | 12 | 150 | «12 o menos»: el límite en sí | | Primera edad de adulto | 13 | 300 | justo después del límite de niño | | Última edad de adulto | 64 | 300 | por debajo del límite de jubilado | | Primera edad de jubilado | 65 | 200 | «65 o más»: el límite en sí | | Edad válida más alta | 120 | 200 | extremo superior del rango válido | | Por debajo del rango | -1 | ValueError | justo afuera, por el extremo inferior | | Por encima del rango | 121 | ValueError | justo afuera, por el extremo superior |
5. No se prueba: una cadena o None para la edad: el contrato especifica un número entero y no promete nada más. Una edad con decimales como 12.5: lo mismo. Los precios de otros cines o el <= de Python: no nos corresponde probarlo. Y un segundo caso típico en la misma clase, como 9 junto a 8: no detectaría nada nuevo.
6. Las comprobaciones, check_cinema.py:
from cinema import ticket_price
def raises_value_error(age):
"""True if ticket_price raises ValueError for this age."""
try:
ticket_price(age)
except ValueError:
return True
return False
# One typical age from each class.
assert ticket_price(1) == 0
assert ticket_price(8) == 150
assert ticket_price(30) == 300
assert ticket_price(80) == 200
# Both sides of every edge between classes.
assert ticket_price(0) == 0
assert ticket_price(2) == 0
assert ticket_price(3) == 150
assert ticket_price(12) == 150
assert ticket_price(13) == 300
assert ticket_price(64) == 300
assert ticket_price(65) == 200
assert ticket_price(120) == 200
# Invalid input, just outside the valid range on each side.
assert raises_value_error(-1)
assert raises_value_error(121)
print("all 14 checks passed")$ python check_cinema.py
all 14 checks passedEl error intencional: if age <= CHILD_UP_TO: pasa a ser if age < CHILD_UP_TO:. Solo la edad de 12 años cambia de clase (cae en el precio de adulto), por lo que solo la comprobación para 12 debería fallar:
$ python check_cinema.py
Traceback (most recent call last):
File "/home/you/cinema/check_cinema.py", line 23, in <module>
assert ticket_price(12) == 150
^^^^^^^^^^^^^^^^^^^^^^^
AssertionErrorLa línea 23 es assert ticket_price(12) == 150, tal como se había predicho.
Por qué se hace de esta manera:
- Las preguntas fueron lo primero. «Niños» y «jubilados» no son números; cada respuesta de la primera tabla convirtió una palabra en un límite que puedes probar. Sin ellas,
12y65habrían sido meras conjeturas. - La función es pura. Entra la edad, sale el precio, nada más; por lo que cada comprobación es de una sola línea y ofrece la misma respuesta a cualquier hora del día en cualquier máquina.
- Constantes con nombre para los límites.
CHILD_UP_TO = 12establece en un solo lugar lo que dice el contrato, y las comprobaciones usan exactamente esos mismos números. - Las filas de los límites hicieron el trabajo. Las cuatro edades típicas (1, 8, 30, 80) siguen pasando con el error presente. Solo la fila de
12, el límite en sí, lo detectó. Ese es todo el argumento a favor de los valores límite en una sola ejecución. - Cada valor esperado provino del contrato. Ninguno de ellos se copió de la ejecución de
ticket_price, por lo que una respuesta errónea del código no puede colarse en las comprobaciones. - Se detuvo en el primer fallo y solo indicó la línea. Sabías qué comprobación falló porque lo habías predicho, pero no te dijo que el precio real para la edad de 12 años fue 300. Ese es el vacío que el próximo capítulo viene a solucionar.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz