martes, 20 de enero de 2026

Aprendiendo Python de 0 a experto - Testing en Python

Testing en Python - Guía Completa
Python // Chapter 10

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

TESTING_01

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

Estructura del Test
Preparation
Execution
Verification
PREPARATION
Configuramos la escena: datos, objetos y servicios necesarios
EXECUTION
Ejecutamos la lógica que está siendo probada
VERIFICATION
Verificamos que los resultados coincidan con nuestras expectativas

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
En pytest, los fixtures, setup y teardown a menudo se combinan en una sola construcción elegante usando el decorador @pytest.fixture.
TESTING_02

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.
Una regla de oro del unit testing: cualquier cosa que cruce los límites de tu aplicación (bases de datos, red, disco) debe ser simulada.

Ejemplo Básico de Unit Test

data.py
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().

TESTING_03

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

test_api.py
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",
    }
Al usar fixtures en lugar de diccionarios a nivel de módulo, pytest nos da un nuevo diccionario para cada test, respetando el principio de independencia.

Usando Fixtures en Tests

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

test_users.py
@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]
pytest tests -vv
tests/test_api.py::TestIsValid::test_minimal PASSED [ 50%]
tests/test_api.py::TestIsValid::test_full PASSED [100%]

====================== 2 passed in 0.03s ======================
TESTING_04

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.

Parametrización vs For Loop
@parametrize
Test Individual
Error Específico

Testeando Rangos de Edad

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

test_email_validation.py
@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
La parametrización con tuplas permite definir tanto la entrada como el resultado esperado en un solo decorador, evitando tests duplicados.
TESTING_05

Boundaries y Granularidad

Al testear rangos de valores, es crucial identificar los límites (boundaries) y usar la granularidad correcta para aproximarse a ellos.

Boundaries de Edad (18-65)
0-17 FAIL
|
18-65 PASS
|
66+ FAIL

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
Combina boundaries con granularidad correcta para asegurar que tus tests no dejen nada al azar.
TESTING_06

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.

Proceso de Patching
Real Object
patch()
Mock Object

Usando mock_open y patch

test_logger.py
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
Los mocks son ventajosos cuando el software interactúa con APIs que no tienen versión de prueba. En la realidad, terminamos usando un enfoque híbrido.
TESTING_07

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

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

test_export_functions.py
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)
pytest tests
collected 132 items

tests/test_api.py ..................................... [100%]

====================== 132 passed in 0.14s ======================
TESTING_08

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

RED
GREEN
REFACTOR
RED
Escribe un test que falle. El test verifica una feature que aún no existe.
GREEN
Escribe el código mínimo para que el test pase. Usa "fake it 'til you make it".
REFACTOR
Mejora el código y los tests hasta que estén en el estado deseado.

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
Aunque TDD puede no ser factible en todos los escenarios (como test suites con miles de tests), el conocimiento y mentalidad que inculca son invaluables.
TESTING_09

Tipos de Tests

Existen diferentes tipos de tests según el nivel de abstracción y el objetivo que persiguen.

Pirámide de Testing
E2E Tests
Integration Tests
Unit Tests
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
Los unit tests forman la vasta mayoría de tests escritos por un desarrollador. Son el backbone del software crafting.

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.

Basado en "Learn Python Programming, 4th Edition" // Fabrizio Romano & Heinrich Kruger

Capítulo 10: Testing