Testing y calidad de código

Objetivos del módulo

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…

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?
Pirámide de pruebas.

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 / 0

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

La 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) == 5

Para 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"] >= 18

Fixtures 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 contenido

El 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) == esperado

Esto 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())  # 42

patch: 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"}
Cuándo usar mocks

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-cov
1# 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.

100% cobertura ≠ 0 bugs

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.py

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

  1. Arrange (Preparar): Reúne los ingredientes y utensilios
  2. Act (Actuar): Cocina el plato
  3. 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.99

Errores comunes al testear

Trampas a evitar
  1. Tests que dependen del orden

    • Los tests deben poder ejecutarse en cualquier orden y de forma independiente
  2. 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
  3. 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
  4. 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
  5. 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 Parsing de datos, conversiones
Integraciones críticas 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
Regla práctica

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

Ejercicio 1: Tests para una clase Stack

Crea tests para una clase Stack (pila) que tenga métodos push, pop, peek y is_empty.

 1import pytest
 2
 3class Stack:
 4    def __init__(self):
 5        self._items = []
 6    
 7    def push(self, item):
 8        self._items.append(item)
 9    
10    def pop(self):
11        if self.is_empty():
12            raise IndexError("Stack vacío")
13        return self._items.pop()
14    
15    def peek(self):
16        if self.is_empty():
17            raise IndexError("Stack vacío")
18        return self._items[-1]
19    
20    def is_empty(self):
21        return len(self._items) == 0
22
23@pytest.fixture
24def stack_vacio():
25    return Stack()
26
27@pytest.fixture
28def stack_con_elementos():
29    s = Stack()
30    s.push(1)
31    s.push(2)
32    s.push(3)
33    return s
34
35def test_stack_nuevo_esta_vacio(stack_vacio):
36    assert stack_vacio.is_empty()
37
38def test_push_hace_stack_no_vacio(stack_vacio):
39    stack_vacio.push(1)
40    assert not stack_vacio.is_empty()
41
42def test_pop_devuelve_ultimo_elemento(stack_con_elementos):
43    assert stack_con_elementos.pop() == 3
44
45def test_peek_no_modifica_stack(stack_con_elementos):
46    stack_con_elementos.peek()
47    assert stack_con_elementos.pop() == 3
48
49def test_pop_en_stack_vacio_lanza_error(stack_vacio):
50    with pytest.raises(IndexError):
51        stack_vacio.pop()
Ejercicio 2: Parametrizar validación de emails

Escribe tests parametrizados para una función que valide emails.

 1import pytest
 2import re
 3
 4def validar_email(email):
 5    patron = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
 6    return bool(re.match(patron, email))
 7
 8@pytest.mark.parametrize("email,es_valido", [
 9    ("[email protected]", True),
10    ("[email protected]", True),
11    ("[email protected]", True),
12    ("invalido@", False),
13    ("@dominio.com", False),
14    ("sin_arroba.com", False),
15    ("espacios [email protected]", False),
16    ("", False),
17])
18def test_validar_email(email, es_valido):
19    assert validar_email(email) == es_valido

Quiz

🎮 Quiz: Testing

0 / 0
Cargando preguntas...

Anterior: Paralelismo Siguiente: Proyectos