Testing y calidad de código
Al completar este módulo serás capaz de:
- Escribir pruebas unitarias con unittest y pytest
- Usar fixtures y parametrización
- Medir cobertura de código
- Aplicar buenas prácticas de testing
¿Por qué hacer testing?
Tu código funciona. Lo has probado manualmente un par de veces y parece que todo va bien. Pero… ¿estás seguro? ¿Y si cambias algo y se rompe otra cosa sin que te des cuenta? ¿Y si un compañero modifica una función que tú usabas y tu código deja de funcionar?
Piensa en un piloto de avión. Antes de despegar, sigue una checklist: revisar combustible, comprobar instrumentos, verificar flaps… No importa si ha volado 10.000 horas: siempre sigue la checklist. ¿Por qué? Porque las consecuencias de olvidar algo son demasiado graves.
Los tests son tu checklist de programador. Cada vez que ejecutas los tests, verificas automáticamente que todo sigue funcionando como debería.
Los beneficios del testing
- Detectar errores antes: Es mucho más barato arreglar un bug durante el desarrollo que en producción (con usuarios enfadados)
- Documentar el comportamiento: Los tests muestran ejemplos reales de cómo usar tu código
- Refactorizar con confianza: Puedes reorganizar tu código sabiendo que si rompes algo, los tests te avisarán
- Dormir tranquilo: Saber que tu código está probado reduce el estrés de los deploys
Sin tests, cada cambio en el código es un acto de fe. “Parece que funciona” no es lo mismo que “funciona, y tengo pruebas que lo demuestran”.
Tipos de tests: La pirámide
No todos los tests son iguales. Imagina que tienes que revisar un coche antes de un viaje largo:
- Tests unitarios: Revisar cada pieza por separado. ¿Funcionan los frenos? ¿Arrancan las luces? ¿El motor tiene aceite?
- Tests de integración: Comprobar que las piezas funcionan juntas. ¿El motor transmite potencia a las ruedas correctamente?
- Tests end-to-end: Conducir el coche de verdad. ¿Puedo ir de Madrid a Barcelona sin problemas?
La base de la pirámide son los tests unitarios: deberías tener muchos. Son rápidos de escribir, rápidos de ejecutar, y cuando fallan te dicen exactamente qué está roto. Los tests end-to-end son importantes pero caros: tardan más en ejecutarse y cuando fallan, a veces cuesta encontrar el problema.
| Tipo | Velocidad | Cantidad | Detecta |
|---|---|---|---|
| Unitarios | Muy rápidos | Muchos | Errores en funciones individuales |
| Integración | Medios | Algunos | Problemas de conexión entre partes |
| End-to-end | Lentos | Pocos | Errores en el flujo completo |
unittest: El veterano
unittest viene incluido con Python, no necesitas instalar nada. Es la herramienta de testing original, inspirada en JUnit de Java. Por eso verás que usa clases y métodos que empiezan con assert.
Lo verás mucho en código legacy (proyectos antiguos), así que es importante conocerlo aunque hoy en día la mayoría prefiere pytest.
1import unittest
2
3def suma(a, b):
4 return a + b
5
6class TestSuma(unittest.TestCase):
7 def test_suma_positivos(self):
8 self.assertEqual(suma(2, 3), 5)
9
10 def test_suma_negativos(self):
11 self.assertEqual(suma(-1, -1), -2)
12
13 def test_suma_cero(self):
14 self.assertEqual(suma(0, 0), 0)
15
16if __name__ == '__main__':
17 unittest.main()Para ejecutarlo: python test_mi_modulo.py
Métodos de aserción
unittest tiene métodos para cada tipo de comprobación:
| Método | En español | Ejemplo |
|---|---|---|
| assertEqual(a, b) | “¿Son iguales?” | assertEqual(suma(2,2), 4) |
| assertNotEqual(a, b) | “¿Son diferentes?” | assertNotEqual(1, 2) |
| assertTrue(x) | “¿Es verdadero?” | assertTrue(5 > 3) |
| assertFalse(x) | “¿Es falso?” | assertFalse(3 > 5) |
| assertIsNone(x) | “¿Es None?” | assertIsNone(resultado) |
| assertIn(a, b) | “¿Está dentro?” | assertIn(3, [1,2,3]) |
| assertRaises(Error) | “¿Lanza error?” | Ver ejemplo abajo |
1import unittest
2
3class MiTest(unittest.TestCase):
4 def test_aserciones(self):
5 # Igualdad
6 self.assertEqual(1 + 1, 2)
7 self.assertNotEqual(1 + 1, 3)
8
9 # Booleanos
10 self.assertTrue(5 > 3)
11 self.assertFalse(3 > 5)
12
13 # None
14 self.assertIsNone(None)
15 self.assertIsNotNone("algo")
16
17 # Contenido
18 self.assertIn(3, [1, 2, 3])
19 self.assertNotIn(4, [1, 2, 3])
20
21 # Tipos
22 self.assertIsInstance("hola", str)
23
24 # Excepciones: verificar que algo lanza un error
25 with self.assertRaises(ZeroDivisionError):
26 1 / 0pytest: El favorito de la comunidad
Si unittest es como llenar formularios en papel (clases, métodos largos, burocracia), pytest es como usar una app moderna: simple, intuitivo y potente.
1pip install pytestLa diferencia salta a la vista:
1# test_calculadora.py
2def suma(a, b):
3 return a + b
4
5# ¡Así de simple! Solo funciones y assert
6def test_suma_positivos():
7 assert suma(2, 3) == 5
8
9def test_suma_negativos():
10 assert suma(-1, -1) == -2
11
12def test_suma_con_cero():
13 assert suma(5, 0) == 5Para ejecutarlo: pytest test_calculadora.py
¿Por qué pytest es mejor?
- Menos código: no necesitas clases ni métodos especiales
- Mejores mensajes de error: te muestra exactamente qué falló y por qué
- Miles de plugins: para todo lo que puedas imaginar
- Compatible con unittest: puedes mezclar ambos
Fixtures: Preparar el escenario
Imagina que vas al teatro. Antes de que empiece la obra, el escenario debe estar montado: decorados, atrezzo, iluminación… Los actores no pueden actuar en un escenario vacío.
Los fixtures hacen exactamente eso para tus tests: preparan todo lo necesario antes de que el test se ejecute.
1import pytest
2
3@pytest.fixture
4def usuario_ejemplo():
5 """Este fixture proporciona un usuario de prueba"""
6 return {
7 "nombre": "Ana",
8 "edad": 25,
9 "email": "[email protected]"
10 }
11
12# El fixture se pasa automáticamente como parámetro
13def test_nombre_usuario(usuario_ejemplo):
14 assert usuario_ejemplo["nombre"] == "Ana"
15
16def test_edad_usuario(usuario_ejemplo):
17 assert usuario_ejemplo["edad"] >= 18Fixtures con setup y teardown
A veces necesitas limpiar después del test. Siguiendo la metáfora del teatro: montas el decorado antes de la obra, y lo desmontas después.
graph LR
A["Setup<br/>(montar escenario)"] --> B["yield<br/>(empieza el test)"]
B --> C["Teardown<br/>(desmontar)"] 1import pytest
2import os
3
4@pytest.fixture
5def archivo_temporal():
6 # SETUP: preparar antes del test
7 nombre = "test_temp.txt"
8 with open(nombre, 'w') as f:
9 f.write("contenido de prueba")
10
11 yield nombre # El test se ejecuta aquí
12
13 # TEARDOWN: limpiar después del test
14 if os.path.exists(nombre):
15 os.remove(nombre)
16
17def test_leer_archivo(archivo_temporal):
18 with open(archivo_temporal, 'r') as f:
19 contenido = f.read()
20 assert "prueba" in contenidoEl yield es el momento en que “sube el telón” y empieza la función. Todo lo que viene antes es setup, todo lo que viene después es teardown.
Parametrización: Un test, muchos casos
Imagina una fábrica de control de calidad. En lugar de probar un solo producto a mano, pasas cientos por la máquina de tests. Mismo test, diferentes datos.
1import pytest
2
3def es_par(n):
4 return n % 2 == 0
5
6# Un solo test que prueba MUCHOS casos
7@pytest.mark.parametrize("numero,esperado", [
8 (2, True),
9 (3, False),
10 (0, True),
11 (-4, True),
12 (-3, False),
13 (100, True),
14])
15def test_es_par(numero, esperado):
16 assert es_par(numero) == esperadoEsto ejecuta el mismo test 6 veces, una por cada combinación de datos. Si alguno falla, pytest te dice exactamente cuál.
Marcar tests
A veces necesitas categorizar tus tests o saltarte algunos temporalmente:
1import pytest
2
3# Saltar un test (temporalmente)
4@pytest.mark.skip(reason="Aún no implementado")
5def test_funcion_futura():
6 pass
7
8# Saltar bajo ciertas condiciones
9@pytest.mark.skipif(condition=True, reason="Solo en Linux")
10def test_solo_linux():
11 pass
12
13# Etiquetar tests lentos
14@pytest.mark.slow
15def test_lento():
16 import time
17 time.sleep(5)
18 assert True
19
20# Ejecutar excluyendo tests lentos:
21# pytest -m "not slow"Mocking: Los dobles de cine
Cuando ves una película de acción y el protagonista salta de un edificio en llamas, no es el actor real. Es un doble de acción: alguien que simula ser el actor para las escenas peligrosas.
En testing, los mocks son esos dobles. Simulan ser objetos reales para que puedas probar tu código sin depender de servicios externos: APIs, bases de datos, sistemas de archivos…
¿Por qué necesitas mocks?
- No quieres hacer peticiones HTTP reales en cada test (lento y poco fiable)
- No quieres enviar emails de verdad al probar tu sistema de notificaciones
- No quieres modificar la base de datos de producción
1from unittest.mock import Mock, patch
2
3# Crear un objeto falso que simula ser real
4mock = Mock()
5mock.metodo.return_value = 42 # "Cuando llamen a .metodo(), devuelve 42"
6print(mock.metodo()) # 42patch: Reemplazar temporalmente
patch es como decir “durante este test, reemplaza X por este doble”:
1from unittest.mock import patch
2
3def obtener_datos_api():
4 """Esta función normalmente haría una petición HTTP real"""
5 import requests
6 return requests.get("https://api.ejemplo.com").json()
7
8def test_obtener_datos():
9 # "Durante este test, requests.get es un doble"
10 with patch('requests.get') as mock_get:
11 # Configurar qué devuelve el doble
12 mock_get.return_value.json.return_value = {"dato": "valor"}
13
14 # Ejecutar la función (usa el doble, no hace petición real)
15 resultado = obtener_datos_api()
16
17 # Verificar el resultado
18 assert resultado == {"dato": "valor"}Usa mocks cuando tu código dependa de:
- APIs externas: No quieres depender de internet ni de servicios de terceros
- Bases de datos: Más rápido y seguro que usar una DB real
- Sistema de archivos: Evitas crear/borrar archivos reales
- Fecha/hora: Para probar código que depende de “ahora”
Cobertura de código: ¿Cuánto has probado?
Imagina un mapa de carreteras de tu código. La cobertura te dice: “¿Por cuántas calles han pasado tus tests?” Si tu cobertura es del 80%, significa que tus tests han ejecutado el 80% de las líneas de código.
1pip install pytest-cov1# Ejecutar tests con medición de cobertura
2pytest --cov=mi_modulo tests/
3
4# Generar un reporte visual en HTML
5pytest --cov=mi_modulo --cov-report=html tests/El reporte HTML te muestra exactamente qué líneas están cubiertas (verde) y cuáles no (rojo). Muy útil para identificar qué partes de tu código necesitan más tests.
La cobertura te dice que el código se ejecutó, no que funciona correctamente. Puedes tener 100% de cobertura y aún tener bugs. Los tests deben verificar que el comportamiento es correcto, no solo que el código se ejecuta.
Buenas prácticas
Estructura de proyecto
Organiza tus tests en una carpeta separada que refleje la estructura del código:
proyecto/
├── src/
│ └── mi_modulo/
│ ├── __init__.py
│ └── calculadora.py
└── tests/
├── __init__.py
├── conftest.py # Fixtures compartidos entre tests
├── test_calculadora.py # Tests para calculadora.py
└── test_utilidades.pyNombrar tests claramente
El nombre del test debe explicar qué prueba y qué espera. Cuando un test falle, el nombre te dirá inmediatamente dónde buscar el problema:
1# Malo: ¿qué prueba esto?
2def test_1():
3 pass
4
5# Bueno: el nombre explica todo
6def test_suma_dos_numeros_positivos_devuelve_suma():
7 assert suma(2, 3) == 5
8
9def test_dividir_por_cero_lanza_excepcion():
10 with pytest.raises(ZeroDivisionError):
11 dividir(10, 0)Patrón AAA: La receta de todo test
Cada test bien escrito sigue el patrón AAA: Arrange, Act, Assert. Es como seguir una receta de cocina:
- Arrange (Preparar): Reúne los ingredientes y utensilios
- Act (Actuar): Cocina el plato
- Assert (Verificar): Comprueba que el resultado es el esperado
1def test_agregar_item_al_carrito():
2 # ARRANGE (preparar): crear los objetos necesarios
3 carrito = Carrito()
4 producto = Producto("Libro", 29.99)
5
6 # ACT (actuar): ejecutar la acción que queremos probar
7 carrito.agregar(producto)
8
9 # ASSERT (verificar): comprobar que el resultado es correcto
10 assert len(carrito.items) == 1
11 assert carrito.total == 29.99Errores comunes al testear
-
Tests que dependen del orden
- Los tests deben poder ejecutarse en cualquier orden y de forma independiente
-
Tests que dependen de datos externos
- Si tu test llama a una API real, fallará cuando no haya internet
- Usa mocks para simular servicios externos
-
Tests demasiado grandes
- Un test debe probar UNA cosa. Si falla, debes saber exactamente qué está mal
- Si tu test tiene muchos asserts, probablemente debería ser varios tests
-
No testear casos límite
- ¿Qué pasa con 0? ¿Con None? ¿Con listas vacías? ¿Con strings muy largos?
- Los bugs suelen esconderse en los extremos
-
Tests que prueban la implementación, no el comportamiento
- Malo: verificar que se llamó a una función interna
- Bueno: verificar que el resultado es el esperado
Guía rápida: ¿Qué testear?
| Situación | ¿Testear? | Ejemplo |
|---|---|---|
| Lógica de negocio | Sí, siempre | Cálculo de precios, validaciones |
| Casos límite | Sí, siempre | Valores 0, None, listas vacías |
| Código que puede fallar | Sí | Parsing de datos, conversiones |
| Integraciones críticas | Sí | Conexión a base de datos |
| Getters/setters simples | Probablemente no | def get_nombre(self): return self._nombre |
| Código de terceros | No | La librería ya tiene sus propios tests |
Testea el código que te daría miedo cambiar sin tests. Si modificar una función te pone nervioso porque podrías romper algo, esa función necesita tests.
Ejercicios prácticos
Crea tests para una clase Stack (pila) que tenga métodos push, pop, peek y is_empty.
Escribe tests parametrizados para una función que valide emails.