Las pruebas también son código. Con el tiempo pueden acumular repetición, nombres poco claros, preparación extensa, datos difíciles de leer o detalles internos que ya no ayudan a entender el comportamiento probado.
Refactorizar pruebas significa mejorar su estructura sin cambiar qué verifican. La intención debe mantenerse: si una prueba protegía una regla de negocio, después de refactorizar debe seguir protegiendo esa misma regla.
Este tema muestra cómo mejorar pruebas existentes con cuidado: renombrar, ordenar, extraer preparación, parametrizar casos, reducir fragilidad y simplificar aserciones sin perder información útil.
Refactorizar una prueba es modificar su forma sin cambiar su comportamiento esperado. No se trata de agregar nuevos casos ni de cambiar reglas de negocio, sino de expresar mejor lo que la prueba ya intentaba verificar.
Algunos ejemplos de refactorización son:
La regla central es que el comportamiento protegido no debe cambiar accidentalmente.
La intención es la respuesta a la pregunta: ¿qué comportamiento intenta proteger esta prueba?
Antes de refactorizar, conviene poder responder:
Si no podemos responder estas preguntas, la primera tarea no es mover código, sino entender qué valor tiene la prueba.
Antes de modificar una prueba existente, conviene ejecutar la suite o al menos el grupo de pruebas afectado. Necesitamos saber si partimos de un estado verde o de un estado con fallas conocidas.
pytest tests/test_carrito.py
Si una prueba ya falla antes de refactorizar, el cambio puede mezclarse con un defecto real. En ese caso conviene registrar la situación y decidir si primero se corrige el fallo o si la refactorización se hace en un paso separado.
Una de las refactorizaciones más simples y útiles es mejorar el nombre de una prueba. El nombre debe comunicar el comportamiento esperado, no solo repetir el nombre de la función probada.
def test_descuento():
assert aplicar_descuento(1000, 10) == 900
El nombre anterior es demasiado general. Podemos mejorarlo:
def test_aplicar_descuento_del_10_por_ciento_reduce_el_precio():
assert aplicar_descuento(1000, 10) == 900
No cambió la aserción ni el comportamiento probado. Solo hicimos explícita la intención.
Una prueba puede volverse difícil de leer cuando mezcla preparación, ejecución y verificación sin orden. Refactorizarla a Arrange, Act, Assert ayuda a entender el flujo.
def test_total_carrito():
carrito = Carrito()
assert carrito.total() == 0
carrito.agregar("mouse", 1000)
assert carrito.total() == 1000
Esta prueba mezcla dos momentos del carrito. Podemos separarla:
def test_carrito_vacio_tiene_total_cero():
carrito = Carrito()
assert carrito.total() == 0
def test_agregar_producto_actualiza_total():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.total() == 1000
Ahora cada prueba tiene una intención más clara. Si una falla, el diagnóstico será más directo.
No toda prueba con varias aserciones es mala, pero muchas aserciones sobre comportamientos distintos pueden ocultar qué se está verificando.
def test_usuario():
usuario = crear_usuario("Ana", 20)
assert usuario.nombre == "Ana"
assert usuario.edad == 20
assert usuario.puede_registrarse() is True
assert usuario.categoria() == "adulto"
Esta prueba mezcla construcción, registro y categorización. Una refactorización posible es dividirla:
def test_crear_usuario_guarda_nombre_y_edad():
usuario = crear_usuario("Ana", 20)
assert usuario.nombre == "Ana"
assert usuario.edad == 20
def test_usuario_mayor_de_edad_puede_registrarse():
usuario = crear_usuario("Ana", 20)
assert usuario.puede_registrarse() is True
def test_usuario_mayor_de_edad_tiene_categoria_adulto():
usuario = crear_usuario("Ana", 20)
assert usuario.categoria() == "adulto"
La intención se conserva, pero ahora cada prueba protege un comportamiento específico.
Si varias pruebas repiten la misma preparación secundaria, podemos extraerla a una fixture o función auxiliar.
def test_pedido_con_total_positivo_puede_confirmarse():
pedido = {"cliente": "Ana", "items": ["mouse"], "total": 1000}
assert puede_confirmarse(pedido) is True
def test_pedido_sin_items_no_puede_confirmarse():
pedido = {"cliente": "Ana", "items": [], "total": 1000}
assert puede_confirmarse(pedido) is False
Podemos crear una función auxiliar para expresar datos válidos por defecto:
def crear_pedido(cliente="Ana", items=None, total=1000):
if items is None:
items = ["mouse"]
return {"cliente": cliente, "items": items, "total": total}
def test_pedido_con_total_positivo_puede_confirmarse():
pedido = crear_pedido()
assert puede_confirmarse(pedido) is True
def test_pedido_sin_items_no_puede_confirmarse():
pedido = crear_pedido(items=[])
assert puede_confirmarse(pedido) is False
La intención de cada prueba sigue visible porque el dato que cambia aparece en el cuerpo del caso.
Al extraer preparación, debemos evitar esconder el valor que explica la regla. Si el caso depende de un límite, ese límite debe verse en la prueba.
def test_cliente_es_vip(cliente_vip):
assert cliente_vip.es_vip() is True
Si la regla es "VIP desde 1000 puntos", el valor puede quedar demasiado oculto. Una versión más explícita sería:
def test_cliente_con_1000_puntos_es_vip():
cliente = Cliente(puntos=1000)
assert cliente.es_vip() is True
La repetición de un dato importante puede ser preferible a una abstracción que obliga a buscar información en otro lugar.
Cuando varias pruebas comparten exactamente la misma estructura y solo cambian los datos, podemos refactorizarlas a una prueba parametrizada.
def test_edad_17_no_puede_registrarse():
assert puede_registrarse(17) is False
def test_edad_18_puede_registrarse():
assert puede_registrarse(18) is True
def test_edad_19_puede_registrarse():
assert puede_registrarse(19) is True
Versión parametrizada:
import pytest
@pytest.mark.parametrize("edad, esperado", [
(17, False),
(18, True),
(19, True),
])
def test_puede_registrarse_segun_edad(edad, esperado):
assert puede_registrarse(edad) is esperado
La intención se mantiene: proteger el límite de mayoría de edad. La tabla permite comparar los casos de forma compacta.
No toda repetición debe convertirse en parametrización. Si los casos tienen intenciones diferentes, una tabla puede ocultar la razón de cada prueba.
def test_usuario_sin_email_no_puede_registrarse():
usuario = crear_usuario(email=None, edad=20)
assert puede_registrarse(usuario) is False
def test_usuario_menor_de_edad_no_puede_registrarse():
usuario = crear_usuario(email="ana@example.com", edad=17)
assert puede_registrarse(usuario) is False
Estos casos podrían parametrizarse, pero mantenerlos separados puede comunicar mejor dos reglas distintas: email requerido y edad mínima.
Los datos de prueba deben ser tan simples como sea posible. Si una prueba usa valores complejos sin necesidad, la lectura se vuelve más lenta.
def test_descuento_cliente_vip():
descuento = calcular_descuento(3789.45, True)
assert descuento == 568.4175
Si la regla es 15% para clientes VIP, un dato más simple expresa mejor el comportamiento:
def test_cliente_vip_recibe_descuento_del_15_por_ciento():
descuento = calcular_descuento(1000, True)
assert descuento == 150
El cambio no altera la intención; la vuelve más fácil de verificar mentalmente.
Una aserción genérica puede funcionar, pero una aserción más específica suele comunicar mejor el resultado esperado.
def test_lista_tiene_producto():
productos = obtener_productos_disponibles()
assert len(productos) > 0
Si el comportamiento esperado es que se incluya un producto concreto, la aserción puede ser más clara:
def test_productos_disponibles_incluye_teclado_con_stock():
productos = obtener_productos_disponibles()
assert "teclado" in productos
La segunda prueba protege una expectativa más concreta. Al refactorizar, debemos confirmar que esa era realmente la intención original y no una regla nueva agregada accidentalmente.
A veces una prueba contiene aserciones que no aportan información adicional. Eliminarlas puede mejorar la claridad, siempre que no se pierda un comportamiento relevante.
def test_agregar_producto_incrementa_total():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.cantidad() == 1
assert carrito.total() == 1000
Si la intención de la prueba es el total, la cantidad podría estar cubierta por otra prueba:
def test_agregar_producto_actualiza_total():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.total() == 1000
Antes de eliminar una aserción, conviene verificar que ese comportamiento esté protegido en otro lugar o que realmente no sea parte de la intención.
Una prueba frágil suele depender de cómo está implementada la unidad por dentro, en lugar de verificar su comportamiento observable.
def test_carrito_guarda_producto_en_lista_interna():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito._productos == [{"nombre": "mouse", "precio": 1000}]
Si la clase ofrece métodos públicos, conviene verificar desde esa interfaz:
def test_agregar_producto_incrementa_cantidad_y_total():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.cantidad() == 1
assert carrito.total() == 1000
Ahora la prueba permite cambiar la estructura interna del carrito sin romperse mientras el comportamiento público se mantenga.
Las fixtures también pueden necesitar refactorización. Una fixture demasiado grande o genérica puede ocultar información importante.
@pytest.fixture
def contexto():
cliente = Cliente("Ana", puntos=1000)
pedido = Pedido(cliente)
repositorio = RepositorioFalso()
servicio = ServicioPedidos(repositorio)
return cliente, pedido, repositorio, servicio
Esta fixture obliga a recordar posiciones y contiene varias responsabilidades. Podemos separar lo que realmente se usa:
@pytest.fixture
def repositorio_pedidos():
return RepositorioFalso()
@pytest.fixture
def servicio_pedidos(repositorio_pedidos):
return ServicioPedidos(repositorio_pedidos)
Las fixtures pequeñas y nombradas suelen ser más fáciles de mantener que un contexto general para todo.
Al refactorizar, hay que evitar introducir estado compartido entre pruebas. Un intento de reducir repetición no debe hacer que una prueba dependa de otra.
carrito = Carrito()
def test_agregar_producto():
carrito.agregar("mouse", 1000)
assert carrito.cantidad() == 1
def test_carrito_inicia_vacio():
assert carrito.cantidad() == 0
Estas pruebas dependen del orden de ejecución. Una refactorización correcta crea un objeto nuevo por prueba:
def test_agregar_producto():
carrito = Carrito()
carrito.agregar("mouse", 1000)
assert carrito.cantidad() == 1
def test_carrito_inicia_vacio():
carrito = Carrito()
assert carrito.cantidad() == 0
La independencia es más importante que ahorrar unas pocas líneas.
Refactorizar no debe alterar el comportamiento esperado. Si una regla de negocio cambia, eso es una modificación de requisito, no una refactorización de prueba.
Por ejemplo, si el envío gratis antes comenzaba en 10000 y ahora comienza en 15000, cambiar la prueba es correcto, pero no es solo una mejora de estructura:
def test_compra_de_15000_tiene_envio_gratis():
assert tiene_envio_gratis(15000) is True
En ese caso también debe cambiar el código productivo y probablemente otras pruebas relacionadas. Conviene distinguir claramente entre "mejoré la prueba" y "cambió la regla".
Las pruebas se refactorizan con menor riesgo cuando los cambios son pequeños. Después de cada paso, conviene ejecutar las pruebas afectadas.
Un orden razonable puede ser:
Hacer todo a la vez dificulta saber qué cambio provocó un fallo.
Cuando una prueba fue refactorizada profundamente, puede ser útil comprobar que todavía puede fallar. Una forma simple es modificar temporalmente el código productivo o el valor esperado para ver que la prueba detecta el cambio.
Esta verificación no debe quedar en el código final. Es una técnica de comprobación durante el trabajo.
La idea es evitar pruebas que pasan siempre por accidente, por ejemplo porque ya no ejecutan la unidad correcta o porque la aserción se volvió demasiado débil.
Esta tabla resume mejoras comunes en pruebas unitarias existentes.
| Problema | Refactorización posible | Cuidado principal |
|---|---|---|
| Nombre genérico. | Renombrar según comportamiento esperado. | No cambiar la regla probada. |
| Preparación repetida. | Extraer fixture o función auxiliar. | No ocultar datos importantes. |
| Pruebas casi idénticas. | Parametrizar casos. | Agrupar solo casos con la misma intención. |
| Muchas aserciones mezcladas. | Separar en pruebas más pequeñas. | Mantener cobertura de comportamientos relevantes. |
| Dependencia de atributos privados. | Probar desde la interfaz pública. | Verificar el mismo efecto observable. |
Al mejorar pruebas existentes, conviene evitar estos errores:
Una refactorización correcta debe dejar la suite más clara y al menos igual de capaz de detectar errores.
Una suite de pruebas unitarias necesita mantenimiento. Si las pruebas se vuelven repetitivas, frágiles o difíciles de leer, pierden parte de su valor como documentación y como herramienta de confianza.
Refactorizar pruebas permite recuperar claridad, reducir ruido y mejorar el diagnóstico cuando algo falla. Pero debe hacerse con cuidado: la intención original debe conservarse, y cualquier cambio de comportamiento esperado debe tratarse como una modificación de requisito.
En el próximo tema veremos errores frecuentes al escribir pruebas unitarias, reuniendo muchos problemas que conviene detectar temprano en una suite de pruebas.