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:
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:
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:
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:
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:
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:
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:
Salida esperada:
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:
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:
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:
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:
- ¿Cuál es el caso normal?
- ¿Qué entradas están en el límite?
- ¿Qué entrada debe rechazarse y con qué error?
- ¿La prueba comprueba el resultado observable o un detalle interno?
- ¿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¶
ModuleNotFoundErroral ejecutarpytest: faltapythonpath = ["."]enpyproject.tomlo 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
Anyy 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
assertpara validar entradas de usuarios en producción. - Medir calidad por número de pruebas en lugar de por comportamientos cubiertos.
Práctica de transferencia¶
- Añade anotaciones a tres funciones de capítulos anteriores y escribe qué entradas acepta cada una.
- Crea pruebas para
normalizar: espacios, texto vacío, mayúsculas y texto con varias palabras. - 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.
- Ejecuta
uv run pytest -qdesde la raíz y comprueba qué ocurre si introduces deliberadamente una regresión. - 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:
- Anotar al menos tres funciones del asistente, incluyendo un retorno opcional o un diccionario estructurado.
- Escribir pruebas del caso normal, dos límites y un error esperado.
- Ejecutar la suite con
uv run pytest -qy conservar la salida. - Documentar en Markdown qué comportamiento queda cubierto y qué riesgo no se ha probado todavía.
- 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.