19. Cómo detectar pruebas frágiles durante el ciclo de TDD

19.1 Objetivo del tema

En este tema aprenderemos a reconocer pruebas frágiles mientras aplicamos TDD. Una prueba frágil falla con facilidad por razones que no representan un defecto real en el comportamiento del sistema.

El objetivo práctico es distinguir entre una prueba que protege una regla importante y una prueba que solo está atada a detalles accidentales de implementación.

19.2 Qué es una prueba frágil

Una prueba frágil es una prueba que se rompe ante cambios internos razonables, aunque el comportamiento visible siga siendo correcto.

En TDD, una prueba frágil dificulta el refactor: en lugar de dar confianza, empieza a castigar cambios sanos del diseño.

No toda prueba que falla es frágil. Si falla porque rompimos una regla del negocio, está cumpliendo su función. El problema aparece cuando falla por motivos irrelevantes.

19.3 Señal 1: prueba detalles internos

Una señal frecuente de fragilidad es verificar atributos, estructuras o métodos privados que no forman parte del contrato público del objeto.

Ejemplo a evitar:

def test_deposito_guarda_el_saldo_en_un_atributo_interno():
    cuenta = CuentaBancaria()

    cuenta.depositar(100)

    assert cuenta._saldo == 100

Esta prueba obliga a que el saldo se guarde exactamente en _saldo. Si mañana el saldo se calcula desde movimientos, la prueba fallará aunque la cuenta siga funcionando.

19.4 Probar comportamiento observable

La prueba debería comprobar lo que el sistema promete hacia afuera. Si el saldo es parte del comportamiento público, podemos verificarlo sin depender del atributo interno usado para almacenarlo.

Archivo a crear o modificar: tests/test_cuenta.py

def test_deposito_aumenta_el_saldo_disponible():
    cuenta = CuentaBancaria()

    cuenta.depositar(100)

    assert cuenta.saldo == 100

Esta prueba permite refactorizar la implementación mientras se mantenga el resultado esperado por el dominio.

19.5 Señal 2: depende del orden accidental

Algunas pruebas fallan porque esperan un orden que el requisito no exige.

Supongamos una función que devuelve etiquetas de una compra:

Archivo a crear o modificar: tests/test_etiquetas.py

def test_etiquetas_de_compra():
    etiquetas = obtener_etiquetas_compra(total=120, cliente_frecuente=True)

    assert etiquetas == ["cliente_frecuente", "compra_grande"]

Si el requisito solo dice que ambas etiquetas deben estar presentes, el orden exacto no debería importar.

19.6 Corregir la prueba según la regla real

Cuando el orden no es parte del comportamiento, la prueba debe expresarlo.

Archivo a crear o modificar: tests/test_etiquetas.py

def test_etiquetas_de_compra():
    etiquetas = obtener_etiquetas_compra(total=120, cliente_frecuente=True)

    assert set(etiquetas) == {"cliente_frecuente", "compra_grande"}

Ahora la prueba protege la regla correcta: deben aparecer las dos etiquetas, sin imponer una secuencia innecesaria.

19.7 Señal 3: usa datos demasiado exactos

Una prueba puede volverse frágil cuando compara una salida completa aunque solo una parte sea relevante para la regla.

Ejemplo a evitar:

def test_resumen_de_cuenta():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)

    resumen = generar_resumen(cuenta)

    assert resumen == {
        "titulo": "Resumen de cuenta",
        "saldo": 100,
        "cantidad_movimientos": 1,
        "mensaje": "Gracias por operar con nosotros"
    }

Si la prueba busca validar el saldo, está comprobando demasiadas cosas al mismo tiempo. Un cambio de texto en mensaje podría romperla sin afectar la regla que quería proteger.

19.8 Comprobar solo lo necesario

En TDD conviene que cada prueba tenga una razón clara para fallar. Si el comportamiento importante es el saldo del resumen, la prueba debe enfocarse en eso.

def test_resumen_incluye_el_saldo_actual():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)

    resumen = generar_resumen(cuenta)

    assert resumen["saldo"] == 100

Esta versión es menos frágil porque no mezcla varias expectativas independientes en una sola comparación.

19.9 Señal 4: depende de la hora actual

Las pruebas que usan directamente la fecha u hora del sistema pueden fallar según el día, la zona horaria o el momento exacto de ejecución.

Ejemplo frágil:

from datetime import date


def test_promocion_de_fin_de_mes():
    descuento = calcular_descuento_por_fecha(date.today())

    assert descuento == 10

Esta prueba no siempre tiene el mismo resultado. Depende del día en que se ejecute.

19.10 Controlar el dato variable

La solución más simple es pasar explícitamente la fecha que queremos probar.

Archivo a crear o modificar: tests/test_promociones.py

from datetime import date


def test_promocion_de_fin_de_mes():
    fecha = date(2026, 5, 31)

    descuento = calcular_descuento_por_fecha(fecha)

    assert descuento == 10

Ahora la prueba es determinística: debería dar el mismo resultado hoy, mañana y dentro de varios meses.

19.11 Señal 5: comparte estado entre pruebas

Una prueba es frágil cuando su resultado depende de lo que otra prueba ejecutó antes. Esto suele pasar con listas globales, objetos compartidos o fixtures con scope demasiado amplio.

Ejemplo a evitar:

cuenta = CuentaBancaria()


def test_deposito():
    cuenta.depositar(100)

    assert cuenta.saldo == 100


def test_retiro():
    cuenta.retirar(40)

    assert cuenta.saldo == 60

