Pruebas unitarias en Python: Guía completa con ejemplos

Pruebas en Python con pytest, TDD, mocking y coverage

Índice

La prueba unitaria asegura que tu código en Python funcione correctamente y continúe funcionando a medida que evolucione tu proyecto. Esta guía completa cubre todo lo que necesitas saber sobre pruebas unitarias en Python, desde conceptos básicos hasta técnicas avanzadas.

Pruebas Unitarias en Python

Por qué las pruebas unitarias importan

Las pruebas unitarias ofrecen numerosos beneficios para los desarrolladores en Python:

  • Detección temprana de errores: Captar errores antes de que lleguen a producción
  • Calidad del código: Te obliga a escribir código modular y comprobable
  • Confianza al refactorizar: Realizar cambios con seguridad sabiendo que las pruebas detectarán regresiones
  • Documentación: Las pruebas sirven como documentación ejecutable de cómo debe funcionar el código
  • Desarrollo más rápido: Las pruebas automatizadas son más rápidas que las pruebas manuales
  • Diseño mejor: Escribir código comprobable lleva a una mejor arquitectura

Entendiendo los fundamentos de las pruebas unitarias

¿Qué es una prueba unitaria?

Una prueba unitaria verifica la parte más pequeña de una aplicación que se puede probar (normalmente una función o método) en aislamiento. Debe ser:

  • Rápida: Ejecutarse en milisegundos
  • Aislada: Independiente de otras pruebas y sistemas externos
  • Repetible: Producir los mismos resultados cada vez
  • Autocomprobable: Pasar o fallar claramente sin inspección manual
  • Oportuna: Escrita antes o junto con el código

La pirámide de pruebas

Un conjunto de pruebas saludable sigue la pirámide de pruebas:

           /\
          /  \     Pruebas E2E (Pocas)
         /____\
        /      \   Pruebas de Integración (Algunas)
       /_____ ___\
      /          \ Pruebas Unitarias (Muchas)
     /_____ __ ___\

Las pruebas unitarias forman la base - son numerosas, rápidas y ofrecen retroalimentación rápida.

Comparación de marcos de pruebas para Python

unittest: El marco estándar

La biblioteca estándar de Python incluye unittest, inspirado en JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Ejecutar antes de cada prueba"""
        self.calc = Calculator()
    
    def tearDown(self):
        """Ejecutar después de cada prueba"""
        self.calc = None
    
    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Ventajas:

  • Incluido de fábrica, no se requiere instalación
  • Bueno para desarrolladores familiarizados con marcos xUnit
  • Amigable para empresas, bien establecido

Desventajas:

  • Sintaxis verbosa con código de plantilla
  • Requiere clases para la organización de pruebas
  • Menos flexible gestión de fixtures

pytest: La opción moderna

pytest es el marco de pruebas de terceros más popular:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

Ventajas:

  • Sintaxis simple, pythonica
  • Sistema poderoso de fixtures
  • Ecosistema de plugins excelente
  • Mejor reporte de errores
  • Pruebas parametrizadas incluidas

Desventajas:

  • Requiere instalación
  • Menos familiar para desarrolladores de otros idiomas

Instalación:

pip install pytest pytest-cov pytest-mock

Escribiendo tus primeras pruebas unitarias

Vamos a construir un ejemplo simple desde cero usando Desarrollo Guiado por Pruebas (TDD). Si eres nuevo en Python o necesitas una referencia rápida para la sintaxis y características del lenguaje, consulta nuestro cheat sheet de Python para una visión general completa de fundamentos de Python.

Ejemplo: Funciones de utilidad de cadena

Paso 1: Escribe la prueba primero (Rojo)

Crea test_string_utils.py:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

Paso 2: Escribe código mínimo para pasar (Verde)

Crea string_utils.py:

def reverse_string(s: str) -> str:
    """Invertir una cadena"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Comprobar si una cadena es palíndromo (ignorando mayúsculas y espacios)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Contar vocales en una cadena"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Paso 3: Ejecutar pruebas

pytest test_string_utils.py -v

Salida:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

Técnicas avanzadas de pruebas

Usando fixtures para configuración de pruebas

Los fixtures ofrecen configuración y limpieza de pruebas reutilizables:

import pytest
from database import Database

@pytest.fixture
def db():
    """Crear una base de datos de prueba"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Proporcionar a la prueba
    database.close()  # Limpieza después de la prueba

@pytest.fixture
def sample_users(db):
    """Añadir usuarios de muestra a la base de datos"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

Ámbitos de fixture

Controlar la vida útil de los fixtures con ámbitos:

@pytest.fixture(scope="function")  # Por defecto: ejecutar para cada prueba
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Una vez por clase de prueba
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Una vez por módulo
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Una vez por sesión de prueba
def session_fixture():
    return create_expensive_resource()

Mockear dependencias externas

Usa mock para aislar el código de dependencias externas:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Prueba con mock
def test_get_temperature():
    service = WeatherService()
    
    # Mockear la función requests.get
    with patch('requests.get') as mock_get:
        # Configurar respuesta mock
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response
        
        # Prueba
        temp = service.get_temperature("Boston")
        assert temp == 72
        
        # Verificar la llamada
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Usando el plugin pytest-mock

pytest-mock ofrece una sintaxis más limpia:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # Mockear usando pytest-mock
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)
    
    temp = service.get_temperature("Boston")
    assert temp == 72

Pruebas parametrizadas

Probar múltiples escenarios de forma eficiente:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

