← Todos os artigos

De 41 para 6 minutos: o que realmente estava lento na suíte

A regressão travava cada PR por 41 minutos e o time simplesmente parou de esperar. Quando eu finalmente fui medir, descobri que o gargalo não tinha quase nada a ver com paralelismo.

Abri um pull request às 14h07. Três arquivos alterados, uma máscara de campo corrigida. O Jenkins me devolveu o resultado às 14h48.

Quarenta e um minutos e doze segundos. Esse era o tempo da suíte de regressão end-to-end em cada PR, todo dia, para todo mundo. E o efeito colateral disso não era o tempo em si — era o comportamento que ele criou. As pessoas paravam de esperar. Abriam o PR, iam almoçar, voltavam, o build estava vermelho por flakiness, clicavam em rebuild, iam para outra tarefa. Em algum momento alguém começou a mergear com o pipeline ainda rodando "porque é só CSS". Depois virou "porque é só um label". Depois virou norma.

Suíte lenta não é um problema de performance. É um problema de confiança: ninguém confia em algo que atrapalha.

Eu resolvi isso. Levou umas três semanas de trabalho picado e terminou em 6m12s. Mas o caminho não foi o que eu esperava, e é por isso que vale escrever: a parte do paralelismo — que é onde todo mundo começa — respondeu por menos de um terço do ganho.

Antes de qualquer coisa, medir. Sem exceção.

Meu primeiro instinto foi errado e vale dizer isso alto: eu abri o Jenkinsfile pensando em quantos containers eu conseguiria subir. Passei meia tarde nisso antes de perceber que estava prestes a paralelizar sem saber o que estava lento.

Paralelizar sem medir é multiplicar o desperdício. Se cada teste perde 3 segundos fazendo algo idiota, quatro workers vão continuar perdendo os mesmos 3 segundos por teste — você só paga o desperdício em quatro máquinas ao mesmo tempo.

O Cypress dá o tempo por spec no sumário do terminal, mas isso some quando o job termina. O que eu queria era um CSV com tempo por teste, acumulado entre execuções, para conseguir ordenar. Dá para fazer isso com o evento after:spec no setupNodeEvents:

cypress.config.ts
// coletor de tempo: roda em toda execução, local e no CI
import { defineConfig } from 'cypress';
import { appendFileSync } from 'node:fs';

export default defineConfig({
  e2e: {
    baseUrl: process.env.APP_URL ?? 'http://localhost:4200',

    setupNodeEvents(on, config) {
      on('after:spec', (spec, results) => {
        const linhas = results.tests.map((teste) => {
          const ultima = teste.attempts[teste.attempts.length - 1];
          return [
            spec.relative,
            teste.title.join(' > ').replace(/;/g, ','),
            ultima.state,
            ultima.duration ?? 0,
          ].join(';');
        });

        // linha extra com o total do arquivo: é ela que eu ordeno depois
        linhas.push(`${spec.relative};__TOTAL_SPEC__;;${results.stats.duration}`);
        appendFileSync('tempos.csv', linhas.join('\n') + '\n');
      });

      return config;
    },
  },
});

Três execuções depois eu tinha o retrato. Trinta e um specs, 247 testes, 36m02s só de execução (o resto do pipeline era build de imagem e subida da stack). E a distribuição era aquela clássica que todo mundo cita e quase ninguém verifica:

SpecTestesTempo% da suíte
ordem-de-servico.cy.ts346m48s18,9%
estoque-movimentacao.cy.ts285m21s14,8%
contrato-de-compra.cy.ts224m37s12,8%
cadastro-produtor.cy.ts193m12s8,9%
relatorio-safra.cy.ts152m44s7,6%
5 specs de 3111822m42s63,0%

Dezesseis por cento dos arquivos, 63% do tempo. Se eu tivesse paralelizado em quatro sem olhar isso, o worker que pegasse ordem-de-servico.cy.ts sozinho já seguraria o pipeline em quase 7 minutos, e eu teria comemorado um número que não significava nada.

A parte constrangedora: 13 minutos fazendo login