Si se ejecuta solo test_retiro, la prueba puede fallar porque depende del depósito hecho por otra prueba.

19.12 Aislar cada prueba

Cada prueba debe preparar su propio escenario. Las fixtures pueden ayudar, siempre que entreguen objetos nuevos y no compartan estado mutable.

import pytest


@pytest.fixture
def cuenta_con_saldo():
    cuenta = CuentaBancaria()
    cuenta.depositar(100)
    return cuenta


def test_deposito_aumenta_saldo():
    cuenta = CuentaBancaria()

    cuenta.depositar(100)

    assert cuenta.saldo == 100


def test_retiro_disminuye_saldo(cuenta_con_saldo):
    cuenta_con_saldo.retirar(40)

    assert cuenta_con_saldo.saldo == 60

Ahora cada prueba tiene el estado que necesita y puede ejecutarse sola o junto con toda la suite.

19.13 Señal 6: demasiado conocimiento del algoritmo

Una prueba frágil puede describir cómo debe resolverse el problema, en lugar de describir qué resultado espera el usuario o el dominio.

Ejemplo a evitar:

def test_calculo_de_puntos_usa_multiplicador_interno():
    puntos = calcular_puntos(total=100, nivel="oro")

    assert puntos == 100 * 2 + 10

Si el negocio solo exige que un cliente oro reciba 210 puntos por una compra de 100, la prueba no necesita exponer la fórmula interna.

19.14 Expresar el ejemplo como regla

Es mejor nombrar el comportamiento esperado y verificar el resultado observable.

def test_cliente_oro_recibe_puntos_extra():
    puntos = calcular_puntos(total=100, nivel="oro")

    assert puntos == 210

Si luego refactorizamos la fórmula o movemos constantes a otro módulo, la prueba seguirá siendo válida mientras el comportamiento se mantenga.

19.15 Señal 7: demasiadas verificaciones en una prueba

Una prueba con muchas aserciones puede ser difícil de diagnosticar. Cuando falla, cuesta entender qué regla se rompió.

def test_transferencia():
    origen = CuentaBancaria()
    destino = CuentaBancaria()
    origen.depositar(100)

    origen.transferir_a(destino, 40)

    assert origen.saldo == 60
    assert destino.saldo == 40
    assert origen.movimientos[-1].tipo == "transferencia_enviada"
    assert destino.movimientos[-1].tipo == "transferencia_recibida"

Esta prueba no siempre está mal, pero puede mezclar dos reglas: saldos resultantes y movimientos registrados.

19.16 Separar cuando mejora el diagnóstico

Podemos dividir la intención en dos pruebas más específicas.

def test_transferencia_mueve_saldo_entre_cuentas():
    origen = CuentaBancaria()
    destino = CuentaBancaria()
    origen.depositar(100)

    origen.transferir_a(destino, 40)

    assert origen.saldo == 60
    assert destino.saldo == 40


def test_transferencia_registra_movimientos_en_ambas_cuentas():
    origen = CuentaBancaria()
    destino = CuentaBancaria()
    origen.depositar(100)

    origen.transferir_a(destino, 40)

    assert origen.movimientos[-1].tipo == "transferencia_enviada"
    assert destino.movimientos[-1].tipo == "transferencia_recibida"

La duplicación de preparación puede resolverse con una fixture o una fábrica, siempre que no oculte la acción principal.

19.17 Detectar fragilidad durante el ciclo TDD

Durante TDD podemos usar preguntas simples en cada etapa.

  • En rojo: ¿la prueba falla por la razón esperada?
  • En verde: ¿el código mínimo satisface una regla real?
  • En refactor: ¿puedo cambiar la implementación sin romper pruebas innecesariamente?
  • Después del refactor: ¿la prueba sigue describiendo comportamiento y no estructura interna?

19.18 Qué hacer cuando una prueba frágil falla

No conviene borrar la prueba de inmediato. Primero hay que entender qué intención tenía.

  1. Identificar qué regla quería proteger.
  2. Eliminar detalles que no pertenecen a esa regla.
  3. Reescribir la prueba en términos de comportamiento observable.
  4. Ejecutar la suite completa con python -m pytest.
  5. Refactorizar el código de producción solo si la prueba nueva lo permite con claridad.

19.19 Ejercicio práctico

Revisá estas pruebas y detectá cuál es el problema de fragilidad en cada una.

def test_cuenta_con_saldo():
    cuenta = CuentaBancaria()
    cuenta.saldo = 100

    assert cuenta.saldo == 100


def test_etiquetas():
    assert obtener_etiquetas_compra(120, True) == [
        "compra_grande",
        "cliente_frecuente"
    ]


def test_descuento_hoy():
    assert calcular_descuento_por_fecha(date.today()) == 10

Luego reescribí cada prueba para que sea más estable, más clara y más cercana al lenguaje del dominio.

19.20 Checklist del tema

  • Una prueba frágil falla por detalles accidentales, no por reglas reales.
  • Las pruebas deben preferir comportamiento observable sobre estructura interna.
  • Los datos variables, como fechas, deben controlarse explícitamente.
  • Cada prueba debe poder ejecutarse sola.
  • Durante el refactor, las pruebas deben dar confianza y no bloquear cambios razonables.

19.21 Conclusión

Las pruebas frágiles reducen la utilidad de TDD porque hacen que el refactor sea costoso y confuso. Detectarlas a tiempo permite mantener una suite que protege el comportamiento importante sin imponer una forma rígida de implementación.

En el próximo tema trabajaremos sobre cómo refactorizar nombres, estructura y duplicación manteniendo todas las pruebas en verde.