Pruebas unitarias, cómo escribir código comprobable y por qué es importante

Publicado: 2022-03-11

Las pruebas unitarias son un instrumento esencial en la caja de herramientas de cualquier desarrollador de software serio. Sin embargo, a veces puede ser bastante difícil escribir una buena prueba unitaria para una pieza de código en particular. Al tener dificultades para probar su propio código o el de otra persona, los desarrolladores a menudo piensan que sus dificultades se deben a la falta de algunos conocimientos fundamentales de prueba o técnicas secretas de prueba de unidades.

En este tutorial de pruebas unitarias, tengo la intención de demostrar que las pruebas unitarias son bastante fáciles; los problemas reales que complican las pruebas unitarias e introducen una complejidad costosa son el resultado de un código no comprobable y mal diseñado. Discutiremos qué hace que el código sea difícil de probar, qué antipatrones y malas prácticas debemos evitar para mejorar la capacidad de prueba y qué otros beneficios podemos lograr al escribir código comprobable. Veremos que escribir pruebas unitarias y generar código comprobable no se trata solo de hacer que las pruebas sean menos problemáticas, sino de hacer que el código en sí sea más robusto y más fácil de mantener.

Tutorial de pruebas unitarias: ilustración de portada

¿Qué es la prueba unitaria?

Esencialmente, una prueba unitaria es un método que instancia una pequeña porción de nuestra aplicación y verifica su comportamiento independientemente de otras partes . Una prueba unitaria típica contiene 3 fases: primero, inicializa una pequeña parte de una aplicación que desea probar (también conocida como el sistema bajo prueba o SUT), luego aplica algún estímulo al sistema bajo prueba (generalmente llamando a un en él), y finalmente, observa el comportamiento resultante. Si el comportamiento observado es consistente con las expectativas, la prueba unitaria pasa; de lo contrario, falla, lo que indica que hay un problema en algún lugar del sistema bajo prueba. Estas tres fases de prueba unitaria también se conocen como Organizar, Actuar y Afirmar, o simplemente AAA.

Una prueba unitaria puede verificar diferentes aspectos del comportamiento del sistema bajo prueba, pero lo más probable es que caiga en una de las siguientes dos categorías: basada en el estado o basada en la interacción . Verificar que el sistema bajo prueba produce resultados correctos, o que su estado resultante es correcto, se denomina prueba de unidad basada en estado , mientras que verificar que invoca correctamente ciertos métodos se denomina prueba de unidad basada en interacción .

Como metáfora de las pruebas unitarias de software adecuadas, imagine a un científico loco que quiere construir una quimera sobrenatural, con ancas de rana, tentáculos de pulpo, alas de pájaro y cabeza de perro. (Esta metáfora es bastante parecida a lo que los programadores realmente hacen en el trabajo). ¿Cómo se aseguraría ese científico de que cada parte (o unidad) que escogió realmente funcione? Bueno, él puede tomar, digamos, una sola anca de rana, aplicarle un estímulo eléctrico y verificar la contracción muscular adecuada. Lo que está haciendo es esencialmente los mismos pasos Arrange-Act-Assert de la prueba unitaria; la única diferencia es que, en este caso, la unidad se refiere a un objeto físico, no a un objeto abstracto a partir del cual construimos nuestros programas.

qué es la prueba unitaria: ilustración

Usaré C# para todos los ejemplos de este artículo, pero los conceptos descritos se aplican a todos los lenguajes de programación orientados a objetos.

Un ejemplo de una prueba unitaria simple podría verse así:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

Prueba unitaria frente a prueba de integración

Otra cosa importante a considerar es la diferencia entre las pruebas unitarias y las pruebas de integración.

El propósito de una prueba unitaria en ingeniería de software es verificar el comportamiento de una pieza de software relativamente pequeña, independientemente de otras partes. Las pruebas unitarias tienen un alcance limitado y nos permiten cubrir todos los casos, asegurando que cada parte funcione correctamente.

Por otro lado, las pruebas de integración demuestran que diferentes partes de un sistema funcionan juntas en el entorno de la vida real . Validan escenarios complejos (podemos pensar en las pruebas de integración como un usuario que realiza alguna operación de alto nivel dentro de nuestro sistema) y, por lo general, requieren la presencia de recursos externos, como bases de datos o servidores web.

