Cuando las pruebas pasan, pero el código sigue «roto»
\n\nImagina la situación: escribiste pruebas unitarias para un proyecto en C#, dotnet test da luz verde, la cobertura de código (code coverage) es del 85%. El equipo está contento, el CI/CD se ejecuta perfectamente. Pero un mes después aparece un error en producción que, al parecer, las pruebas unitarias deberían haber detectado. ¿Te suena familiar?
El problema es que la cobertura de código (code coverage) es una métrica engañosa. Muestra qué líneas de código fueron ejecutadas, pero no verifica la calidad de las propias comprobaciones. Puedes tener un 100% de cobertura, pero ni una sola aserción significativa. Aquí es donde entran en escena las pruebas de mutación en C#: una metodología que «rompe» tu código para comprobar qué tan bien tus pruebas pueden detectar errores.
\n\nEn este artículo analizaremos qué son las pruebas de mutación, cómo implementarlas en proyectos .NET con Stryker.NET, y por qué son imprescindibles para cualquier desarrollador serio de C#.
\n\n¿Qué son las pruebas de mutación (Mutation Testing)?
\n\nLas pruebas de mutación son un método para evaluar la calidad de las pruebas, basado en la introducción de pequeños cambios intencionales (mutaciones) en el código fuente del programa. Cada una de estas versiones modificadas del código se denomina mutante (mutant).
\n\nLa lógica es simple:
\n- \n
- Se toma tu código de producción. \n
- Se generan mutantes automáticamente — por ejemplo, el operador
+se reemplaza por-, la condiciónif (x > 5)se cambia aif (x >= 5), ytrueporfalse. \n - Se ejecutan tus pruebas unitarias contra cada mutante. \n
- Si la prueba falla — el mutante está «muerto» (killed). Buena prueba. \n
- Si la prueba pasa — el mutante «sobrevive» (survived). Mala señal: tu prueba no detectó el cambio de lógica. \n
El objetivo es lograr un alto porcentaje de mutantes muertos (normalmente 80%+). Esto es una garantía real de que tus pruebas verifican el comportamiento, y no solo «recorren» el código.
\n\n¿Por qué la cobertura de código es insuficiente?
\n\nConsideremos un ejemplo simple en C#. Supongamos que tenemos un método que verifica si un número es par:
\n\npublic class MathUtils\n{\n public bool IsEven(int number)\n {\n return number % 2 == 0;\n }\n}\n\nY una prueba «ideal» con 100% de cobertura:
\n\n[Test]\npublic void IsEven_ShouldReturnTrueForEvenNumber()\n{\n var utils = new MathUtils();\n var result = utils.IsEven(4);\n Assert.IsTrue(result);\n}\n\nLa prueba es verde, cobertura del 100%. Pero ¿qué pasa si alguien accidentalmente (o maliciosamente) cambia el método a return number % 2 == 1;? ¿Nuestra única prueba fallará? ¡No! Probará el número 4 (4 % 2 == 0, pero ahora el método devolverá false), la aserción fallará — el mutante será muerto. ¿Todo bien? Espera.
¿Y si cambiamos el código a return number % 2 != 1;? Lógicamente es lo mismo, pero las pruebas de mutación crearán un mutante: return number % 2 != 0;. Nuestra prueba con el número 4 seguirá pasando (4 % 2 != 0 — false, el método devolverá false, pero la aserción espera true — la prueba fallará). Aquí todo está bien. Pero imagina que escribimos la prueba así:
[Test]\npublic void IsEven_ShouldNotThrow()\n{\n var utils = new MathUtils();\n Assert.DoesNotThrow(() => utils.IsEven(4));\n}\n\nLa cobertura sigue siendo del 100%, ¡pero la aserción no verifica el resultado! Ahora cualquier mutante sobrevivirá. Las pruebas de mutación mostrarán inmediatamente que tus pruebas son vacías.
\n\nHerramientas para pruebas de mutación en C#
\n\nLa herramienta principal y prácticamente estándar para .NET es Stryker.NET. Es un framework de código abierto que se integra fácilmente en cualquier proyecto de C#. Soporta xUnit, NUnit y MSTest.
\n\nOtras herramientas (menos populares):
\n- \n
- VisualMutator — un plugin obsoleto para Visual Studio. \n
- Nester \n