Por qué mis tests no me daban confianza
April 23, 2026
Hace unos días, Ignacio Meléndez publicó un post que me pegó fuerte: "AI + Mutation Testing: Write Tests That Actually Fail".
Mientras lo leía pensé: claro, por eso mis tests no me dan confianza.
No es que estén mal escritos. Es que nunca fueron diseñados para fallar.
El test que no puede fallar
Cuando le pedís a una IA que genere tests para una función existente, lo que hace es leer el código y validar su comportamiento. No cuestionarlo.
Es como pedirle a alguien que escribió un email que también diseñe las preguntas para revisarlo: va a hacer preguntas que ya sabe responder.
Hace un tiempo tuve que testear una función que validaba fechas:
1function isValidDate(date: string): boolean {2 const d = new Date(date)3 return !isNaN(d.getTime()) && d.getTime() <= Date.now()4}
Le pedí tests a Cursor. Me devolvió algo así:
1test('returns true for valid past date', () => {2 expect(isValidDate('2024-01-15')).toBe(true)3})45test('returns false for future date', () => {6 expect(isValidDate('2030-01-15')).toBe(false)7})
Todo en verde. 100% de coverage. Sensación de productividad.
Pero había un problema: la función tiene un edge case sutil. Tanto Date.now() como d.getTime() devuelven milisegundos, pero la comparación <= deja una zona gris cuando la fecha es exactamente “ahora”. En sistemas concurrentes, eso no es tan teórico.
El test no lo detecta.
No porque la IA sea mala, sino porque su objetivo es confirmar, no encontrar errores.
Lo que me dejó el post de Ignacio
Hay una frase que se me quedó grabada:
"Un test que nunca fue rojo no prueba que el código funcione. Prueba que el test se escribió después del código."
Es TDD al revés. Y la IA, por defecto, hace TDD al revés.
La idea que propone Ignacio es simple y potente: si no podés hacer fallar un test rompiendo el código, ese test no vale.
Lo probé con una función que uso seguido:
1function formatPrice(amount: number, currency: 'EUR' | 'USD'): string {2 const symbols = { EUR: '€', USD: '$' }3 return `${symbols[currency]}${amount.toFixed(2)}`4}
Los tests que tenía:
1test('formats EUR correctly', () => {2 expect(formatPrice(10.5, 'EUR')).toBe('€10.50')3})45test('formats USD correctly', () => {6 expect(formatPrice(10.5, 'USD')).toBe('$10.50')7})
Intenté romperlos cambiando la implementación:
1// Eliminé el toFixed2return `${symbols[currency]}${amount}`
Y sorpresa: el test puede seguir pasando si amount es entero.
En mi caso real, siempre hay decimales. Pero el test no lo garantiza. No me protege.
Entonces lo reescribí:
1test('always shows two decimals', () => {2 expect(formatPrice(10, 'EUR')).toBe('€10.00')3 expect(formatPrice(10.9, 'USD')).toBe('$10.90')4})56test('rejects negative amounts', () => {7 expect(() => formatPrice(-10, 'EUR')).toThrow()8})
Ahora sí: si rompo la lógica, el test cae. Y eso cambia todo.
Mutation testing: la prueba real
En el post, Ignacio habla de mutation testing: romper automáticamente tu código y ver si los tests lo detectan.
Nunca lo había usado. Después de leerlo, probé Stryker en un proyecto chico.
Resultado: tenía 100% de coverage… pero varios mutantes sobrevivientes.
En una función de validación de emails, por ejemplo, cambiar un || por un && no hacía fallar ningún test.
Los tests decían “todo bien”. Los mutantes decían “nadie está mirando acá”.
Tenían razón. Ese bug llegó a producción.
Lo que me llevo
No dejé de usar IA para tests. La uso todos los días. Pero cambié el enfoque:
- Escribo primero qué debería hacer la función (en lenguaje natural).
- Le pido a la IA tests basados en esa intención, no en la implementación.
- Intento romper los tests modificando el código.
- Si no puedo romperlos, los descarto o los mejoro.
- Recién después escribo (o ajusto) la implementación.
Es más lento. Pero ahora los tests que tengo realmente pueden fallar.
Conclusión
Si tu test no puede fallar, no es un test. Es un comentario ejecutable.
Referencia: AI + Mutation Testing: Write Tests That Actually Fail — Ignacio Meléndez