Pruebas de excepciones y manejo de errores

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("No se puede dividir entre cero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="No se puede dividir entre cero"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "cero" in str(exc_info.value).lower()

# Prueba que no se lance ninguna excepción
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Pruebas de código asíncrono

Probar código asíncrono es esencial para aplicaciones modernas de Python, especialmente cuando se trabaja con APIs, bases de datos o servicios de IA. Aquí está cómo probar funciones asíncronas:

import pytest
import asyncio

async def fetch_data(url):
    """Función asíncrona para obtener datos"""
    await asyncio.sleep(0.1)  # Simular llamada a API
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # Mockear la función asíncrona
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

Para ejemplos prácticos de pruebas de código asíncrono con servicios de IA, consulta nuestra guía sobre integrar Ollama con Python, que incluye estrategias de prueba para interacciones con modelos de lenguaje grande (LLM).

Cobertura de código

Medir cuánto de tu código está probado:

Usando pytest-cov

# Ejecutar pruebas con cobertura
pytest --cov=myproject tests/

# Generar informe HTML
pytest --cov=myproject --cov-report=html tests/

# Mostrar líneas faltantes
pytest --cov=myproject --cov-report=term-missing tests/

Configuración de cobertura

Crear .coveragerc:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Mejores prácticas de cobertura

  1. Aim for 80%+ coverage para rutas críticas de código
  2. No obsesiones con 100% - enfócate en pruebas significativas
  3. Prueba casos límite no solo caminos felices
  4. Excluye código de plantilla de informes de cobertura
  5. Usa cobertura como guía no como objetivo

Organización de pruebas y estructura del proyecto

Estructura recomendada

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Fixtures compartidos
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.py para fixtures compartidos

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """Base de datos de prueba a nivel de sesión"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Base de datos limpia para cada prueba"""
    test_db.clear_all_tables()
    return test_db

Configuración de pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: marca pruebas como lentas
    integration: marca pruebas como pruebas de integración
    unit: marca pruebas como pruebas unitarias

Mejores prácticas para pruebas unitarias

1. Sigue el patrón AAA

Arrange-Act-Assert hace que las pruebas sean claras:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"
    
    # Act
    user = User(username, email)
    
    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Una afirmación por prueba (Guía, no regla)

# Buena: prueba enfocada
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# También aceptable: afirmaciones relacionadas
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. Usa nombres descriptivos para las pruebas

# Mal
def test_user():
    pass

# Bien
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. Prueba casos límite y bordes

def test_age_validation():
    # Casos válidos
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # Casos de borde
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # Casos límite
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Mantén las pruebas independientes

# Mal: Las pruebas dependen del orden
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # Fails if run alone
    global counter
    counter += 1
    assert counter == 2

# Bien: Las pruebas son independientes
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. No pruebes detalles de implementación

# Mal: Prueba de implementación
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Bien: Prueba de comportamiento
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Prueba casos de uso del mundo real

Cuando pruebas bibliotecas que procesan o transforman datos, enfócate en escenarios del mundo real. Por ejemplo, si estás trabajando con web scraping o conversión de contenido, consulta nuestra guía sobre convertir HTML a Markdown con Python, que incluye estrategias de prueba y comparaciones de benchmarks para diferentes bibliotecas de conversión.

Integración con Continuous Integration

Ejemplo de GitHub Actions

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Configurar Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Instalar dependencias
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: Ejecutar pruebas
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: Subir cobertura
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Pruebas de funciones serverless

Cuando pruebas funciones de AWS Lambda o aplicaciones serverless, considera estrategias de integración junto con pruebas unitarias. Nuestra guía sobre construir una función AWS Lambda dual con Python y Terraform cubre enfoques de prueba para aplicaciones Python serverless, incluyendo cómo probar manejadores de Lambda, consumidores de SQS y integraciones de API Gateway.

Lista de verificación de mejores prácticas de pruebas

  • Escribe pruebas antes o junto con el código (TDD)
  • Mantén las pruebas rápidas (< 1 segundo por prueba)
  • Haz que las pruebas sean independientes y aisladas
  • Usa nombres descriptivos para las pruebas
  • Sigue el patrón AAA (Arrange-Act-Assert)
  • Prueba casos límite y condiciones de error
  • Mockea dependencias externas
  • Asegúrate de tener una cobertura de código del 80% o más
  • Ejecuta pruebas en la tubería CI/CD
  • Revisa y refactoriza las pruebas regularmente
  • Documenta escenarios de prueba complejos
  • Usa fixtures para configuraciones comunes
  • Parametriza pruebas similares
  • Mantén las pruebas simples y legibles

Patrones comunes de pruebas

Pruebas de clases

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")
    
    def test_username(self, user):
        assert user.username == "john_doe"
    
    def test_email(self, user):
        assert user.email == "john@example.com"
    
    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

Pruebas con archivos temporales

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Crear un archivo temporal"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

Pruebas de generación de archivos

Cuando pruebas código que genera archivos (como PDFs, imágenes o documentos), usa directorios temporales y verifica propiedades de archivos:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Proporcionar un directorio de salida temporal"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")
    
    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

Para ejemplos completos de pruebas de generación de PDF, consulta nuestra guía sobre generar PDF en Python, que cubre estrategias de prueba para varias bibliotecas de PDF.

Pruebas con monkeypatch

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

Enlaces útiles y recursos

Recursos relacionados

Fundamentos y mejores prácticas de Python

  • Cheatsheet de Python - Referencia completa para sintaxis de Python, estructuras de datos y patrones comunes

Pruebas de casos específicos de Python

Pruebas en entornos serverless y en la nube


La prueba unitaria es una habilidad esencial para los desarrolladores en Python. Ya elijas unittest o pytest, lo importante es escribir pruebas consistentemente, mantenerlas mantenibles y integrarlas en tu flujo de trabajo de desarrollo. Comienza con pruebas simples, adopta gradualmente técnicas avanzadas como el mock y los fixtures, y usa herramientas de cobertura para identificar código no probado.