Quando eu abri o CSV por teste, e não por spec, a coisa ficou feia. O padrão se repetia em 246 dos 247 testes: entre 3,1 e 3,8 segundos antes de qualquer asserção acontecer. Sempre no mesmo lugar.

o beforeEach que eu mesmo escrevi, em 2023
beforeEach(() => {
  cy.visit('/login');
  cy.get('[data-cy=usuario]').type(usuario);
  cy.get('[data-cy=senha]').type(senha, { log: false });
  cy.get('[data-cy=entrar]').click();
  cy.url().should('include', '/painel');
});

Essa ideia era minha e era ruim. Eu defendia ela em code review, inclusive: "o login é o caminho crítico, tem que passar por ele". A matemática nunca me passou pela cabeça até eu ter o CSV na mão. 246 testes × 3,3 segundos de média dá 13 minutos e 35 segundos — mais de um terço da suíte inteira gasto reautenticando um usuário que já estava autenticado dois segundos antes.

Existe um contra-argumento sério aqui, e não é "mas é boa prática". É este: se você nunca exercita o formulário de login pela interface, uma regressão de front nesse formulário passa batido e derruba o produto inteiro para 100% dos usuários. Isso é verdade. A resposta não é ignorar, é dimensionar. Eu mantive autenticacao.cy.ts com 7 testes que fazem login pela tela de verdade — credencial errada, usuário bloqueado, token expirado, troca de tenant. Os outros 240 testes não estão testando login. Estão testando ordem de serviço.

A troca foi autenticar por requisição direta à API e deixar o Cypress restaurar a sessão com cy.session:

cypress/support/comandos/autenticacao.ts
type PerfilDeAcesso = 'administrador' | 'operador-campo' | 'financeiro';

const CREDENCIAIS: Record<PerfilDeAcesso, { usuario: string; senha: string }> = {
  'administrador':   { usuario: 'qa.admin',     senha: Cypress.env('SENHA_QA') },
  'operador-campo': { usuario: 'qa.operador',  senha: Cypress.env('SENHA_QA') },
  'financeiro':      { usuario: 'qa.financeiro', senha: Cypress.env('SENHA_QA') },
};

Cypress.Commands.add('autenticarComo', (perfil: PerfilDeAcesso) => {
  const { usuario, senha } = CREDENCIAIS[perfil];
  const tenant = Cypress.env('TENANT_ID');
  const api = Cypress.env('API_URL');

  cy.session(
    ['sessao', perfil, tenant],
    () => {
      cy.request({
        method: 'POST',
        url: `${api}/autenticacao/login`,
        body: { usuario, senha, tenantId: tenant },
      }).then(({ body }) => {
        // precisa visitar a origem antes de escrever no localStorage,
        // senão você grava no about:blank e o cy.session salva vazio.
        cy.visit('/login');
        cy.window().then((janela) => {
          janela.localStorage.setItem('access_token', body.accessToken);
          janela.localStorage.setItem('refresh_token', body.refreshToken);
        });
      });
    },
    {
      cacheAcrossSpecs: true,
      validate() {
        cy.window()
          .then((janela) => janela.localStorage.getItem('access_token'))
          .then((token) =>
            cy.request({
              url: `${api}/autenticacao/eu`,
              headers: { Authorization: `Bearer ${token}` },
              failOnStatusCode: false,
            }),
          )
          .its('status')
          .should('eq', 200);
      },
    },
  );
});

O que dá errado com cy.session (porque deu comigo)

Três coisas me morderam, nessa ordem.

A primeira foi eu esquecer o cacheAcrossSpecs: true. Sem ele, o cache morre no fim de cada spec e você paga o login de novo 31 vezes em vez de uma. Não é o fim do mundo, mas eu passei dois dias achando que o ganho tinha sido menor do que devia.

A segunda foi a chave. Eu comecei com a string 'sessao-admin' e depois adicionei suporte a multi-tenant nos testes. Resultado: o teste do tenant B reaproveitava a sessão do tenant A e falhava com 403 em lugares aleatórios. A chave precisa conter tudo que diferencia a sessão. É por isso que ali em cima ela é um array com perfil e tenant.

