12k
All articles

Testing en la era de la IA: usar IA para escribir tests

Las pruebas generadas por IA pueden subir la cobertura sin encontrar fallos. Usa prompts basados en requisitos, pruebas de mutación y revisión de mocks.

OpenReplay Team
OpenReplay Team
Testing en la era de la IA: usar IA para escribir tests

Un test cuyo valor esperado se obtuvo ejecutando el código bajo prueba solo puede confirmar lo que el código ya hace; si el bug ya está ahí, el test lo consolida.

Si generas tests con un asistente, el patrón te resultará familiar: el PR añade cuarenta tests, la cobertura sube dos puntos, CI está en verde y el mismo bug de facturación sigue llegando a producción el jueves. Los tests nunca tuvieron forma de discrepar del código.

Este artículo cubre las tres formas que adoptan los tests generados cuando pasan sin proteger nada, y las tres comprobaciones que las corrigen: partir del requisito en el prompt en lugar de la implementación, romper el código a propósito y leer los mocks antes que las aserciones.

Puntos clave

  • La cobertura de líneas registra qué líneas se ejecutaron y no registra nada sobre si una aserción fallaría en caso de que esas líneas produjeran una respuesta incorrecta.
  • Un valor esperado calculado a mano y otro copiado de la salida de la función se ven idénticos en un diff; solo difiere su procedencia, y por eso los tests generados superan la revisión.
  • Pedirle a un modelo que escriba tests para el nombre de una función le deja únicamente la implementación como punto de partida; proporcionarle las reglas de negocio le da una fuente de verdad capaz de discrepar del código.
  • La comprobación más rápida sobre una suite generada consiste en cambiar una constante o invertir una comparación en el código fuente y volver a ejecutarla; si todo sigue en verde, nada estaba protegiendo esa línea.
  • Si un test sustituye por un mock la función que dice estar probando, su aserción comprueba el valor de retorno configurado del mock, y ese test debería eliminarse en lugar de repararse.

¿Por qué sube la cobertura mientras los bugs siguen llegando a producción?

La cobertura mide ejecución, no verificación. Los proveedores de cobertura de Jest (Babel/Istanbul por defecto, V8 opcional) y los proveedores de cobertura de Vitest (V8 por defecto, Istanbul opcional) informan de las mismas cuatro métricas: statements, branches, functions y lines. Ninguno evalúa si alguna aserción podría fallar. Un test que llama a una función y hace una aserción sobre lo que sea que devolvió cubre todas las líneas que tocó, exactamente igual que un test con una aserción correcta.

Por eso el número sigue subiendo mientras los bugs siguen llegando a producción. Los tests generados son baratos, así que se ejecuta más código bajo prueba. Pero un test derivado de la implementación comparte todos sus defectos con la implementación, y el informe de cobertura no tiene ninguna columna para eso.

Fallo uno: el test afirma lo que el código ya devuelve

Un modelo al que solo se le da el código fuente dispone de un único oráculo para los valores esperados: el propio código fuente. Razona sobre la función (o la ejecuta), observa la salida y escribe esa salida como literal en la aserción. Si la función está mal, el literal está mal de la misma manera.

Veamos una función de descuento con un bug de copiar y pegar:

// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
  const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
  return price * (1 - rate);
}

La API describe/it/expect de abajo funciona tanto en Jest como en Vitest; Jest la expone globalmente, mientras que Vitest requiere un import o globals: true.

import { calculateDiscount } from "./discount";

// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
  expect(calculateDiscount(100, "silver")).toBe(75);
});

// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
  expect(calculateDiscount(100, "silver")).toBe(85);
});

El primer test pasa contra la función defectuosa. El segundo falla, que es justamente el objetivo. En un diff, 75 y 85 parecen igual de legítimos. Nada en la sintaxis le indica a quien revisa que uno se calculó a partir de la regla de precios y el otro se copió de la salida de la función. La única defensa es preguntar de dónde salió el número.

Fallo dos: los tests generados solo cubren el happy path

Una suite generada prueba lo que el prompt describió, y un prompt que se limita a nombrar una función describe únicamente su funcionamiento normal. Los casos que una suite generada omite con más frecuencia (entrada mal formada, una dependencia inaccesible, un timeout) son precisamente los que el prompt nunca mencionó, así que el modelo no tuvo motivo para escribirlos.

El resultado son cinco tests casi idénticos para entradas bien formadas con tiers válidos y ninguno para un precio negativo, una cadena de tier desconocida o un servicio upstream que nunca responde. Esas son las rutas que llegan a producción sin probar, porque son también las que los desarrolladores ejercitan menos a mano.

Fallo tres: la unidad bajo prueba está mockeada

Si un test sustituye por un mock la función que dice estar probando, su aserción verifica el valor de retorno configurado del mock en lugar del comportamiento de la función. Los modelos mockean de forma agresiva porque mockear hace que los tests pasen de manera fiable, y un test que mockea la base de datos, el cliente de red y el servicio bajo prueba pasará con cualquier implementación.

El patrón se ve más claro con una dependencia inyectada, lo que además mantiene el ejemplo libre de las diferencias entre jest.mock y vi.mock:

// cart.ts
import type { calculateDiscount } from "./discount";

export function cartTotal(price: number, tier: "silver" | "gold",
                          discount: typeof calculateDiscount): number {
  return Math.round(discount(price, tier) * 100) / 100;
}

// cart.test.ts
import { cartTotal } from "./cart";

