ConstructiCat Logo
CodeBust.
Browse section ▾

Conditional Test Logic.

Una prueba que usa `if`/`switch`/ternarios, bucles o `try`/`catch` para decidir qué ejecutar o aseverar, de modo que su comportamiento (y si verifica algo siquiera) depende de qué rama se ejecute en tiempo de ejecución.

##Signs and Symptoms

Una prueba se lee como un pequeño programa en lugar de como un guion lineal "preparar → actuar → aseverar". Busca flujo de control dentro del cuerpo de la prueba:

  • if/else, switch, ternarios o cortocircuitos &&/|| que controlan qué aserciones se ejecutan.
  • Bucles for/while/forEach que construyen entradas o iteran sobre aserciones.
  • try/catch usado para "probar" una ruta de error, con las llamadas a expect escondidas dentro del catch.
  • Una misma prueba reutilizada para varios casos ramificando según un flag o el estado del entorno.
  • Valores esperados calculados en la prueba (a menudo con un bucle o una fórmula) en lugar de codificados de forma literal.
// Olor: las aserciones viven dentro de código condicional/ramificado
test('user discount', () => {
  const user = getUser();
  if (user.isPremium) {
    expect(price(user)).toBe(80);   // puede no ejecutarse nunca
  } else {
    expect(price(user)).toBe(100);  // puede no ejecutarse nunca
  }
});

test('throws on bad input', () => {
  try {
    parse('!!!');
    // si parse() NO lanza, caemos hasta aquí y no aseveramos nada → la prueba pasa
  } catch (err) {
    expect(err.message).toMatch(/invalid/);
  }
});

El modo de fallo revelador: la prueba sigue en verde aunque el código esté roto, porque nunca se tomó la rama que contenía la aserción.

##Reasons for the Problem

Por qué ocurre

  • DRY llevado al extremo. Los autores intentan cubrir varios escenarios con una única prueba "flexible", ramificando según las entradas o un flag en lugar de escribir una prueba por caso (Meszaros: Flexible Test).
  • Acoplamiento al entorno. El SUT no se desacopló de sus dependencias, así que la prueba se adapta a cualquier estado que encuentre en tiempo de ejecución.
  • Teardown defensivo. Aparece if (resource) resource.close() para evitar desmontar fixtures que quizá no existan (Complex Teardown).
  • Expectativas calculadas. El resultado esperado se deriva con el mismo algoritmo que el código de producción, arrastrando esa lógica (bucles incluidos) hasta la prueba (Production Logic in Test).
  • Prueba manual de la ruta de error. Se usa try/catch para aseverar sobre un error lanzado en lugar de un matcher integrado.

Por qué es perjudicial

  • Falsa confianza (lo peor). La mayoría de los runners solo hacen fallar una prueba cuando una aserción lanza. Si se omite la rama que asevera, o el código bajo prueba no lanza dentro del try, la prueba pasa sin haber verificado nada.
  • Código de prueba no probado. Las ramas y los bucles dentro de una prueba son lógica que, a su vez, no tiene pruebas; un error en el propio flujo de control de la prueba pasa desapercibido.
  • Diagnósticos pobres. Cuando una prueba con ramificaciones falla, primero debes averiguar qué camino se ejecutó antes de poder interpretar el fallo.
  • Menor legibilidad y mantenibilidad. Una prueba lineal documenta un comportamiento con un único resultado esperado; una prueba con ramificaciones obliga al lector a simular la ejecución para saber qué se garantiza en realidad.
  • Fragilidad. Las pruebas que dependen del estado del entorno o de la ejecución pasan o fallan de forma no determinista.

##Treatment

Haz que cada prueba sea un único camino lineal e incondicional. En concreto:

  1. Un escenario por prueba. Divide una prueba con ramificaciones en pruebas separadas, o usa la API basada en datos del framework (test.each, it.each, pruebas parametrizadas) para que cada caso sea su propia ejecución, con nombre claro e informada de forma independiente.
  2. Saca la ramificación fuera de la prueba. Si un caso solo aplica bajo cierta condición, decídelo en tiempo de definición (por ejemplo, eligiendo describe/it según la configuración), no dentro del cuerpo de la prueba; las aserciones en sí permanecen incondicionales.
  3. Codifica los valores esperados de forma literal. Sustituye las expectativas calculadas por resultados esperados literales (o un Expected Object / matcher personalizado). No reimplementes la lógica de producción en la prueba.
  4. Prueba las rutas de error con matchers, no con try/catch: expect(fn).toThrow(...), await expect(p).rejects.toThrow(...). Estos fallan de forma sonora cuando no se lanza ningún error.
  5. Si una aserción condicional es realmente inevitable, fija el recuento con expect.assertions(n) / expect.hasAssertions() para que una rama omitida falle en lugar de pasar en silencio.
  6. Sustituye el teardown condicional por los hooks de ciclo de vida del framework (afterEach) y una limpieza automática/idempotente, de modo que no haga falta ningún if para proteger el desmontaje.
// Antes: prueba con ramificaciones, las aserciones pueden omitirse
test('user discount', () => {
  const user = getUser();
  if (user.isPremium) expect(price(user)).toBe(80);
  else                expect(price(user)).toBe(100);
});

// Después: un caso explícito por fila, cada aserción se ejecuta siempre
test.each([
  ['premium', { isPremium: true },  80],
  ['regular', { isPremium: false }, 100],
])('price for %s user', (_label, user, expected) => {
  expect(price(user)).toBe(expected);
});

// Antes: try/catch que pasa cuando no se lanza nada
try { parse('!!!'); } catch (e) { expect(e.message).toMatch(/invalid/); }

// Después: falla si parse() no lanza
expect(() => parse('!!!')).toThrow(/invalid/);

##Detected by