Revisemos las pruebas unitarias y los mocks en Python
7 de julio de 2018
0 minutos de lecturaNota del editor
Este blog se publicó originalmente en fugue.co. Fugue se unió a Snyk en 2022 y es un componente clave de Snyk IaC.
En mi publicación anterior, Python Mocking 101: Fake It Before You Make It, expliqué los fundamentos de los mocks y las pruebas unitarias en Python. Esta publicación aborda algunos principios de ingeniería de software de alto nivel que se reflejan en mi experiencia con las pruebas de Python durante el último año y medio. En particular, quiero volver a analizar la idea de aplicar parches a objetos mock en las pruebas unitarias.
Aplicar parches a clientes externos
En esta publicación, los clientes son cualquier objeto que genere efectos secundarios, como operaciones de E/S en disco o por red. Considera una clase, CloudCreator, que recibe mensajes por HTTP, genera efectos secundarios al crear infraestructura en la nube y responde enviando mensajes por HTTP:
Podemos probar CloudCreator de la siguiente manera:
patch nos permite probar nuestra clase CloudCreator sin generar efectos secundarios en la red. Sin embargo, este diseño tiene algunos problemas. Si CloudCreator usa muchos clientes externos, tenemos que encadenar muchas llamadas a patch. Además, CloudCreator y sus pruebas unitarias dependen estrechamente de HTTPClient, lo que dificulta cambiar el cliente de red.
El ingeniero de Fugue Josh Einhorn señala otras desventajas de patch:
Usarlo implica que hay dependencias implícitas en alguna parte de la clase, algo que otro desarrollador nunca sabría. Los argumentos del constructor hacen que las dependencias sean explícitas.
Al refactorizar implementaciones subyacentes, usar
patchobliga a actualizar varias pruebas unitarias que no están relacionadas. Además, no siempre queda claro qué pruebas unitarias hay que cambiar, porquepatchusa cadenas codificadas de forma rígida en lugar de referencias más "tipadas" (que los linters o los IDE pueden detectar).Usar
patches una señal de alerta en el código, porque significa que la clase bajo prueba está acoplada a una o más clases concretas externas.El código que depende de
patchpara las pruebas unitarias no es portable a otros lenguajes. Los lenguajes con tipos estáticos y compiladores no permiten el monkey patching (sin un trabajo considerable). Para probar correctamente una clase de este tipo en otro lenguaje, habría que refactorizar su estructura.
En general, si para probar una clase hay que aplicar muchos patch a clientes externos, es señal de que hace falta una refactorización. Los ingenieros de software con experiencia verán que este ejemplo es una oportunidad ideal para invertir las dependencias.
Inversión e inyección de dependencias
La inversión de dependencias, y en particular la inyección de dependencias en este caso, son temas muy conocidos en el mundo de la ingeniería de software. Para quienes no los conozcan, la inyección de dependencias consiste en que una clase o función reciba los clientes externos de los que depende, en lugar de crearlos por su cuenta. Así, el código puede funcionar en distintos contextos, según los clientes que reciba.
En nuestro ejemplo, la función principal de CloudCreator, crear infraestructura en la nube, no depende de una forma específica de enviar y recibir mensajes. Por lo tanto, lo lógico es escribir la clase de modo que la E/S de red esté a cargo de un cliente que se inyecta en tiempo de ejecución, en lugar de estar codificado de forma rígida (este código usa la sintaxis de Python para las anotaciones de tipo):
Esta encapsulación permite usar la clase con un cliente HTTP, TCP/IP, ZMQ o SQS/SNS, siempre que la E/S de red cumpla con una interfaz predefinida en NetworkClient, como NetworkClient.recv() y NetworkClient.send(data). Esto simplifica mucho el proceso. Los observadores atentos notarán que definir la interfaz del cliente cobra una importancia fundamental, pero ese es tema para otra publicación.
Una de las principales ventajas de la inyección de dependencias es que permite al desarrollador pasar objetos mock con facilidad durante las pruebas unitarias. Ahora podemos configurar las pruebas así:
Creamos un cliente de red mock para las pruebas unitarias y usamos el argumento autospec de MagicMock para crear un objeto mock que cumpla con la interfaz NetworkClient.
En este sencillo ejemplo, patch nos permitió disimular un diseño inflexible creando una prueba unitaria aún más inflexible. Como señalé antes, si aplicas parches a más de unas cuantas llamadas, es señal de que deberías refactorizar. Ten en cuenta que patch sigue siendo útil, por ejemplo, para aplicar parches a llamadas como time.time() o a otras llamadas de biblioteca sin efectos secundarios.
Más sobre la inyección de dependencias
La inyección de dependencias es útil, pero ¿qué debemos hacer cuando nuestra clase necesita muchos clientes? Podemos agregar más parámetros e inyectarlos por separado:
Al agregar valores predeterminados, podemos inicializar los clientes de forma selectiva, lo que facilita las pruebas unitarias (volveremos sobre esto más adelante). Sin embargo, esta forma sigue siendo poco práctica, sobre todo al hacer pruebas de integración. Imagina que modificamos nuestro controlador de mensajes y luego queremos comprobar que se comunica correctamente con el servidor. Inicializar un CloudCreator implica el tedioso trabajo de crear e inicializar objetos cliente. Una de las fortalezas de Python es su intérprete interactivo, que permite un proceso de desarrollo iterativo. Mantener la posibilidad de usar fácilmente el REPL facilita la vida de los desarrolladores. Exigirles que creen e inicialicen una docena de objetos cliente antes de probar un pequeño cambio en la clase principal genera frustración.
Una solución es encapsular la creación de clientes en un objeto aparte. Incluso podemos codificar en el constructor información sobre el orden de las operaciones y las dependencias:
Observa que CloudCreator sigue inicializándose con referencias explícitas a los servicios que necesita. Así, los futuros desarrolladores pueden entender fácilmente qué servicios requiere CloudCreator. También es posible proponer un diseño en el que el constructor de CloudCreator solo espere un objeto CloudCreatorServices:
Sin embargo, esto vincula CloudCreator a una implementación específica de CloudCreatorServices, con exactamente los servicios que CloudCreator necesita. Si generalizamos CloudCreatorServices para crear servicios para varias clases, quien llama debe suponer que todas las clases que usan la clase generalizada Service necesitan todos los servicios.
Por desgracia, esta implementación ingenua pierde la flexibilidad de la inyección de dependencias directa. Un paso adelante y otro atrás. Es fácil solucionarlo:
En este punto, lo único que hicimos fue trasladar la complejidad. El desarrollador sigue siendo responsable de inicializar todos los clientes. No le hemos dado herramientas para facilitarle la vida. Podemos hacerlo incorporando procedimientos de inicialización complejos en CloudCreatorServices:
Ahora ocultamos la creación de clientes en estos métodos de inicialización. Parece una buena solución, pero, si lo analizamos con más detenimiento, tiene una desventaja. Al inicializar CloudCreatorServices, creamos todos los clientes, aunque sepamos que no los vamos a usar. ¿Qué hacemos si uno de nuestros servicios cliente falla y agota el tiempo de espera, pero aun así queremos probar otra funcionalidad? ¿Podemos hacerlo aún más flexible?
Podemos usar métodos getter para cambiar el orden de inicialización:
Esta solución nos permite cargar los clientes de forma diferida, es decir, solo se inicializan cuando se necesitan, y mantener la posibilidad de reemplazarlos según sea necesario. Sin embargo, los métodos getter no son muy propios de Python. ¿Hay alguna característica del lenguaje que podamos aprovechar para encontrar una forma más propia de Python?
@property
La característica que buscamos es @property:
Esto se parece a nuestra solución anterior con métodos getter, pero quitamos get_ y agregamos el decorador @property. @property convierte un método getter en una propiedad. Se puede acceder directamente a una propiedad, como CloudCreatorServices.database_client, sin paréntesis. Además, usar @property nos permite agregar un setter en el futuro. Por ejemplo, podemos decorar la función setter de una propiedad con @.setter:
El setter se llamará de forma transparente cuando asignemos un valor a database_client:
Usar @property mantiene la convención de Python de acceder directamente a los atributos de instancia y, al mismo tiempo, nos da la flexibilidad de envolver el acceso a los atributos en getters y setters.
Mocks con inyección de dependencias
Una de las ventajas de invertir las dependencias es que simplifica mucho las pruebas unitarias. Recuerda que los argumentos de inicialización de CloudCreator tienen valores predeterminados, lo que nos permite crear mocks de forma selectiva para los objetos de servicios cliente en pruebas específicas:
Como los demás servicios cliente no se inicializan, es fácil detectar si la ruta de código de red accede a objetos fuera de su ámbito, lo que suele ser señal de que algo anda mal. Por supuesto, para tener pruebas unitarias exhaustivas se necesitarían mocks para cada servicio, pero es fácil agregarlos.
Al invertir las dependencias, eliminamos la necesidad de aplicar patch en nuestras pruebas unitarias y, a la vez, ofrecemos a los desarrolladores una herramienta eficaz que les ahorra tiempo en las pruebas de integración.
