Tests con unittest
En resumen: Un test unitario es código que comprueba un trozo pequeño, normalmente una función, con cada cambio; en Python se escribe con unittest y assertEqual.
Problema
El descuento que se rompió en silencio
La regla de la tienda es: el descuento va en porcentaje, y en una compra de 100 € o más se suma otro 5%. Escribes la función y la pruebas a mano:
def apply_discount(price, percent): # Descuento en porcentaje. Desde 100 € se suma otro 5%. if price > 100: percent = percent + 5 return price * (100 - percent) / 100
print(apply_discount(50, 10))print(apply_discount(200, 10))45.0170.050 € con un 10% son 45 €, y 200 € con un 15% son 170 €. Parece correcto y lo publicas. Una semana después, un cliente con una compra de exactamente 100 € paga 90 € en lugar de 85 €. La condición es price > 100, y debería ser price >= 100.
Nadie probó exactamente 100 €. Y con cada cambio en la función tienes que probar otra vez todos los casos a mano.
La idea
Código que comprueba código
En lugar de mirar la salida con tus propios ojos, escribes métodos pequeños que llaman a tu código y comparan el resultado con el esperado. Eso es un test. Ejecutas todos los tests con un solo clic, después de cada cambio, y te dicen qué se ha roto.
| Tipo | Qué comprueba | Ejemplo |
|---|---|---|
| Test unitario | un trozo pequeño, normalmente una función, separado del resto | apply_discount(100, 10) devuelve 85 |
| De integración | cómo funcionan las piezas juntas: con una base de datos, un archivo, otro servicio | el pedido se guarda en la base de datos y se vuelve a leer |
| De extremo a extremo (end-to-end) | toda la aplicación, tal como la usa una persona | el cliente añade al carrito, paga y recibe un correo |
Por qué vale la pena:
- el error se ve enseguida, mientras aún recuerdas qué cambiaste, y no cuando el cliente se queja;
- el test muestra cómo se usa el código: qué caso devuelve qué;
- puedes ordenar el código sin miedo. Si los tests siguen en verde después del cambio, el comportamiento no ha cambiado.
Más precisamente, para curiosos
Hay otros tipos de tests: funcionales (si el programa cumple los requisitos), de rendimiento (cuántas peticiones aguanta), de seguridad (si tiene agujeros) y de interfaz de usuario (los botones, los formularios). Todos se apoyan en los tests unitarios: son los más numerosos, los más rápidos y los más baratos de mantener.
Además de unittest, en Python verás a menudo pytest: una biblioteca aparte (pip install pytest) en la que los tests son funciones normales con assert. Aquí usamos unittest, porque viene con el propio Python.Míralo
El primer test
apply_discount está en el archivo discount.py. Sus tests están en un archivo aparte, test_discount.py, en la clase DiscountTest:import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase): def test_ten_percent_off(self): self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self): self.assertEqual(apply_discount(100, 10), 85.0)- La clase de tests hereda de
unittest.TestCase. Cada método cuyo nombre empieza portestes un test: unittest lo encuentra solo, por el nombre. Cómo exactamente lo verás en la última lección del módulo. self.assertEqual(a, b)compara los dos valores. Si son distintos, el test falla y dicea != b. Aquí escribimos primero lo que devolvió el código y después el valor esperado.
Ejecutamos los dos tests contra el código de la primera tarjeta:
FALLA test_extra_five_percent_from_100: 90.0 != 85.0OK test_ten_percent_offTests: 2, pasan: 1, fallan: 1El primer test pasa. El segundo falla y dice exactamente qué ha pasado: esperaba 85.0, y la función ha devuelto 90.0. Corriges la condición a price >= 100, los ejecutas otra vez y los dos están en verde:
OK test_extra_five_percent_from_100OK test_ten_percent_offTests: 2, pasan: 2, fallan: 0Cada test hace tres cosas: prepara los valores, llama al código y comprueba el resultado.
class DiscountTest(unittest.TestCase): def test_ten_percent_off(self): self.assertEqual(apply_discount(50, 10), 45.0)
def test_extra_five_percent_from_100(self): self.assertEqual(apply_discount(100, 10), 85.0)Paso a paso, en palabras
- DiscountTest tiene dos tests. unittest ejecuta cada uno por separado y enciende una luz a su lado.
- Test 1, preparar: un precio de 50 € y un 10% de descuento.
- Actuar: apply_discount(50, 10) devuelve 45.0.
- Comprobar: assertEqual compara el 45.0 esperado con el 45.0 devuelto. Son iguales, así que el test pasa.
- Test 2: exactamente 100 € y un 10%. Esperamos 85.0, porque desde 100 € hay otro 5%.
- La condición en la función es price > 100, y 100 no es mayor que 100. La función devuelve 90.0.
- Esperado 85.0, devuelto 90.0: el test falla y dice exactamente eso.
- Corriges la condición a price >= 100 y ejecutas los tests otra vez.
- Ahora 100 cumple la condición y la función devuelve 85.0.
- Esperado y devuelto coinciden. Los dos tests están en verde.
Más precisamente, para curiosos
- En el proyecto, el código y los tests son archivos uno junto a otro. Los archivos de tests empiezan por
test, para que unittest los encuentre:
discount.py el códigotest_discount.py sus tests- Se ejecutan desde la terminal. Sin
-v, unittest imprime un punto por cada test que pasa y unaFpor cada uno que falla, y después los detalles de los que fallaron:
python -m unittest busca test*.py y los ejecutapython -m unittest -v con una línea por testpython -m unittest test_discount.DiscountTest.test_ten_percent_off- Aquí, en el sitio, el código y los tests están en un solo archivo, y los tests se ejecutan a través de la interfaz de programación de unittest: un
TestResultpropio reúne el resultado de cada test, yrun_testslos imprime ordenados por nombre. El orden mantiene la salida igual en cada ejecución:
def name_of(test): # El docstring del test o el nombre del método (con el caso de subTest). return test.shortDescription() or test.id().split(".", 2)[2]
class Report(unittest.TestResult): def __init__(self): super().__init__() self.lines = [] self.failed = 0 self.with_cases = set()
def passed(self, name): self.lines.append(f"OK {name}")
def fell(self, name, err): self.failed += 1 self.lines.append(f"FALLA {name}: {err[1]}")
def addSuccess(self, test): if test.id() not in self.with_cases: self.passed(name_of(test))
def addFailure(self, test, err): self.fell(name_of(test), err)
addError = addFailure
def addSubTest(self, test, subtest, err): self.with_cases.add(test.id()) name = subtest.id().split(".", 2)[2] if err is None: self.passed(name) else: self.fell(name, err)
def run_tests(*tests): loader = unittest.TestLoader() suite = unittest.TestSuite() for t in tests: suite.addTest(t if isinstance(t, unittest.TestSuite) else loader.loadTestsFromTestCase(t)) report = Report() suite.run(report) for line in sorted(report.lines): print(line) total = len(report.lines) print(f"Tests: {total}, pasan: {total - report.failed}, fallan: {report.failed}")Pruébalo
IVA con float
El precio con IVA es el precio por 1,2. Dos tests:
¿Qué mostrarán los tests?
def with_vat(price): return price * 1.2
class VatTest(unittest.TestCase): def test_coffee(self): self.assertEqual(with_vat(10), 12.0)
def test_tea(self): self.assertEqual(with_vat(3), 3.6)3 por 1,2 en un float no es exactamente 3,6. Los números decimales se guardan en binario, y en las cuentas se pierde una parte diminuta. assertEqual compara de forma exacta, así que el test falla:
FALLA test_tea: 3.5999999999999996 != 3.6OK test_coffeeTests: 2, pasan: 1, fallan: 1assertAlmostEqual con una diferencia permitida: self.assertAlmostEqual(with_vat(3), 3.6, delta=0.001) pasa, porque la diferencia es menor que 0,001. Y 12.0 != 12 no fallaría: en Python 12.0 == 12 es verdadero.La idea
Sí o no, y una excepción esperada
Además de la igualdad, a menudo compruebas una condición, o que el código se niega. Aquí está Account del módulo «Errores y excepciones», con un método nuevo, can_pay:
class InsufficientFundsError(Exception): def __init__(self, shortfall): super().__init__(f"Faltan {shortfall} €")
class Account: def __init__(self, balance): self._balance = balance
@property def balance(self): return self._balance
def can_pay(self, amount): return amount <= self._balance
def withdraw(self, amount): if amount <= 0: raise ValueError("El importe debe ser positivo") if amount > self._balance: raise InsufficientFundsError(amount - self._balance) self._balance -= amountimport unittest
from account import Account, InsufficientFundsError
class AccountTest(unittest.TestCase): def test_can_pay_what_is_there(self): account = Account(100) self.assertTrue(account.can_pay(100)) self.assertFalse(account.can_pay(101))
def test_withdraw_too_much(self): account = Account(100) with self.assertRaises(InsufficientFundsError) as caught: account.withdraw(130) self.assertEqual(str(caught.exception), "Faltan 30 €") self.assertEqual(account.balance, 100)OK test_can_pay_what_is_thereOK test_withdraw_too_muchTests: 2, pasan: 2, fallan: 0assertTrueyassertFalsecomprueban una condición.with self.assertRaises(Tipo):falla si el código del bloque no lanza una excepción de ese tipo. Conas caughtobtienes la propia excepción:caught.exception, ystr()de ella es el mensaje.
El segundo test comprueba también algo que se olvida fácilmente: después de la negativa, el saldo es el mismo.
La idea
Cada test empieza de cero
Aquí los dos tests comparten una cuenta con 100 €:
class AccountTest(unittest.TestCase): account = Account(100)
def test_withdraw_40(self): """retirar 40 € deja 60 €""" self.account.withdraw(40) self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self): """retirar 70 € deja 30 €""" self.account.withdraw(70) self.assertEqual(self.account.balance, 30)FALLA retirar 70 € deja 30 €: Faltan 10 €OK retirar 40 € deja 60 €Tests: 2, pasan: 1, fallan: 1account es un atributo de la clase: un objeto para todos los tests. unittest ejecuta los tests por orden alfabético de sus nombres, así que el test de 40 € va primero. Al segundo le quedan 60 €, y retira 70 €. Basta con que alguien renombre un test o añada uno nuevo, y falla otro. Un test que depende de otro es una trampa. Por eso la cuenta se crea de nuevo antes de cada test, en el método setUp:
import unittest
from account import Account
class AccountTest(unittest.TestCase): def setUp(self): self.account = Account(100)
def test_withdraw_40(self): """retirar 40 € deja 60 €""" self.account.withdraw(40) self.assertEqual(self.account.balance, 60)
def test_withdraw_70(self): """retirar 70 € deja 30 €""" self.account.withdraw(70) self.assertEqual(self.account.balance, 30)OK retirar 40 € deja 60 €OK retirar 70 € deja 30 €Tests: 2, pasan: 2, fallan: 0setUp antes, así que self.account es una cuenta nueva cada vez. La cadena debajo de def (el docstring) describe el test con palabras: su primera línea se muestra en el resultado en lugar del nombre del método.Más precisamente, para curiosos
setUp / tearDown antes / después de cada test (limpieza)setUpClass / tearDownClass una vez para toda la clase (@classmethod)@unittest.skip("...") el test no se ejecuta por ahora@unittest.skipIf(condición, "...") se salta si la condición es verdadera@unittest.expectedFailure se espera que fallewith self.subTest(...): un caso aparte dentro de un testself.addCleanup(función) limpieza después del test, aunque haya falladopython -m unittest -k withdraw solo los tests con «withdraw» en el nombrepython -m unittest -f se detiene en el primer test que fallaLa idea
Una comprobación, muchos casos
Cuatro casos para el descuento son cuatro métodos casi iguales. En su lugar escribes un método con un bucle sobre los casos «precio, porcentaje, esperado». with self.subTest(...) convierte cada caso en una comprobación aparte:
import unittest
from discount import apply_discount
class DiscountTest(unittest.TestCase): def test_apply(self): cases = [ (50, 10, 45.0), (99, 0, 99.0), (100, 10, 85.0), (200, 20, 150.0), ] for price, percent, expected in cases: with self.subTest(f"{price} € con {percent}% = {expected} €"): self.assertEqual(apply_discount(price, percent), expected)OK test_apply [100 € con 10% = 85.0 €]OK test_apply [200 € con 20% = 150.0 €]OK test_apply [50 € con 10% = 45.0 €]OK test_apply [99 € con 0% = 99.0 €]Tests: 4, pasan: 4, fallan: 0
Cada caso tiene su propio resultado, y el texto de subTest es su nombre. Sin subTest, el primer caso que falla detiene todo el método y no sabes si los demás pasan. Un caso nuevo es una fila nueva en la lista, no un método nuevo.
Los casos importantes están en los bordes: exactamente 100 €, 99 €, 0%.
Pruébalo
Encuentra el error con el test
El envío cuesta 4 €, y desde 50 € es gratis. El test:
class DeliveryTest(unittest.TestCase): def test_price(self): for total, expected in [(20, 4), (50, 0), (80, 0)]: with self.subTest(f"pedido de {total} €: envío {expected} €"): self.assertEqual(delivery_price(total), expected)FALLA test_price [pedido de 50 €: envío 0 €]: 4 != 0OK test_price [pedido de 20 €: envío 4 €]OK test_price [pedido de 80 €: envío 0 €]Tests: 3, pasan: 2, fallan: 1¿En qué línea del código está el error?
Elige la línea con el error.
El test que falla dice: en un pedido de exactamente 50 €, el envío es de 4 €, y debería ser 0. Es decir, 50 no cumple la condición. total > 50 deja fuera el propio 50. Debería ser total >= 50. El test señala el caso, y el código muestra la causa.
La idea
Primero el test
La caja tiene que aceptar el código promocional OTOÑO10 para un 10%, da igual si está en minúsculas o en mayúsculas. Esta vez escribes primero los tests:
class PromoTest(unittest.TestCase): def test_known_code(self): self.assertEqual(promo_percent("OTOÑO10"), 10)
def test_code_is_case_insensitive(self): self.assertEqual(promo_percent("otoño10"), 10)
def test_unknown_code(self): self.assertEqual(promo_percent("NINGUNO"), 0)- Rojo. Por ahora la función devuelve 0. Los tests fallan, y eso está bien: significa que de verdad comprueban algo.
def promo_percent(code): return 0FALLA test_code_is_case_insensitive: 0 != 10FALLA test_known_code: 0 != 10OK test_unknown_codeTests: 3, pasan: 1, fallan: 2- Verde. Escribes el código más simple con el que pasan:
def promo_percent(code): if code.upper() == "OTOÑO10": return 10 return 0OK test_code_is_case_insensitiveOK test_known_codeOK test_unknown_codeTests: 3, pasan: 3, fallan: 0- Ordenar. Ahora puedes mover los códigos a un diccionario (dict), añadir otros nuevos y ordenar la función. Los tests te avisarán si rompes algo.
Esto es TDD (test-driven development, desarrollo guiado por tests). Escribir primero el test te obliga a decidir qué tiene que hacer exactamente la función, antes de pensar en cómo.
Más precisamente
Suites y objetos falsos
Una suite (unittest.TestSuite) es un grupo de tests que se ejecutan juntos, por ejemplo todos los tests de la caja. La función la arma con las tres clases de tests, y TextTestRunner la ejecuta:
def checkout_suite(): loader = unittest.TestLoader() suite = unittest.TestSuite() suite.addTests(loader.loadTestsFromTestCase(DiscountTest)) suite.addTests(loader.loadTestsFromTestCase(AccountTest)) suite.addTests(loader.loadTestsFromTestCase(DeliveryTest)) return suite
unittest.TextTestRunner().run(checkout_suite())OK retirar 40 € deja 60 €OK retirar 70 € deja 30 €OK test_extra_five_percent_from_100OK test_price [pedido de 20 €: envío 4 €]OK test_price [pedido de 50 €: envío 0 €]OK test_price [pedido de 80 €: envío 0 €]OK test_ten_percent_offTests: 7, pasan: 7, fallan: 0La salida es del ejecutor del sitio. python -m unittest hace lo mismo sin suite: reúne él solo los tests de todos los archivos test*.py.
Más precisamente, para curiosos
- A veces el código depende de algo que no quieres llamar en un test: el tipo de cambio de un banco, un pago, un correo. Entonces le pasas un objeto falso (mock) que devuelve lo que tú le digas. En Python está en la biblioteca estándar:
unittest.mock.Mock:
import unittestfrom unittest.mock import Mock
class Converter: def __init__(self, rates): self.rates = rates
def to_usd(self, eur): return eur * self.rates.eur_to("USD")
class PriceInUsdTest(unittest.TestCase): def test_converts_with_todays_rate(self): rates = Mock() rates.eur_to.return_value = 1.10
converter = Converter(rates) self.assertAlmostEqual(converter.to_usd(10), 11.0, delta=0.001) rates.eur_to.assert_called_once_with("USD")OK test_converts_with_todays_rateTests: 1, pasan: 1, fallan: 0Mock() es un objeto que acepta cualquier método. return_value dice qué devuelve eur_to, y assert_called_once_with comprueba que se llamó exactamente una vez y con "USD". No hace falta una interfaz: Converter llama a eur_to sobre lo que le pases (duck typing).
- Más comprobaciones además de assertEqual, assertTrue y assertRaises:
assertNotEqual(a, b) si son distintosassertIs(a, b), assertIsNot(a, b) si es el mismo objeto (is), no solo igualesassertIsNone(x), assertIsNotNone(x)assertIn(x, items), assertNotIn(x, items)assertCountEqual(a, b) los mismos elementos, en cualquier ordenassertListEqual(a, b) dos listas con los mismos elementos en el mismo ordenassertRegex(text, pattern) el texto coincide con una expresión regularassertGreater(a, b), assertLess(a, b)assertAlmostEqual(a, b, delta=0.01)self.fail("mensaje") el test falla al instanteEjercicio
Escribe los tests
Resuelve los ejercicios en tu editor, con unittest (viene con el propio Python).
1. La calculadora de descuentos. Escribe test_discount.py para apply_discount de la primera tarjeta. Cubre estos casos: 80 € con un 10% son 72 €, 100 € con un 0% son 95 €, 150 € con un 20% son 112,50 €, 0 € con un 30% son 0 € y exactamente 100 € con un 10% son 85 €. Ejecútalos también contra la versión rota con price > 100. Tienen que fallar exactamente los dos casos con 100 €:
FALLA test_apply [100 € con 0% = 95.0 €]: 100.0 != 95.0 within 0.001 delta (5.0 difference)FALLA test_exactly_100_gets_the_extra: 90.0 != 85.0 within 0.001 delta (5.0 difference)OK test_apply [0 € con 30% = 0.0 €]OK test_apply [150 € con 20% = 112.5 €]OK test_apply [80 € con 10% = 72.0 €]Tests: 5, pasan: 3, fallan: 22. La cuenta. Escribe AccountTest para Account de la tarjeta «Sí o no, y una excepción esperada». Crea la cuenta con 100 € en setUp. Comprueba que:
- retirar 30 € deja 70 €;
- se puede retirar todo, y queda 0;
- retirar 150 € lanza
InsufficientFundsErrorcon el mensaje «Faltan 50 €», y el saldo sigue en 100 €; - 0 y -20 € lanzan
ValueError(un test con subTest para los dos importes).
3. El error en la nota media. La función parece estar bien:
def average(grades): total = 0 for grade in grades: total += grade return total // len(grades)FALLA test_average_with_a_fraction: 5 != 5.5 within 0.001 delta (0.5 difference)OK test_whole_averageTests: 2, pasan: 1, fallan: 1Encuentra la causa y corrige la función, para que pasen los dos.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: dict y set · Clases anidadas y datos · Lambdas · Listas por comprensión y generadores
- Continúa en: Hilos · Reflexión y decoradores · Las pruebas como red de seguridad · Refactorización en pasos pequeños
- Ejercicios sobre el tema: Programas en el mundo real