Pruebas unitarias de .NET: gaste por adelantado para ahorrar más adelante

Publicado: 2022-03-11

A menudo hay mucha confusión y dudas con respecto a las pruebas unitarias cuando se discuten con las partes interesadas y los clientes. Las pruebas unitarias a veces suenan como el hilo dental para un niño: "Ya me lavé los dientes, ¿por qué necesito hacer esto?"

Sugerir pruebas unitarias a menudo suena como un gasto innecesario para las personas que consideran que sus métodos de prueba y las pruebas de aceptación del usuario son lo suficientemente fuertes.

Pero las pruebas unitarias son una herramienta muy poderosa y son más simples de lo que piensas. En este artículo, veremos las pruebas unitarias y qué herramientas están disponibles en DotNet, como Microsoft.VisualStudio.TestTools y Moq .

Intentaremos construir una biblioteca de clases simple que calcule el término n en la secuencia de Fibonacci. Para hacer eso, querremos crear una clase para calcular secuencias de Fibonacci que dependa de una clase matemática personalizada que suma los números. Luego, podemos usar .NET Testing Framework para asegurarnos de que nuestro programa se ejecute como se esperaba.

¿Qué es la prueba unitaria?

Las pruebas unitarias dividen el programa en el código más pequeño, generalmente a nivel de función, y aseguran que la función devuelva el valor esperado. Al utilizar un marco de pruebas unitarias, las pruebas unitarias se convierten en una entidad separada que luego puede ejecutar pruebas automatizadas en el programa a medida que se construye.

 [TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }

Una prueba unitaria simple que usa la metodología Arrange, Act, Assert que prueba que nuestra biblioteca de matemáticas puede sumar correctamente 2 + 2.

Una vez que se configuran las pruebas unitarias, si se realiza un cambio en el código, para tener en cuenta una condición adicional que no se conocía cuando se desarrolló el programa por primera vez, por ejemplo, las pruebas unitarias mostrarán si todos los casos coinciden con los valores esperados. salida por la función.

Las pruebas unitarias no son pruebas de integración. No es una prueba de extremo a extremo. Si bien ambas son metodologías poderosas, deberían funcionar junto con las pruebas unitarias, no como un reemplazo.

Los beneficios y el propósito de las pruebas unitarias

El beneficio más difícil de entender de las pruebas unitarias, pero el más importante, es la capacidad de volver a probar el código modificado sobre la marcha. La razón por la que puede ser tan difícil de entender es porque muchos desarrolladores piensan: "Nunca volveré a tocar esa función" o "Volveré a probarla cuando termine". Y las partes interesadas piensan en términos de: "Si esa pieza ya está escrita, ¿por qué necesito volver a probarla?"

Como alguien que ha estado en ambos lados del espectro del desarrollo, he dicho ambas cosas. El desarrollador dentro de mí sabe por qué tenemos que volver a probarlo.

Los cambios que hacemos día a día pueden tener un gran impacto. Por ejemplo:

  • ¿Su interruptor tiene en cuenta correctamente un nuevo valor que ingresó?
  • ¿Sabes cuántas veces usaste ese interruptor?
  • ¿Contabilizó correctamente las comparaciones de cadenas que no distinguen entre mayúsculas y minúsculas?
  • ¿Está comprobando los valores nulos correctamente?
  • ¿Se maneja una excepción de lanzamiento como esperaba?

Las pruebas unitarias toman estas preguntas y las memorizan en un código y un proceso para garantizar que estas preguntas siempre se respondan. Las pruebas unitarias se pueden ejecutar antes de una compilación para garantizar que no haya introducido nuevos errores. Debido a que las pruebas unitarias están diseñadas para ser atómicas, se ejecutan muy rápidamente, generalmente menos de 10 milisegundos por prueba. Incluso en una aplicación muy grande, se puede realizar un conjunto completo de pruebas en menos de una hora. ¿Puede su proceso UAT coincidir con eso?

Ejemplo de una convención de nomenclatura configurada para buscar fácilmente una clase o método dentro de una clase para probar.
Aparte de Fibonacci_GetNthTerm_Input2_AssertResult1 , que es la primera ejecución e incluye el tiempo de configuración, todas las pruebas unitarias se ejecutan en menos de 5 ms. Mi convención de nomenclatura aquí está configurada para buscar fácilmente una clase o método dentro de una clase que quiero probar