Volvamos a nuestra metáfora del científico loco y supongamos que ha combinado con éxito todas las partes de la quimera. Quiere realizar una prueba de integración de la criatura resultante, asegurándose de que pueda, digamos, caminar sobre diferentes tipos de terreno. En primer lugar, el científico debe emular un entorno para que la criatura camine. Luego, arroja a la criatura a ese ambiente y la pincha con un palo, observando si camina y se mueve como fue diseñado. Después de terminar una prueba, el científico loco limpia toda la tierra, arena y rocas que ahora están esparcidas en su encantador laboratorio.

ilustración de ejemplo de prueba unitaria

Observe la diferencia significativa entre las pruebas unitarias y de integración: una prueba unitaria verifica el comportamiento de una pequeña parte de la aplicación, aislada del entorno y otras partes, y es bastante fácil de implementar, mientras que una prueba de integración cubre las interacciones entre diferentes componentes, en el entorno cercano a la vida real, y requiere más esfuerzo, incluidas las fases adicionales de instalación y desmontaje.

Una combinación razonable de pruebas unitarias y de integración garantiza que cada unidad funcione correctamente, independientemente de las demás, y que todas estas unidades funcionen bien cuando se integran, lo que nos brinda un alto nivel de confianza de que todo el sistema funciona como se espera.

Sin embargo, debemos recordar identificar siempre qué tipo de prueba estamos implementando: una unidad o una prueba de integración. La diferencia a veces puede ser engañosa. Si pensamos que estamos escribiendo una prueba unitaria para verificar algún caso sutil en una clase de lógica de negocios y nos damos cuenta de que requiere recursos externos como servicios web o bases de datos para estar presentes, algo no está bien; esencialmente, estamos usando un mazo para romper una nuez. Y eso significa mal diseño.

¿Qué hace que una prueba unitaria sea buena?

Antes de profundizar en la parte principal de este tutorial y escribir pruebas unitarias, analicemos rápidamente las propiedades de una buena prueba unitaria. Los principios de las pruebas unitarias exigen que una buena prueba sea:

  • Fácil de escribir. Los desarrolladores suelen escribir muchas pruebas unitarias para cubrir diferentes casos y aspectos del comportamiento de la aplicación, por lo que debería ser fácil codificar todas esas rutinas de prueba sin un gran esfuerzo.

  • Legible. La intención de una prueba unitaria debe ser clara. Una buena prueba unitaria cuenta una historia sobre algún aspecto del comportamiento de nuestra aplicación, por lo que debería ser fácil de entender qué escenario se está probando y, si la prueba falla, fácil de detectar cómo abordar el problema. ¡Con una buena prueba unitaria, podemos corregir un error sin tener que depurar el código!

  • De confianza. Las pruebas unitarias deberían fallar solo si hay un error en el sistema bajo prueba. Eso parece bastante obvio, pero los programadores a menudo se encuentran con un problema cuando sus pruebas fallan incluso cuando no se introdujeron errores. Por ejemplo, las pruebas pueden pasar cuando se ejecutan una por una, pero fallar cuando se ejecuta todo el conjunto de pruebas, o pasar en nuestra máquina de desarrollo y fallar en el servidor de integración continua. Estas situaciones son indicativas de un defecto de diseño. Las buenas pruebas unitarias deben ser reproducibles e independientes de factores externos como el entorno o el orden de ejecución.

  • Rápido. Los desarrolladores escriben pruebas unitarias para poder ejecutarlas repetidamente y verificar que no se hayan introducido errores. Si las pruebas unitarias son lentas, es más probable que los desarrolladores no las ejecuten en sus propias máquinas. Una prueba lenta no hará una diferencia significativa; agregue mil más y seguramente estaremos atrapados esperando por un tiempo. Las pruebas unitarias lentas también pueden indicar que el sistema bajo prueba, o la prueba misma, interactúa con sistemas externos, haciéndolo dependiente del entorno.

  • Verdaderamente unidad, no integración. Como ya discutimos, las pruebas unitarias y de integración tienen diferentes propósitos. Tanto la unidad de prueba como el sistema bajo prueba no deben acceder a los recursos de la red, bases de datos, sistema de archivos, etc., para eliminar la influencia de factores externos.

Eso es todo: no hay secretos para escribir pruebas unitarias . Sin embargo, existen algunas técnicas que nos permiten escribir código comprobable .

Código comprobable y no comprobable

Algunos códigos están escritos de tal manera que es difícil, o incluso imposible, escribir una buena prueba unitaria para ellos. Entonces, ¿qué hace que el código sea difícil de probar? Revisemos algunos antipatrones, olores de código y malas prácticas que debemos evitar al escribir código comprobable.

Envenenamiento de la base de código con factores no deterministas

