Testing en Python
Domina el arte de escribir pruebas robustas con pytest y unittest
El testing es una de las habilidades más importantes que un desarrollador debe dominar. Las pruebas no son opcionales; están íntimamente conectadas con el código. El código fuente y las pruebas unitarias se influencian mutuamente.
En este artículo exploraremos la anatomía de un test, las mejores prácticas con pytest, el uso de fixtures y mocks, y la metodología Test-Driven Development (TDD).
La Anatomía de un Test
Un test es un fragmento de código cuyo propósito es verificar algo en nuestro sistema. Puede ser que estemos llamando una función con dos enteros, que un objeto tenga una propiedad específica, o que cuando se coloca una orden en una API, después de un minuto puedas ver los elementos en la base de datos.
Las Tres Fases del Test
Componentes del Test Suite
| Componente | Descripción |
|---|---|
| Setup | Lógica que se ejecuta antes de los tests (conexiones DB, datos iniciales) |
| Teardown | Limpieza después de los tests (destruir artefactos, limpiar estado) |
| Fixtures | Datos específicos usados en los tests para resultados predecibles |
pytest, los fixtures, setup y teardown a menudo se combinan en una sola construcción elegante usando el decorador @pytest.fixture.
Guías para Escribir Buenos Tests
Como el software, los tests pueden ser buenos o malos. Para escribir tests de calidad, sigue estas directrices fundamentales.
Principios Fundamentales
- Simplicidad: Es válido violar algunas reglas de código limpio. Los tests deben ser legibles y fáciles de entender.
- Fixtures mínimos: Usa el conjunto más pequeño de fixtures que permita al test funcionar correctamente.
- Recursos mínimos: Cualquier desarrollador debería poder ejecutar los tests sin importar qué tan potente sea su máquina.
- Velocidad: Los tests deben ejecutarse lo más rápido posible. Cuanto más rápido, más rápido volvemos a escribir código.
Ejemplo Básico de Unit Test
def get_clean_data(source): data = load_data(source) cleaned_data = clean_data(data) return cleaned_data
Para testear esta función, debemos verificar que load_data() fue llamada con source como argumento, que clean_data() fue llamada con el retorno de load_data(), y que el valor retornado es el de clean_data().
Fixtures con Pytest
Un fixture en pytest es una función decorada con @pytest.fixture. Los fixtures se ejecutan antes de cada test al que se aplican y típicamente retornan datos que podemos usar en nuestros tests.
Definiendo Fixtures
import pytest @pytest.fixture def min_user(): """Representa un usuario válido con datos mínimos.""" return { "email": "minimal@example.com", "name": "Primus Minimus", "age": 18, } @pytest.fixture def full_user(): """Representa usuario válido con todos los datos.""" return { "email": "full@example.com", "name": "Maximus Plenus", "age": 65, "role": "emperor", }
pytest nos da un nuevo diccionario para cada test, respetando el principio de independencia.
Usando Fixtures en Tests
class TestIsValid: """Test de validación de usuarios.""" def test_minimal(self, min_user): assert is_valid(min_user) def test_full(self, full_user): assert is_valid(full_user)
Fixtures Componibles
Los fixtures pueden usar otros fixtures, lo que permite construir datos complejos de forma modular:
@pytest.fixture def users(min_user, full_user): """Lista de usuarios: dos válidos y uno inválido.""" bad_user = { "email": "invalid@example.com", "name": "Horribilis", } return [min_user, bad_user, full_user]
tests/test_api.py::TestIsValid::test_full PASSED [100%]
====================== 2 passed in 0.03s ======================
Parametrización de Tests
La parametrización es una técnica que permite ejecutar el mismo test múltiples veces con diferentes datos. Es superior a usar un bucle for dentro del test porque el framework puede proporcionar información específica para cada iteración.
Testeando Rangos de Edad
class TestIsValid: @pytest.mark.parametrize("age", range(18)) def test_invalid_age_too_young(self, age, min_user): min_user["age"] = age assert not is_valid(min_user) @pytest.mark.parametrize("age", range(66, 100)) def test_invalid_age_too_old(self, age, min_user): min_user["age"] = age assert not is_valid(min_user) @pytest.mark.parametrize("age", ["NaN", 3.1415, None]) def test_invalid_age_wrong_type(self, age, min_user): min_user["age"] = age assert not is_valid(min_user) @pytest.mark.parametrize("age", range(18, 66)) def test_valid_age(self, age, min_user): min_user["age"] = age assert is_valid(min_user)
Parametrización con Múltiples Valores
@pytest.mark.parametrize( ("email", "outcome"), [ ("missing_at.com", False), ("@missing_start.com", False), ("missing_end@", False), ("missing_dot@example", False), ("good.one@example.com", True), ("δοκιμή@παράδειγμα.δοκιμή", True), ("аджай@экзампл.рус", True), ], ) def test_email(self, email, outcome, min_user): min_user["email"] = email assert is_valid(min_user) == outcome
Boundaries y Granularidad
Al testear rangos de valores, es crucial identificar los límites (boundaries) y usar la granularidad correcta para aproximarse a ellos.
Concepto de Boundary
Un boundary es el punto donde el comportamiento del código cambia. Si alguien modifica la validación de 18 <= value <= 65 a 18 <= value < 65, debe haber un test que falle en 65.
Concepto de Granularidad
| Tipo de Dato | Granularidad | Ejemplo |
|---|---|---|
| Edad (enteros) | 1 | 16, 17, 18, 19, 20... |
| Timestamp (segundos) | 1 segundo | 59s, 60s, 61s |
| Timestamp (años) | 1 año | 2023, 2024, 2025 |
Mock Objects y Patching
En Python, los objetos falsos se llaman mocks. Desde Python 3.3, la librería mock está incluida en la biblioteca estándar bajo unittest.mock.
Usando mock_open y patch
import unittest from unittest.mock import mock_open, patch class Logger: def __init__(self, filepath): self.filepath = filepath def log(self, message): with open(self.filepath, "a") as file: file.write(f"{message}\n") class TestLogger(unittest.TestCase): def test_log(self): msg = "Hello, logging world!" m_open = mock_open() with patch("builtins.open", m_open): logger = Logger("dummy.log") logger.log(msg) # Verificar que el archivo fue abierto correctamente m_open.assert_called_once_with("dummy.log", "a")
Herramientas de Mocking
| Herramienta | Uso |
|---|---|
mock_open() |
Simula la función open() para operaciones de archivo |
patch() |
Reemplaza temporalmente objetos con mocks durante el test |
side_effect |
Define comportamiento condicional o excepciones |
return_value |
Establece una respuesta fija para el mock |
Fixtures con Setup y Teardown
Pytest permite combinar setup, fixture y teardown usando yield. Todo lo anterior a yield es setup, el valor yielded es el fixture, y todo después es teardown.
Archivos Temporales
class TestExport: """Test del comportamiento de la función export.""" @pytest.fixture def csv_file(self, tmp_path): """Yield un filename en un folder temporal.""" csv_path = tmp_path / "out.csv" yield csv_path # ← SETUP termina aquí csv_path.unlink(missing_ok=True) # ← TEARDOWN @pytest.fixture def existing_file(self, tmp_path): """Crea un archivo temporal con contenido.""" existing = tmp_path / "existing.csv" existing.write_text("Please leave me alone...") return existing
Tests de Export
class TestExport: def test_export(self, users, csv_file): export(csv_file, users) text = csv_file.read_text() assert ( "email,name,age,role\n" "minimal@example.com,Primus Minimus,18,\n" "full@example.com,Maximus Plenus,65,emperor\n" ) == text def test_export_quoting(self, min_user, csv_file): min_user["name"] = "A name, with a comma" export(csv_file, [min_user]) text = csv_file.read_text() assert ( "email,name,age,role\n" 'minimal@example.com,"A name, with a comma",18,\n' ) == text def test_does_not_overwrite(self, users, existing_file): with pytest.raises(IOError): export(existing_file, users, overwrite=False)
tests/test_api.py ..................................... [100%]
====================== 132 passed in 0.14s ======================
Test-Driven Development
TDD es una metodología de desarrollo basada en la repetición continua de un ciclo de desarrollo muy corto. Fue redescubierta por Kent Beck y se basa en el mantra: Red-Green-Refactor.
El Ciclo TDD
Ventajas del TDD
- Mejora significativa en la calidad del código
- Tests sirven como documentación viva
- Diseño emergente y modular
- Confianza al refactorizar
Desventajas del TDD
- Lentitud inicial: Escribir tests antes del código puede ralentizar el desarrollo inicial
- Curva de aprendizaje: Requiere una mentalidad diferente al coding tradicional
- Overhead para cambios simples: Para fixes triviales, puede parecer innecesario
- Dificultad con UI/sistemas externos: Mocking añade complejidad
Tipos de Tests
Existen diferentes tipos de tests según el nivel de abstracción y el objetivo que persiguen.
| Tipo | Descripción |
|---|---|
| Unit Tests | Prueban pequeñas unidades de código de forma aislada |
| Integration Tests | Verifican la interacción entre múltiples componentes |
| Functional Tests | Prueban el sistema contra especificaciones funcionales |
| Acceptance Tests | Verifican que el software cumple requisitos de negocio |
| Regression Tests | Aseguran que features existentes no se rompan con updates |
Resumen del Capítulo
01 // Anatomía del Test
Preparación, Ejecución y Verificación son las tres fases fundamentales de cualquier test.
02 // Pytest Fixtures
Usa @pytest.fixture para crear datos reutilizables que se regeneran para cada test.
03 // Parametrización
@pytest.mark.parametrize permite ejecutar el mismo test con múltiples conjuntos de datos.
04 // Mocks y Patching
unittest.mock permite simular dependencias externas y verificar interacciones.
05 // Boundaries
Identifica los límites en tu código y usa la granularidad correcta para testearlos.
06 // TDD
Red-Green-Refactor: escribe tests primero, código después, mejora siempre.