A terceira é o validate, e essa é a que as pessoas pulam. Sem ele, o Cypress restaura um token que pode já ter expirado e o teste quebra num ponto sem nenhuma relação com autenticação — você vai debugar um grid quebrado por meia hora até descobrir que o problema era um JWT vencido. O validate falha, o Cypress refaz o setup sozinho, e ninguém fica sabendo.

Depois dessa etapa: 36m02s → 22m57s de execução.

Parar de montar cenário clicando na tela

Com o login resolvido, o próximo pico ficou óbvio. ordem-de-servico.cy.ts tinha um beforeEach que criava um produtor, uma propriedade, um talhão e três insumos — tudo navegando por quatro telas e preenchendo formulário. Cerca de 11 segundos, 34 vezes.

Nada disso é o objeto do teste. O objeto do teste é o que acontece depois que a ordem existe.

A solução foi um conjunto de endpoints de teste no back NestJS, atrás de um guard que só é registrado quando NODE_ENV !== 'production' e que exige um header com chave compartilhada. O Cypress chama isso por cy.task, do lado Node, sem passar pelo browser:

cypress.config.ts — trecho de setupNodeEvents
on('task', {
  async semearOrdemDeServico(parametros: ParametrosOrdemServico) {
    const resposta = await fetch(`${process.env.API_URL}/_teste/ordens-de-servico`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        'x-chave-seed': process.env.CHAVE_SEED!,
      },
      body: JSON.stringify(parametros),
    });

    if (!resposta.ok) {
      // erro de seed tem que estourar alto: senão vira "teste flaky"
      throw new Error(`seed falhou ${resposta.status}: ${await resposta.text()}`);
    }

    return resposta.json();
  },
});

E no spec:

beforeEach(() => {
  cy.autenticarComo('operador-campo');
  cy.task('semearOrdemDeServico', {
    prefixo: Cypress.env('PREFIXO_WORKER'),
    talhao: 'T-12',
    quantidadeInsumos: 3,
  }).as('ordem');
});

Onze segundos viraram cerca de 400 milissegundos. Somando todos os specs que faziam esse tipo de preparação por clique, foram mais 8m52s a menos. A suíte caiu para 14m05s de execução.

Na prática

Repare que eu ainda não paralelizei nada. Só de medir e consertar setup, o pipeline foi de 41m12s para 19m15s. Mais da metade do ganho total do projeto veio daqui — de coisas que estavam sendo feitas de forma burra, não de falta de máquina.

Isolamento de estado, e a conta que eu paguei por pular essa etapa

Aqui é onde eu tropecei feio, e é a parte que eu mais recomendo ler.

Eu subi quatro workers apontando para o mesmo Postgres, com um TRUNCATE ... RESTART IDENTITY CASCADE nas tabelas de negócio dentro do before de cada spec. Parecia limpo. Rodou verde na primeira tentativa e eu quase abri o PR.

Aí veio a segunda execução. E a terceira. O worker 2 chegava no before do seu terceiro spec e truncava a tabela enquanto o worker 3 estava no meio de uma asserção sobre um registro que ele mesmo tinha acabado de criar. Falha em lugar diferente toda vez. Nada reproduzível localmente, porque local só tem um worker.

Em uma semana a taxa de build vermelho por instabilidade saiu de 2% para 19% — 11 de 58 execuções. Eu tinha transformado uma suíte lenta e confiável numa suíte rápida e mentirosa, que é estritamente pior. Revertido.

As três saídas reais, com o trade-off de cada uma sem enfeite:

Banco por container. É a mais correta e a mais cara. Cada worker sobe o próprio Postgres, roda migration, semeia. Isolamento perfeito, zero corrida. O problema é o custo fixo: no nosso caso eram uns 50 segundos por worker só de migration, e memória suficiente para o agente do Jenkins começar a fazer swap com quatro instâncias. Descartei por infraestrutura, não por conceito.