Comencemos con un ejemplo simple. Imagine que estamos escribiendo un programa para un microcontrolador doméstico inteligente, y uno de los requisitos es encender automáticamente la luz en el patio trasero si se detecta algún movimiento allí durante la tarde o la noche. Hemos comenzado de abajo hacia arriba implementando un método que devuelve una representación de cadena de la hora aproximada del día ("Noche", "Mañana", "Tarde" o "Noche"):

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

Esencialmente, este método lee la hora actual del sistema y devuelve un resultado basado en ese valor. Entonces, ¿qué tiene de malo este código?

Si lo pensamos desde la perspectiva de las pruebas unitarias, veremos que no es posible escribir una prueba unitaria basada en el estado adecuada para este método. DateTime.Now es, esencialmente, una entrada oculta, que probablemente cambiará durante la ejecución del programa o entre ejecuciones de prueba. Por lo tanto, las llamadas posteriores producirán resultados diferentes.

Tal comportamiento no determinista hace que sea imposible probar la lógica interna del método GetTimeOfDay() sin cambiar realmente la fecha y la hora del sistema. Echemos un vistazo a cómo debería implementarse dicha prueba:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

Pruebas como esta violarían muchas de las reglas discutidas anteriormente. Sería costoso escribirlo (debido a la configuración no trivial y la lógica de desmontaje), poco confiable (puede fallar incluso si no hay errores en el sistema bajo prueba, debido a problemas de permisos del sistema, por ejemplo) y no se garantiza que corre rapido. Y, por último, esta prueba no sería en realidad una prueba unitaria: sería algo entre una prueba unitaria y una prueba de integración, porque pretende probar un caso extremo simple pero requiere que se configure un entorno de una manera particular. El resultado no merece la pena, ¿eh?

Resulta que todos estos problemas de capacidad de prueba son causados ​​por la API GetTimeOfDay() de baja calidad. En su forma actual, este método adolece de varios problemas:

  • Está estrechamente acoplado a la fuente de datos concreta. No es posible reutilizar este método para procesar la fecha y la hora recuperadas de otras fuentes o pasadas como argumento; el método funciona solo con la fecha y la hora de la máquina en particular que ejecuta el código. El acoplamiento estrecho es la raíz principal de la mayoría de los problemas de comprobabilidad.

  • Viola el Principio de Responsabilidad Única (PRS). El método tiene múltiples responsabilidades; consume la información y también la procesa. Otro indicador de violación de SRP es cuando una sola clase o método tiene más de una razón para cambiar . Desde esta perspectiva, el método GetTimeOfDay() podría cambiarse debido a ajustes lógicos internos o porque se debería cambiar la fuente de fecha y hora.

  • Miente sobre la información requerida para hacer su trabajo. Los desarrolladores deben leer cada línea del código fuente real para comprender qué entradas ocultas se utilizan y de dónde provienen. La firma del método por sí sola no es suficiente para comprender el comportamiento del método.

  • Es difícil de predecir y mantener. El comportamiento de un método que depende de un estado global mutable no puede predecirse simplemente leyendo el código fuente; es necesario tener en cuenta su valor actual, junto con toda la secuencia de eventos que podrían haberlo cambiado antes. En una aplicación del mundo real, tratar de desentrañar todo eso se convierte en un verdadero dolor de cabeza.

Después de revisar la API, ¡finalmente arreglémoslo! Afortunadamente, esto es mucho más fácil que discutir todos sus defectos: solo necesitamos romper las preocupaciones estrechamente relacionadas.

Arreglando la API: Introduciendo un Argumento de Método

La forma más obvia y fácil de arreglar la API es introduciendo un argumento de método:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

Ahora el método requiere que la persona que llama proporcione un argumento DateTime , en lugar de buscar secretamente esta información por sí mismo. Desde la perspectiva de las pruebas unitarias, esto es genial; el método ahora es determinista (es decir, su valor de retorno depende completamente de la entrada), por lo que la prueba basada en el estado es tan fácil como pasar algún valor de fecha y DateTime y verificar el resultado:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

Tenga en cuenta que este refactor simple también resolvió todos los problemas de API discutidos anteriormente (acoplamiento estrecho, violación de SRP, API poco clara y difícil de entender) al introducir una unión clara entre qué datos deben procesarse y cómo deben hacerse.

Excelente: el método es comprobable, pero ¿qué hay de sus clientes ? Ahora es responsabilidad de la persona que llama proporcionar la fecha y la hora al GetTimeOfDay(DateTime dateTime) , lo que significa que podrían volverse imposibles de comprobar si no prestamos suficiente atención. Echemos un vistazo a cómo podemos lidiar con eso.

