Testes e qualidade de software

Testes de desempenho como disciplina: SLOs, benchmarks JMH e barreiras de regressão na CI

Intermediário10 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O trabalho de desempenho fracassa quando vira uma fase no fim do projeto. Este guia o trata como parte da estratégia de testes: escrever requisitos não funcionais capazes de reprovar um build, medir o código corretamente com JMH, fazer profiling de um teste lento em vez de chutar e rodar barreiras de regressão em um pipeline onde as medições têm ruído.

O que você vai aprender

  • Transformar uma expectativa vaga de desempenho em um SLO que um teste possa aprovar ou reprovar
  • Posicionar as verificações de desempenho no nível certo, do microbenchmark ao teste de carga completo
  • Escrever benchmarks JMH que resistam ao aquecimento do JIT e à eliminação de código morto
  • Fazer profiling de um teste ou endpoint lento com profilers por amostragem e JFR em vez de chutar
  • Automatizar a detecção de regressões de desempenho na CI apesar das medições ruidosas

Problemas de desempenho têm um formato característico: ninguém percebe nada por meses, até que uma release deixa uma página quatro vezes mais lenta e a causa fica enterrada em trezentos commits. O motivo quase nunca é que o time não tinha uma ferramenta de teste de carga. É que o desempenho foi tratado como uma atividade que acontece perto do fim, em outro ambiente, conduzida por outras pessoas, contra requisitos escritos como adjetivos.

Tratá-lo como parte da estratégia de testes muda isso. As expectativas de desempenho viram números capazes de reprovar uma verificação, as medições acontecem em vários níveis de granularidade e uma regressão é pega pelo mesmo pipeline que a produziu. Este guia cobre o lado metodológico: objetivos, níveis, benchmarking, profiling e barreiras na CI. Para modelagem de carga, perfis de rampa e a mecânica de rodar um teste de carga em escala, veja o guia de metodologia de testes de carga.

Requisitos capazes de reprovar um build

"O sistema precisa ser rápido" não pode ser testado. Um enunciado testável precisa de quatro partes: qual operação, qual estatística, qual limite e em quais condições.

O endpoint POST /orders responde em menos de 400 ms no percentil 95 e em menos de 900 ms no 99, sob uma carga sustentada de 150 requisições por segundo, em duas instâncias de 2 vCPUs, com um banco de dados contendo 5 milhões de pedidos.

Isso é um objetivo de nível de serviço (SLO), e ele é falseável. Repare no que cada parte acrescenta. Nomear a operação impede que uma média de todos os endpoints esconda o que está lento. Escolher um percentil reconhece que os usuários sentem a cauda: uma média de 120 ms é totalmente compatível com uma requisição em cada cinquenta levando quatro segundos. Declarar a carga torna o número reproduzível. Declarar o ambiente e o volume de dados evita o clássico vexame de um sistema que é rápido com dez mil linhas e quadrático com dez milhões.

Outras três grandezas acompanham a latência, e muitas vezes são elas que de fato quebram: throughput (quantas requisições por segundo são atendidas antes de a latência degradar), folga de recursos (uso de CPU, memória e pool de conexões na carga-alvo) e taxa de erros sob estresse (que fração das requisições falha quando o sistema é levado além do seu ponto confortável, e se ele degrada aos poucos ou desaba).

Onde as verificações de desempenho entram na estratégia

Desempenho não é uma atividade só, e sim uma família delas, com escopos e custos diferentes.

NívelO que medeCustoQuando roda
Microbenchmark (JMH)Um método ou algoritmo, de nanossegundos a microssegundosde segundos a minutosSob demanda, ou a cada commit nos caminhos críticos
Benchmark de componenteUma chamada de serviço de ponta a ponta, no mesmo processosegundosA cada commit
Asserção em um teste funcionalNúmero de queries, número de alocações, limite de tempo decorridogratuitoA cada build
Teste de cargaO sistema implantado sob uma carga modeladade minutos a horasToda noite, a cada release
Soak e estresseEstabilidade ao longo de horas, comportamento além do limitehorasA cada release
Observação em produçãoTráfego real, dados reaiscontínuoSempre

A entrada mais barata e menos aproveitada dessa tabela é a terceira. Muitas regressões de desempenho não têm nada de sutil: uma query dentro de um loop, um full table scan, uma comparação O(n²) introduzida sem querer. Dá para pegá-las com um teste funcional que verifica uma propriedade contável em vez de uma duração:

@Test
void loadingAnOrderPageIssuesAFixedNumberOfQueries() {
    QueryCounter counter = QueryCounter.attachTo(dataSource);

    orderPageService.render(customerWith(50).orders());

    assertThat(counter.count())
            .as("N+1 regression: one query per order has crept back in")
            .isLessThanOrEqualTo(3);
}

Contar queries, alocações ou chamadas HTTP é determinístico, então a asserção nunca fica flaky, ao contrário de um limite de tempo decorrido, que cedo ou tarde vai falhar em um runner de CI sobrecarregado. Sempre que corrigir um problema de N+1, deixe um teste de contagem para trás, para que ele não volte em silêncio.

Microbenchmarks com JMH

Quando a pergunta é "esta rotina de parsing é mais rápida que aquela?", você precisa de um harness de benchmark, porque um loop de cronometragem escrito à mão mede a opinião do compilador JIT sobre o seu código, e não o código.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class IbanValidationBenchmark {

    @Param({"FR7630006000011234567890189", "DE89370400440532013000"})
    public String iban;

    private IbanValidator regexValidator;
    private IbanValidator arithmeticValidator;

    @Setup(Level.Trial)
    public void setUp() {
        regexValidator = new RegexIbanValidator();
        arithmeticValidator = new ArithmeticIbanValidator();
    }

    @Benchmark
    public boolean regex() {
        return regexValidator.isValid(iban);
    }

    @Benchmark
    public boolean arithmetic() {
        return arithmeticValidator.isValid(iban);
    }

    @Benchmark
    public void consumesExplicitly(Blackhole blackhole) {
        blackhole.consume(arithmeticValidator.normalise(iban));
    }
}

As anotações não estão ali por enfeite: cada uma protege contra uma forma específica de a medição mentir.

@Warmup descarta as iterações em que o método ainda roda interpretado ou está sendo recompilado. Sem ela, você mede a inicialização.

@Fork roda o benchmark em JVMs novas. A poluição de perfil é real: rodar o benchmark A antes do B na mesma JVM pode deixar o compilador com um ponto de chamada polimórfico que faz B parecer mais lento do que é. Dois forks também expõem a variação entre execuções que uma única JVM esconde.

Retornar um valor ou usar Blackhole evita a eliminação de código morto. Um benchmark cujo resultado não é usado pode ser eliminado inteiramente pelo otimizador, e o sintoma clássico é uma rotina que parece levar 0,3 nanossegundos, menos tempo que um único acesso à memória, o que é uma impossibilidade física, e não uma otimização incrível.

@State controla o que é compartilhado e quando é criado. As entradas não podem ser constantes em tempo de compilação, ou o otimizador vai reduzir todo o cálculo a uma constante; @Param as fornece em tempo de execução e, de quebra, percorre a matriz inteira de combinações.

Rode-o a partir de um módulo separado (mvn clean verify e depois java -jar target/benchmarks.jar) em vez de pela IDE, e leia a saída incluindo a margem de erro: o JMH informa um intervalo de confiança, e uma melhoria menor que esse intervalo não é melhoria.

Mesmo um benchmark correto pode enganar. Ele mede um método isolado, já aquecido, com cache quente e sem contenção. Em produção, esse método pode rodar uma vez por requisição com cache frio enquanto quarenta threads disputam a largura de banda da memória. Uma melhoria de 40% em uma rotina que responde por 0,2% do tempo da requisição não vale absolutamente nada. Sempre olhe o profile antes de otimizar e confira de novo o número de ponta a ponta depois.

Fazer profiling em vez de chutar

Quando algo está lento, o instinto é inspecionar o código e montar uma teoria. Teorias sobre desempenho costumam estar erradas, porque o custo mora em algum lugar sem glamour: uma chamada de log que formata uma string a cada iteração, uma expressão regular recompilada a cada requisição, um bloco synchronized que serializa oito threads.

Profilers por amostragem interrompem as threads periodicamente e registram suas pilhas. Têm overhead baixo e geram um flame graph em que a largura representa tempo. São a ferramenta certa para começar a responder "para onde vai o tempo", e funcionam tanto em um processo de produção em execução quanto em um teste.

O JDK Flight Recorder vem embutido na JVM e pode ser ativado em uma única execução de teste:

java -XX:StartFlightRecording=duration=60s,filename=slow-test.jfr,settings=profile \
     -jar target/app.jar