Sin embargo, como desarrollador, tal vez esto suene como más trabajo para ti. Sí, tienes la tranquilidad de saber que el código que liberas es bueno. Pero las pruebas unitarias también le ofrecen la oportunidad de ver dónde falla su diseño. ¿Está escribiendo las mismas pruebas unitarias para dos piezas de código? ¿Deberían estar en una pieza de código en su lugar?

Lograr que su código sea comprobable por unidad en sí mismo es una forma de mejorar su diseño. Y para la mayoría de los desarrolladores que nunca han realizado pruebas unitarias, o que no se toman tanto tiempo para considerar el diseño antes de codificar, pueden darse cuenta de cuánto mejora su diseño al prepararlo para las pruebas unitarias.

¿Es comprobable su unidad de código?

Además de DRY, también tenemos otras consideraciones.

¿Están sus métodos o funciones tratando de hacer demasiado?

Si necesita escribir pruebas unitarias demasiado complejas que duran más de lo esperado, su método puede ser demasiado complicado y más adecuado como métodos múltiples.

¿Está aprovechando adecuadamente la inyección de dependencia?

Si su método bajo prueba requiere otra clase o función, lo llamamos dependencia. En las pruebas unitarias, no nos importa lo que hace la dependencia bajo el capó; para el propósito del método bajo prueba, es una caja negra. La dependencia tiene su propio conjunto de pruebas unitarias que determinarán si su comportamiento funciona correctamente.

Como probador, desea simular esa dependencia y decirle qué valores devolver en instancias específicas. Esto le dará un mayor control sobre sus casos de prueba. Para hacer esto, deberá inyectar una versión ficticia (o, como veremos más adelante, simulada) de esa dependencia.

¿Tus componentes interactúan entre sí como esperas?

Una vez que haya resuelto sus dependencias y su inyección de dependencia, es posible que haya introducido dependencias cíclicas en su código. Si la Clase A depende de la Clase B, que a su vez depende de la Clase A, debe reconsiderar su diseño.

La belleza de la inyección de dependencia

Consideremos nuestro ejemplo de Fibonacci. Su jefe le dice que tiene una nueva clase que es más eficiente y precisa que el operador de adición actual disponible en C#.

Si bien este ejemplo en particular no es muy probable en el mundo real, vemos ejemplos análogos en otros componentes, como la autenticación, el mapeo de objetos y prácticamente cualquier proceso algorítmico. A los efectos de este artículo, supongamos que la nueva función de agregar de su cliente es la última y la mejor desde que se inventaron las computadoras.

Como tal, su jefe le entrega una biblioteca de caja negra con una sola clase Math y, en esa clase, una sola función Add . Es probable que su trabajo de implementar una calculadora de Fibonacci se vea así:

 public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }

Esto no es horrendo. Instancias una nueva clase de Math y la usas para sumar los dos términos anteriores para obtener el siguiente. Ejecuta este método a través de su batería normal de pruebas, calculando hasta 100 términos, calculando el término 1000, el término 10,000, y así sucesivamente hasta que se sienta satisfecho de que su metodología funciona bien. Luego, en algún momento en el futuro, un usuario se queja de que el término 501 no funciona como se esperaba. Pasas la noche revisando tu código y tratando de averiguar por qué este caso de la esquina no funciona. Empiezas a sospechar que la última y mejor clase de Math no es tan buena como cree tu jefe. Pero es una caja negra y realmente no puedes probar eso, llegas a un callejón sin salida internamente.

El problema aquí es que la dependencia Math no se inyecta en su calculadora Fibonacci. Por lo tanto, en sus pruebas, siempre confía en los resultados existentes, no probados y desconocidos de Math para comparar Fibonacci. Si hay un problema con Math , entonces Fibonacci siempre será incorrecto (sin codificar un caso especial para el término 501).

La idea para corregir este problema es inyectar la clase Math en su calculadora Fibonacci. Pero aún mejor, es crear una interfaz para la clase Math que defina los métodos públicos (en nuestro caso, Add ) e implementar la interfaz en nuestra clase Math .

 public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }

En lugar de inyectar la clase Math en Fibonacci, podemos inyectar la interfaz IMath en Fibonacci. El beneficio aquí es que podríamos definir nuestra propia clase OurMath que sabemos que es precisa y probar nuestra calculadora contra eso. Aún mejor, usando Moq podemos simplemente definir lo que devuelve Math.Add . Podemos definir una cantidad de sumas o simplemente decirle a Math.Add que devuelva x + y.

 private IMath _math; public Fibonacci(IMath math) { _math = math; }

