Saltar a contenido

1.17 · Type hints y testing con pytest

Objetivos. Al terminar este capítulo podrás: anotar parámetros y retornos; expresar valores opcionales y colecciones; interpretar las anotaciones como un contrato para personas y herramientas; y escribir pruebas con pytest para casos normales, límites y errores.

Evidencia de logro. Tiparás una función del asistente y crearás una suite pequeña de pruebas que detecte un caso normal, un límite y una entrada inválida. La práctica final de la unidad reutilizará esta estructura.

Núcleo y ampliaciones

El núcleo son los apartados 1 a 5: anotaciones, tipos de colecciones, primera prueba y casos límite con pytest.raises. Los apartados 6 (parametrizar) y 7 (diseño de pruebas) pueden quedar como ampliación autónoma; el apartado 8 es contexto sobre herramientas. El producto evaluable se resuelve con el núcleo.

Contexto y motivación

Python comprueba muchos errores de tipos cuando ejecuta el código, pero no siempre puede detectar antes que una función reciba una lista donde esperaba un número. Las anotaciones de tipo documentan el contrato y permiten que VS Code (Pylance) encuentre problemas antes de ejecutar.

Una prueba automatizada convierte una expectativa en una comprobación que puede repetirse después de cada cambio. No demuestra que el programa no tenga ningún bug; aporta evidencia sobre los comportamientos que hemos decidido cubrir.

Vocabulario

Término Significado
Anotación Tipo escrito junto a un parámetro, variable o retorno
Contrato Entradas, salida y errores que una función promete
Tipo opcional Valor que puede ser de un tipo o None
Prueba Código que comprueba un comportamiento esperado
Fixture Preparación reutilizable para una prueba
Regresión Fallo nuevo que aparece tras un cambio
Cobertura Parte del comportamiento ejecutada por las pruebas

Prerrequisitos

1.8 · Funciones, 1.12 · Módulos y paquetes y 1.15 · Librerías externas.

1. Anotar funciones

Una anotación hace visible qué espera una función y qué devuelve:

def calcular_media(notas: list[float]) -> float:
    if not notas:
        raise ValueError("Se necesita al menos una nota")
    return sum(notas) / len(notas)


print(round(calcular_media([7.0, 8.5, 6.5]), 2))

Salida esperada:

7.33

Python no convierte automáticamente una anotación en una validación. La función sigue necesitando una comprobación explícita para rechazar una lista vacía.

1.1 Valores opcionales y diccionarios estructurados

En Python actual, str | None expresa un texto que puede faltar:

from typing import TypedDict


class Configuracion(TypedDict, total=False):
    modelo: str
    temperatura: float


def nombre_modelo(configuracion: Configuracion, alternativa: str | None = None) -> str:
    return configuracion.get("modelo", alternativa or "local")


configuracion: Configuracion = {"modelo": "demo", "temperatura": 0.2}
print(nombre_modelo(configuracion))
print(nombre_modelo({}, alternativa="local"))

Salida esperada:

demo
local

total=False indica que las claves pueden faltar en esta configuración de entrada. TypedDict documenta la forma esperada para el editor, pero no valida por sí mismo los datos que llegan de una API. Esa validación pertenece a la frontera de entrada y se verá con Pydantic en la UD2.

Las anotaciones no son decoración

Una buena anotación debe responder «¿qué valores son válidos aquí?». Si el tipo es demasiado amplio (object o Any en todas partes), se pierde la ayuda; si es falso, induce a decisiones equivocadas. Anota el contrato real.

2. Tipos útiles para colecciones

Puedes anotar diccionarios y secuencias con sus tipos de elementos:

from collections.abc import Iterable


def nombres_mayusculas(nombres: Iterable[str]) -> list[str]:
    return [nombre.upper() for nombre in nombres]


print(nombres_mayusculas(("Ana", "Luis")))

Salida esperada:

['ANA', 'LUIS']

Iterable[str] acepta listas, tuplas y otros objetos recorribles, mientras que list[str] comunica que la función espera específicamente una lista. Elige el tipo más pequeño que describa el contrato.

Los tipos no sustituyen a un diseño claro. Si una función devuelve una tupla con cinco posiciones difíciles de recordar, quizá necesites un diccionario tipado, una clase o una estructura más explícita.

3. Una prueba mínima con assert

Una prueba expresa un resultado que debe seguir siendo cierto:

def normalizar(texto: str) -> str:
    return " ".join(texto.strip().lower().split())


resultado = normalizar("  Hola   IA ")
assert resultado == "hola ia"
print("prueba manual superada")

Salida esperada:

prueba manual superada

assert detiene el programa si la condición es falsa. Es útil para una comprobación rápida, pero pytest permite descubrir, agrupar y repetir pruebas con mejores informes.

4. Primera suite con pytest

Instala pytest como dependencia de desarrollo:

uv add --dev pytest

Una estructura sencilla es:

asistente_ia/
├── pyproject.toml
├── asistente/
│   ├── __init__.py
│   └── texto.py
└── tests/
    └── test_texto.py

Para que las pruebas de tests/ puedan importar el paquete asistente, pytest debe buscar módulos desde la raíz del proyecto. Añade al final de pyproject.toml:

[tool.pytest.ini_options]
pythonpath = ["."]

Sin esta línea, uv run pytest falla al recoger las pruebas con ModuleNotFoundError: No module named 'asistente', aunque el código sea correcto: pytest arranca desde la carpeta tests/ y no ve la raíz.

asistente/texto.py:

def normalizar(texto: str) -> str:
    return " ".join(texto.strip().lower().split())


def extraer_palabras(texto: str) -> list[str]:
    return normalizar(texto).split()

tests/test_texto.py:

from asistente.texto import extraer_palabras, normalizar


def test_normalizar_quita_espacios_y_mayusculas():
    assert normalizar("  Hola   IA ") == "hola ia"


def test_extraer_palabras_devuelve_lista():
    assert extraer_palabras("Hola IA") == ["hola", "ia"]

Ejecuta desde la raíz del proyecto:

uv run pytest -q

Salida esperada:

2 passed in ...s

El tiempo varía; 2 passed es la evidencia relevante. El prefijo test_ en el fichero y en las funciones permite que pytest las descubra automáticamente.

5. Casos límite y errores esperados

Una suite útil no solo prueba el caso feliz. Guarda la función anterior en asistente/notas.py:

def calcular_media(notas: list[float]) -> float:
    if not notas:
        raise ValueError("Se necesita al menos una nota")
    return sum(notas) / len(notas)

Y prueba también sus límites:

# tests/test_media.py
import pytest

from asistente.notas import calcular_media


def test_media_de_dos_notas():
    assert calcular_media([6.0, 8.0]) == 7.0


def test_media_rechaza_lista_vacia():
    with pytest.raises(ValueError, match="al menos una"):
        calcular_media([])

Salida esperada:

2 passed in ...s

pytest.raises comprueba que ocurre el error correcto. No escribas una prueba que solo compruebe que «ocurre algún error»: el tipo y, cuando sea estable y útil, una parte del mensaje forman parte del contrato.

Prueba también los límites que tengan significado para el dominio: lista vacía, una sola nota, valor mínimo y máximo, o una entrada con espacios.

5.1 Fixture: preparación reutilizable

Cuando varias pruebas necesitan la misma preparación, una fixture evita copiarla en cada una. pytest localiza las funciones marcadas con @pytest.fixture y las inyecta por el nombre del parámetro. El ejemplo supone que asistente/notas.py incluye también la función cargar_notas(ruta) del asistente de notas de 1.11:

# tests/test_notas_fixture.py
import json
import pytest

from asistente.notas import cargar_notas


@pytest.fixture
def ruta_notas(tmp_path):
    """Crea un fichero temporal con dos notas y devuelve su ruta."""
    ruta = tmp_path / "notas.json"
    ruta.write_text('["aprender pytest", "revisar tipos"]', encoding="utf-8")
    return ruta


def test_cargar_notas_desde_fichero(ruta_notas):
    assert cargar_notas(ruta_notas) == ["aprender pytest", "revisar tipos"]


def test_cargar_notas_inexistente(tmp_path):
    assert cargar_notas(tmp_path / "no_existe.json") == []

Salida esperada:

2 passed in ...s

La fixture recibe tmp_path, otra fixture integrada de pytest que ofrece una carpeta temporal única para cada prueba: así ninguna prueba escribe en el disco real ni depende del estado dejado por otra. Con esto basta para el curso; las fixtures con alcance compartido (scope) quedan como ampliación.

6. Parametrizar sin copiar y pegar

Cuando una misma regla necesita varios ejemplos, pytest.mark.parametrize los convierte en casos separados:

import pytest

from asistente.texto import normalizar


@pytest.mark.parametrize(
    ("entrada", "esperada"),
    [
        (" Ana ", "ana"),
        ("LUIS", "luis"),
        ("  IA   práctica ", "ia práctica"),
    ],
)
def test_normalizar_varios_casos(entrada, esperada):
    assert normalizar(entrada) == esperada

Salida esperada:

3 passed in ...s

Cada fila representa un caso de comportamiento y si falla pytest identifica la combinación concreta. No parametrices casos que tengan reglas diferentes solo para ahorrar líneas: una prueba debe seguir siendo legible.

7. Diseñar pruebas que aporten confianza

Para cada función pregunta:

  1. ¿Cuál es el caso normal?
  2. ¿Qué entradas están en el límite?
  3. ¿Qué entrada debe rechazarse y con qué error?
  4. ¿La prueba comprueba el resultado observable o un detalle interno?
  5. ¿Es determinista y rápida?