Transação com rollback por teste. Elegante em teste de integração, inviável em E2E de verdade. O teste roda no browser, a aplicação abre a própria conexão, e não existe transação compartilhada entre o processo do Cypress e o processo do NestJS. Só funciona se você aceitar acoplar o teste ao pool de conexão da aplicação, o que é pior do que o problema.

Namespace por worker. Foi o que ficou. Cada worker recebe um PREFIXO_WORKER tipo w2_4471 e todo dado criado pelo seed carrega esse prefixo no documento, no código e na razão social. Ninguém trunca nada; a limpeza é um job noturno que apaga por prefixo. Não é bonito — os dados de teste têm nome de gente estranho e o banco de QA cresce durante o dia. Mas custa 36 segundos a mais na suíte inteira e acabou com a corrida.

Sim, essa etapa aumentou o tempo. 14m05s → 14m41s. Foi o melhor dinheiro que eu gastei no projeto, porque sem ela o paralelismo não teria significado nada.

A imagem que custava 4 minutos antes de rodar um teste

O Dockerfile original tinha um COPY . . antes do npm ci. Só isso. Qualquer commit — inclusive num arquivo de teste — invalidava a camada de dependências e o build refazia npm ci mais o download do binário do Cypress. Eram 4m06s em toda execução para instalar exatamente as mesmas dependências de ontem.

Dockerfile
# syntax=docker/dockerfile:1.7

FROM cypress/base:20.18.0 AS dependencias
ENV CYPRESS_CACHE_FOLDER=/opt/cache-cypress
WORKDIR /app

# manifesto sozinho, antes de tudo: esta camada só invalida quando o lockfile muda
COPY package.json package-lock.json ./

# caches do npm e do binário ficam em mount, não engordam a imagem final
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/opt/cache-cypress \
    npm ci --no-audit --no-fund && \
    npx cypress install && \
    cp -r /opt/cache-cypress /cache-materializado

FROM cypress/base:20.18.0 AS testes
ENV CYPRESS_CACHE_FOLDER=/opt/cache-cypress \
    NODE_ENV=test
WORKDIR /app

COPY --from=dependencias /app/node_modules ./node_modules
COPY --from=dependencias /cache-materializado /opt/cache-cypress

# o que muda todo commit vem por último, na camada mais barata
COPY cypress.config.ts tsconfig.json ./
COPY cypress ./cypress

ENTRYPOINT ["npx", "cypress", "run"]

Com o cache de camadas quente no agente, o build ficou em 58 segundos. E a imagem final saiu de 2,1 GB para 1,3 GB, o que também encurtou o docker pull de cada worker.

Agora sim: paralelizar

Com estado isolado e setup enxuto, a divisão virou uma decisão de aritmética. Dividir por número de arquivos é a armadilha: com 31 specs em 4 fatias, o worker que pegar ordem-de-servico.cy.ts e estoque-movimentacao.cy.ts juntos fica 6m48s no ar enquanto outro termina em 1m50s. Eu testei — o worker mais lento deu 6m48s e o pipeline inteiro ficou em 9m08s.

Dividir por tempo histórico resolve. Cada build verde grava a duração de cada spec num JSON, e um script simples distribui os specs entre as fatias em ordem decrescente de duração, sempre jogando o próximo na fatia mais vazia. Com isso o worker mais lento caiu para 3m52s.

Jenkinsfile
def FATIAS = 4

