Las pruebas bien escritas reducen significativamente el riesgo de "romper" la aplicación al agregar una nueva función o corregir un error. En sistemas complejos que constan de varios componentes interconectados, lo más difícil es probar su terreno común.
En este artículo, hablaré sobre cómo encontramos la dificultad de escribir buenas pruebas al desarrollar un componente en Go y cómo resolvimos este problema usando la biblioteca RSpec en Ruby on Rails.
Agregar Ir a la pila de tecnología del proyecto
Uno de los proyectos que eTeam está desarrollando, donde trabajo, se puede dividir en: panel de administración, cuenta de usuario, generador de informes y procesamiento de solicitudes de varios servicios con los que estamos integrados.
La parte responsable de procesar las solicitudes es lo más importante, por lo que quería que fuera lo más confiable y asequible posible. Al ser parte de una aplicación monolítica, se arriesgaba a recibir un error al cambiar secciones de código no relacionadas con él. También existía el riesgo de perder el procesamiento al cargar otros componentes de la aplicación. El número de trabajadores de Ngnix por aplicación es limitado y, a medida que la carga creció, por ejemplo, al abrir muchas páginas pesadas en el panel de administración, los trabajadores libres se detuvieron y el procesamiento de solicitudes se ralentizó o incluso disminuyó.
Estos riesgos, así como la madurez de este sistema (durante meses no tuvo que hacer cambios) lo convirtieron en un candidato ideal para la separación en un servicio separado.
Se decidió escribir este servicio por separado en Go. Tenía que compartir el acceso a la base de datos con la aplicación Rails. La responsabilidad de los posibles cambios en la estructura de la tabla permaneció con Rails. En principio, dicho esquema con una base de datos común funciona bien, mientras que solo hay dos aplicaciones. Se veía así:

El servicio fue escrito y desplegado en instancias separadas de Rails. Ahora, al implementar aplicaciones Rails, no tiene que preocuparse de que afecte el procesamiento de consultas. El servicio aceptaba solicitudes HTTP directamente, sin Ngnix, usaba un poco de memoria, era de alguna manera minimalista.
El problema con nuestras pruebas unitarias en Go
Las pruebas unitarias se implementaron en la aplicación Go, y todas las consultas de la base de datos en ellas se bloquearon. Entre otros argumentos a favor de tal solución se encontraba el siguiente: la aplicación principal de Rails es responsable de la estructura de la base de datos, por lo que la aplicación go no "posee" la información para crear una base de datos de prueba. El procesamiento de solicitudes para la mitad consistió en lógica de negocios y la otra mitad en trabajar con la base de datos, y esta mitad estaba completamente bloqueada. Moki en Go parece menos "legible" que en Ruby. Al agregar una nueva función para leer datos de la base de datos, era necesario agregarle moki en el conjunto de pruebas fallidas que funcionaban antes. Como resultado, tales pruebas unitarias fueron ineficaces y extremadamente frágiles.
Método de solución
Para eliminar estas deficiencias, se decidió cubrir el servicio con pruebas funcionales ubicadas en la aplicación Rails y probar el servicio en Go como una caja negra. Como una caja blanca, todavía no funcionaría, porque desde ruby, incluso con todo el deseo, sería imposible intervenir en el servicio, por ejemplo, mojar algún método para verificar si se está llamando. También significaba que las solicitudes enviadas por el servicio probado también eran imposibles de bloquear, por lo tanto, se necesitaba otra aplicación para capturarlas y registrarlas. Algo así como RequestBin, pero local. Ya escribimos una utilidad similar, así que la usamos.
El siguiente esquema ha resultado:
- rspec compila e inicia el servicio sobre la marcha, pasándole una configuración, que contiene acceso a la base de prueba y un cierto puerto para recibir solicitudes HTTP, por ejemplo 8082
- También se lanza una utilidad para registrar las solicitudes HTTP recibidas en el puerto 8083
- escribimos pruebas ordinarias en RSpec, es decir cree los datos necesarios en la base de datos y envíe una solicitud a localhost: 8082, como a un servicio externo, por ejemplo, usando HTTParty
- respuesta parsim; verificar cambios en la base de datos; obtenemos la lista de solicitudes registradas de "RequestBin" y las verificamos.
Detalles de implementación:
Ahora sobre cómo se implementó. Para fines de demostración, nombremos el servicio probado: "TheService" y creemos un contenedor para él:
Por si acaso, haré una reserva de que en Rspec debe configurarse para cargar automáticamente los archivos desde la carpeta de "soporte":
Dir[Rails.root.join('spec/support/**/*.rb')].each {|f| require f}
El método de inicio:
- lee desde una configuración separada la ruta a las fuentes de TheService y la información necesaria para ejecutar. Porque esta información puede diferir de los diferentes desarrolladores, esta configuración está excluida de Git. La misma configuración contiene la configuración necesaria para que se inicie el programa. Estas configuraciones heterogéneas se encuentran en un solo lugar para no producir archivos adicionales.
- compila y ejecuta el programa a través de "go run {ruta a main.go} {ruta a config}"
- sondeando cada segundo, espera hasta que el programa en ejecución esté listo para aceptar solicitudes
- recuerda el identificador de proceso para no reiniciar y poder detenerlo.
config en sí mismo:
#/spec/support/the_service_config.yml server: addr: 127.0.0.1:8082 db: dsn: dbname=project_test sslmode=disable user=postgres password=secret redis: url: redis://127.0.0.1:6379/1 rails: main_go: /home/me/go/src/github.com/company/theservice/main.go recorder_addr: 127.0.0.1:8083 env: PATH: '/home/me/.gvm/gos/go1.10.3/bin' GOROOT: '/home/me/.gvm/gos/go1.10.3' GOPATH: '/home/me/go'
El método de detención simplemente detiene el proceso. Lo nuevo es que ruby ejecuta el comando "go run" que ejecuta el binario compilado en un proceso secundario cuya identificación es desconocida. Si simplemente detiene el proceso iniciado desde ruby, el proceso secundario no se detiene automáticamente y el puerto permanece ocupado. Por lo tanto, la detención se produce por ID de grupo de procesos:
Ahora prepararemos un contexto compartido donde definiremos las variables predeterminadas, iniciaremos TheService si no se ha iniciado y deshabilitaremos temporalmente la videograbadora (desde su punto de vista, hablamos con un servicio externo, pero para nosotros ahora no es así):
y ahora puedes comenzar a escribir las especificaciones ellos mismos:
TheService puede realizar sus solicitudes HTTP a servicios externos. Usando la configuración, redirigimos a una utilidad local que los escribe. También hay un contenedor para que se inicie y pare, es similar a la clase "TheServiceControl", excepto que la utilidad simplemente puede iniciarse sin compilación.
Bollos extra
La aplicación Go se escribió para que todos los registros y la información de depuración se muestren en STDOUT. Cuando se inicia en producción, esta salida se envía a un archivo. Y cuando se inicia desde Rspec, se muestra en la consola, lo que ayuda mucho al depurar.
Si las especificaciones se ejecutan de forma selectiva, para lo cual no se necesita TheService, entonces no se inicia.
Para evitar perder tiempo desarrollando el servicio cada vez que reinicia la especificación al desarrollar, puede iniciar el servicio manualmente en el terminal y no desactivarlo. Si es necesario, incluso puede ejecutarlo en el IDE en modo de depuración, y luego la especificación preparará todo lo que necesita, lanzará una solicitud de servicio, se detendrá y podrá degradar sin problemas. Esto hace que el enfoque TDD sea muy conveniente.
Conclusiones
Tal esquema ha estado funcionando durante aproximadamente un año y nunca ha fallado. Las especificaciones son mucho más legibles que las pruebas unitarias en Go, y no se basan en el conocimiento de la estructura interna del servicio. Si, por alguna razón, necesitamos reescribir el servicio en otro idioma, no tendremos que cambiar las especificaciones, excepto el contenedor, que solo necesita iniciar el servicio de prueba con otro comando.