Pular para conteúdo

e2e-local — suíte de teste ponta a ponta local

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-15

Aplica-se a: perfil app que participa de um fluxo de integração, e os repos de config que ele exercita.

Como construir uma suíte de teste ponta a ponta local, automatizada e repetível, por cliente e fluxo. É o teste de fio de um fluxo de integração multi-app (definição de pronto · Plataforma de Integração): sobe os apps de verdade, roda o fluxo inteiro e verifica que não deu erro, sem mão humana. As suítes vivem em etc/tests/<tier>/e2e-<cliente>-<fluxo>/; fluxo transversal a clientes usa e2e-<capacidade> (ex. e2e-auth, e2e-google-signin). A suíte de referência é a e2e-maxsul-pied.

1. Princípios (o que é / o que não é)

  • Automatizado, zero interação, rodado pelo dev antes da tag; não roda em CI. As suítes são PowerShell no Windows, e o runner da CI é Linux.
  • Projetos reais, fakes só nas bordas. Roda os apps de verdade (build local), com fake apenas no que não dá para ter local (o sistema externo, o parceiro).
  • Auth e serviço nosso rodam de verdade, local, como qualquer app da suíte: auth é lógica nossa, exercitada no fluxo automático.
  • Os três tiers: local/ (jar na JVM) e native/ (o binário native) são zero interação e semeiam schema fresco. O tier staging/ de etc/tests é e2e local sobre dump de produção anonimizado, com gate de migração; não é ambiente de staging (seção 11).
  • Build local, carimbado por commit. Roda antes do deploy — o local está à frente do servidor. Nunca artefato remoto. A ferramenta da toolchain (smoke.py, lint de sync rules) sai do checkout local do central, com o commit dele no relatório; a toolchain publicada (docs.xadm.biz/toolchain/) é o fallback de quem não tem o checkout.
  • Tudo por arquivo, nada de produção ou remoto: banco, credenciais, tokens, portas e caminhos são locais, com config por projeto.
  • Um comando sobe tudo, roda o fluxo, verifica, reporta e derruba tudo. Exit 0 = passou, 1 = falhou.
  • Runtime é descartável: logs, bancos, artefatos e jars são gerados e gitignored.
  • Imagem de terceiro cravada na versão de produção, nunca :latest. O serviço que a suíte não builda (ex. journeyapps/powersync-service) roda a mesma tag de produção, pinada nos dois lados (compose do e2e e deploy), com tag e digest.
  • Config que o serviço de terceiro carrega em runtime é exercitada aqui. Sync rules, roteamento, template: o repo de config prova que ela compila (PowerSync); que ela filtra o que devia se prova no tier local/, com a mesma imagem pinada e a config daquele cliente. Mudou a config, rode o e2e do cliente antes de taggar; a suíte reprova log do serviço com erro fatal de config.
  • Lib própria ainda não publicada vem do mavenLocal, pelo bloco de repositórios da casa (libs), e o ~/.m2 guarda o último publish, não o fonte. Antes de buildar, o harness recusa dependência da casa fora do registro que não seja a versão pendente do HEAD da lib — número abandonado nunca vai ao registro — e republica a lib local quando o ~/.m2 é anterior ao último commit dela. Com lib pendente, o artefato não se identifica só pelo commit do app: o carimbo leva também o da lib, e o cache por commit fica de fora. Isso prova o fio, não o deploy: o pipeline.yml builda num runner limpo, onde uma lib não publicada é 404, e a /xadm-release do app recusa dependência da casa fora do registro.

2. Estrutura de pastas (canônica)

As suítes vivem sob etc/tests/<tier>/ (local/, native/, staging/), uma pasta por fluxo: e2e-<cliente>-<fluxo>.

