Pruebas unitarias: primeros tests y diffs de fallos
Tu primer archivo unittest en Python: casos borde en los límites, diffs de fallos reales, doctest, Dart test sin red y el costo medido de 10,000 tests.
Serie: curso de pruebas y depuración 3/6 (intermedio). Para quien ya sabe leer un stack trace y usar un depurador (lecciones 1 y 2), pero nunca escribió un archivo de pruebas propio. Al terminar vas a poder escribir un archivo unittest desde cero, elegir casos mirando los límites, leer un diff de fallo sin adivinar y decidir qué no vale la pena probar.
Si saliste de un bootcamp, conseguiste tu primer trabajo y tu primer pull request volvió con un solo comentario, "¿y los tests?", esta lección es para ti. También es para quien trabaja como freelance y tuvo que explicarle a un cliente por qué un cálculo de cobro salió mal en producción. Todo lo de abajo se ejecutó con Python 3.9.6 (parte del comportamiento también se revisó en 3.12.14) y Dart 3.11 en una Mac con Apple Silicon (M1 Pro); las rutas en las salidas se acortaron a ~/demo/.
Lo principal, todo medido más abajo: un archivo de pruebas con un error en el nombre ejecutó cero tests y aun así imprimió OK en Python 3.9; pensar en los límites encontró cuatro entradas que los tests del camino feliz nunca tocaban; y una suite de 10,000 tests pequeños tardó 286 ms, así que la velocidad nunca es excusa para no escribir tests pequeños.
Tu primer archivo de pruebas en cinco minutos
Primero, tres términos. Una prueba unitaria (unit test) llama a una pieza pequeña de código (normalmente una función) con entradas conocidas y revisa la salida. Una aserción es esa revisión: "este valor debe ser igual a este otro". Un test runner encuentra tus pruebas, las ejecuta y reporta. Python trae uno en la biblioteca estándar, unittest, así que no hay nada que instalar.
Este es el código que vamos a probar: divide entre varias personas la cuenta de la pizza del bootcamp. El dinero se guarda en centavos enteros, porque un float no puede representar 0.1 de forma exacta.
# bill.py
"""Dividir la cuenta de la pizza del bootcamp. Montos en centavos (enteros)."""
FREE_SHIPPING_FROM = 99900 # $999.00
SHIPPING_FEE = 9900 # $99.00
def split_bill(total_cents, people):
"""Divide total_cents en partes enteras que suman exactamente el total.
>>> split_bill(1000, 3)
[334, 333, 333]
>>> split_bill(900, 3)
[300, 300, 300]
>>> split_bill(10.0, 3)
Traceback (most recent call last):
...
TypeError: total_cents must be an int, got float
"""
if not isinstance(total_cents, int):
raise TypeError(f"total_cents must be an int, got {type(total_cents).__name__}")
if people < 1:
raise ValueError(f"people must be >= 1, got {people}")
base, extra = divmod(total_cents, people)
return [base + 1] * extra + [base] * (people - extra)
def shipping_fee(subtotal_cents):
return 0 if subtotal_cents >= FREE_SHIPPING_FROM else SHIPPING_FEE
def format_share(name, cents):
return f"{name} paga ${cents // 100}.{cents % 100:02d}"
Y el archivo de pruebas al lado:
# test_bill.py
import doctest
import unittest
import bill
from bill import format_share, shipping_fee, split_bill
class SplitBillTest(unittest.TestCase):
def test_even_total_gives_equal_shares(self):
# Arrange (preparar)
total, people = 90000, 3
# Act (actuar)
shares = split_bill(total, people)
# Assert (verificar)
self.assertEqual(shares, [30000, 30000, 30000])
def test_remainder_goes_to_first_people(self):
self.assertEqual(split_bill(100000, 3), [33334, 33333, 33333])
def test_shares_always_add_up_to_total(self):
for total in (0, 1, 2, 99, 100000, 100001):
with self.subTest(total=total):
self.assertEqual(sum(split_bill(total, 3)), total)
def test_zero_people_is_rejected(self):
with self.assertRaises(ValueError):
split_bill(1000, 0)
def test_float_total_is_rejected_clearly(self):
with self.assertRaisesRegex(TypeError, "total_cents must be an int"):
split_bill(10.0, 3)
def test_negative_total_still_adds_up(self):
self.assertEqual(split_bill(-100, 3), [-33, -33, -34])
class ShippingFeeTest(unittest.TestCase):
def test_one_centavo_below_threshold_pays_fee(self):
self.assertEqual(shipping_fee(99899), 9900)
def test_exactly_threshold_is_free(self):
self.assertEqual(shipping_fee(99900), 0)
class FormatShareTest(unittest.TestCase):
def test_pads_centavos(self):
self.assertEqual(format_share("Lucía", 3305), "Lucía paga $33.05")
def load_tests(loader, tests, ignore):
tests.addTests(doctest.DocTestSuite(bill))
return tests
if __name__ == "__main__":
unittest.main()
Ejecútalo desde la carpeta que tiene ambos archivos:
$ python3 -m unittest -v
test_pads_centavos (test_bill.FormatShareTest) ... ok
test_exactly_threshold_is_free (test_bill.ShippingFeeTest) ... ok
test_one_centavo_below_threshold_pays_fee (test_bill.ShippingFeeTest) ... ok
test_even_total_gives_equal_shares (test_bill.SplitBillTest) ... ok
test_float_total_is_rejected_clearly (test_bill.SplitBillTest) ... ok
test_negative_total_still_adds_up (test_bill.SplitBillTest) ... ok
test_remainder_goes_to_first_people (test_bill.SplitBillTest) ... ok
test_shares_always_add_up_to_total (test_bill.SplitBillTest) ... ok
test_zero_people_is_rejected (test_bill.SplitBillTest) ... ok
split_bill (bill)
Doctest: bill.split_bill ... ok
----------------------------------------------------------------------
Ran 10 tests in 0.002s
OK
Diez tests: nueve métodos más el doctest que agrega load_tests (lo vemos más abajo). Fíjate en el orden: las clases y los métodos corren en orden alfabético, no en el orden del archivo. Nunca escribas un test que dependa de que otro haya corrido antes.
La trampa del nombre: "Ran 0 tests" y OK
python3 -m unittest sin argumentos usa descubrimiento automático (discovery): importa los archivos que coinciden con test*.py y ejecuta los métodos cuyo nombre empieza con test. Si nombras el archivo bill_tests.py y el método check_remainder, obtienes esto, aunque la aserción de adentro esté mal a propósito:
$ python3 -m unittest # Python 3.9.6
----------------------------------------------------------------------
Ran 0 tests in 0.000s
OK
$ echo $?
0
$ python3.12 -m unittest # Python 3.12.14, mismos archivos
----------------------------------------------------------------------
Ran 0 tests in 0.000s
NO TESTS RAN
$ echo $?
5
Cambiar solo el nombre del archivo a test_bill.py siguió dando 0 tests, porque el nombre del método también estaba mal. En 3.9, un pipeline de CI se quedaría en verde para siempre. Costumbre sana: cada vez que agregas un test, hazlo fallar una vez a propósito y confirma que de verdad falla.
Arrange, act, assert, y nombres que se leen como frases
test_even_total_gives_equal_shares muestra la forma en tres partes que tienen casi todos los buenos tests:
- Arrange (preparar): arma las entradas (y, en la próxima lección, los colaboradores falsos).
- Act (actuar): llama una sola vez a lo que estás probando.
- Assert (verificar): revisa el resultado.
No vas a escribir los comentarios siempre, pero si un test tiene dos pasos de "act", casi siempre son dos tests. El nombre debe decir qué, bajo qué condición y qué se espera: test_one_centavo_below_threshold_pays_fee te dice cuál es el bug antes de abrir el archivo. test_shipping_2 no te dice nada cuando el cliente te escribe un domingo.
subTest es la herramienta para "la misma verificación con muchas entradas". Sin él, el ciclo se detiene en el primer valor que falla; con él, cada valor se reporta por separado, como vas a ver en la salida de fallos.
Encontrar casos borde pensando en los límites
Los primeros tests que uno escribe siguen el camino feliz: $900 entre tres personas. Los bugs viven en los bordes. Antes de escribir más tests, probé la primera versión de split_bill (la que no tenía el chequeo con isinstance) con entradas elegidas en los límites: una persona, menos centavos que personas, cero, negativo y tipos equivocados.
split_bill(1000, 1) -> [1000]
split_bill(2, 3) -> [1, 1, 0]
split_bill(0, 3) -> [0, 0, 0]
split_bill(-100, 3) -> [-33, -33, -34]
split_bill(1000, True) -> [1000]
split_bill(1000, 2.5) -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(1000.0, 3) -> TypeError: can't multiply sequence by non-int of type 'float'
split_bill(1000, '3') -> TypeError: '<' not supported between instances of 'str' and 'int'
split_bill(1000, 0) -> ValueError: people must be >= 1, got 0
Cuatro hallazgos que los tests del camino feliz nunca habrían mostrado:
| Entrada | Qué pasó | Decisión |
|---|---|---|
(2, 3) |
[1, 1, 0]: alguien no paga nada |
correcto; el total cuadra |
(-100, 3) |
un reembolso, y la suma sigue siendo -100 | se queda; lo fijamos con un test |
(1000.0, 3) |
un TypeError sobre "multiply sequence" que apunta al problema equivocado |
rechazar floats con un mensaje claro |
(1000, True) |
True cuenta como 1 persona, porque bool es subclase de int |
aceptado; documentado, sin test |
El caso del float es el realista: alguien pasa pesos desde un formulario (10.0) en lugar de centavos. El mensaje de error habla de secuencias, así que quien depura empieza a buscar en el lugar equivocado. Por eso split_bill ahora revisa el tipo, y por eso test_float_total_is_rejected_clearly revisa el mensaje y no solo la clase de la excepción.
Una lista rápida de límites: cero, uno, muchos; justo debajo, exactamente en y justo arriba de un umbral (99899, 99900); entrada vacía; negativo; el tipo equivocado; valores muy grandes. Los tests de envío usan justo el par 99899/99900, porque >= contra > es el bug de un carácter más común en código de precios.
Cómo leer un test que falla
Ahora rompe el código de tres maneras pequeñas, del tipo que se le escapa a un revisor cansado: reemplaza la lógica de divmod por [total_cents // people] * people, cambia >= por > en shipping_fee y quita el relleno :02d. La salida real, recortada:
$ python3 -m unittest
FF...FF.F
======================================================================
FAIL: test_pads_centavos (test_bill.FormatShareTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "~/demo/bill/test_bill.py", line 47, in test_pads_centavos
self.assertEqual(format_share("Lucía", 3305), "Lucía paga $33.05")
AssertionError: 'Lucía paga $33.5' != 'Lucía paga $33.05'
- Lucía paga $33.5
+ Lucía paga $33.05
? +
======================================================================
FAIL: test_exactly_threshold_is_free (test_bill.ShippingFeeTest)
...
AssertionError: 9900 != 0
======================================================================
FAIL: test_negative_total_still_adds_up (test_bill.SplitBillTest)
...
AssertionError: Lists differ: [-34, -34, -34] != [-33, -33, -34]
First differing element 0:
-34
-33
- [-34, -34, -34]
? ^ ^
+ [-33, -33, -34]
? ^ ^
======================================================================
FAIL: test_shares_always_add_up_to_total (test_bill.SplitBillTest) (total=1)
...
AssertionError: 0 != 1
...
----------------------------------------------------------------------
Ran 10 tests in 0.002s
FAILED (failures=9)
Cómo leerla, de arriba abajo:
- La línea de progreso
FF...FF.Ftiene un carácter por test:.pasó,Ffalló,Eerror. Son 9 caracteres para 10 tests y aun así el resumen dice 9 fallos: los fallos desubTestse cuentan uno por cada valor que falla (total=1, 2, 100000 y 100001), pero comparten un solo carácter. - El traceback termina en tu línea de test, no dentro de
unittest. La línea 47 detest_bill.pyes donde hay que mirar. first != second:unittestllama a sus dos argumentos first y second, no "real" y "esperado". Como siempre escribimosassertEqual(real, esperado), la línea con-es lo que devolvió el código y la línea con+es lo que queríamos. Mantén ese orden en todos tus tests, o el diff te va a mentir.- La línea con
?apunta a los caracteres exactos que cambian: un+debajo del0que falta, dos^debajo de los dígitos cambiados. Los acentos como la "í" de Lucía ocupan una sola columna, así que aquí el marcador cae en el lugar correcto. - Las listas muestran "First differing element 0" antes del diff. Aquí el caso negativo revela un segundo bug que a cualquier cliente le importaría: la división entera hacia abajo convierte un reembolso de 100 centavos en 102.
(total=1)después del nombre del test es la etiqueta desubTest. SinsubTestsolo verías el primer total que falla.
El código de salida fue 1, que es lo que revisa el CI.
FAIL contra ERROR
Quita la guarda people < 1 y el chequeo de tipo, y aparecen dos letras distintas:
....F...EF
======================================================================
ERROR: test_zero_people_is_rejected (test_bill.SplitBillTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "~/demo/bill/test_bill.py", line 27, in test_zero_people_is_rejected
split_bill(1000, 0)
File "~/demo/bill/bill.py", line 19, in split_bill
base, extra = divmod(total_cents, people)
ZeroDivisionError: integer division or modulo by zero
======================================================================
FAIL: test_float_total_is_rejected_clearly (test_bill.SplitBillTest)
----------------------------------------------------------------------
TypeError: can't multiply sequence by non-int of type 'float'
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "~/demo/bill/test_bill.py", line 31, in test_float_total_is_rejected_clearly
split_bill(10.0, 3)
AssertionError: "total_cents must be an int" does not match "can't multiply sequence by non-int of type 'float'"
FAIL significa que una aserción se evaluó y resultó falsa. ERROR significa que el test se cayó antes de poder decidir: assertRaises(ValueError) solo atrapa ValueError, así que el ZeroDivisionError se escapó. El segundo bloque es una excepción encadenada (lección 1): primero aparece el TypeError original y luego la aserción que rechazó su mensaje.
doctest: ejemplos que no se quedan viejos
Las líneas >>> del docstring de split_bill son un doctest: Python las ejecuta y compara lo que imprime con la línea de abajo. Ejecútalas solas con python3 -m doctest bill.py (no imprime nada si todo pasa y sale con código 0) o agrega -v. La función load_tests del archivo de pruebas las suma a la corrida normal de unittest.
Cuando cambié la lista esperada del docstring a [333, 333, 334]:
**********************************************************************
File "~/demo/bill/bill.py", line 10, in bill.split_bill
Failed example:
split_bill(1000, 3)
Expected:
[333, 333, 334]
Got:
[334, 333, 333]
**********************************************************************
1 items had failures:
1 of 3 in bill.split_bill
***Test Failed*** 1 failures.
Usa doctest para ejemplos cortos que además sirven de documentación. Evítalo para cualquier cosa cuya forma impresa sea inestable: el orden de un dict que viene de datos externos, floats con colas largas, direcciones de objetos como <Foo at 0x10...>. Eso va en unittest.
Una trampa con la que me topé al preparar esto: edité el docstring dos veces en menos de un segundo, y la segunda edición dejó el archivo con el mismo tamaño. Python reutilizó el bytecode en caché (.pyc), porque la fecha de modificación y el tamaño registrados coincidían, así que el docstring viejo siguió fallando después de la corrección. Borrar __pycache__ no sirvió, porque este Python guarda los .pyc bajo un prefijo de caché aparte. Hacer touch al archivo un segundo después lo arregló. A mano casi nunca te va a pasar, pero a los scripts que reescriben código fuente sí.
El mismo test en Dart, sin red
¿Se puede correr package:test de Dart sin acceso a la red? La condición era que dart pub get --offline resolviera todo desde la caché local de paquetes. Lo hizo en 1.0 s y resolvió test 1.30.0:
// lib/pricing.dart
int shippingFee(int subtotal) {
if (subtotal < 0) throw ArgumentError.value(subtotal, 'subtotal');
return subtotal >= 5000 ? 0 : 500;
}
// test/pricing_test.dart
import 'package:pricing/pricing.dart';
import 'package:test/test.dart';
void main() {
group('shippingFee', () {
test('charges 500 just below the threshold', () {
expect(shippingFee(4999), 500);
});
test('is free exactly at the threshold', () {
expect(shippingFee(5000), 0);
});
test('rejects a negative subtotal', () {
expect(() => shippingFee(-1), throwsArgumentError);
});
});
}
Con >= cambiado por >, dart test reportó (código de salida 1):
00:00 +1 -1: test/pricing_test.dart: shippingFee is free exactly at the threshold [E]
Expected: <0>
Actual: <500>
package:matcher expect
test/pricing_test.dart 10:7 main.<fn>.<fn>
00:00 +2 -1: Some tests failed.
El matcher de Dart etiqueta los lados como Expected y Actual, así que el orden de los argumentos importa menos que en unittest.
Si package:test no está disponible sin red, la alternativa es un assert simple, pero revisa antes cómo lo trata tu entorno. En esta prueba, los tres casos siguientes llegaron hasta la última línea sin ningún error:
| Entorno | El assert simple se ignora cuando | Visto aquí |
|---|---|---|
| Dart | dart run file.dart (los asserts están apagados sin --enable-asserts) |
sí, se salta en silencio |
| Python | python3 -O file.py (se eliminan las sentencias assert) |
sí, se salta en silencio |
| PHP 8.5 | php -d zend.assertions=-1 (la configuración típica de producción) |
sí, se salta en silencio |
Dentro de dart test los asserts sí están activos: un test que esperaba AssertionError pasó. PHPUnit no está instalado en esta máquina, así que en PHP solo se ejecutó assert con zend.assertions=1.
¿Cuánto tarda una suite a medida que crece?
Una excusa común es "los tests son lentos". Generé archivos con N tests triviales (cada uno llama una vez a split_bill) y medí python3 -m unittest como proceso completo: 6 corridas por tamaño, descartando la primera (en frío) y tomando la mediana de las otras 5. Python 3.9.6, Mac con Apple Silicon (M1 Pro).
| Tests (N) | Tiempo total del proceso | "Ran N tests in" |
|---|---|---|
| 1 | 48.4 ms | 0.0 ms |
| 10 | 46.1 ms | 0.0 ms |
| 100 | 52.7 ms | 1.0 ms |
| 1,000 | 69.8 ms | 19.0 ms |
| 10,000 | 285.9 ms | 192.0 ms |
Hasta 100 tests estás midiendo el arranque de Python (unos 45 ms). Después, cada test extra cuesta unos 19 a 24 microsegundos. Con -v, la corrida de 10,000 tests tardó 432 ms en vez de 272 ms (una medición aparte): imprimir una línea por test cuesta más que ejecutarlo.
El mismo experimento con dart test (3 corridas por tamaño después de un calentamiento): de 0.91 a 0.95 s para 1 test, de 1.07 a 1.24 s para 100, de 1.84 a 1.94 s para 1,000 y de 6.3 a 6.4 s para 10,000. El costo fijo de Dart es mayor (compila y arranca un isolate), y su reporter hace más trabajo por test.
La conclusión: los tests unitarios pequeños son baratos. Lo que vuelve lenta una suite son unos pocos tests que duermen, tocan la red o una base de datos real. La lección 4 muestra cómo reemplazarlos.
Qué NO probar
Los tests son código que tienes que mantener. Estos casi siempre cuestan más de lo que devuelven:
- El lenguaje y la biblioteca estándar. Un test de que
divmod(7, 2) == (3, 1)prueba Python, no tu código. - Funciones auxiliares privadas, directamente. Pruébalas a través de la función pública. Así puedes refactorizar la auxiliar sin reescribir tests.
- Textos exactos que no son tuyos. Comparar contra el mensaje de Python "can't multiply sequence…" se rompería en la siguiente versión de Python. Nuestro test compara contra el mensaje que nosotros escribimos.
- Código trivial sin lógica, como una propiedad que devuelve un campo.
- La implementación en lugar del resultado. "
split_billllama adivmoduna vez" se rompe con refactorizaciones inofensivas. "Las partes suman el total" sobrevive a ellas. - Salidas aleatorias o que dependen de la hora, tal cual. Fija primero la semilla o el reloj (lección 5).
Lo que siempre conviene probar: cada bug que corriges (primero el test que falla, luego la corrección), los límites de cada regla que tenga un número y el manejo de errores del que dependen quienes llaman a tu código.
Límites honestos y notas de versión
- Python 3.9.6 fue el entorno principal; Python 3.12.14 se usó solo para comparar mensajes. El código de salida 5 de
python -m unittestcuando no corre ningún test y la opción--durationsson nuevos en 3.12; en 3.9,--durationsfalló conunrecognized arguments. La primera corrida con--durationsen 3.12 mostró 0.335 s para el doctest (arranque en frío); las siguientes mostraron entre 0.001 y 0.002 s. assertRaisesRegexysubTestexisten desde Python 3.2 y 3.4.- Dart 3.11.5 con
test 1.30.0desde la caché local de pub; no se descargó nada. Otras instalaciones de Dart pueden resolver otra versión. - Los tiempos son de una sola Mac con Apple Silicon (M1 Pro). Los números absolutos van a cambiar en tu máquina; la forma (un costo fijo de arranque y luego unos microsegundos por test pequeño) no debería.
- pytest es el runner de terceros más popular y tiene diffs más amables, pero no está instalado aquí y no se ejecutó.
Ejercicios
- Agrega un test de que
split_bill(5, 5)devuelve cinco partes de 1, y otro de quesplit_bill(4, 5)devuelve[1, 1, 1, 1, 0]. Antes de correrlos, predice si pasan. - Escribe un test que falle si alguien cambia
>=por>enshipping_fee, y explica por quéshipping_fee(99899)por sí solo no puede atrapar ese bug.
Respuestas
- Los dos pasan.
divmod(5, 5)es(1, 0), así que no sobra ningún centavo;divmod(4, 5)es(0, 4), así que las primeras cuatro personas reciben0 + 1y la última0. Si tu predicción falló, anota por qué: ese es el límite en el que no habías pensado. self.assertEqual(shipping_fee(99900), 0). Con99899, tanto>=como>dan la misma respuesta (9900), así que el test no puede distinguirlos. Solo el valor exactamente en el umbral separa los dos operadores.
Para seguir
- Anterior: Lección 2: print, logging y el depurador
- Siguiente: Lección 4: dobles de prueba, mocks y fixtures
- Inicio de la serie: Lección 1: leer mensajes de error y stack traces
- Más adelante: tests inestables y git bisect
- Relacionado: Python: funciones, ámbito y argumentos por defecto (los argumentos por defecto mutables son un clásico para probar), profiling y optimización en Python (en inglés) y la ruta de aprendizaje de programación para ver dónde encaja el testing.
Preguntas frecuentes
¿Por qué unittest dice 'Ran 0 tests' y aun así OK?
El descubrimiento automático solo toma archivos llamados test*.py y métodos que empiezan con test. Un archivo bill_tests.py con un método check_remainder ejecutó cero tests, y Python 3.9 imprimió OK con código de salida 0. Python 3.12 imprime NO TESTS RAN y sale con código 5, que el CI toma como fallo.
En el diff de un fallo de unittest, ¿cuál lado es el valor esperado?
unittest no los nombra: los llama first y second. Si siempre escribes assertEqual(real, esperado), las líneas con - son lo que devolvió tu código y las líneas con + son lo que esperabas. La línea con ? marca exactamente los caracteres que difieren.
¿Cuánto tarda una suite de unittest a medida que crece?
En una Mac con Apple Silicon (M1 Pro) y Python 3.9, entre 46 y 53 ms para 1 a 100 tests pequeños (casi todo es el arranque del intérprete), 70 ms para 1,000 y 286 ms para 10,000: unos 20 microsegundos por test extra. Un solo test lento que toca la red cuesta más que miles de tests rápidos.