Arreglando la API del cliente: Inyección de dependencia

Digamos que continuamos trabajando en el sistema de hogar inteligente e implementamos el siguiente cliente del GetTimeOfDay(DateTime dateTime) : el código del microcontrolador de hogar inteligente mencionado anteriormente responsable de encender o apagar la luz, según la hora del día y la detección de movimiento. :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

¡Ay! Tenemos el mismo tipo de problema de entrada oculto DateTime.Now : la única diferencia es que se encuentra en un nivel de abstracción un poco más alto. Para resolver este problema, podemos introducir otro argumento, delegando nuevamente la responsabilidad de proporcionar un valor DateTime a la persona que llama a un nuevo método con la firma ActuateLights(bool motionDetected, DateTime dateTime) . Pero, en lugar de mover el problema un nivel más alto en la pila de llamadas una vez más, empleemos otra técnica que nos permitirá mantener tanto ActuateLights(bool motionDetected) como sus clientes comprobables: inversión de control o IoC.

La inversión de control es una técnica simple, pero extremadamente útil, para desacoplar código y, en particular, para pruebas unitarias. (Después de todo, mantener las cosas poco acopladas es esencial para poder analizarlas independientemente unas de otras). El punto clave de IoC es separar el código de toma de decisiones ( cuándo hacer algo) del código de acción ( qué hacer cuando algo sucede). ). Esta técnica aumenta la flexibilidad, hace que nuestro código sea más modular y reduce el acoplamiento entre componentes.

La inversión de control se puede implementar de varias maneras; echemos un vistazo a un ejemplo en particular: Inyección de dependencia usando un constructor, y cómo puede ayudar a construir una API SmartHomeController comprobable.

Primero, creemos una interfaz IDateTimeProvider , que contenga una firma de método para obtener alguna fecha y hora:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

Luego, haga que SmartHomeController haga referencia a una implementación de IDateTimeProvider y delegue la responsabilidad de obtener la fecha y la hora:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

Ahora podemos ver por qué Inversion of Control se llama así: el control de qué mecanismo usar para leer la fecha y la hora se invirtió , y ahora pertenece al cliente de SmartHomeController , no al propio SmartHomeController . Por lo tanto, la ejecución del ActuateLights(bool motionDetected) depende completamente de dos cosas que se pueden administrar fácilmente desde el exterior: el argumento motionDetected y una implementación concreta de IDateTimeProvider , pasada a un constructor SmartHomeController .

¿Por qué es esto importante para las pruebas unitarias? Significa que se pueden usar diferentes implementaciones IDateTimeProvider en código de producción y código de prueba de unidad. En el entorno de producción, se inyectará alguna implementación de la vida real (por ejemplo, una que lea la hora real del sistema). En la prueba unitaria, sin embargo, podemos inyectar una implementación "falsa" que devuelve un valor DateTime constante o predefinido adecuado para probar el escenario particular.

Una implementación falsa de IDateTimeProvider podría verse así:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

Con la ayuda de esta clase, es posible aislar SmartHomeController de factores no deterministas y realizar una prueba unitaria basada en el estado. Verifiquemos que, si se detectó movimiento, la hora de ese movimiento se registra en la propiedad LastMotionTime :

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

¡Genial! Una prueba como esta no era posible antes de la refactorización. Ahora que eliminamos los factores no deterministas y verificamos el escenario basado en el estado, ¿crees que SmartHomeController es completamente comprobable?

Envenenamiento de la base de código con efectos secundarios

A pesar de que resolvimos los problemas causados ​​por la entrada oculta no determinista y pudimos probar ciertas funciones, ¡el código (o, al menos, parte de él) aún no se puede probar!

Revisemos la siguiente parte del ActuateLights(bool motionDetected) responsable de encender o apagar la luz:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

Como podemos ver, SmartHomeController delega la responsabilidad de encender o apagar la luz a un objeto BackyardLightSwitcher , que implementa un patrón Singleton. ¿Qué tiene de malo este diseño?

Para realizar pruebas unitarias completas del ActuateLights(bool motionDetected) , debemos realizar pruebas basadas en la interacción además de las pruebas basadas en el estado; es decir, debemos asegurarnos de que los métodos para encender o apagar la luz se llamen si, y solo si, se cumplen las condiciones apropiadas. Desafortunadamente, el diseño actual no nos permite hacer eso: los TurnOn() y TurnOff() de BackyardLightSwitcher desencadenan algunos cambios de estado en el sistema o, en otras palabras, producen efectos secundarios . La única forma de verificar que estos métodos fueron llamados es verificar si sus efectos secundarios correspondientes realmente ocurrieron o no, lo que podría ser doloroso.