pipeline {
  agent none
  options { timeout(time: 20, unit: 'MINUTES') }
  environment {
    IMAGEM = "registry.interno/e2e-agro:${env.GIT_COMMIT.take(12)}"
  }

  stages {
    stage('Imagem de teste') {
      agent { label 'docker' }
      steps {
        sh 'docker build --target testes -t $IMAGEM .'
        sh 'docker push $IMAGEM'
      }
    }

    stage('E2E') {
      agent none
      steps {
        script {
          def fatias = [:]

          for (int i = 0; i < FATIAS; i++) {
            def indice = i  // captura por valor: sem isso todo worker vira o último índice

            fatias["worker-${indice}"] = {
              node('docker') {
                checkout scm
                unstash 'balanceamento'

                def specs = sh(
                  script: "node ferramentas/dividir-specs.js --fatias ${FATIAS} --indice ${indice}",
                  returnStdout: true
                ).trim()

                sh """
                  docker run --rm \
                    -e PREFIXO_WORKER=w${indice}_${env.BUILD_NUMBER} \
                    -e API_URL=${env.API_URL} \
                    -e CHAVE_SEED=\$CHAVE_SEED \
                    -v \$PWD/relatorios:/app/relatorios \
                    \$IMAGEM --spec '${specs}' \
                      --reporter mochawesome \
                      --reporter-options reportDir=relatorios,overwrite=false,html=false,json=true
                """

                stash name: "relatorio-${indice}", includes: 'relatorios/*.json'
              }
            }
          }

          parallel fatias
        }
      }
    }

    stage('Relatório consolidado') {
      agent { label 'docker' }
      steps {
        script { (0..<FATIAS).each { unstash "relatorio-${it}" } }

        sh 'npx mochawesome-merge relatorios/*.json > relatorios/consolidado.json'
        sh 'npx marge relatorios/consolidado.json --reportDir relatorios/html'

        // alimenta o balanceamento do próximo build
        sh 'node ferramentas/atualizar-balanceamento.js relatorios/consolidado.json'
        stash name: 'balanceamento', includes: 'balanceamento.json'
      }
    }
  }
}

O estágio de consolidação é o que faz o desenvolvedor não odiar você. Sem ele, quem quebra o build precisa abrir quatro logs diferentes para achar o teste vermelho. Com o merge do mochawesome, é um HTML só.

O placar honesto

#EtapaPipelineDelta
0Baseline (4m06s build + 1m04s stack + 36m02s testes)41m12s
1Instrumentar tempo por spec e por teste41m12s0
2Login via API + cy.session28m07s−13m05s
3Seed por cy.task no lugar de cliques19m15s−8m52s
4Dockerfile multi-stage com cache de camadas16m07s−3m08s
5Namespace por worker (isolamento de estado)16m43s+36s
64 workers balanceados por tempo histórico6m12s−10m31s

Trinta e cinco minutos cortados no total. Vinte e um minutos e cinquenta e sete segundos vieram de consertar setup — 63% do ganho. O paralelismo, que é onde eu ia começar, entregou 10m31s.

Onde eu parei, e por quê

Testei 6 e 8 workers. Com 6, o pipeline foi para 5m28s. Com 8, para 5m11s. Dobrar o número de containers comprou 61 segundos.

O motivo é aritmético e não tem escapatória: dos 6m12s, apenas 3m52s são execução de teste. O resto é chão. Cinquenta e oito segundos de build de imagem, 1m04s subindo a stack e esperando o healthcheck da API, 18 segundos consolidando relatório. Esses 2m20s de custo fixo não dividem por N. E cada worker novo cobra o seu próprio docker pull e a sua própria fatia de CPU no agente, que é compartilhado com todo o resto do time. Com 8 workers eu já via job de build de outro time esperando executor.

Então ficou em 4. O que continua lento e eu não resolvi: o healthcheck da aplicação, que insiste em levar mais de um minuto porque o NestJS roda migration na subida. Dá para separar isso num passo anterior e reaproveitar entre builds. Está na lista há três meses. Provavelmente vale mais que ir para 6 workers.


Quando alguém te disser que a suíte está lenta e pedir mais máquina, peça o CSV primeiro. Nove em cada dez vezes o problema não é a quantidade de núcleos — é aquele beforeEach que alguém escreveu com boa intenção e ninguém nunca cronometrou.

Luciano Martins

Luciano Martins da Silva Junior

Bacharel em Engenharia de Software, pós-graduado em Ciência de Dados e Machine Learning. 8+ anos entre suporte, automação de testes e engenharia de dados. Escreve sobre o que quebra em produção.

Tem um problema difícil de qualidade?

Suíte instável, pipeline lento, dado que mente. É esse tipo de coisa que eu gosto de desembaraçar.

Vamos conversar