El patrón Arrange–Act–Assert ayuda a leerlas:

def test_media_organiza_la_comprobacion():
    # Arrange: preparar datos
    notas = [5.0, 7.0, 9.0]

    # Act: ejecutar la operación
    media = calcular_media(notas)

    # Assert: comprobar el contrato
    assert media == 7.0

Una prueba no debe depender de la hora actual, de una API real ni de un fichero que otra prueba haya dejado en el disco. Usa datos pequeños, dobles o rutas temporales cuando una dependencia externa sea inevitable.

No uses assert para validar entradas de producción

Python puede desactivar assert con ciertas opciones de ejecución. Para validar datos de usuario usa if y lanza una excepción apropiada; reserva assert para invariantes internas y pruebas.

8. Herramientas del editor

Pylance puede señalar tipos incompatibles mientras escribes. pytest comprueba el comportamiento al ejecutar. Son ayudas distintas:

  • una anotación puede estar bien escrita y la lógica ser incorrecta;
  • una prueba puede pasar y no cubrir una entrada importante;
  • un diagnóstico de tipos puede descubrir un error que todavía no tiene prueba.

El núcleo del curso usa las comprobaciones de Pylance disponibles en VS Code y pytest. No hace falta introducir un segundo comprobador de tipos antes de entender estos contratos; mypy o pyright quedan como ampliación.

Aplicación práctica: preparar el capstone

Antes de añadir una funcionalidad al proyecto final, extrae una función pequeña y tipada:

def clasificar_nota(nota: float) -> str:
    if not 0 <= nota <= 10:
        raise ValueError("La nota debe estar entre 0 y 10")
    return "aprobada" if nota >= 5 else "suspensa"

Sus pruebas mínimas deberían cubrir:

import pytest


def test_clasificar_aprobado():
    assert clasificar_nota(5.0) == "aprobada"


def test_clasificar_suspenso():
    assert clasificar_nota(4.99) == "suspensa"


def test_clasificar_rechaza_fuera_de_rango():
    with pytest.raises(ValueError):
        clasificar_nota(10.1)

La función tiene un contrato observable: intervalo válido, frontera de aprobado y error fuera de rango. Ese patrón es más valioso que acumular muchas líneas de código sin una expectativa comprobable.

Errores frecuentes

  • ModuleNotFoundError al ejecutar pytest: falta pythonpath = ["."] en pyproject.toml o se ejecuta desde otra carpeta que no es la raíz.
  • Pensar que una anotación convierte o valida el valor automáticamente.
  • Anotar todo como Any y perder la ayuda del editor.
  • Probar solo el caso feliz.
  • Hacer que una prueba dependa del orden o del estado dejado por otra.
  • Llamar a una API real en cada prueba y obtener fallos intermitentes.
  • Usar mensajes exactos que cambian entre versiones cuando no forman parte del contrato.
  • Usar assert para validar entradas de usuarios en producción.
  • Medir calidad por número de pruebas en lugar de por comportamientos cubiertos.

Práctica de transferencia

  1. Añade anotaciones a tres funciones de capítulos anteriores y escribe qué entradas acepta cada una.
  2. Crea pruebas para normalizar: espacios, texto vacío, mayúsculas y texto con varias palabras.
  3. Añade una función tipada que valide una prioridad entre 1 y 3 y prueba los dos límites y un valor inválido.
  4. Ejecuta uv run pytest -q desde la raíz y comprueba qué ocurre si introduces deliberadamente una regresión.
  5. Elige una función del asistente que todavía sea difícil de probar y explica qué dependencia o efecto secundario deberías separar.

Producto evaluable

Crea 13_tipos_y_tests.py y una carpeta tests/ para:

  1. Anotar al menos tres funciones del asistente, incluyendo un retorno opcional o un diccionario estructurado.
  2. Escribir pruebas del caso normal, dos límites y un error esperado.
  3. Ejecutar la suite con uv run pytest -q y conservar la salida.
  4. Documentar en Markdown qué comportamiento queda cubierto y qué riesgo no se ha probado todavía.
  5. Preparar el proyecto final para que pueda reutilizar estas pruebas sin depender de servicios externos.

Criterio de aceptación: las pruebas son deterministas, descubiertas por pytest, comprueban resultados observables y fallan cuando se rompe el contrato que dicen cubrir.

Formato de entrega: script o cuaderno marimo ejecutado, carpeta tests/, salida de pytest, interpretación y conclusión.

Resumen y referencia rápida

Necesitas Patrón
Anotar parámetro y retorno def f(valor: str) -> int:
Opcional str | None
Lista tipada list[float]
Diccionario estructurado class Datos(TypedDict): ...
Prueba def test_comportamiento(): assert ...
Error esperado with pytest.raises(ValueError):
Varios casos @pytest.mark.parametrize(...)
Ejecutar pruebas uv run pytest -q

Siguiente: 1.18 · Proyecto final de la unidad.