Criando testes de performance em Scala

Alexandre Eleutério Santos Lourenço
Bemobi
Published in
10 min readMay 8, 2020

Olá, pessoal! Tudo bom? Neste artigo, iremos falar sobre o Gatling, uma biblioteca Scala especialmente feita para o desenvolvimento de testes de performance e stress. Mas por que fazer esse tipo de testes? Vamos descobrir!

Medindo o código

Não é mistério para ninguém que performance é um requisito muito importante em qualquer arquitetura. No mundo de hoje, onde cada vez mais temos a necessidade de triturar grandes quantidades de dados em alta velocidade, temos que ficar de olho em aspectos como I/O, rede, etc.

Existe muito debate na comunidade sobre quando devemos pensar em performance, com algumas pessoas defendendo que deve ser pensado logo no início do desenvolvimento, e outros defendendo que devemos pensar apenas quando a necessidade surgir. Seja qual for a sua "bandeira", o fato é que, em algum momento, você irá precisar pensar em performance.

Quando essa necessidade surgir, uma boa ferramenta a sua disposição são testes de stress e carga.

A diferença entre testes de stress e de carga é essencialmente os seus objetivos. Testes de stress tem por objetivo checar o quanto uma aplicação pode aguentar antes de apresentar problemas, ou chegar bem perto desse limite. Isso pode ajudar a definir thresholds para políticas de escalabilidade, onde instâncias podem ser adicionadas ou removidas conforme o ponto de limite é atingido.

Testes de carga, por outro lado, tem por objetivo testar o comportamento das aplicações quando as mesmas se encontram sob diferentes cargas, até chegar em condições de pico de acesso. Isso pode ajudar a identificar pontos de gargalo, fazendo com que medidas possam ser tomadas antes que a aplicação siga para o ambiente produtivo.

Então, agora sabemos que testes são importantes. Mas por que precisamos de uma ferramenta para isso????

Por que usar uma biblioteca?

É claro, poderíamos simplesmente fazer nossos testes manualmente — vamos assumir a partir deste momento que estamos falando de uma API REST — chamando nossa API até que a mesma quebre após uma barragem de chamadas HTTP. Simples, não?

O problema dessa abordagem é que os recursos de uma suíte de testes não são tão simples de se desenvolver. Temos coisas como cenários complexos de teste, como simular usuários acessando a aplicação de maneira escalável através de um período, chamadas uniformes dentro de um período, etc, além de processar os resultados, como cálculo de percentils, médias, etc.

Quando usamos uma ferramenta como Gatling, temos todos esses recursos já prontos. Além disso, como se trata de código já pensado em reutilização, podemos fazer testes que serão reutilizados por diversas aplicações e até mesmo podem ser usados em processos de integração contínua.

E as plataformas de monitoria?

Você pode estar pensando em plataformas de monitoria, como NewRelic, DataDog etc que já possuem tecnologias como Java Profiling para fazer monitoramento em tempo real, apontando problemas diretamente nas camadas "defeituosas" das aplicações, como por exemplo nos acessos à bases relacionais. Essas plataformas são excelentes e devem ser usadas, sem dúvida.

Porém, quando possível, para aplicações que possuem requisitos críticos de performance, o uso de ferramentas como Gatling pode ser uma ótima saída, já que podem ser integradas ao processo de CI, por exemplo, tornando possível que a performance seja posta à prova antes mesmo de o código ir para a produção.

Portanto, agora que sabemos o que é o Gatling e por que usa-lo, vamos começar um simples exemplo para observar a sua utilidade na prática.

Testando na prática

Para este exemplo, vamos usar uma simples API Java utilizando Spring Boot, com Postgres como camada de banco de dados. Será um simples CRUD, já que o nosso foco principal não é aprender como desenvolver uma API, mas como utilizar o Gatling para testá-la.

A API possui os seguintes endpoints:

Todo o projeto é dockerizado, criando uma API junto de uma base de dados pré-populada. O próprio Gatling também será executado dentro de um container. Para facilitar a execução do projeto, também temos disponibilizado um script Make com os comandos necessários. Para rodar, é necessário ter o Docker e Java 11 instalado.

Se você não tiver — ou não quiser — o Java instalado na sua máquina, é disponibilizado também uma imagem Docker no Docker Hub que permite executar todo o projeto usando apenas o Docker-compose.

Todo o código deste laboratório pode ser encontrado neste link. Para executar o Make apenas com o Docker-compose, por exemplo, basta rodar:

make run-docker

No container que contém o Gatling, nós iremos executar nossas simulações (nome que o Gatling dá para os seus agrupamentos de testes). Todas as simulações do projeto estão dentro da pasta src/gatling/simulations.

Em outra pasta, chamada gatling, temos duas pastas, a conf e a reports. Na primeira setamos configurações globais para o Gatling, como a descrição padrão para as simulações, e na segunda temos os relatórios HTML que serão gerados pela ferramenta a cada execução. É possível ver todas as configurações possíveis para o Gatling no link a seguir.