A gravação contém os pontos de alocação, as pausas do garbage collector, a contenção de locks, as exceções lançadas, os estados das threads e a atividade do JIT. Ao abri-la, você vê coisas que um profiler de pilhas sozinho não mostra: que 30% do tempo decorrido é uma pausa de GC, ou que um lock fica retido por milissegundos em um caminho crítico.

O profiling de alocação é o mais esquecido e o que mais vezes se mostra decisivo. Alocação excessiva não parece gargalo em um profile de CPU; ela aparece como pressão sobre o garbage collector e cache misses. Se uma requisição aloca duzentos megabytes, nenhum ajuste algorítmico vai resolver o sintoma.

Para um teste lento valem as mesmas ferramentas, mas antes vem uma pergunta mais simples: o teste é lento porque o código é lento, ou porque ele sobe um contêiner, monta um contexto de aplicação ou faz sleep? Em uma suíte de integração a resposta é quase sempre a segunda, e aí o problema é da suíte, não de desempenho: veja a seção sobre controle de custos do guia de testes de integração.

Memória, vazamentos e comportamento de longa duração

Algumas falhas só aparecem com o tempo. Um cache sem política de despejo, um thread-local que nunca é limpo, uma lista de listeners que só cresce, um pool de conexões vazando em um caminho de erro específico: nada disso reprova um teste funcional, e tudo isso derruba um deploy em produção no quarto dia.

Duas verificações automatizáveis pegam a maioria dos casos:

@Test
void repeatedRenderingDoesNotRetainDocuments() {
    ReferenceQueue<RenderedDocument> queue = new ReferenceQueue<>();
    List<PhantomReference<RenderedDocument>> refs = new ArrayList<>();

    for (int i = 0; i < 200; i++) {
        RenderedDocument doc = renderer.render(sampleInvoice());
        refs.add(new PhantomReference<>(doc, queue));
    }

    System.gc();
    await().atMost(Duration.ofSeconds(5)).until(() -> drain(queue) >= 190);
}

Um soak test, em que o sistema é mantido sob carga moderada por várias horas enquanto se registram o uso de heap, o número de threads, os descritores de arquivo e o número de conexões, pega o resto. O sinal não é o valor absoluto, e sim a inclinação: um piso de heap que sobe continuamente depois de cada garbage collection é um vazamento, seja qual for a cara do pico. Dois heap dumps tirados em momentos diferentes e comparados por histograma de classes normalmente apontam o culpado em minutos.

Automatizando a detecção de regressões na CI

A dificuldade de lidar com desempenho em um pipeline é que a medição tem ruído. Runners compartilhados têm vizinhos, a frequência das CPUs varia, os limites dos contêineres são diferentes. Uma barreira ingênua ("falhar se passar de 400 ms") ou dispara o tempo todo ou é configurada tão frouxa que nunca dispara.

Técnicas que tornam isso viável:

Compare, não afirme valores absolutos. Rode a baseline e a versão candidata no mesmo job, intercaladas, e compare as duas. O ruído compartilhado afeta ambas, então a razão entre elas é muito mais estável que qualquer um dos números.

Bloqueie com um limite largo, alerte com um estreito. Reprove o build apenas diante de uma regressão grande, digamos 25%, e gere um relatório não bloqueante para qualquer coisa acima de 10%. Assim o pipeline continua utilizável e a deriva fica visível.

Acompanhe a tendência, não a execução. Guarde cada medição junto com o commit e plote o gráfico. O desempenho raramente regride em um único degrau dramático; ele piora 2% de cada vez ao longo de quarenta commits, e só uma linha de tendência mostra isso. Um degrau no gráfico aponta o commit que o causou.

Prefira indicadores determinísticos quando puder. Número de queries, número de alocações e tamanho de objetos não variam com a carga da máquina, então podem ter limites apertados sem virar teste flaky.

Isole o que for possível. Fixe as CPUs, desative a variação de frequência, use um runner dedicado para benchmarks. Quando isso não for possível, aumente o número de repetições e use a mediana em vez da média.

performance:
  stage: verify
  script:
    - java -jar target/benchmarks.jar -rf json -rff target/jmh-result.json
    - ./scripts/compare-benchmarks.sh baseline/jmh-result.json target/jmh-result.json --fail-over 25 --warn-over 10
  artifacts:
    paths: ["target/jmh-result.json"]