etc/tests/<tier>/e2e-<cliente>-<fluxo>/   # ex.: etc/tests/local/e2e-maxsul-pied
├── README.md  docker-compose.yml  .gitignore   (só isto solto na raiz)
├── scripts/                     # AUTOMACAO
│   ├── run.ps1                  # ENTRYPOINT: e2e automatico + verificacao (exit 0/1)
│   ├── _config.ps1              # harness: carrega os .env, deriva portas, repos, JDK
│   ├── 1-startup.ps1  2-seed.ps1  3-drive.ps1  9-shutdown.ps1   # blocos que o run.ps1 compoe
│   └── exemplos/                # (opcional) geracao de artefatos p/ validar
├── config/                      # CONFIG POR PROJETO (numerada)
│   ├── 1-<appA>/  <appA>.env  config.md         # app configurado por env
│   ├── 2-<appB>/  <appB>.properties  keys/       # app com arquivo proprio de config
│   └── 3-<infra>/ <infra>.yaml  <init>.sql
├── fakes/<sistema>/             # FAKES das bordas (simula o externo)
├── docs/                        # README/RUNBOOK/COBERTURA
├── builds-latest/               # (conteudo gitignored, pasta versionada) jar por app: <app>-<commit>.jar
└── out/                         # RUNTIME (gitignored)
    ├── rodada-<AAAAMMDD-HHMMSS>/  # UMA por execucao: log do run, relatorio, FIM.txt, patch se dirty
    └── logs/  data/  <artefatos>/

Raiz limpa (três arquivos); scripts numerados pela ordem do fluxo. Uma pasta por rodada em out/rodada-<AAAAMMDD-HHMMSS>/: o log do run.ps1, o relatório, o marcador de fim (seção 7) e o patch da árvore suja (seção 4) — sem ela, o marcador de uma run seria lido como o da seguinte.

3. Config por projeto

Cada projeto tem a config isolada em config/<n>-<projeto>/, para saber o que configura o quê e auditar produção:

  • App configurado por env → um <app>.env (KEY=VALUE) + um config.md curto apontando o equivalente em produção.
  • App com arquivo próprio (.properties, .yaml) → o arquivo real, como produção usa.
  • Órfãos (chaves, init de banco) entram no projeto que os consome.

O harness (scripts/_config.ps1) carrega os .env, deriva o que os scripts precisam (portas, token) e guarda o que é do harness (repos, JDK); o startup alimenta cada app com o seu .env.

# _config.ps1: le um .env simples num hashtable
function Read-EnvFile($path) {
  $h = @{}
  foreach ($l in Get-Content $path) { $l = $l.Trim()
    if ($l -and -not $l.StartsWith('#')) { $i = $l.IndexOf('='); if ($i -gt 0) { $h[$l.Substring(0,$i).Trim()] = $l.Substring($i+1).Trim() } } }
  return $h
}
$APPA_ENV = Read-EnvFile "$ROOT\config\1-appA\appA.env"
$PORTA_A  = $APPA_ENV['PORT']   # deriva o que os scripts usam

Sem .env na raiz: o docker-compose.yml traz os defaults embutidos (${VAR:-default}).

4. builds-latest (carimbo por commit)

O startup builda o working tree local e copia o jar para builds-latest/<app>-<commit>.jar; com a árvore suja, o carimbo vira <commit>-dirty. builds-latest/ guarda só o commit atual de cada app.

function Commit-Curto($dir) {
  $c = "$(& git -C $dir rev-parse --short HEAD 2>$null)".Trim()
  if (& git -C $dir status --porcelain 2>$null) { $c = "$c-dirty" }
  return $c
}
# builda se faltar o jar do commit atual, se -Rebuild ou se dirty; depois apaga <app>-<outro>.jar

Manifesto de build committado (recomendado)

O jar é gitignored, então o carimbo de commit some do git. Um arquivo pequeno committado (builds-latest/BUILD-INFO.md) registra, uma linha por app (e uma para a lib pendente, com o commit dela), o commit sob teste — SHA completo + assunto curto:

| app       | commit (SHA full)                        | assunto            |
|-----------|------------------------------------------|--------------------|
| int-pied  | 9f3c1a2b8e7d6c5f4a3b2c1d0e9f8a7b6c5d4e3f | fix: retorno lote  |
| excel     | 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b | feat: seed kit     |
  • Deriva do .jar presente em builds-latest\, não do git ao vivo: o nome do jar é o que vai rodar.
  • Sem timestamp volátil no arquivo: ele só muda quando um app muda de commit.
  • Só rodada verde e limpa promove o manifesto: a vermelha mantém a âncora da última verde, e árvore suja não existe no git — a âncora apontaria para algo que ninguém remonta. A run suja grava na pasta da rodada o patch completo de cada app sujo (git diff HEAD → <app>.patch) e a lista de arquivos sujos, e o relatório avisa em destaque. Patch reproduz a árvore; hash do diff só confere.

App omitido de uma run (-Sem<App>) some do manifesto — efeito cosmético aceitável.

5. Fakes (bordas)

  • O fake fica no caminho real do protocolo (ex. no handshake por arquivos), não um mock por fora.
  • O fake arquiva o que recebe e responde em out/<artefatos>/ — os bytes exatos, capturados, não inventados.
  • Preserva o wire fielmente (encoding, quebras de linha, larguras de campo).

Serviço externo que precisa ASSINAR — o seam de config

Quando o externo é um validador que a casa executa (ID token do Google/Firebase por RS256 contra o JWKS do provedor), em ordem de preferência:

  1. Emulador oficial do próprio externo.
  2. Chave e JWKS próprios, quando o emulador não serve (o do Firebase Auth emite alg:none, que o validador de produção rejeita): a suíte assina com chave própria e aponta a busca do JWKS para ela por um seam de config — property com default de produção, sobrescrita só em teste.
# application.yml — default = produção intacta; o e2e sobrescreve via env/system prop
auth:
  firebase-jwks-url: ${AUTH_FIREBASE_JWKS_URL:`https://www.googleapis.com/.../publicKeys`}

As crases no default são obrigatórias: sem elas o Micronaut re-parseia o : e a URL perde o esquema (Java/Micronaut).

App que valida token de terceiro por URL fixa no código ganha esse ponto de injeção (retrocompatível). O override é de config (property ou env): o harness é externo ao processo e não alcança um if (teste) no código.

6. Scripts (blocos + entrypoint)

Blocos numerados pela ordem do fluxo, idempotentes e ancorados na raiz:

$ROOT = Split-Path $PSScriptRoot -Parent   # scripts vivem em scripts/; a raiz e o pai
Set-Location $ROOT                          # docker compose e paths relativos a raiz
. "$PSScriptRoot\_config.ps1"
  • 1-startup.ps1 — sobe a infra (compose), builda e carimba, sobe os apps e espera o health.
  • 2-seed.ps1 — injeta os dados de entrada (captura real, replay).
  • 3-drive.ps1 — dirige o fluxo pelos endpoints que um humano acionaria no console.
  • 9-shutdown.ps1 — para os apps e faz docker compose down.
  • run.ps1 — o entrypoint: compõe os blocos, verifica e finaliza.

7. run.ps1 — automação total (o coração)

[0] cria out/rodada-<ts>/ (tudo desta execucao cai ali)
[1] startup (builda o commit atual)
[2] seed (dados reais)
[3] espera normalizar/estabilizar
[4] dirige o fluxo ate o fim -- cada cenario isolado
[5] VERIFICA (secao 8)
[6] relatorio OK / FALHA / CRASH
[7] shutdown  (a menos de -Manter)
[8] finally: grava FIM.txt na rodada (exit code + hora) -> exit 0/1

Cada cenário roda isolado: exceção de script, throw numa pré-condição ou dado que não apareceu vira crash daquele cenário, e a run segue até o teardown, a cobertura e o relatório. Crash conta para o exit 1, mas é contado à parte; cenário que depende de um que crashou fica como não rodado.

$crashes = [System.Collections.ArrayList]@()
function Invoke-Cenario($nome, [scriptblock]$sb) {
  try { & $sb }                                     # o cenario chama Ok/Falha (secao 8)
  catch { Write-Host "  [CRASH] $nome"; [void]$crashes.Add("$nome :: $($_.Exception.Message)") }
}