Vamos começar nossa primeira simulação. Todo o código é escrito em Scala, quando trabalhamos com o Gatling. Scala é uma linguagem fortemente tipificada baseada na JVM, que vale muito a pena conhecer! Nossa primeira simulação apenas simula um usuário chamando cada operação da API, como podemos ver abaixo:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class CrudSimulation extends Simulation {

val httpProtocol = http // 1
.baseUrl("http://api:8080/user") // 2
.acceptHeader("application/json") // 3
.userAgentHeader("Mozilla/5.0 (Windows NT 5.1; rv:31.0) " +
"Gecko/20100101 Firefox/31.0") // 4

val scn = scenario("CrudSimulation") // 5
.exec(http("request_get") // 6
.get("/1")) // 7
.pause(5) // 8
.exec(http("request_post")
.post("/")
.body(StringBody(
"""{ "name": "MytestDummy",
| "phone":11938284334 }""".stripMargin)).asJson)
.pause(5)
.exec(http("request_patch")
.patch("/")
.body(StringBody(
"""{ "id":1, "name": "MytestDummy2",
|"phone":11938284123 }""".stripMargin)).asJson)
.pause(5)
.exec(http("request_get_name")
.get("/name/MytestDummy"))
.pause(5)
.exec(http("request_delete")
.delete("/1"))
.pause(5)


setUp( // 9
scn.inject(atOnceUsers(1)) // 10
).protocols(httpProtocol) // 11
}

Vamos conhecer mais da estrutura do Gatling analisando o código:

  1. Primeiro definimos um objeto http, onde definimos as propriedades para a execução das simulações;
  2. Aqui definimos a URL base das simulações;
  3. Aqui definimos o media type das nossas chamadas;
  4. Aqui definimos o user-agent. Esse header não é comumente necessário em testes de APIs, mas pode ser útil para testes de websites;
  5. Aqui criamos o cenário. Simulações são compostas de cenários, que são como testes dentro de uma classe de teste, por exemplo;
  6. Aqui definimos a nossa primeira chamada;
  7. Aqui definimos o HTTP method da chamada, no caso um GET. Nas próximas linhas fazemos também um POST e um PATCH, passando JSONs como os "corpos" das chamadas;
  8. Aqui definimos uma pausa de 5 segundos antes da próxima chamada;
  9. Aqui nós invocamos o método setUp, que irá inicializar e rodar o cenário;
  10. Aqui definimos como iremos executar o cenário. Para este primeiro exemplo, iremos apenas executar com um usuário executando cada chamada;
  11. Aqui definimos o protocolo das chamadas. É aqui que passamos aquele primeiro objeto que criamos no começo do script.

O Gatling também oferece um "gravador", onde podemos gravar a navegação de um site através do browser, para uso em testes de performance de websites. O gravador pode ser encontrado aqui.

Como podemos ver, é bem simples criar simulações. Para executar, basta rodar o make run, ou make run-docker, como comentamos anteriormente.

Após executar a simulação, podemos ver no terminal informações como estas:

A tabela possui informações sumarizando a execução, como a média, mínimo e máximo tempo de resposta das chamadas — em milisegundos — além de percentils. Percentils são cálculos que mostram que, para uma dada massa, qual é a porcentagem para que um dado valor ocorra. Por exemplo, na tabela acima, nós podemos ver que, para 5 requisições, 50% das chamadas tem tempo de resposta de 24 milisegundos.

Depois de execução, o Gatling também gerou um relatório HTML dentro da pasta reports. Se abrirmos o relatório, iremos observar além dos dados já citados, outras informações como usuários ativos durante a simulação, distribuição dos tempos de resposta, etc.

Agora, vamos observar outros dois cenários. Nós iremos começar a realizar os testes de performance, tanto em operações de leitura quanto escrita.

Vamos começar realizando uma refatoração. Nós iremos criar algumas traits — traits são como interfaces do Java, porém com algumas diferenças que a tornam mais poderosas, como por exemplo permitir que múltiplas traits sejam extendidas por uma classe — para reutilizar código e agrupar nossos cenários em uma única simulação. Primeiro, vamos criar uma trait chamada GatlingProtocol:

import io.gatling.core.Predef._
import io.gatling.http.Predef._

trait GatlingProtocol {

val httpProtocol = http
.baseUrl("http://api:8080/user")
.acceptHeader("application/json")
.userAgentHeader("Mozilla/5.0 (Windows NT 5.1; rv:31.0) " +
"Gecko/20100101 Firefox/31.0")

}

A seguir, criamos uma trait chamada NumberUtils, com código utilitário que iremos usar nos dois cenários:

trait NumberUtils {

val leftLimit = 1L
val rightLimit = 10L
def generateLong = leftLimit + (Math.random * (rightLimit - leftLimit)).toLong

}

Depois da refatoração, nosso primeiro cenário fica neste formato:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

