Al escribir pruebas unitarias, no use simulacros
Publicado: 2018-05-02Nota: Esta es nuestra última publicación de ingeniería técnica escrita por el ingeniero principal, Seth Ammons. Un agradecimiento especial a Sam Nguyen, Kane Kim, Elmer Thomas y Kevin Gillette por revisar esta publicación . Y para más publicaciones como esta, consulte nuestro blog técnico.
Realmente disfruto escribiendo pruebas para mi código, especialmente pruebas unitarias. La sensación de confianza que me da es genial. Recoger algo en lo que no he trabajado en mucho tiempo y poder ejecutar las pruebas unitarias y de integración me da el conocimiento de que puedo refactorizar sin piedad si es necesario y, siempre que mis pruebas tengan una cobertura buena y significativa y continúen pasando. , todavía tendré software funcional después.
Las pruebas unitarias guían el diseño del código y nos permiten verificar rápidamente que los modos de falla y los flujos lógicos funcionan según lo previsto. Con eso, quiero escribir sobre algo quizás un poco más controvertido: al escribir pruebas unitarias, no use simulacros.
Pongamos algunas definiciones sobre la mesa
¿Cuál es la diferencia entre las pruebas unitarias y de integración? ¿Qué quiero decir con simulacros y qué deberías usar en su lugar? Esta publicación se centra en trabajar en Go, por lo que mi inclinación sobre estas palabras está en el contexto de Go.
Cuando digo pruebas unitarias , me refiero a aquellas pruebas que aseguran el manejo adecuado de errores y guían el diseño del sistema probando pequeñas unidades de código. Por unidad, podríamos referirnos a un paquete completo, una interfaz o un método individual.
La prueba de integración es donde realmente interactúa con sistemas y/o bibliotecas dependientes. Cuando digo "simulacros", me refiero específicamente al término "Objeto simulado", que es donde "reemplazamos el código de dominio con implementaciones ficticias que emulan la funcionalidad real y hacen cumplir las afirmaciones sobre el comportamiento de nuestro código [1]" (énfasis Mia).
Dicho un poco más corto: se burla del comportamiento afirmado, como:
MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")
Abogo por "falsificaciones en lugar de simulacros".
Una falsificación es una especie de doble de prueba que puede contener comportamiento comercial [2]. Las falsificaciones son simplemente estructuras que se ajustan a una interfaz y son una forma de inyección de dependencia donde controlamos el comportamiento. El principal beneficio de las falsificaciones es que disminuyen el acoplamiento en el código, mientras que los simulacros aumentan el acoplamiento y el acoplamiento dificulta la refactorización [3].
En esta publicación, tengo la intención de demostrar que las falsificaciones brindan flexibilidad y permiten pruebas y refactorizaciones fáciles. Reducen las dependencias en comparación con los simulacros y son fáciles de mantener.
Profundicemos con un ejemplo que es un poco más avanzado que "probar una función de suma", como puede ver en una publicación típica de esta naturaleza. Sin embargo, necesito darte algo de contexto para que puedas entender más fácilmente el código que sigue en esta publicación.
En SendGrid, uno de nuestros sistemas tradicionalmente tenía archivos en el sistema de archivos local, pero debido a la necesidad de una mayor disponibilidad y un mejor rendimiento, trasladamos estos archivos a S3.
Tenemos una aplicación que necesita poder leer estos archivos y optamos por una aplicación que puede ejecutarse en dos modos, "local" o "remoto", según la configuración. Una advertencia que se omite en muchos de los ejemplos de código es que, en el caso de una falla remota, recurrimos a leer el archivo localmente.
Con eso fuera del camino, esta aplicación tiene un captador de paquetes. Necesitamos asegurarnos de que el captador de paquetes pueda obtener archivos del sistema de archivos remoto o del sistema de archivos local.
Enfoque ingenuo: simplemente llame a la biblioteca y al nivel del sistema
El enfoque ingenuo es que nuestro paquete de implementación llamará a getter.New(...) y le pasará la información necesaria para configurar la obtención de archivos locales o remotos y devolverá un Getter . El valor devuelto podrá llamar a MyGetter.GetFile(...) con los parámetros necesarios para ubicar el archivo remoto o local.
Esto nos dará nuestra estructura básica. Cuando creamos el nuevo Getter , inicializamos los parámetros que se necesitan para cualquier posible recuperación remota de archivos (una clave de acceso y un secreto) y también pasamos algunos valores que se originan en la configuración de nuestra aplicación, como useRemoteFS que le indicará al código que intente el sistema de archivos remoto.
Necesitamos proporcionar alguna funcionalidad básica. Consulte el código ingenuo aquí [4]; a continuación se muestra una versión reducida. Tenga en cuenta que este es un ejemplo no terminado y vamos a refactorizar cosas.
La idea básica aquí es que si estamos configurados para leer desde el sistema de archivos remoto y obtenemos detalles del sistema de archivos remoto (host, depósito y clave), entonces debemos intentar leer desde el sistema de archivos remoto. Una vez que tengamos confianza en que el sistema lee de forma remota, cambiaremos toda la lectura de archivos al sistema de archivos remoto y eliminaremos las referencias a la lectura del sistema de archivos local.
Este código no es muy amigable para las pruebas unitarias; tenga en cuenta que para verificar cómo funciona, en realidad necesitamos acceder no solo al sistema de archivos local, sino también al sistema de archivos remoto. Ahora, podríamos simplemente hacer una prueba de integración y configurar un poco de magia de Docker para tener una instancia s3 que nos permita verificar el camino feliz en el código.
Sin embargo, tener solo pruebas de integración es menos que ideal, ya que las pruebas unitarias nos ayudan a diseñar un software más sólido al probar fácilmente el código alternativo y las rutas de falla. Deberíamos guardar las pruebas de integración para pruebas más grandes de tipo "¿realmente funciona?". Por ahora, concentrémonos en las pruebas unitarias.
¿Cómo podemos hacer que este código sea más comprobable por unidades? Hay dos escuelas de pensamiento. Una es usar un generador de simulacros (como https://github.com/vektra/mockery o https://github.com/golang/mock) que crea código repetitivo para usar cuando se prueban simulacros.
Podría seguir esta ruta y generar las llamadas del sistema de archivos y las llamadas del cliente Minio. O tal vez quiera evitar una dependencia, por lo que genera sus simulacros a mano. Resulta que burlarse del cliente de Minio no es sencillo porque tiene un cliente con un tipo concreto que devuelve un objeto con un tipo concreto.
Yo digo que hay una mejor manera que burlarse. Si reestructuramos nuestro código para que sea más comprobable, no necesitamos importaciones adicionales para simulacros y cruft relacionados y no habrá necesidad de conocer DSL de prueba adicionales para probar las interfaces con confianza. Podemos configurar nuestro código para que no esté demasiado acoplado y el código de prueba será solo el código Go normal usando las interfaces de Go. ¡Vamos a hacerlo!
Enfoque de interfaz: mayor abstracción, pruebas más sencillas
¿Qué es lo que tenemos que probar? Aquí es donde algunos Gophers nuevos se equivocan. He visto a personas entender el valor de aprovechar las interfaces, pero sienten que necesitan interfaces que coincidan con la implementación concreta del paquete que están usando.
Es posible que vean que tenemos un cliente Minio, por lo que podrían comenzar creando interfaces que coincidan con TODOS los métodos y usos del cliente Minio (o cualquier otro cliente s3). Se olvidan del proverbio Go [5][6] de "Cuanto más grande es la interfaz, más débil es la abstracción".
No necesitamos probar contra el cliente Minio. Necesitamos probar que podemos obtener archivos de forma remota o local (y verificar algunas rutas de falla, como fallas remotas). Refactoricemos ese enfoque inicial y extraigamos el cliente Minio en un captador remoto. Mientras hacemos eso, hagamos lo mismo con nuestro código para la lectura de archivos locales y hagamos un getter local. Aquí están las interfaces básicas, y tendremos tipo para implementar cada una:
Con estas abstracciones en su lugar, podemos refactorizar nuestra implementación inicial. Vamos a colocar localFetcher y remoteFetcher en la estructura Getter y refactorizaremos GetFile para usarlos. Consulte la versión completa del código refactorizado aquí [7]. A continuación se muestra un fragmento ligeramente simplificado que utiliza la nueva versión de la interfaz:
Este nuevo código refactorizado es mucho más comprobable por unidad porque tomamos las interfaces como parámetros en la estructura Getter y podemos cambiar los tipos concretos por falsificaciones. En lugar de burlarse de las llamadas del sistema operativo o necesitar una burla completa del cliente Minio o de las interfaces grandes, solo necesitamos dos falsificaciones simples: fakeLocalFetcher y fakeRemoteFetcher .
Estas falsificaciones tienen algunas propiedades que nos permiten especificar lo que devuelven. Podremos devolver los datos del archivo o cualquier error que deseemos y podemos verificar que el método GetFile que llama maneje los datos y los errores como pretendíamos.
Con esto en mente, el corazón de las pruebas se convierte en:
Con esta estructura básica, podemos envolverlo todo en pruebas basadas en tablas [8]. Cada caso en la tabla de pruebas probará el acceso a archivos locales o remotos. Podremos inyectar un error en el acceso a archivos local o remoto. Podemos verificar los errores propagados, que el contenido del archivo se pasa y que las entradas de registro esperadas están presentes.
Seguí adelante e incluí todos los posibles casos de prueba y permutaciones en la prueba basada en una tabla disponible aquí [9] (puede notar que algunas firmas de métodos son un poco diferentes; nos permite hacer cosas como inyectar un registrador y afirmar contra declaraciones de registro ).
Ingenioso, ¿eh? Tenemos el control total de cómo queremos que se comporte GetFile y podemos hacer valer los resultados. Hemos diseñado nuestro código para que sea compatible con las pruebas unitarias y ahora podemos verificar las rutas de éxito y error implementadas en el método GetFile .
El código está débilmente acoplado y la refactorización en el futuro debería ser muy sencilla. Hicimos esto escribiendo un código simple de Go que cualquier desarrollador familiarizado con Go debería poder entender y ampliar cuando sea necesario.
Simulacros: ¿qué pasa con los detalles de implementación esenciales y arenosos?
¿Qué nos comprarían los simulacros que no obtenemos en la solución propuesta? Una gran pregunta que muestra un beneficio para un simulacro tradicional podría ser, "¿cómo sabes que llamaste al cliente s3 con los parámetros correctos? Con los simulacros, puedo asegurarme de que pasé el valor clave al parámetro clave y no al parámetro del depósito”.
Esta es una preocupación válida y debe ser cubierta por una prueba en alguna parte . El enfoque de prueba que defiendo aquí no verifica que haya llamado al cliente de Minio con el depósito y los parámetros clave en el orden correcto.
Una gran cita que leí recientemente decía: "La burla introduce suposiciones, lo que introduce riesgos [10]". Está asumiendo que la biblioteca del cliente está implementada correctamente, está suponiendo que todos los límites son sólidos, está suponiendo que sabe cómo se comporta realmente la biblioteca.
Burlarse de la biblioteca solo se burla de las suposiciones y hace que sus pruebas sean más frágiles y estén sujetas a cambios cuando actualiza el código (que es lo que concluyó Martin Fowler en Mocks Aren't Stubs [3]). Cuando la goma se encuentre con el camino, vamos a tener que verificar que realmente estamos usando el cliente Minio correctamente y esto significa pruebas de integración (estas pueden vivir en una configuración de Docker o un entorno de prueba). Debido a que tendremos pruebas unitarias y de integración, no hay necesidad de una prueba unitaria para cubrir la implementación exacta, ya que la prueba de integración cubrirá eso.
En nuestro ejemplo, las pruebas unitarias guían nuestro diseño de código y nos permiten probar rápidamente que los errores y los flujos lógicos funcionan según lo diseñado, haciendo exactamente lo que deben hacer.
Para algunos, sienten que esto no es suficiente cobertura de prueba unitaria. Están preocupados por los puntos anteriores. Algunos insistirán en las interfaces estilo muñeca rusa donde una interfaz devuelve otra interfaz que devuelve otra interfaz, tal vez como la siguiente:
Y luego podrían extraer cada parte del cliente Minio en cada contenedor y luego usar un generador simulado (agregando dependencias a las compilaciones y pruebas, aumentando las suposiciones y haciendo que las cosas sean más frágiles). Al final, el imitador podrá decir algo como:
myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – y eso es si puede recordar el conjuro correcto para este DSL específico.
Esto supondría una gran cantidad de abstracción adicional vinculada directamente a la elección de implementación de usar el cliente Minio. Esto provocará pruebas frágiles para cuando descubramos que necesitamos cambiar nuestras suposiciones sobre el cliente, o necesitamos un cliente completamente diferente.
Esto aumenta el tiempo de desarrollo de código de extremo a extremo ahora y en el futuro, aumenta la complejidad del código y reduce la legibilidad, aumenta potencialmente las dependencias de los generadores simulados y nos brinda el dudoso valor adicional de saber si mezclamos el depósito y los parámetros clave. de los cuales habríamos descubierto en las pruebas de integración de todos modos.
A medida que se introducen más y más objetos, el acoplamiento se vuelve más y más estrecho. Es posible que hayamos hecho un simulacro de registrador y luego empecemos a tener un simulacro de métricas. Antes de que se dé cuenta, está agregando una entrada de registro o una nueva métrica y acaba de romper innumerables pruebas que no esperaban que apareciera una métrica adicional.
La última vez que me mordió esto en Go, el marco burlón ni siquiera me dijo qué prueba o archivo estaba fallando, ya que entró en pánico y tuvo una muerte horrible porque se encontró con una nueva métrica (esto requería una búsqueda binaria en las pruebas comentándolas para poder encontrar dónde necesitábamos alterar el comportamiento simulado). ¿Pueden los simulacros agregar valor? Por supuesto. ¿Vale la pena el costo? En la mayoría de los casos, no estoy convencido.
Interfaces: simplicidad y pruebas unitarias para ganar
Hemos demostrado que podemos guiar el diseño y garantizar que se sigan las rutas correctas de código y error con el uso simple de las interfaces en Go. Al escribir falsificaciones simples que se adhieren a las interfaces, podemos ver que no necesitamos simulacros, marcos de trabajo simulados o generadores simulados para crear código diseñado para pruebas. También hemos notado que las pruebas unitarias no lo son todo, y debe escribir pruebas de integración para garantizar que los sistemas estén correctamente integrados entre sí.
Espero recibir una publicación sobre algunas formas ingeniosas de ejecutar pruebas de integración en el futuro; ¡Manténganse al tanto!
Referencias
1: Endo-Pruebas: Pruebas unitarias con objetos simulados (2000): Véase la introducción para la definición de objeto simulado
2: The Little Mocker: vea la parte sobre las falsificaciones, específicamente, “una falsificación tiene un comportamiento comercial. Puedes hacer que una falsificación se comporte de diferentes maneras dándole datos diferentes”.
3: Los simulacros no son stubs: consulta la sección "¿Debería ser un clasicista o un simulacro?" Martin Fowler afirma: "No veo ningún beneficio convincente para el TDD simulado y me preocupan las consecuencias de combinar las pruebas con la implementación".
4: Enfoque ingenuo: una versión simplificada del código. Véase [7].
5: https://go-proverbs.github.io/: La lista de Go Proverbs con enlaces a charlas.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: enlace directo a la charla de Rob Pike sobre el tamaño y la abstracción de la interfaz.
7: Versión completa del código de demostración: puede clonar el repositorio y ejecutar `go test`.
8: Pruebas basadas en tablas: una estrategia de prueba para organizar el código de prueba para reducir la duplicación.
9: Pruebas para la versión completa del código de demostración. Puede ejecutarlos con `go test`.
10: Preguntas que debe hacerse al escribir pruebas por Michal Charemza: La burla introduce suposiciones y las suposiciones introducen riesgos.