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) enative/(o binário native) são zero interação e semeiam schema fresco. O tierstaging/deetc/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~/.m2guarda 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: opipeline.ymlbuilda num runner limpo, onde uma lib não publicada é 404, e a/xadm-releasedo 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) + umconfig.mdcurto 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
.jarpresente embuilds-latest\, não dogitao 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:
- Emulador oficial do próprio externo.
- 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 fazdocker 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:
- Health dos apps — cada app responde
200com o shape do/health. - Logs sem exceção — varre
out/logs/*.logporERROR/Exception(os apps rodam comCWD=out). - Estado no banco — os registros do fluxo chegaram ao estado final esperado.
- 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 odown -vde 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.nativecom--build-arg XADM_COMMIT=<sha>, como opipeline.yml. Com lib pendente ele não serve — resolve dependência dentro do container, sem~/.m2—, e a suíte builda pelodockerBuildNativedo 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. OXADM_COMMITinjetado por env do container, de que o caminho dodockerBuildNativeprecisa para o/healthtercommit, não prova identidade: a imagem de outro commit responde o sha que o harness mandou. - O e2e native roda o
smoke.pycontra o binário local com as credenciais de smoke: rotam2m/sessionafirma o status declarado com credencial; "≠ 404 sem credencial" vale só para rotapublic. A lista é asmoke.routesdodocs/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 blocosmoke.glitchtipda cópia local doapp.json(osmoke.pyreprova bloco declarado sem token), e as envs de reversão vão vazias: ensaio nunca reverte produção. /healthresponde200comflavor == "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,56no binário (a GraalVM só embarca o localeensem-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_dumpem 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.mdversionado, 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
.shnem WSL. .ps1em ASCII puro: o PowerShell 5.1 lê.ps1como Windows-1252, e travessão ou acento num string literal quebra o parser.- stderr de comando nativo mata o script sob
Stop:docker/gradlewmandam 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_FORMULASviraemitir.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 -vzera os volumes; sem-vpreserva (-Wipequando 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-ha checagem responde "pronto" nele, e o app que conecta nessa janela falha comEOFException. Vale para ohealthcheck:do compose. gradlewcom Testcontainers chamado pelo harness deixa daemon órfão: o harness faz a barreira pós-chamada com identificação positiva, nuncagradlew --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>/comREADME.md,docker-compose.yml(defaults embutidos) e.gitignore. - [ ]
scripts/:_config.ps1,1-startup2-seed3-drive9-shutdowne orun.ps1. - [ ]
config/<n>-<projeto>/por app; órfãos no projeto que os consome; segredos gitignored. - [ ]
fakes/<sistema>/no caminho real do protocolo, arquivando emout/. - [ ]
builds-latest/carimbado por commit, conteúdo gitignored. - [ ]
run.ps1verifica health + logs + estado no banco + artefatos e sai0/1. - [ ]
run.ps1criaout/rodada-<ts>/, isola cada cenário (OK / FALHA / CRASH) e gravaFIM.txtpor ú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.ps1do zero deu PASSOU (exit 0) e derrubou tudo.