Qué se persigue realmente con las pruebas
Una prueba automática es una especificación que se ejecuta. Su objetivo no es demostrar que no hay errores (eso es imposible), sino reducir el coste de cambiar el código: dar feedback rápido, fiable y localizado cuando algo se rompe. Una buena batería permite refactorizar sin miedo; una mala frena el desarrollo y se ignora.
Tres propiedades distinguen una batería útil de una cara de mantener:
- Confianza: si pasa, se puede desplegar; si falla, hay un problema real.
- Velocidad: se ejecuta con tanta frecuencia que cuesta poco.
- Mantenibilidad: cambia con el comportamiento, no con cada detalle interno.
Niveles de prueba
| Nivel | Qué comprueba | Dependencias reales | Velocidad |
|---|---|---|---|
| Unitaria | Una función o clase aislada | Ninguna | Milisegundos |
| Integración | Que varias piezas cooperan (base de datos, cola, API) | Algunas | Segundos |
| De contrato | Que cliente y proveedor cumplen la interfaz acordada | Ninguna entre ellos | Rápida |
| Extremo a extremo (E2E) | Un recorrido completo desde la interfaz | Todas | Lenta |
Pirámide frente a trofeo
La pirámide clásica recomienda muchas unitarias, algunas de integración y pocas E2E, porque las lentas son caras y frágiles. El trofeo de pruebas, popular en el desarrollo web, argumenta que las pruebas de integración dan más confianza por unidad de esfuerzo, porque los fallos suelen estar en las uniones entre piezas más que en la lógica aislada.
Ninguna forma es correcta en abstracto. La pregunta útil es: ¿qué prueba más barata detecta este riesgo? Una regla de cálculo se prueba mejor con unitarias; una consulta a base de datos, con integración contra una base real; un flujo de compra, con un puñado de E2E.
Pruebas de contrato
En sistemas con varios servicios, cada uno puede pasar sus pruebas y aun así fallar al hablar con el otro. Una prueba de contrato fija qué espera el consumidor de una API y verifica que el proveedor lo cumple, sin levantar todo el entorno. Es la alternativa económica a las E2E entre servicios.
Tipos de prueba según su objetivo
Los niveles dicen cuánto del sistema se ejercita; los tipos dicen qué se quiere comprobar.
| Tipo | Pregunta que responde | Cuándo ejecutarla |
|---|---|---|
| Funcional | ¿Hace lo que debe? | Siempre |
| De regresión | ¿Sigue funcionando lo que funcionaba? | En cada cambio |
| De humo (smoke) | ¿Arranca y responde lo esencial? | Tras cada despliegue |
| De rendimiento | ¿Responde dentro de los tiempos exigidos? | Periódicamente y antes de versiones importantes |
| De carga | ¿Aguanta el tráfico esperado? | Antes de picos conocidos |
| De estrés | ¿Cómo se degrada más allá de su límite y se recupera? | Para conocer el punto de rotura |
| De seguridad | ¿Resiste entradas y usos hostiles? | En CI y en revisiones específicas |
| De accesibilidad | ¿Es utilizable con lector de pantalla y teclado? | En CI y con revisión manual |
| Exploratoria | ¿Qué se le escapa a lo previsto? | Sesiones acotadas de una persona con criterio |
La exploración manual no es lo contrario de la automatización: encuentra lo que nadie pensó en escribir como caso, y sus hallazgos se convierten después en pruebas automáticas de regresión.
Cómo se escribe una buena prueba
Patrón AAA y Given-When-Then
Estructurar cada prueba en tres fases la hace legible y evita que mezcle varias cosas. Given-When-Then es la misma idea con vocabulario de comportamiento.
def test_descuento_del_diez_por_ciento_para_clientes_vip():
# Arrange
cliente = Cliente(nivel="vip")
carrito = Carrito(cliente, lineas=[Linea("libro", precio=100.0, cantidad=1)])
# Act
total = carrito.calcular_total()
# Assert
assert total == 90.0El nombre de la prueba debe contar el comportamiento y la condición. Cuando falla, el nombre ya dice qué dejó de cumplirse.
Una prueba, una razón para fallar
Una prueba con diez aserciones sobre cosas distintas oculta cuál de ellas falla y obliga a depurar. Se agrupan aserciones solo si describen un mismo comportamiento.
Desarrollo guiado por pruebas (TDD)
Y vuelta a empezar con el siguiente comportamiento.
Escribir el test antes obliga a pensar en la interfaz desde el uso y garantiza que el test es capaz de fallar. Un test que nunca se ha visto en rojo puede estar pasando por casualidad. TDD no obliga a escribir todas las pruebas antes: el ritmo natural es un comportamiento pequeño cada vez.
Aplicado a un error: primero se escribe el test que lo reproduce y falla, después se corrige, y el test queda como protección contra regresión.
Desarrollo guiado por comportamiento (BDD) y Gherkin
BDD parte de que el problema más caro no es programar mal, sino programar lo que no era. Su práctica central es fijar con ejemplos concretos, entre negocio, desarrollo y calidad, cómo debe comportarse el sistema. Gherkin es el lenguaje estructurado para escribirlos.
Característica: Descuento por pedido grande
Escenario: Un pedido de más de 100 euros recibe envío gratuito
Dado un carrito con un total de 120 euros
Cuando el cliente elige envío estándar
Entonces el coste de envío es 0 euros
Esquema del escenario: Gastos de envío por tramo
Dado un carrito con un total de <total> euros
Cuando el cliente elige envío estándar
Entonces el coste de envío es <envio> euros
Ejemplos:
| total | envio |
| 20 | 4.90 |
| 100 | 4.90 |
| 100.01 | 0 |BDD solo aporta si los escenarios nacen de una conversación real y están en lenguaje del dominio. Si los escribe en solitario una persona de QA y nadie más los lee, es una capa de indirección costosa sobre las pruebas normales.
Dobles de prueba
Un doble sustituye una dependencia (red, reloj, base de datos, correo) para que la prueba sea rápida y determinista.
Por ejemplo, un repositorio en memoria en lugar de una base de datos.
| Doble | Qué hace | Se usa para |
|---|---|---|
| Dummy | Se pasa pero nunca se utiliza | Rellenar un parámetro obligatorio |
| Stub | Devuelve respuestas preparadas | Controlar lo que entra a la prueba |
| Spy | Registra las llamadas recibidas | Inspeccionar después qué ocurrió |
| Mock | Falla si no se le llama como se esperaba | Verificar una interacción importante |
| Fake | Implementación real pero simplificada | Sustituir una base de datos por una en memoria |
from unittest.mock import Mock
class RepositorioEnMemoria: # Fake
def __init__(self):
self.pedidos = {}
def guardar(self, pedido):
self.pedidos[pedido.id] = pedido
def test_confirmar_pedido_envia_un_aviso():
repo = RepositorioEnMemoria() # Fake
notificador = Mock() # Mock
servicio = ServicioPedidos(repo, notificador)
servicio.confirmar(Pedido(id=7))
assert 7 in repo.pedidos
notificador.enviar.assert_called_once_with("Pedido 7 confirmado")Regla práctica: simular lo que no controlas (servicios externos, reloj, aleatoriedad) y preferir dobles con comportamiento real (fakes) frente a mocks que verifican detalles internos. Una prueba que comprueba cada llamada interna se rompe en cada refactorización sin haber detectado ningún error.
Técnicas para diseñar casos
No se pueden probar todas las entradas. Estas técnicas eligen pocas que representen a muchas.
Particiones de equivalencia y valores límite
Se divide el dominio en clases que el programa trata igual y se prueba un representante de cada una; después se prueban los bordes, donde se concentran los errores de comparación (< frente a <=).
import pytest
@pytest.mark.parametrize(
"edad, esperado",
[
(-1, "invalida"), # fuera del dominio
(0, "menor"), # límite inferior
(17, "menor"), # justo antes del límite
(18, "adulto"), # el límite exacto
(120, "adulto"), # límite superior
(121, "invalida"), # fuera del dominio
],
)
def test_clasificar_edad(edad, esperado):
assert clasificar_edad(edad) == esperadoTablas de decisión
Cuando el resultado depende de combinaciones de condiciones, una tabla evita olvidar casos.
| Cliente VIP | Pedido mayor de 100 | Cupón válido | Resultado |
|---|---|---|---|
| Sí | Sí | Sí | Descuento VIP más cupón |
| Sí | Sí | No | Descuento VIP |
| No | Sí | Sí | Solo cupón |
| No | No | No | Sin descuento |
Cada fila es un caso; las filas imposibles o equivalentes se descartan de forma consciente.
Pruebas basadas en propiedades
En lugar de ejemplos concretos, se enuncia una regla que debe cumplirse para cualquier entrada y una librería genera cientos de casos, reduciendo el que falla al más pequeño posible.
from hypothesis import given, strategies as st
@given(st.lists(st.integers()))
def test_ordenar_es_idempotente_y_conserva_elementos(lista):
ordenada = sorted(lista)
assert sorted(ordenada) == ordenada
assert len(ordenada) == len(lista)Fixtures y datos de prueba
Los fixtures preparan el contexto de una prueba y lo limpian al terminar. Con pytest son funciones reutilizables:
import pytest
@pytest.fixture
def cliente_vip():
return Cliente(nombre="Ana", nivel="vip")
@pytest.fixture
def bd(tmp_path):
conexion = crear_base(tmp_path / "test.db")
yield conexion # la prueba se ejecuta aquí
conexion.close() # limpieza garantizada
def test_guarda_cliente(bd, cliente_vip):
guardar(bd, cliente_vip)
assert buscar(bd, "Ana").nivel == "vip"Para los datos conviene usar constructores (builders o fábricas) que crean objetos válidos con valores por defecto y permiten cambiar solo lo relevante para la prueba. Evitan duplicar datos y dejan claro qué importa en cada caso. Los datos reales de producción no deben usarse sin anonimizar.
Cobertura: útil como alarma, mala como objetivo
| Métrica | Mide |
|---|---|
| De línea | Qué líneas se han ejecutado |
| De rama | Si se han recorrido las dos salidas de cada condición |
pytest --cov=app --cov-branch --cov-report=term-missingLa cobertura dice qué código no se ha ejecutado nunca, que es información valiosa. Pero un 100 % no implica que se compruebe nada: una prueba sin aserciones cubre líneas igual. Convertirla en objetivo numérico invita a escribir pruebas vacías. Se usa mejor como alarma: vigilar que no baje y mirar las zonas críticas sin cubrir.
Pruebas de mutación
Para medir si los tests detectan errores, se introducen errores a propósito y se comprueba si alguna prueba falla.
Algún test falla: mutante muerto, el test protege. Todo sigue en verde: mutante vivo, falta un test.
Un mutante que sobrevive señala una prueba que ejecuta el código sin verificarlo. Es costoso en tiempo, así que se aplica a módulos críticos o de forma periódica, no en cada commit. En Python se usan herramientas como mutmut; en Java, PIT; en JavaScript y .NET, Stryker.
Tests inestables (flaky)
Un test inestable pasa y falla sin que el código cambie. Es peor que no tener test: obliga a repetir ejecuciones, y el equipo aprende a ignorar los fallos.
| Causa habitual | Solución |
|---|---|
| Dependencia del orden o estado compartido | Aislar datos; ejecutar en orden aleatorio para detectarlo |
| Tiempo real (esperas fijas, fechas) | Inyectar un reloj controlable; esperar por condiciones, no por segundos |
| Red o servicios externos | Dobles o contenedores locales |
| Concurrencia | Sincronizar explícitamente; evitar dormir y esperar |
| Datos que varían (aleatorios, identificadores) | Fijar la semilla y registrarla |
| Recursos de la máquina | Tiempos límite razonables y entornos estables |
Reintentar automáticamente un test fallido oculta el problema. Si se usa, que sea como medida temporal con seguimiento, no como política.
Pruebas en integración continua
- Rápidas primero: el feedback de lint, tipos y unitarias debe llegar en minutos.
- Paralelizar y distribuir las lentas.
- Entornos reproducibles: los mismos contenedores y versiones que en producción.
- Bloquear la integración si fallan; un fallo conocido no se tolera.
- Publicar los resultados (informes, capturas, trazas) para diagnosticar sin reproducir en local.
Probar sistemas con inteligencia artificial
Un modelo de lenguaje no devuelve siempre lo mismo, así que no se prueba con igualdad exacta. Se separan dos problemas:
Se compara cada versión con la anterior para detectar regresiones.
- Lo determinista (prompts construidos, parseo de la respuesta, llamada a herramientas, gestión de errores) se prueba con un modelo falso que devuelve respuestas predefinidas: rápido, gratuito y repetible.
- Lo probabilístico (calidad de las respuestas) se mide con un conjunto de evaluación: casos reales con criterio de acierto, ejecutados sobre cada versión del prompt o del modelo para detectar regresiones.
class ModeloFalso:
def __init__(self, respuestas):
self.respuestas = iter(respuestas)
def invocar(self, prompt):
return next(self.respuestas)
def test_extrae_el_importe_de_la_respuesta():
modelo = ModeloFalso(['{"importe": 42.5}'])
assert extraer_importe(modelo, "factura de ejemplo") == 42.5
def test_respuesta_mal_formada_se_trata_como_error():
modelo = ModeloFalso(["esto no es JSON"])
with pytest.raises(RespuestaInvalida):
extraer_importe(modelo, "factura de ejemplo")Los criterios de evaluación pueden ser comprobaciones exactas (el JSON es válido, aparece el dato), reglas o la valoración de otro modelo; esta última debe validarse a su vez contra juicio humano.
Lista de comprobación
- Cada error corregido tiene su prueba de regresión.
- Las pruebas se ejecutan en CI y bloquean la integración.
- No dependen del orden, de la hora ni de la red.
- Los límites y los casos de error están probados, no solo el camino feliz.
- Se vigila la cobertura de rama en las zonas críticas.
- Los tests inestables se tratan de inmediato.
- El tiempo total de la batería rápida sigue siendo corto.
Antipatrones
- Cono de helado: casi todo son E2E manuales o lentos y apenas hay unitarias.
- Perseguir un porcentaje de cobertura en lugar de comprobar comportamiento.
- Probar la implementación (llamadas internas) en vez del resultado observable.
- Mocks por todas partes: la prueba pasa aunque el sistema real falle.
- Pruebas con lógica propia (bucles, condiciones) que a su vez necesitarían pruebas.
- Estado compartido entre pruebas.
- Desactivar una prueba «temporalmente» y no volver a mirarla.
- Esperas fijas (dormir) en lugar de esperar a una condición.
- Aserciones ausentes o vagas que nunca pueden fallar.