O uso de Testcontainers melhorou radicalmente o processo de trabalho com cenários de teste. Graças a esta ferramenta, criar ambientes para testes de integração ficou mais simples (veja o artigo Isolamento em Testes com Kafka ). Agora podemos lançar facilmente contêineres com diferentes versões de bancos de dados, corretores de mensagens e outros serviços. Para testes de integração, Testcontainers provou ser indispensável.
Embora o teste de carga seja menos comum que o teste funcional, ele pode ser muito mais agradável. Estudar gráficos e analisar o desempenho de um determinado serviço pode trazer um verdadeiro prazer. Essas tarefas são raras, mas são especialmente emocionantes para mim.
O objetivo deste artigo é demonstrar uma abordagem para criar uma configuração para testes de carga da mesma forma que os testes de integração regulares são escritos: na forma de testes Spock usando Testcontainers em um ambiente de projeto Gradle. Utilitários de teste de carga como Gatling, WRK e Yandex.Tank são usados.
Conjunto de ferramentas: Gradle + Spock Framework + Testcontainers. A variante de implementação é um módulo Gradle separado. Os utilitários de teste de carga usados são Gatling, WRK e Yandex.Tank.
Existem duas abordagens para trabalhar com o objeto de teste:
No primeiro caso, temos um conjunto de testes de carga independentes da versão e alterações do projeto. Esta abordagem é mais fácil de manter no futuro, mas está limitada a testar apenas imagens publicadas. Podemos, é claro, construir manualmente essas imagens localmente, mas isso é menos automatizado e reduz a reprodutibilidade. Ao executar em CI/CD sem as imagens necessárias, os testes falharão.
No segundo caso, os testes são executados na versão mais recente do serviço. Isso permite integrar testes de carga no CI e obter alterações nos dados de desempenho entre versões de serviço. No entanto, os testes de carga geralmente demoram mais que os testes de unidade. A decisão de incluir esses testes no CI como parte do portão de qualidade depende de você.
Este artigo considera a primeira abordagem. Graças ao Spock, podemos executar testes em múltiplas versões do serviço para análise comparativa:
where: image | _ 'avvero/sandbox:1.0.0' | _ 'avvero/sandbox:1.1.0' | _
É importante observar que o objetivo deste artigo é demonstrar a organização do espaço de teste, e não o teste de carga em grande escala.
Para o objeto de teste, vamos usar um serviço HTTP simples chamado Sandbox, que publica um endpoint e usa dados de uma fonte externa para lidar com solicitações. O serviço possui um banco de dados.
O código fonte do serviço, incluindo o Dockerfile, está disponível no repositório do projeto spring-sandbox .
À medida que nos aprofundamos nos detalhes posteriormente neste artigo, quero começar com uma breve visão geral da estrutura do módulo Gradle de load-tests
para fornecer uma compreensão de sua composição:
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
Repositório do projeto - https://github.com/avvero/testing-bench .
Pela descrição acima, vemos que o serviço possui duas dependências: o serviço https://external-weather-api.com e um banco de dados. Sua descrição será fornecida abaixo, mas vamos começar habilitando todos os componentes do esquema para se comunicarem em um ambiente Docker — descreveremos a rede:
def network = Network.newNetwork()
e forneça aliases de rede para cada componente. Isto é extremamente conveniente e nos permite descrever estaticamente os parâmetros de integração.
Dependências como WireMock e os próprios utilitários de teste de carga requerem configuração para funcionar. Podem ser parâmetros que podem ser passados para o contêiner ou arquivos e diretórios inteiros que precisam ser montados nos contêineres.
Além disso, precisamos recuperar os resultados do seu trabalho nos contêineres. Para resolver essas tarefas, precisamos fornecer dois conjuntos de diretórios:
workingDirectory
— o diretório de recursos do módulo, diretamente em load-tests/
.
reportDirectory
— o diretório para os resultados do trabalho, incluindo métricas e logs. Mais sobre isso estará na seção de relatórios.O serviço Sandbox usa Postgres como banco de dados. Vamos descrever essa dependência da seguinte maneira:
def postgres = new PostgreSQLContainer<>("postgres:15-alpine") .withNetwork(network) .withNetworkAliases("postgres") .withUsername("sandbox") .withPassword("sandbox") .withDatabaseName("sandbox")
A declaração especifica o alias de rede postgres
, que o serviço Sandbox usará para se conectar ao banco de dados. Para completar a descrição da integração com o banco de dados, o serviço precisa ser fornecido com os seguintes 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'
A estrutura do banco de dados é gerenciada pelo próprio aplicativo usando Flyway, portanto, nenhuma manipulação adicional do banco de dados é necessária no teste.
Se não tivermos a possibilidade, a necessidade ou o desejo de executar o componente real em um contêiner, podemos fornecer uma simulação para sua API. Para o serviço https://external-weather-api.com, é usado WireMock.
A declaração do contêiner WireMock ficará assim:
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 requer configuração simulada. A instrução withFileSystemBind
descreve a ligação do sistema de arquivos entre o caminho do arquivo local e o caminho dentro do contêiner Docker. Neste caso, o diretório "${workingDirectory}/src/test/resources/wiremock/mappings"
na máquina local será montado em /home/wiremock/mappings
dentro do contêiner WireMock.
Abaixo está uma parte adicional da estrutura do projeto para entender a composição do arquivo no diretório:
load-tests/ |-- src/ | |-- test/ | | |-- resources/ | | | |-- wiremock/ | | | | |-- mappings/ | | | | | |-- health.json | | | | | |-- forecast.json
Para garantir que os arquivos de configuração simulados sejam carregados e aceitos corretamente pelo WireMock, você pode usar um contêiner auxiliar:
helper.execInContainer("wget", "-O", "-", "http://wiremock:8080/health").getStdout() == "Ok"
O contêiner auxiliar é descrito a seguir:
def helper = new GenericContainer<>("alpine:3.17") .withNetwork(network) .withCommand("top")
A propósito, o IntelliJ IDEA versão 2024.1 introduziu suporte para WireMock e o IDE fornece sugestões ao formar arquivos de configuração simulados.
A declaração do contêiner de serviço Sandbox é a seguinte:
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()
Parâmetros notáveis e configurações de JVM incluem:
Além disso, são configurados diretórios para salvar os resultados de diagnóstico do serviço.
Se você precisar ver os logs de qualquer contêiner em um arquivo, o que provavelmente será necessário durante o estágio de gravação e configuração do cenário de teste, você poderá usar as seguintes instruções ao descrever o contêiner:
.withLogConsumer(new FileHeadLogConsumer("${reportDirectory}/logs/${alias}.log"))
Neste caso, é utilizada a classe FileHeadLogConsumer
, que permite gravar uma quantidade limitada de logs em um arquivo. Isto é feito porque o registo inteiro provavelmente não é necessário em cenários de teste de carga, e um registo parcial será suficiente para avaliar se o serviço está a funcionar corretamente.
Existem muitas ferramentas para teste de carga. Neste artigo, proponho considerar o uso de três deles: Gatling, Wrk e Yandex.Tank. Todas as três ferramentas podem ser usadas independentemente umas das outras.
Gatling é uma ferramenta de teste de carga de código aberto escrita em Scala. Permite a criação de cenários de testes complexos e fornece relatórios detalhados. O arquivo de simulação principal do Gatling é conectado como um recurso Scala ao módulo, tornando-o conveniente para trabalhar usando toda a gama de suporte do IntelliJ IDEA, incluindo destaque de sintaxe e navegação por métodos para referência de documentação.
A configuração do contêiner para Gatling é a seguinte:
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()
A configuração é quase idêntica a outros contêineres:
reportDirectory
.workingDirectory
.workingDirectory
.
Além disso, os parâmetros são passados para o contêiner:
SERVICE_URL
com o valor de URL do serviço Sandbox. Embora, como mencionado anteriormente, o uso de aliases de rede permita codificar a URL diretamente no código do cenário.
-s MainSimulation
para executar uma simulação específica.
Aqui está um lembrete da estrutura do arquivo fonte do projeto para entender o que está sendo passado e onde:
load-tests/ |-- src/ | |-- gatling/ | | |-- scala/ | | | |-- MainSimulation.scala # Main Gatling simulation file | | |-- resources/ | | | |-- gatling.conf # Gatling configuration file | | | |-- logback-test.xml # Logback configuration for testing
Como este é o contêiner final e esperamos obter resultados após sua conclusão, definimos a expectativa .withRegEx(".*Please open the following file: /opt/gatling/results.*")
. O teste terminará quando esta mensagem aparecer nos logs do contêiner ou após 60 * 2
segundos.
Não vou me aprofundar no DSL dos cenários desta ferramenta. Você pode conferir o código do cenário utilizado no repositório do projeto .
Wrk é uma ferramenta de teste de carga simples e rápida. Pode gerar uma carga significativa com recursos mínimos. Os principais recursos incluem:
A configuração do contêiner para Wrk é a seguinte:
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 fazer o Wrk funcionar com solicitações ao serviço Sandbox, é necessária a descrição da solicitação por meio de um script Lua, portanto montamos o diretório do script a partir workingDirectory
. Usando o comando, executamos o Wrk, especificando o script e a URL do método de serviço de destino. Wrk grava um relatório no log com base em seus resultados, que pode ser usado para definir expectativas.
Yandex.Tank é uma ferramenta de teste de carga desenvolvida pela Yandex. Ele oferece suporte a vários mecanismos de teste de carga, como JMeter e Phantom. Para armazenar e exibir resultados de testes de carga, você pode usar o serviço gratuito Overload .
Aqui está a configuração do contêiner:
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()
A configuração do teste de carga do Sandbox é representada por dois arquivos: load.yaml
e ammo.txt
. Como parte da descrição do contêiner, os arquivos de configuração são copiados para reportDirectory
, que será montado como o diretório de trabalho. Aqui está a estrutura dos arquivos fonte do projeto para entender o que está sendo passado e onde:
load-tests/ |-- src/ | |-- test/ | | |-- resources/ | | | |-- yandex-tank/ | | | | |-- ammo.txt | | | | |-- load.yaml | | | | |-- make_ammo.py
Os resultados do teste, incluindo registros e registros de desempenho da JVM, são salvos no diretório build/${timestamp}
, onde ${timestamp}
representa o carimbo de data/hora de cada execução de teste.
Os seguintes relatórios estarão disponíveis para revisão:
Se Gatling foi usado:
Se Wrk foi usado:
Se Yandex.Tank foi usado:
A estrutura de diretórios dos relatórios é a seguinte:
load-tests/ |-- build/ | |-- ${timestamp}/ | | |-- gatling-results/ | | |-- jfr/ | | |-- yandex-tank/ | | |-- logs/ | | | |-- sandbox.log | | | |-- gatling.log | | | |-- gc.log | | | |-- wiremock.log | | | |-- wrk.log | | | |-- yandex-tank.log | |-- ${timestamp}/ | |-- ...
O teste de carga é uma fase crucial no ciclo de vida de desenvolvimento de software. Ajuda a avaliar o desempenho e a estabilidade de uma aplicação sob diversas condições de carga. Este artigo apresentou uma abordagem para criar um ambiente de teste de carga usando Testcontainers, que permite uma configuração fácil e eficiente do ambiente de teste.
Testcontainers simplificam significativamente a criação de ambientes para testes de integração, proporcionando flexibilidade e isolamento. Para testes de carga, esta ferramenta permite a implantação dos containers necessários com diferentes versões de serviços e bancos de dados, facilitando a realização de testes e melhorando a reprodutibilidade dos resultados.
Os exemplos de configuração fornecidos para Gatling, Wrk e Yandex.Tank, juntamente com a configuração do contêiner, demonstram como integrar efetivamente várias ferramentas e gerenciar parâmetros de teste.
Além disso, foi descrito o processo de registro e salvamento dos resultados dos testes, essencial para analisar e melhorar o desempenho da aplicação. Esta abordagem pode ser expandida no futuro para suportar cenários mais complexos e integração com outras ferramentas de monitorização e análise.
Obrigado por sua atenção a este artigo e boa sorte em seu esforço para escrever testes úteis!