El uso de Testcontainers ha mejorado radicalmente el proceso de trabajo con escenarios de prueba. Gracias a esta herramienta, la creación de entornos para pruebas de integración se ha vuelto más sencilla (ver el artículo Aislamiento en Pruebas con Kafka ). Ahora podemos lanzar fácilmente contenedores con diferentes versiones de bases de datos, intermediarios de mensajes y otros servicios. Para las pruebas de integración, Testcontainers ha demostrado ser indispensable.
Aunque las pruebas de carga son menos comunes que las pruebas funcionales, pueden resultar mucho más divertidas. Estudiar gráficos y analizar el rendimiento de un servicio en particular puede brindar un verdadero placer. Estas tareas son raras, pero para mí resultan especialmente apasionantes.
El propósito de este artículo es demostrar un enfoque para crear una configuración para pruebas de carga de la misma manera que se escriben las pruebas de integración regulares: en forma de pruebas de Spock utilizando Testcontainers en un entorno de proyecto Gradle. Se utilizan utilidades de prueba de carga como Gatling, WRK y Yandex.Tank.
Conjunto de herramientas: Gradle + Spock Framework + Testcontainers. La variante de implementación es un módulo Gradle separado. Las utilidades de prueba de carga utilizadas son Gatling, WRK y Yandex.Tank.
Hay dos enfoques para trabajar con el objeto de prueba:
En el primer caso, tenemos un conjunto de pruebas de carga que son independientes de la versión y cambios del proyecto. Este enfoque es más fácil de mantener en el futuro, pero se limita a probar únicamente imágenes publicadas. Por supuesto, podemos crear manualmente estas imágenes localmente, pero esto está menos automatizado y reduce la reproducibilidad. Cuando se ejecuta en CI/CD sin las imágenes necesarias, las pruebas fallarán.
En el segundo caso, las pruebas se ejecutan en la última versión del servicio. Esto permite integrar pruebas de carga en CI y obtener cambios en los datos de rendimiento entre versiones del servicio. Sin embargo, las pruebas de carga suelen tardar más que las pruebas unitarias. La decisión de incluir dichas pruebas en CI como parte del control de calidad depende de usted.
Este artículo considera el primer enfoque. Gracias a Spock, podemos realizar pruebas en múltiples versiones del servicio para realizar análisis comparativos:
where: image | _ 'avvero/sandbox:1.0.0' | _ 'avvero/sandbox:1.1.0' | _
Es importante señalar que el objetivo de este artículo es demostrar la organización del espacio de pruebas, no pruebas de carga a gran escala.
Para el objeto de prueba, tomemos un servicio HTTP simple llamado Sandbox, que publica un punto final y utiliza datos de una fuente externa para manejar las solicitudes. El servicio cuenta con una base de datos.
El código fuente del servicio, incluido Dockerfile, está disponible en el repositorio del proyecto spring-sandbox .
A medida que profundizamos en los detalles más adelante en el artículo, quiero comenzar con una breve descripción general de la estructura del módulo Gradle load-tests
para comprender su composición:
load-tests/ |-- src/ | |-- gatling/ | | |-- scala/ | | | |-- MainSimulation.scala # Main Gatling simulation file | | |-- resources/ | | | |-- gatling.conf # Gatling configuration file | | | |-- logback-test.xml # Logback configuration for testing | |-- test/ | | |-- groovy/ | | | |-- pw.avvero.spring.sandbox/ | | | | |-- GatlingTests.groovy # Gatling load test file | | | | |-- WrkTests.groovy # Wrk load test file | | | | |-- YandexTankTests.groovy # Yandex.Tank load test file | | |-- java/ | | | |-- pw.avvero.spring.sandbox/ | | | | |-- FileHeadLogConsumer.java # Helper class for logging to a file | | |-- resources/ | | | |-- wiremock/ | | | | |-- mappings/ # WireMock setup for mocking external services | | | | | |-- health.json | | | | | |-- forecast.json | | | |-- yandex-tank/ # Yandex.Tank load testing configuration | | | | |-- ammo.txt | | | | |-- load.yaml | | | | |-- make_ammo.py | | | |-- wrk/ # LuaJIT scripts for Wrk | | | | |-- scripts/ | | | | | |-- getForecast.lua |-- build.gradle
Repositorio de proyectos: https://github.com/avvero/testing-bench .
De la descripción anterior, vemos que el servicio tiene dos dependencias: el servicio https://external-weather-api.com y una base de datos. Su descripción se proporcionará a continuación, pero comencemos permitiendo que todos los componentes del esquema se comuniquen en un entorno Docker; describiremos la red:
def network = Network.newNetwork()
y proporcionar alias de red para cada componente. Esto es extremadamente conveniente y nos permite describir estáticamente los parámetros de integración.
Dependencias como WireMock y las propias utilidades de prueba de carga requieren configuración para funcionar. Estos pueden ser parámetros que se pueden pasar al contenedor o archivos y directorios completos que deben montarse en los contenedores.
Además, necesitamos recuperar los resultados de su trabajo de los contenedores. Para resolver estas tareas, necesitamos proporcionar dos conjuntos de directorios:
workingDirectory
: el directorio de recursos del módulo, directamente en load-tests/
.
reportDirectory
: el directorio de los resultados del trabajo, incluidas métricas y registros. Más sobre esto estará en la sección de informes.El servicio Sandbox utiliza Postgres como base de datos. Describamos esta dependencia de la siguiente manera:
def postgres = new PostgreSQLContainer<>("postgres:15-alpine") .withNetwork(network) .withNetworkAliases("postgres") .withUsername("sandbox") .withPassword("sandbox") .withDatabaseName("sandbox")
La declaración especifica el alias de red postgres
, que el servicio Sandbox utilizará para conectarse a la base de datos. Para completar la descripción de la integración con la base de datos, es necesario dotar al servicio de los siguientes parámetros:
'spring.datasource.url' : 'jdbc:postgresql://postgres:5432/sandbox', 'spring.datasource.username' : 'sandbox', 'spring.datasource.password' : 'sandbox', 'spring.jpa.properties.hibernate.default_schema': 'sandbox'
La estructura de la base de datos la gestiona la propia aplicación mediante Flyway, por lo que no se necesitan manipulaciones adicionales de la base de datos en la prueba.
Si no tenemos la posibilidad, la necesidad o el deseo de ejecutar el componente real en un contenedor, podemos proporcionar una simulación de su API. Para el servicio https://external-weather-api.com, se utiliza WireMock.
La declaración del contenedor WireMock se verá así:
def wiremock = new GenericContainer<>("wiremock/wiremock:3.5.4") .withNetwork(network) .withNetworkAliases("wiremock") .withFileSystemBind("${workingDirectory}/src/test/resources/wiremock/mappings", "/home/wiremock/mappings", READ_WRITE) .withCommand("--no-request-journal") .waitingFor(new LogMessageWaitStrategy().withRegEx(".*https://wiremock.io/cloud.*")) wiremock.start()
WireMock requiere una configuración simulada. La instrucción withFileSystemBind
describe el enlace del sistema de archivos entre la ruta del archivo local y la ruta dentro del contenedor Docker. En este caso, el directorio "${workingDirectory}/src/test/resources/wiremock/mappings"
en la máquina local se montará en /home/wiremock/mappings
dentro del contenedor WireMock.
A continuación se muestra una parte adicional de la estructura del proyecto para comprender la composición de los archivos en el directorio:
load-tests/ |-- src/ | |-- test/ | | |-- resources/ | | | |-- wiremock/ | | | | |-- mappings/ | | | | | |-- health.json | | | | | |-- forecast.json
Para garantizar que WireMock cargue y acepte correctamente los archivos de configuración simulados, puede utilizar un contenedor auxiliar:
helper.execInContainer("wget", "-O", "-", "http://wiremock:8080/health").getStdout() == "Ok"
El contenedor auxiliar se describe a continuación:
def helper = new GenericContainer<>("alpine:3.17") .withNetwork(network) .withCommand("top")
Por cierto, la versión 2024.1 de IntelliJ IDEA introdujo soporte para WireMock y el IDE proporciona sugerencias al crear archivos de configuración simulados.
La declaración del contenedor del servicio Sandbox tiene el siguiente aspecto:
def javaOpts = ' -Xloggc:/tmp/gc/gc.log -XX:+PrintGCDetails' + ' -XX:+UnlockDiagnosticVMOptions' + ' -XX:+FlightRecorder' + ' -XX:StartFlightRecording:settings=default,dumponexit=true,disk=true,duration=60s,filename=/tmp/jfr/flight.jfr' def sandbox = new GenericContainer<>(image) .withNetwork(network) .withNetworkAliases("sandbox") .withFileSystemBind("${reportDirectory}/logs", "/tmp/gc", READ_WRITE) .withFileSystemBind("${reportDirectory}/jfr", "/tmp/jfr", READ_WRITE) .withEnv([ 'JAVA_OPTS' : javaOpts, 'app.weather.url' : 'http://wiremock:8080', 'spring.datasource.url' : 'jdbc:postgresql://postgres:5432/sandbox', 'spring.datasource.username' : 'sandbox', 'spring.datasource.password' : 'sandbox', 'spring.jpa.properties.hibernate.default_schema': 'sandbox' ]) .waitingFor(new LogMessageWaitStrategy().withRegEx(".*Started SandboxApplication.*")) .withStartupTimeout(Duration.ofSeconds(10)) sandbox.start()
Los parámetros notables y las configuraciones de JVM incluyen:
Además, se configuran directorios para guardar los resultados de diagnóstico del servicio.
Si necesita ver los registros de cualquier contenedor en un archivo, lo cual probablemente sea necesario durante la etapa de configuración y redacción del escenario de prueba, puede utilizar las siguientes instrucciones al describir el contenedor:
.withLogConsumer(new FileHeadLogConsumer("${reportDirectory}/logs/${alias}.log"))
En este caso, se utiliza la clase FileHeadLogConsumer
, que permite escribir una cantidad limitada de registros en un archivo. Esto se hace porque probablemente no sea necesario el registro completo en escenarios de pruebas de carga, y un registro parcial será suficiente para evaluar si el servicio está funcionando correctamente.
Existen muchas herramientas para pruebas de carga. En este artículo, propongo considerar el uso de tres de ellos: Gatling, Wrk y Yandex.Tank. Las tres herramientas se pueden utilizar de forma independiente.
Gatling es una herramienta de prueba de carga de código abierto escrita en Scala. Permite la creación de escenarios de prueba complejos y proporciona informes detallados. El archivo de simulación principal de Gatling está conectado como un recurso de Scala al módulo, lo que hace que sea conveniente trabajar con toda la gama de soporte de IntelliJ IDEA, incluido el resaltado de sintaxis y la navegación a través de métodos para referencia de documentación.
La configuración del contenedor para Gatling es la siguiente:
def gatling = new GenericContainer<>("denvazh/gatling:3.2.1") .withNetwork(network) .withFileSystemBind("${reportDirectory}/gatling-results", "/opt/gatling/results", READ_WRITE) .withFileSystemBind("${workingDirectory}/src/gatling/scala", "/opt/gatling/user-files/simulations", READ_WRITE) .withFileSystemBind("${workingDirectory}/src/gatling/resources", "/opt/gatling/conf", READ_WRITE) .withEnv("SERVICE_URL", "http://sandbox:8080") .withCommand("-s", "MainSimulation") .waitingFor(new LogMessageWaitStrategy() .withRegEx(".*Please open the following file: /opt/gatling/results.*") .withStartupTimeout(Duration.ofSeconds(60L * 2)) ); gatling.start()
La configuración es casi idéntica a la de otros contenedores:
reportDirectory
.workingDirectory
.workingDirectory
.
Además, los parámetros se pasan al contenedor:
SERVICE_URL
con el valor de URL para el servicio Sandbox. Aunque, como se mencionó anteriormente, el uso de alias de red permite codificar la URL directamente en el código del escenario.
-s MainSimulation
para ejecutar una simulación específica.
Aquí hay un recordatorio de la estructura del archivo fuente del proyecto para comprender qué se pasa y dónde:
load-tests/ |-- src/ | |-- gatling/ | | |-- scala/ | | | |-- MainSimulation.scala # Main Gatling simulation file | | |-- resources/ | | | |-- gatling.conf # Gatling configuration file | | | |-- logback-test.xml # Logback configuration for testing
Dado que este es el contenedor final y esperamos obtener resultados una vez finalizado, establecemos la expectativa .withRegEx(".*Please open the following file: /opt/gatling/results.*")
. La prueba finalizará cuando aparezca este mensaje en los registros del contenedor o después 60 * 2
segundos.
No profundizaré en el DSL de los escenarios de esta herramienta. Puede consultar el código del escenario utilizado en el repositorio del proyecto .
Wrk es una herramienta de prueba de carga sencilla y rápida. Puede generar una carga importante con recursos mínimos. Las características clave incluyen:
La configuración del contenedor para Wrk es la siguiente:
def wrk = new GenericContainer<>("ruslanys/wrk") .withNetwork(network) .withFileSystemBind("${workingDirectory}/src/test/resources/wrk/scripts", "/tmp/scripts", READ_WRITE) .withCommand("-t10", "-c10", "-d60s", "--latency", "-s", "/tmp/scripts/getForecast.lua", "http://sandbox:8080/weather/getForecast") .waitingFor(new LogMessageWaitStrategy() .withRegEx(".*Transfer/sec.*") .withStartupTimeout(Duration.ofSeconds(60L * 2)) ) wrk.start()
Para que Wrk funcione con solicitudes al servicio Sandbox, se requiere la descripción de la solicitud a través de un script Lua, por lo que montamos el directorio del script workingDirectory
. Usando el comando, ejecutamos Wrk, especificando el script y la URL del método de servicio de destino. Wrk escribe un informe en el registro basado en sus resultados, que puede usarse para establecer expectativas.
Yandex.Tank es una herramienta de prueba de carga desarrollada por Yandex. Admite varios motores de prueba de carga, como JMeter y Phantom. Para almacenar y mostrar los resultados de las pruebas de carga, puede utilizar el servicio gratuito Overload .
Aquí está la configuración del contenedor:
copyFiles("${workingDirectory}/src/test/resources/yandex-tank", "${reportDirectory}/yandex-tank") def tank = new GenericContainer<>("yandex/yandex-tank") .withNetwork(network) .withFileSystemBind("${reportDirectory}/yandex-tank", "/var/loadtest", READ_WRITE) .waitingFor(new LogMessageWaitStrategy() .withRegEx(".*Phantom done its work.*") .withStartupTimeout(Duration.ofSeconds(60L * 2)) ) tank.start()
La configuración de prueba de carga para Sandbox está representada por dos archivos: load.yaml
y ammo.txt
. Como parte de la descripción del contenedor, los archivos de configuración se copian en reportDirectory
, que se montará como directorio de trabajo. Aquí está la estructura de los archivos fuente del proyecto para comprender qué se pasa y dónde:
load-tests/ |-- src/ | |-- test/ | | |-- resources/ | | | |-- yandex-tank/ | | | | |-- ammo.txt | | | | |-- load.yaml | | | | |-- make_ammo.py
Los resultados de las pruebas, incluidas las grabaciones y registros de rendimiento de JVM, se guardan en el directorio build/${timestamp}
, donde ${timestamp}
representa la marca de tiempo de cada ejecución de prueba.
Los siguientes informes estarán disponibles para su revisión:
Si se utilizó Gatling:
Si se utilizó Wrk:
Si se utilizó Yandex.Tank:
La estructura del directorio para los informes es la siguiente:
load-tests/ |-- build/ | |-- ${timestamp}/ | | |-- gatling-results/ | | |-- jfr/ | | |-- yandex-tank/ | | |-- logs/ | | | |-- sandbox.log | | | |-- gatling.log | | | |-- gc.log | | | |-- wiremock.log | | | |-- wrk.log | | | |-- yandex-tank.log | |-- ${timestamp}/ | |-- ...
Las pruebas de carga son una fase crucial en el ciclo de vida del desarrollo de software. Ayuda a evaluar el rendimiento y la estabilidad de una aplicación bajo diversas condiciones de carga. Este artículo presentó un enfoque para crear un entorno de prueba de carga utilizando Testcontainers, que permite una configuración fácil y eficiente del entorno de prueba.
Los Testcontainers simplifican significativamente la creación de entornos para pruebas de integración, brindando flexibilidad y aislamiento. Para las pruebas de carga, esta herramienta permite implementar los contenedores necesarios con diferentes versiones de servicios y bases de datos, lo que facilita la realización de pruebas y mejora la reproducibilidad de los resultados.
Los ejemplos de configuración proporcionados para Gatling, Wrk y Yandex.Tank, junto con la configuración del contenedor, demuestran cómo integrar de manera efectiva varias herramientas y administrar los parámetros de prueba.
Además, se describió el proceso de registro y guardado de resultados de pruebas, que es esencial para analizar y mejorar el rendimiento de las aplicaciones. Este enfoque se puede ampliar en el futuro para admitir escenarios más complejos y la integración con otras herramientas de seguimiento y análisis.
¡Gracias por su atención a este artículo y buena suerte en su esfuerzo por escribir pruebas útiles!