39. Refactorizar pruebas sin cambiar su intención

39.1 Introducción

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.

39.2 Qué significa refactorizar una prueba

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:

  • Renombrar una prueba para que explique mejor su intención.
  • Separar una prueba que verifica demasiadas cosas.
  • Eliminar preparación repetida mediante una fixture o función auxiliar.
  • Convertir pruebas casi idénticas en una prueba parametrizada.
  • Reemplazar datos confusos por datos más simples.
  • Cambiar una aserción genérica por una más expresiva.

La regla central es que el comportamiento protegido no debe cambiar accidentalmente.

39.3 La intención de una prueba

La intención es la respuesta a la pregunta: ¿qué comportamiento intenta proteger esta prueba?

Antes de refactorizar, conviene poder responder:

  • ¿Qué unidad se está probando?
  • ¿Qué situación concreta representa?
  • ¿Cuál es el resultado esperado?
  • ¿Qué error detectaría si fallara?
  • ¿Qué información aporta al lector?

Si no podemos responder estas preguntas, la primera tarea no es mover código, sino entender qué valor tiene la prueba.

39.4 Ejecutar las pruebas antes de refactorizar

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.

Refactorizar con pruebas fallando aumenta el riesgo de cambiar la intención sin notarlo.

39.5 Renombrar pruebas

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.

39.6 Ordenar la estructura Arrange, Act, Assert

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.

39.7 Separar pruebas con demasiadas aserciones

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.

39.8 Extraer preparación repetida

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.

39.9 No ocultar el dato importante

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.

39.10 Convertir repetición en parametrización

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.

39.11 Cuándo no convertir a parametrización

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.

39.12 Simplificar datos de prueba

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.

39.13 Mejorar aserciones

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.

39.14 Eliminar aserciones redundantes

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.

39.15 Reducir acoplamiento a detalles internos

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.

39.16 Refactorizar fixtures

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.

39.17 Mantener pruebas independientes

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.

39.18 Cambiar la prueba y cambiar el requisito no son lo mismo

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".

39.19 Refactorizar en pasos pequeños

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:

  • Renombrar pruebas poco claras.
  • Separar pruebas con demasiadas responsabilidades.
  • Simplificar datos de prueba.
  • Extraer preparación repetida.
  • Parametrizar casos realmente similares.
  • Eliminar acoplamientos internos innecesarios.

Hacer todo a la vez dificulta saber qué cambio provocó un fallo.

39.20 Usar fallas intencionales para comprobar sensibilidad

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.

39.21 Tabla de refactorizaciones frecuentes

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.

39.22 Errores frecuentes al refactorizar pruebas

Al mejorar pruebas existentes, conviene evitar estos errores:

  • Cambiar el comportamiento esperado sin reconocer que cambió el requisito.
  • Eliminar aserciones que eran la única protección de una regla.
  • Extraer fixtures que ocultan los datos relevantes del caso.
  • Parametrizar casos con intenciones distintas.
  • Introducir estado compartido para reducir repetición.
  • Hacer una refactorización grande sin ejecutar pruebas en pasos intermedios.
  • Dejar nombres genéricos después de mover código.
  • Debilitar aserciones para que las pruebas pasen.

Una refactorización correcta debe dejar la suite más clara y al menos igual de capaz de detectar errores.

39.23 Qué debes recordar de este tema

  • Refactorizar pruebas significa mejorar su estructura sin cambiar su intención.
  • Antes de refactorizar conviene entender qué comportamiento protege cada prueba.
  • Los nombres, datos y aserciones deben hacer visible la regla esperada.
  • Las fixtures y funciones auxiliares ayudan si reducen ruido sin ocultar información clave.
  • La parametrización sirve para casos con la misma estructura e intención.
  • Las pruebas deben seguir siendo independientes después de refactorizar.
  • Cambiar una regla de negocio no es refactorizar: es modificar el comportamiento esperado.

39.24 Conclusión

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.