trait CrudSimulation {

val crudScn = scenario("CrudScenario")
.exec(http("request_get")
.get("/1"))
.pause(5)
.exec(http("request_post")
.post("/")
.body(StringBody(
"""{ "name": "MytestDummy",
| "phone":11938284334 }""".stripMargin)).asJson)
.pause(5)
.exec(http("request_patch")
.patch("/")
.body(StringBody(
"""{ "id":11, "name": "MytestDummy2",
|"phone":11938284123 }""".stripMargin)).asJson)
.pause(5)
.exec(http("request_get_name")
.get("/name/MytestDummy"))
.pause(5)
.exec(http("request_delete")
.delete("/11"))
.pause(5)


}

Dando prosseguimento, criamos os nossos novos cenários, um com as operações de escrita, outro com as de leitura:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

trait WriteOperationsSimulation extends NumberUtils {

val writeScn = scenario("WriteScenario")
.exec(http("request_post")
.post("/")
.body(StringBody(
"""{ "name": "MytestDummy",
| "phone":11938284334 }""".stripMargin)).asJson)
.pause(5)
.exec(http("request_patch")
.patch("/")
.body(StringBody(
s"""{ "id":$generateLong, "name": "MytestDummy$generateLong",
|"phone":11938284123 }""".stripMargin)).asJson)

}
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

trait ReadOperationsSimulation extends NumberUtils {

val readScn = scenario("ReadScenario")
.exec(http("request_get")
.get("/" + generateLong))
.pause(5)
.exec(http("request_get_name")
.get("/name/Alexandre"))
.pause(5)


}

Finalmente, criamos um executor, que irá agregar os cenários em uma simulação e executar:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class SimulationRunner extends Simulation with CrudSimulation with ReadOperationsSimulation with WriteOperationsSimulation with GatlingProtocol {

setUp(
crudScn.inject(atOnceUsers(1)),
readScn.inject(constantUsersPerSec(50) during (5 minutes)), // 1
writeScn.inject(rampUsers(200) during (2 minutes)) // 2
).protocols(httpProtocol)

}

Outros recursos que podemos ver no código acima são novas formas de configurar cenários de execução. No nosso exemplo, estamos configurando o Gatling para:

  1. Disparar 50 usuários por segundo durante todo o período da execução (5 minutos);
  2. Disparar até 200 usuários, distribuídos uniformemente por um período de 2 minutos.

Mais exemplos de configurações podem ser encontrados aqui.

Depois de executar novamente os testes, podemos ver que foi executada uma quantidade bem maior de requisições e bastantes dados para analisar:

Analisando gargalos

Agora, vamos ver se conseguimos utilizar nossos testes para checar possíveis gargalos de performance.

Se nós observarmos o endpoint que realiza a busca por nome, vamos ver que o tempo máximo de execução ultrapassa 1 seg:

Agora, vamos imaginar um cenário onde esse tempo de resposta não é aceitável para as nossas necessidades, dado que nossos requisitos de negócio apontam que esse endpoint será altamente usado por nossos clientes. Ao usar o Gatling, pudemos detectar o problema, antes de o código ir para a produção.

No nosso caso, nosso culpado mais provável é a base de dados, dado que a API é bastante simples. Vamos tentar incrementar a performance da consulta, criando um índice na coluna name. Depois de criar o índice e reexecutar os testes, podemos ver que a performance melhorou, reduzindo o maior tempo de resposta a menos de 1 segundo, o que atinge os nossos requisitos:

Estressando a API

Vamos realizar um último teste antes de finalizar. Vamos fazer um teste de stress e observar o quanto a nossa API pode suportar antes de começar a apresentar erros devido a altas cargas de chamadas.

Primeiro vamos aumentar a quantidade de usuários nos cenários:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class SimulationRunner extends Simulation with CrudSimulation with ReadOperationsSimulation with WriteOperationsSimulation with GatlingProtocol {

setUp(
crudScn.inject(atOnceUsers(1)),
readScn.inject(constantUsersPerSec(150) during (5 minutes)),
writeScn.inject(rampUsers(500) during (2 minutes))
).protocols(httpProtocol)

}

A seguir, executamos novamente os testes:

Nossa, ainda não temos erros! Porém, a degradação nos tempos de resposta é perceptível. Vamos experimentar aumentar mais um pouco a quantidade de usuários:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class SimulationRunner extends Simulation with CrudSimulation with ReadOperationsSimulation with WriteOperationsSimulation with GatlingProtocol {

setUp(
crudScn.inject(atOnceUsers(1)),
readScn.inject(constantUsersPerSec(250) during (5 minutes)),
writeScn.inject(rampUsers(600) during (2 minutes))
).protocols(httpProtocol)


}

E executar novamente:

Agora sim temos erros! A causa, pelo que podemos observar no relatório, consistem de timeouts causados pelas threads do servidor serem sobrecarregadas pela massiva quantidade de chamadas. Em um cenários real, poderíamos pensar em opções como escalar horizontalmente, programação reativa etc. Porém, dado que o nosso foco neste artigo foi dar apenas uma demonstração da ferramenta, vamos parando por aqui.

E assim concluímos o nosso artigo. Espero que possa ter demonstrado para o leitor o poder desta ferramenta, que pode ser muito útil para testar a performance de nossas aplicações.

Bons testes!

--

--