// Before: the fake is the thing being checked.
it("returns discounted total", () => {
  const fake = () => 85;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
  const fake = () => 85.004999;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

El test «before» seguiría pasando si cartTotal ignorara sus argumentos y devolviera 85. El test «after» pasa al fake un valor que solo sale correcto si se ejecuta la lógica de redondeo, de modo que observa la función y no el stub.

Corrección uno: dale a la generación de tests unitarios con IA el requisito, no el código

Pedirle a un modelo que escriba tests para el nombre de una función le deja únicamente la implementación como punto de partida. Suministrarle las reglas de negocio como requisitos escritos le da algo que el código no puede aportar: una especificación capaz de discrepar del código.

El prompt débil:

Write unit tests for calculateDiscount.

El prompt más sólido:

Write unit tests for calculateDiscount in discount.ts.

Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.

Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.

El segundo prompt produce expect(calculateDiscount(100, "silver")).toBe(85) porque 85 es lo que dice la regla, y falla contra la función defectuosa en la primera ejecución. Los nombres de test que se leen como reglas («charges 85 for a 100 silver order») también hacen que la suite pueda revisarse como una especificación. El prompt no garantiza que el modelo ignore el código fuente; le da una fuente de verdad con mayor autoridad que este.

Corrección dos: rompe el código y vigila que aparezca el rojo

La comprobación más rápida sobre una suite de tests generada es cambiar una constante o invertir una comparación en el código bajo prueba y volver a ejecutar los tests. Si todo sigue en verde, la suite no estaba protegiendo ese código.

1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
   If nothing fails, no test asserts the silver rate.

El mutation testing automatiza esto. StrykerJS (@stryker-mutator/core) inyecta pequeños cambios en el código fuente, vuelve a ejecutar la suite para cada uno e informa de una puntuación de mutación, que divide los mutantes detectados por tus tests (un test falló, o la ejecución agotó el tiempo) entre el número de mutantes válidos. Sus mutadores soportados invierten operadores de comparación, niegan booleanos, intercambian operadores aritméticos, vacían literales de cadena y eliminan cuerpos de bloque. Existen plugins de runner para Jest y para Vitest; comprueba la compatibilidad del plugin con la versión que tengas instalada antes de adoptarlo.

Corrección tres: lee los mocks antes que las aserciones

Revisa el diff de un test generado en este orden: qué se está falseando, de dónde vinieron los valores esperados y, por último, qué se afirma. Lo que se falsea determina qué puede observar el test. De dónde vino el valor esperado determina si la aserción puede discrepar del código. La aserción en sí es la línea menos informativa.

  • Un mock que hace de sustituto de la unidad bajo prueba: elimina el test.
  • Un valor esperado que es una llamada a función, o un literal sin ninguna regla detrás: recalcúlalo a partir del requisito.
  • Mocks solo para fronteras reales (base de datos, red, reloj) y aserciones sobre la salida propia de la unidad: consérvalo.

Conserva el criterio

La IA es fiable en las partes mecánicas del testing: fixtures, setup y teardown, tablas parametrizadas de casos casi idénticos, boilerplate para rutas de error asíncronas. No es fiable a la hora de decidir qué comportamientos merece la pena afirmar, porque esa decisión vive en el requisito, no en el código. Genera el andamiaje y después toma el test que más importa del diff, rompe la línea que debería proteger y confirma que se pone en rojo antes de hacer merge.

Preguntas frecuentes

¿El mutation testing sustituye a la cobertura de código?

No. Ambos informan de carencias distintas. La cobertura te dice qué líneas no se ejecutaron nunca bajo ningún test; el mutation testing te dice qué líneas ejecutadas no están protegidas por ninguna aserción. StrykerJS informa de ambas cosas: un mutante en código no ejecutado recibe el estado No coverage, mientras que un mutante en código ejecutado que todos los tests siguen pasando queda como Survived. La puntuación de mutación cuenta ambos casos como no detectados, así que una cobertura baja reduce la puntuación directamente.

¿Funciona StrykerJS con Vitest y Jest?

Sí, mediante plugins de runner. Para Vitest, instala @stryker-mutator/vitest-runner y establece testRunner a vitest en la configuración de Stryker; el plugin no incluye Vitest, así que consulta su package.json para localizar la versión más antigua de Vitest que acepta. Los tests que se ejecutan mediante el Browser Mode de Vitest quedan fuera de lo que el runner gestiona. Para Jest, usa @stryker-mutator/jest-runner. Con el runner de Vitest, Stryker desactiva la propia recolección de cobertura de Vitest y hace que cada ejecución de un mutante se detenga en el primer test que falla, porque un solo fallo basta para matar al mutante.

¿Cómo detecto tests generados por IA que no contienen ninguna aserción?

Haz que el framework falle cualquier test con cero aserciones. En Vitest, establece expect.requireAssertions en la configuración o pasa --expect.requireAssertions por CLI; un test que termina sin llamar a expect falla. En Jest, llama a expect.hasAssertions() dentro del cuerpo del test, o desde un hook beforeEach para aplicarlo en todas partes. Ninguna de estas opciones comprueba si una aserción podría fallar, así que un test que afirma el propio valor de retorno de un mock sigue pasando.

¿Se puede confiar en los tests de snapshot generados por IA?

No sin revisarlos. Un snapshot grabado a partir de la salida actual solo afirma que el código sigue produciendo lo que producía cuando se grabó, bugs incluidos, lo que es la misma tautología que un literal copiado. Volver a grabarlo está a un flag de distancia: jest -u y vitest -u reescriben todos los snapshots que fallan. En CI, el flag --ci de Jest falla ante snapshots nuevos en lugar de escribirlos en silencio. Revisa los diffs de snapshot contra el requisito, no contra la salida anterior.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.