Ir al contenido
wannacode

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:

main.py
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))
Salida
45.0
170.0

50 € 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
En esta lección escribimos tests unitarios. Python tiene para ellos el módulo unittest en la biblioteca estándar: no hay nada que instalar.

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:
test_discount.py
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 por test es 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 dice a != 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:

Resultado
FALLA test_extra_five_percent_from_100: 90.0 != 85.0
OK test_ten_percent_off
Tests: 2, pasan: 1, fallan: 1

El 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:

Resultado
OK test_extra_five_percent_from_100
OK test_ten_percent_off
Tests: 2, pasan: 2, fallan: 0

Cada 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
  1. DiscountTest tiene dos tests. unittest ejecuta cada uno por separado y enciende una luz a su lado.
  2. Test 1, preparar: un precio de 50 € y un 10% de descuento.
  3. Actuar: apply_discount(50, 10) devuelve 45.0.
  4. Comprobar: assertEqual compara el 45.0 esperado con el 45.0 devuelto. Son iguales, así que el test pasa.
  5. Test 2: exactamente 100 € y un 10%. Esperamos 85.0, porque desde 100 € hay otro 5%.
  6. La condición en la función es price > 100, y 100 no es mayor que 100. La función devuelve 90.0.
  7. Esperado 85.0, devuelto 90.0: el test falla y dice exactamente eso.
  8. Corriges la condición a price >= 100 y ejecutas los tests otra vez.
  9. Ahora 100 cumple la condición y la función devuelve 85.0.
  10. 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:
Estructura
discount.py el código
test_discount.py sus tests
  • Se ejecutan desde la terminal. Sin -v, unittest imprime un punto por cada test que pasa y una F por cada uno que falla, y después los detalles de los que fallaron:
Comandos
python -m unittest busca test*.py y los ejecuta
python -m unittest -v con una línea por test
python -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 TestResult propio reúne el resultado de cada test, y run_tests los imprime ordenados por nombre. El orden mantiene la salida igual en cada ejecución:
El ejecutor
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)
Respuesta

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:

account.py
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 -= amount
test_account.py
import 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)
Salida
OK test_can_pay_what_is_there
OK test_withdraw_too_much
Tests: 2, pasan: 2, fallan: 0
  • assertTrue y assertFalse comprueban una condición.
  • with self.assertRaises(Tipo): falla si el código del bloque no lanza una excepción de ese tipo. Con as caught obtienes la propia excepción: caught.exception, y str() 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 €:

test_account.py
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)
Salida
FALLA retirar 70 € deja 30 €: Faltan 10 €
OK retirar 40 € deja 60 €
Tests: 2, pasan: 1, fallan: 1

account 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:

test_account.py
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)
Salida
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
Tests: 2, pasan: 2, fallan: 0
unittest crea un objeto nuevo de la clase para cada test y llama a setUp 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
Más cosas de unittest que te vas a encontrar:
unittest
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 falle
with self.subTest(...): un caso aparte dentro de un test
self.addCleanup(función) limpieza después del test, aunque haya fallado
python -m unittest -k withdraw solo los tests con «withdraw» en el nombre
python -m unittest -f se detiene en el primer test que falla

La 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:

test_discount.py
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)
Salida
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:

test_delivery.py
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)
Salida
FALLA test_price [pedido de 50 €: envío 0 €]: 4 != 0
OK 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.

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:

test_promo.py
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)
  1. 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 0
Salida
FALLA test_code_is_case_insensitive: 0 != 10
FALLA test_known_code: 0 != 10
OK test_unknown_code
Tests: 3, pasan: 1, fallan: 2
  1. Verde. Escribes el código más simple con el que pasan:
def promo_percent(code):
if code.upper() == "OTOÑO10":
return 10
return 0
Salida
OK test_code_is_case_insensitive
OK test_known_code
OK test_unknown_code
Tests: 3, pasan: 3, fallan: 0
  1. 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:

test_checkout.py
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())
Salida
OK retirar 40 € deja 60 €
OK retirar 70 € deja 30 €
OK test_extra_five_percent_from_100
OK 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_off
Tests: 7, pasan: 7, fallan: 0

La 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:
test_converter.py
import unittest
from 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")
Salida
OK test_converts_with_todays_rate
Tests: 1, pasan: 1, fallan: 0

Mock() 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:
Comprobaciones
assertNotEqual(a, b) si son distintos
assertIs(a, b), assertIsNot(a, b) si es el mismo objeto (is), no solo iguales
assertIsNone(x), assertIsNotNone(x)
assertIn(x, items), assertNotIn(x, items)
assertCountEqual(a, b) los mismos elementos, en cualquier orden
assertListEqual(a, b) dos listas con los mismos elementos en el mismo orden
assertRegex(text, pattern) el texto coincide con una expresión regular
assertGreater(a, b), assertLess(a, b)
assertAlmostEqual(a, b, delta=0.01)
self.fail("mensaje") el test falla al instante

Ejercicio

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 €:

Resultado contra la versión rota
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: 2

2. 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 InsufficientFundsError con 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:

grades.py
def average(grades):
total = 0
for grade in grades:
total += grade
return total // len(grades)
Escribe dos tests: la media de 4 y 6 es 5,0, y la media de 5 y 6 es 5,5 (con una diferencia permitida de 0,001). Uno de ellos falla:
Resultado
FALLA test_average_with_a_fraction: 5 != 5.5 within 0.001 delta (0.5 difference)
OK test_whole_average
Tests: 2, pasan: 1, fallan: 1

Encuentra la causa y corrige la función, para que pasen los dos.

Test

Ponte a prueba

1. ¿Qué comprueba un test unitario?
2. self.assertEqual(apply_discount(100, 10), 85.0) falla con «90.0 != 85.0». ¿Qué devolvió la función?
3. ¿Por qué la cuenta se crea en setUp y no una sola vez como atributo de la clase?
4. ¿Cómo compruebas que withdraw(130) lanza InsufficientFundsError?
5. ¿Cuál es el orden en TDD?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·