De hecho, supongamos que el sensor de movimiento, la linterna del patio trasero y el microcontrolador doméstico inteligente están conectados a una red de Internet de las cosas y se comunican mediante algún protocolo inalámbrico. En este caso, una prueba unitaria puede intentar recibir y analizar ese tráfico de red. O, si los componentes de hardware están conectados con un cable, la prueba de la unidad puede verificar si el voltaje se aplicó al circuito eléctrico apropiado. O, después de todo, puede verificar que la luz realmente se encendió o apagó usando un sensor de luz adicional.

Como podemos ver, los métodos de efectos secundarios de las pruebas unitarias pueden ser tan difíciles como los métodos no deterministas de las pruebas unitarias, e incluso pueden ser imposibles. Cualquier intento conducirá a problemas similares a los que ya hemos visto. La prueba resultante será difícil de implementar, poco confiable, potencialmente lenta y no realmente unitaria. Y, después de todo eso, ¡el parpadeo de la luz cada vez que ejecutamos el conjunto de pruebas eventualmente nos volverá locos!

Una vez más, todos estos problemas de capacidad de prueba son causados ​​por la mala API, no por la capacidad del desarrollador para escribir pruebas unitarias. No importa cómo se implemente exactamente el control de la luz, la API de SmartHomeController sufre estos problemas ya familiares:

  • Está estrechamente acoplado a la implementación concreta. La API se basa en la instancia concreta y codificada de BackyardLightSwitcher . No es posible reutilizar el ActuateLights(bool motionDetected) para cambiar cualquier luz que no sea la del patio trasero.

  • Viola el Principio de Responsabilidad Única. La API tiene dos razones para cambiar: primero, cambios en la lógica interna (como elegir que la luz se encienda solo por la noche, pero no por la tarde) y segundo, si el mecanismo de cambio de luz se reemplaza por otro.

  • Miente sobre sus dependencias. No hay forma de que los desarrolladores sepan que SmartHomeController depende del componente BackyardLightSwitcher codificado, aparte de profundizar en el código fuente.

  • Es difícil de entender y mantener. ¿Qué pasa si la luz se niega a encender cuando las condiciones son las adecuadas? Podríamos pasar mucho tiempo tratando de arreglar el SmartHomeController en vano, solo para darnos cuenta de que el problema fue causado por un error en el BackyardLightSwitcher (o, aún más divertido, ¡una bombilla quemada!).

La solución tanto para la capacidad de prueba como para los problemas de API de baja calidad es, como era de esperar, separar los componentes estrechamente acoplados entre sí. Al igual que con el ejemplo anterior, el empleo de Inyección de Dependencia resolvería estos problemas; simplemente agregue una dependencia de ILightSwitcher al SmartHomeController , delegue la responsabilidad de encender el interruptor de la luz y pase una implementación falsa de ILightSwitcher solo de prueba que registrará si se llamaron a los métodos apropiados en las condiciones adecuadas. Sin embargo, en lugar de usar Inyección de dependencia nuevamente, revisemos un enfoque alternativo interesante para desacoplar las responsabilidades.

Arreglando la API: funciones de orden superior

Este enfoque es una opción en cualquier lenguaje orientado a objetos que admita funciones de primera clase . Aprovechemos las características funcionales de C# y hagamos que el ActuateLights(bool motionDetected) acepte dos argumentos más: un par de delegados de Action , que apuntan a métodos que deben llamarse para encender y apagar la luz. Esta solución convertirá el método en una función de orden superior :

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

Esta es una solución con un sabor más funcional que el clásico enfoque de inyección de dependencia orientado a objetos que hemos visto antes; sin embargo, nos permite lograr el mismo resultado con menos código y más expresividad que la inyección de dependencia. Ya no es necesario implementar una clase que se ajuste a una interfaz para proporcionar a SmartHomeController la funcionalidad requerida; en cambio, podemos simplemente pasar una definición de función. Las funciones de orden superior se pueden considerar como otra forma de implementar la inversión de control.

Ahora, para realizar una prueba unitaria basada en la interacción del método resultante, podemos pasarle acciones falsas fácilmente verificables:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