Qualquer que seja a barreira, registre o resultado mesmo quando ele passar. É o arquivo de medições que permite responder "quando isso ficou lento?", e essa pergunta aparece com muito mais frequência do que "este build está lento?", como o guia de integração contínua defende para a duração dos builds em geral.

Quando os números enganam

Uma lista curta de maneiras pelas quais resultados de desempenho enganam as pessoas, a maioria das quais já custou uma semana a alguém:

  • Medir uma JVM aquecida e implantar uma fria. Se as instâncias reiniciam com frequência, o tempo de inicialização e de aquecimento faz parte da experiência do usuário.
  • Testar com um conjunto de dados minúsculo. Tudo é rápido com mil linhas. O comportamento dos índices, os planos de join e as taxas de acerto do cache mudam com o volume.
  • Gerar carga a partir de uma única máquina, que satura primeiro. Se a CPU do gerador está em 100%, é o gerador que você está medindo.
  • Ignorar a omissão coordenada. Uma ferramenta de carga que espera uma resposta antes de enviar a próxima requisição subestima a latência justamente quando o sistema está em apuros, porque as requisições que teriam sido lentas nunca foram enviadas.
  • Otimizar os 3% errados. Faça profiling primeiro, sempre, e confirme a melhoria de ponta a ponta depois.
  • Uma única execução. Um número só não é uma medição. Repita, informe a variação e desconfie de qualquer efeito menor que ela.

Principais conclusões

  • Requisitos de desempenho só se tornam testáveis como SLOs: operação nomeada, percentil, limite, carga e ambiente.
  • Coloque verificações de desempenho em vários níveis: contagens determinísticas de queries e alocações nos testes funcionais, JMH para o código crítico e testes de carga para o sistema.
  • O aquecimento, os forks e os blackholes do JMH protegem, cada um, contra uma mentira específica de medição; um loop de cronometragem caseiro não reproduz nenhum deles.
  • Faça profiling antes de otimizar, com um profiler por amostragem e o JDK Flight Recorder, e olhe a alocação além da CPU.
  • Pegue vazamentos com soak tests e observe o piso do heap após o GC em vez do pico.
  • Na CI, compare com uma baseline no mesmo job, bloqueie com folga, alerte com rigor e guarde cada medição para que a tendência aponte o commit que causou a regressão.

Fontes e leituras complementares

Perguntas frequentes

Por que não posso simplesmente cronometrar um loop com System.nanoTime?
Porque a JVM vai ser mais esperta que você. As primeiras mil iterações rodam em modo interpretado antes de o JIT compilar o método, então um aquecimento sem cronometragem é obrigatório. Depois de compilado, o otimizador pode tirar sua chamada de dentro do loop, reduzi-la a uma constante ou apagá-la por completo porque o resultado não é usado. O garbage collector e a variação de frequência da CPU adicionam um ruído maior que a maioria dos efeitos que você está tentando medir. O JMH existe justamente para cuidar de tudo isso.
Os testes de desempenho devem rodar a cada commit?
Testes de carga completos, não: eles precisam de um ambiente parecido com produção e demoram demais para um pull request. Um pequeno conjunto de benchmarks focados nos caminhos críticos pode rodar a cada commit se o runner for estável o bastante, e um teste de carga noturno em um ambiente dedicado pega o resto. O importante é que algum sinal de desempenho chegue em até um dia depois da mudança que causou a regressão, enquanto o diff ainda é pequeno.
Qual a diferença entre a média e o p99, e qual devo usar?
A média é dominada pelo caso comum e esconde a cauda; p99 significa que 1% das requisições foi mais lento que aquele valor. Os usuários sentem a cauda, e em uma página que faz vinte chamadas ao backend, o p99 de cada chamada afeta uma boa parte dos carregamentos de página. Defina objetivos sobre percentis, normalmente p95 e p99, mais um máximo, e trate a média como uma checagem de sanidade, não como meta.
Meu benchmark diz que o código novo é 15% mais rápido. Devo acreditar?
Só se a diferença for grande em relação à variação entre execuções e você conseguir reproduzi-la em execuções repetidas, de preferência em uma máquina ociosa ou em bare metal. Informe o intervalo de confiança que o JMH fornece, rode as duas versões intercaladas em vez de uma depois da outra e desconfie de qualquer melhoria abaixo de uns 10% em um runner de CI compartilhado. E confirme que o microbenchmark reflete um caminho que realmente importa em produção.