Inyecte la interfaz IMath en la clase Fibonacci

 //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);

Usar Moq para definir qué devuelve Math.Add .

Ahora tenemos un método probado y verdadero (bueno, si ese operador + es incorrecto en C#, tenemos problemas mayores) para sumar dos números. Usando nuestro nuevo IMath , podemos codificar una prueba unitaria para nuestro término 501 y ver si nos equivocamos en nuestra implementación o si la clase Math personalizada necesita un poco más de trabajo.

No permita que un método intente hacer demasiado

Este ejemplo también apunta a la idea de un método que hace demasiado. Claro, la adición es una operación bastante simple sin mucha necesidad de abstraer su funcionalidad de nuestro método GetNthTerm . Pero, ¿y si la operación fuera un poco más complicada? En lugar de agregar, tal vez fue la validación del modelo, llamar a una fábrica para obtener un objeto para operar o recopilar datos adicionales necesarios de un repositorio.

La mayoría de los desarrolladores intentarán ceñirse a la idea de que un método tiene un propósito. En las pruebas unitarias, tratamos de apegarnos al principio de que las pruebas unitarias deben aplicarse a los métodos atómicos y al introducir demasiadas operaciones en un método lo hacemos imposible de probar. A menudo podemos crear un problema en el que tenemos que escribir tantas pruebas para probar correctamente nuestra función.

Cada parámetro que agregamos a un método aumenta exponencialmente el número de pruebas que tenemos que escribir de acuerdo con la complejidad del parámetro. Si agrega un booleano a su lógica, debe duplicar la cantidad de pruebas para escribir, ya que ahora necesita verificar los casos verdaderos y falsos junto con sus pruebas actuales. En el caso de la validación de modelos, la complejidad de nuestras pruebas unitarias puede aumentar muy rápidamente.

Diagrama de las pruebas adicionales necesarias cuando se agrega un valor booleano a la lógica.

Todos somos culpables de agregar un poco más a un método. Pero estos métodos más grandes y complejos crean la necesidad de demasiadas pruebas unitarias. Y rápidamente se hace evidente cuando escribe las pruebas unitarias que el método está tratando de hacer demasiado. Si siente que está tratando de probar demasiados resultados posibles a partir de sus parámetros de entrada, considere el hecho de que su método debe dividirse en una serie de resultados más pequeños.

No te repitas

Uno de nuestros inquilinos favoritos de la programación. Este debería ser bastante sencillo. Si se encuentra escribiendo las mismas pruebas más de una vez, ha introducido código más de una vez. Puede beneficiarlo refactorizar ese trabajo en una clase común que sea accesible para ambas instancias en las que está tratando de usarlo.

¿Qué herramientas de prueba unitaria están disponibles?

DotNet nos ofrece una plataforma de prueba unitaria muy poderosa lista para usar. Con esto, puede implementar lo que se conoce como la metodología Arrange, Act, Assert. Usted organiza sus consideraciones iniciales, actúa sobre esas condiciones con su método bajo prueba y luego afirma que algo sucedió. Puede afirmar cualquier cosa, lo que hace que esta herramienta sea aún más poderosa. Puede afirmar que se llamó a un método una cantidad específica de veces, que el método devolvió un valor específico, que se lanzó un tipo particular de excepción o cualquier otra cosa que se le ocurra. Para aquellos que buscan un marco más avanzado, NUnit y su contraparte de Java, JUnit, son opciones viables.

 [TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }

Probando que nuestro Método Fibonacci maneja números negativos lanzando una excepción. Las pruebas unitarias pueden verificar que se lanzó la excepción.

Para manejar la inyección de dependencia, tanto Ninject como Unity existen en la plataforma DotNet. Hay muy poca diferencia entre los dos, y se trata de si desea administrar las configuraciones con Fluent Syntax o XML Configuration.

Para simular las dependencias, recomiendo Moq. Moq puede ser un desafío para tener en tus manos, pero la esencia es que creas una versión simulada de tus dependencias. Luego, le dices a la dependencia qué devolver bajo condiciones específicas. Por ejemplo, si tuviera un método llamado Square(int x) que elevara al cuadrado el número entero, podría indicarle que cuando x = 2 devuelva 4. También podría indicarle que devuelva x^2 para cualquier número entero. O podría decirle que devuelva 5 cuando x = 2. ¿Por qué realizaría el último caso? En el caso de que el método bajo el rol de la prueba sea validar la respuesta de la dependencia, es posible que desee forzar el retorno de las respuestas no válidas para asegurarse de que está detectando el error correctamente.

 [TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }

Usando Moq para decirle a la interfaz IMath cómo manejar Add bajo prueba. Puede establecer casos explícitos con It.Is o un rango con It.IsInRange .

Marcos de pruebas unitarias para DotNet

Marco de pruebas unitarias de Microsoft

Microsoft Unit Testing Framework es la solución de prueba unitaria lista para usar de Microsoft y se incluye con Visual Studio. Debido a que viene con VS, se integra muy bien con él. Cuando comience un proyecto, Visual Studio le preguntará si desea crear una biblioteca de pruebas unitarias junto con su aplicación.

Microsoft Unit Testing Framework también viene con una serie de herramientas para ayudarlo a analizar mejor sus procedimientos de prueba. Además, como es propiedad y está escrito por Microsoft, existe cierta sensación de estabilidad en su existencia en el futuro.

Pero cuando trabaja con las herramientas de Microsoft, obtiene lo que le brindan. El marco de pruebas unitarias de Microsoft puede ser engorroso de integrar.

NUnidad

La mayor ventaja para mí al usar NUnit son las pruebas parametrizadas. En nuestro ejemplo anterior de Fibonacci, podemos ingresar una cantidad de casos de prueba y asegurarnos de que esos resultados sean verdaderos. Y en el caso de nuestro problema 501, siempre podemos agregar un nuevo conjunto de parámetros para garantizar que la prueba siempre se ejecute sin necesidad de un nuevo método de prueba.

El principal inconveniente de NUnit es integrarlo en Visual Studio. Carece de las campanas y silbatos que vienen con la versión de Microsoft y significa que necesitará descargar su propio conjunto de herramientas.

xUnit.Net

xUnit es muy popular en C# porque se integra muy bien con el ecosistema .NET existente. Nuget tiene muchas extensiones de xUnit disponibles. También se integra muy bien con Team Foundation Server, aunque no estoy seguro de cuántos desarrolladores de .NET todavía usan TFS en varias implementaciones de Git.

En el lado negativo, muchos usuarios se quejan de que falta un poco la documentación de xUnit. Para los nuevos usuarios de pruebas unitarias, esto puede causar un gran dolor de cabeza. Además, la extensibilidad y la adaptabilidad de xUnit también hacen que la curva de aprendizaje sea un poco más pronunciada que NUnit o el marco de pruebas unitarias de Microsoft.

Diseño/Desarrollo Basado en Pruebas

El diseño/desarrollo basado en pruebas (TDD) es un tema un poco más avanzado que merece su propia publicación. Sin embargo, quería proporcionar una introducción.

La idea es comenzar con sus pruebas unitarias y decirle a sus pruebas unitarias lo que es correcto. Luego, puede escribir su código alrededor de esas pruebas. En teoría, el concepto suena simple, pero en la práctica, es muy difícil entrenar tu cerebro para que piense al revés sobre la aplicación. Pero el enfoque tiene el beneficio incorporado de no tener que escribir sus pruebas unitarias después del hecho. Esto conduce a menos refactorización, reescritura y confusión de clases.

TDD ha sido una palabra de moda en los últimos años, pero la adopción ha sido lenta. Su naturaleza conceptual es confusa para las partes interesadas, lo que dificulta su aprobación. Pero como desarrollador, lo animo a escribir incluso una aplicación pequeña utilizando el enfoque TDD para acostumbrarse al proceso.

Por qué no puede tener demasiadas pruebas unitarias

Las pruebas unitarias son una de las herramientas de prueba más poderosas que los desarrolladores tienen a su disposición. De ninguna manera es suficiente para una prueba completa de su aplicación, pero sus beneficios en las pruebas de regresión, el diseño de código y la documentación del propósito son inigualables.

No existe tal cosa como escribir demasiadas pruebas unitarias. Cada caso extremo puede proponer grandes problemas en el futuro en su software. Conmemorar los errores encontrados como pruebas unitarias puede garantizar que esos errores no encuentren formas de volver a colarse en su software durante los cambios de código posteriores. Si bien puede agregar entre un 10 y un 20 % al presupuesto inicial de su proyecto, podría ahorrar mucho más que eso en capacitación, corrección de errores y documentación.

Puede encontrar el repositorio de Bitbucket utilizado en este artículo aquí.