Finalmente, hemos hecho que la API de SmartHomeController totalmente comprobable y podemos realizar pruebas unitarias basadas en el estado y en la interacción. Una vez más, tenga en cuenta que, además de mejorar la capacidad de prueba, la introducción de una unión entre la toma de decisiones y el código de acción ayudó a resolver el problema de acoplamiento estrecho y condujo a una API más limpia y reutilizable.

Ahora, para lograr una cobertura completa de las pruebas unitarias, simplemente podemos implementar un montón de pruebas similares para validar todos los casos posibles, lo que no es gran cosa, ya que las pruebas unitarias ahora son bastante fáciles de implementar.

Impureza y capacidad de prueba

El no determinismo descontrolado y los efectos secundarios son similares en sus efectos destructivos en el código base. Cuando se usan sin cuidado, conducen a un código engañoso, difícil de entender y mantener, estrechamente acoplado, no reutilizable y no comprobable.

Por otro lado, los métodos que son tanto deterministas como libres de efectos secundarios son mucho más fáciles de probar, razonar y reutilizar para construir programas más grandes. En términos de programación funcional, estos métodos se denominan funciones puras . Rara vez tendremos una unidad problemática probando una función pura; todo lo que tenemos que hacer es pasar algunos argumentos y verificar que el resultado sea correcto. Lo que realmente hace que el código no sea comprobable son los factores impuros codificados que no se pueden reemplazar, anular o abstraer de alguna otra manera.

La impureza es tóxica: si el método Foo() depende del método Bar() no determinista o con efectos secundarios, entonces Foo() también se vuelve no determinista o con efectos secundarios. Eventualmente, podemos terminar envenenando todo el código base. Multiplique todos estos problemas por el tamaño de una aplicación compleja de la vida real, y nos encontraremos abrumados con una base de código difícil de mantener llena de olores, antipatrones, dependencias secretas y todo tipo de cosas feas y desagradables.

ejemplo de prueba unitaria: ilustración

Sin embargo, la impureza es inevitable; cualquier aplicación de la vida real debe, en algún momento, leer y manipular el estado al interactuar con el entorno, las bases de datos, los archivos de configuración, los servicios web u otros sistemas externos. Entonces, en lugar de apuntar a eliminar la impureza por completo, es una buena idea limitar estos factores, evitar que envenenen su base de código y romper las dependencias codificadas tanto como sea posible, para poder analizar y realizar pruebas unitarias de forma independiente.

Señales de advertencia comunes de código difícil de probar

¿Problemas para escribir exámenes? El problema no está en su conjunto de pruebas. Está en tu código.
Pío

Finalmente, revisemos algunas señales de advertencia comunes que indican que nuestro código puede ser difícil de probar.

Propiedades y campos estáticos

Las propiedades y campos estáticos o, simplemente, el estado global, pueden complicar la comprensión y la capacidad de prueba del código, al ocultar la información requerida para que un método haga su trabajo, al introducir el no determinismo o al promover el uso extensivo de efectos secundarios. Las funciones que leen o modifican el estado global mutable son inherentemente impuras.

Por ejemplo, es difícil razonar sobre el siguiente código, que depende de una propiedad accesible globalmente:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

¿Qué pasa si no se llama al método HeatWater() cuando estamos seguros de que debería haberlo hecho? Dado que cualquier parte de la aplicación puede haber cambiado el valor de CostSavingEnabled , debemos buscar y analizar todos los lugares que modifican ese valor para descubrir qué está mal. Además, como ya hemos visto, no es posible establecer algunas propiedades estáticas con fines de prueba (por ejemplo, DateTime.Now o Environment.MachineName ; son de solo lectura, pero aún no deterministas).

Por otro lado, el estado global inmutable y determinista está totalmente bien. De hecho, hay un nombre más familiar para esto: una constante. Los valores constantes como Math.PI no introducen ningún tipo de no determinismo y, dado que sus valores no se pueden cambiar, no permiten ningún efecto secundario:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

solteros

Esencialmente, el patrón Singleton es solo otra forma del estado global. Los Singleton promueven API oscuras que mienten sobre las dependencias reales e introducen un acoplamiento estrecho innecesario entre los componentes. También violan el principio de responsabilidad única porque, además de sus funciones principales, controlan su propia inicialización y ciclo de vida.

Singletons puede fácilmente hacer que las pruebas unitarias dependan del orden porque transportan el estado durante la vida útil de toda la aplicación o conjunto de pruebas unitarias. Echa un vistazo al siguiente ejemplo:

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Static Methods

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. Por ejemplo:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.