O fim da run é o marcador, não o processo. O último ato do finally grava FIM.txt na pasta da rodada, com o exit code e a hora; quem acompanha decide que a run terminou por esse arquivo, nunca porque o pipe fechou (Agentes). Processo filho de longa duração escreve em arquivo e nunca herda o stdout de quem chamou (Start-Process -RedirectStandardOutput/-RedirectStandardError).

$RODADA = Join-Path $ROOT ('out\rodada-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
New-Item -ItemType Directory -Force $RODADA | Out-Null
$exit = 1
try {
  # ... blocos 1-4, cada cenario via Invoke-Cenario, verificacao da secao 8 (define $exit) ...
} finally {
  # ... shutdown (a menos de -Manter) ... e, por ULTIMO:
  "exit=$exit fim=$(Get-Date -Format s)" | Set-Content -Encoding ascii "$RODADA\FIM.txt"
}
exit $exit

8. Verificações obrigatórias (o "deu erro ou não")

O run.ps1 só passa se todas baterem:

  1. Health dos apps — cada app responde 200 com o shape do /health.
  2. Logs sem exceção — varre out/logs/*.log por ERROR/Exception (os apps rodam com CWD=out).
  3. Estado no banco — os registros do fluxo chegaram ao estado final esperado.
  4. Artefatos gerados — os arquivos que o fluxo produz existem.
$ok = 0; $falhas = [System.Collections.ArrayList]@()   # $crashes vem do Invoke-Cenario (secao 7)
function Ok($n){ Write-Host "  [ OK ] $n"; $script:ok++ }
function Falha($n){ Write-Host "  [FALHA] $n"; [void]$falhas.Add($n) }
# ... checks ...
Write-Host "OK=$ok FALHA=$($falhas.Count) CRASH=$($crashes.Count)"
if ($falhas.Count + $crashes.Count -eq 0) { 'PASSOU'; $exit = 0 }
else { "FALHOU: $((@($falhas) + @($crashes)) -join '; ')"; $exit = 1 }

O relatório conta três números: OK, FALHA (assert que rodou e não bateu) e CRASH (cenário que não chegou ao assert). Gate que não pôde rodar (ferramenta ou credencial ausente) nunca imprime OK: sai como PULADO, com o motivo.

Assert negativo ("X não acontece") exige prova de que o ciclo rodou. Tempo fixo não prova: se o intervalo do job sobe, a espera fica menor que um ciclo; se o job morreu, "nada acontece" é verdade por omissão. A suíte espera evidência positiva do ciclo (linha de tick no log, contador, coluna de última execução posterior à ação) e só então afirma o nada. O teto da espera é derivado do intervalo que a própria suíte configurou (seção 3); estourar sem evidência é falha.

  • Suíte com job agendado configura o intervalo baixo, explicitamente.
  • Property cujo dono está em disputa entre app e lib: a suíte seta as duas.

9. Runtime + .gitignore

Tudo gerado vai para out/ e builds-latest/:

out/
builds-latest/*                      # jars nao versiona...
!builds-latest/.gitkeep              # ...mas a pasta fica no git
!builds-latest/BUILD-INFO.md         # ...e o manifesto de build committado, se adotado
config/<n>-<app-que-assina>/keys/    # segredo dev (nunca versiona)
fakes/<sistema>/<arquivos-transientes>

Pasta cujo conteúdo é gerado mas que deve existir no clone fica com .gitkeep e o padrão pasta/* + !pasta/.gitkeep.

10. e2e native — sobe o binário native em Docker

App server Micronaut native (Java/Micronaut) tem uma suíte irmã em etc/tests/native/. Ela reusa a infra da local/ (o mesmo compose, por include) e troca uma peça: sobe o app como imagem native Linux, como produção sobe. Falha de native-image é só em runtime — reflexão sem registro, recurso não embarcado, biblioteca hostil ao AOT —, e este é o gate que a pega antes do deploy.

  • O compose da suíte declara name: e2e-native-<suíte>. Sem ele, o projeto compose sai do nome da pasta, que é o mesmo nos dois tiers, e o down -v de um derruba containers e volumes do outro.
  • A imagem é a de produção sempre que dá. Sem lib da casa pendente, a suíte builda o Dockerfile.native com --build-arg XADM_COMMIT=<sha>, como o pipeline.yml. Com lib pendente ele não serve — resolve dependência dentro do container, sem ~/.m2 —, e a suíte builda pelo dockerBuildNative do plugin, que não prova o runtime do kit (base, tini, usuário, HEALTHCHECK).
  • A imagem subida carrega a identidade do build, e o harness recusa a que não bate. O label xadm.build.key (commit do app, mais o da lib pendente) é conferido antes de reusar a imagem; não bateu, rebuilda. O XADM_COMMIT injetado por env do container, de que o caminho do dockerBuildNative precisa para o /health ter commit, não prova identidade: a imagem de outro commit responde o sha que o harness mandou.
  • O e2e native roda o smoke.py contra o binário local com as credenciais de smoke: rota m2m/session afirma o status declarado com credencial; "≠ 404 sem credencial" vale só para rota public. A lista é a smoke.routes do docs/app.json, a mesma que o smoke de produção usa depois do deploy. Sem o token do GlitchTip na máquina, o ensaio tira o bloco smoke.glitchtip da cópia local do app.json (o smoke.py reprova bloco declarado sem token), e as envs de reversão vão vazias: ensaio nunca reverte produção.
  • /health responde 200 com flavor == "native": que o binário sete a property de native não é exercitável na JVM, e este é o único gate que prova o ramo.
  • App que formata em pt-BR afirma 1.234,56 no binário (a GraalVM só embarca o locale en sem -H:IncludeLocales=pt-BR).
  • Paridade antes do native: quando a migração troca um motor de I/O (XLSX de POI para FastExcel, 0023), o golden-master passa primeiro na JVM e só então se builda native.

11. Tier staging/ — dado real de produção, avaliação humana

O tier staging/ de etc/tests é e2e local sobre dump de produção anonimizado, com gate de migração; não é ambiente de staging. Builda tudo, sobe tudo e para: quem avalia é a pessoa. Prova a migração aplicada sobre o dado real (os outros tiers semeiam schema fresco) e a jornada que só um humano julga (popup do provedor de identidade, e-mail, tela). Não propaga veredito nem substitui os outros dois. Suíte de referência: etc/tests/staging/e2e-onpetro.

Regras obrigatórias:

  • Produção é só leitura, e só no pull: o único contato é o pg_dump em stream por SSH; nada é escrito em produção.
  • O dump cru é dado de produção em disco local: vive em pasta gitignored, é apagado ao fim do mask, nunca é commitado e não sai da máquina. O que sobrevive — e o caminho de re-rodar sem tocar em produção — é o seed mascarado.
  • Anonimizar antes de qualquer app subir, incluindo credencial e segredo de provedor (DSN, chave de analytics, identificador de recurso de deploy).
  • A guarda do mask tem duas camadas: anti-regressão heurística (aborta se sobrar valor com cara de segredo ou PII) e fail-closed por schema (o mask declara as colunas que conhece e aborta em coluna nova não declarada).
  • O guard de produção é fail-closed e roda antes de subir app, sobre o estado efetivo (arquivos de config, envs herdadas do shell, envs de cada app).
  • builds-latest/BUILD-INFO.md versionado, como nos outros tiers.
  • Achado automatizável desce para o tier automático no mesmo trabalho: o tier manual não acumula cobertura própria.

Provar a migração contra o dado real não autoriza DDL destrutivo direto: a release N segue sem remover o que a N-1 usa (Java/Micronaut §Banco).

12. COBERTURA.md — cada gap no nível mais barato

E2E é o nível mais caro. Antes de cobrir um gap aqui, audite os testes dos repos: o que fecha em unit ou integração não se re-testa no fio (definição de pronto). Toda suíte carrega um COBERTURA.md:

Gap Nível Onde fecha
Validador Firebase rejeita exp/kid/iss/aud/alg unit FirebaseTokenValidatorTest (repo do app)
/api/admin/** nega sem Bearer (fail-closed) integração AdminSecurityTest (@MicronautTest)
health com banco down → 503 integração HealthDbDownTest (PostgreSQLContainer.stop())
dXpEnvio gerado ponta a ponta (A grava → B lê → arquivo) e2e esta suíte

13. Convenções e armadilhas

  • Windows, PowerShell. Sem .sh nem WSL.
  • .ps1 em ASCII puro: o PowerShell 5.1 lê .ps1 como Windows-1252, e travessão ou acento num string literal quebra o parser.
  • stderr de comando nativo mata o script sob Stop: docker/gradlew mandam progresso no stderr. Envolva o nativo:
    function Invoke-Native([scriptblock]$sb){ $p=$ErrorActionPreference; $ErrorActionPreference='Continue'
      try { & $sb 2>&1 | ForEach-Object { Write-Host $_ } } finally { $ErrorActionPreference=$p }
      if ($LASTEXITCODE -ne 0){ throw "nativo falhou ($LASTEXITCODE): $sb" } }
    
  • Property kebab do Micronaut não binda por env var: EMITIR_FORMULAS vira emitir.formulas. Passe como system property JVM (-Dpied.integracao.transform.emitir-formulas=true), antes do -jar.
  • (q ...).Trim() estoura em null: use (@(q ...) -join '').Trim().
  • Lista por parâmetro é frágil: prefira selecionar do banco por prefixo ou filtro.
  • docker compose down -v zera os volumes; sem -v preserva (-Wipe quando quiser banco limpo).
  • Postgres 18+ monta o volume em /var/lib/postgresql, não em /var/lib/postgresql/data — no caminho antigo o volume nomeado fica vazio e o dado vai para um volume anônimo (Java/Micronaut).
  • Prontidão do Postgres: pg_isready -h 127.0.0.1, nunca sem -h. O entrypoint sobe primeiro um servidor temporário só em socket unix para o initdb; sem -h a checagem responde "pronto" nele, e o app que conecta nessa janela falha com EOFException. Vale para o healthcheck: do compose.
  • gradlew com Testcontainers chamado pelo harness deixa daemon órfão: o harness faz a barreira pós-chamada com identificação positiva, nunca gradlew --stop (Java/Micronaut).
  • Reprocessar parcial gera saída incompleta: para um artefato completo, entre com o registro fresco e dirija um por vez.

14. Checklist — criar uma suíte nova

  • [ ] Pasta etc/tests/<tier>/e2e-<cliente>-<fluxo>/ com README.md, docker-compose.yml (defaults embutidos) e .gitignore.
  • [ ] scripts/: _config.ps1, 1-startup 2-seed 3-drive 9-shutdown e o run.ps1.
  • [ ] config/<n>-<projeto>/ por app; órfãos no projeto que os consome; segredos gitignored.
  • [ ] fakes/<sistema>/ no caminho real do protocolo, arquivando em out/.
  • [ ] builds-latest/ carimbado por commit, conteúdo gitignored.
  • [ ] run.ps1 verifica health + logs + estado no banco + artefatos e sai 0/1.
  • [ ] run.ps1 cria out/rodada-<ts>/, isola cada cenário (OK / FALHA / CRASH) e grava FIM.txt por último; apps e daemons escrevem em arquivo.
  • [ ] Assert negativo espera evidência do ciclo, com teto derivado do intervalo.
  • [ ] Postgres 18+ monta o volume em /var/lib/postgresql.
  • [ ] Só rodada verde e limpa promove o BUILD-INFO.md.
  • [ ] Suíte native: compose com name: e2e-native-<suíte> e imagem conferida pelo label de identidade.
  • [ ] run.ps1 do zero deu PASSOU (exit 0) e derrubou tudo.