Testes de Carga
O example_server inclui uma suíte de testes de carga com k6 em packages/example_server/k6/. Os testes cobrem desde latência de baseline até geração concorrente de relatórios e compartilhamento público.
Pré-requisitos
brew install k6Os testes não exigem banco de dados externo, email real nem API externa. Tudo roda localmente.
Início rápido
cd packages/example_server
# 1. Popula o SQLite local (5.000 vendas, 100 produtos, 500 clientes)
fvm dart run bin/seed_sqlite.dart /tmp/k6_test.db
# 2. Inicia o servidor sem email real
RESEND_API_TOKEN="" RESEND_FROM_EMAIL="" \
SQLITE_DB_PATH="/tmp/k6_test.db" \
fvm dart run bin/example_server.dart
# 3. Em outro terminal, rode todos os testes
./k6/run.shRodar um teste específico
./k6/run.sh 01 # baseline
./k6/run.sh danfe # DANFE isolado
./k6/run.sh sqlite # relatórios SQLite
./k6/run.sh pokemon # Duelo Pokémon (mock)
# Ou diretamente com k6:
k6 run --env BASE_URL=http://localhost:8090 k6/scripts/test_danfe.js
k6 run --env MAX_VUS=20 k6/scripts/03_generate.jsScripts disponíveis
| Script | O que testa | VUs | Duração |
|---|---|---|---|
01_baseline.js | /health + /info — latência HTTP pura | até 100 | ~75s |
02_reports.js | Listagem e detalhe de relatórios (in-memory) | 30 | ~60s |
03_generate.js | Geração mista PDF/HTML/CSV (só relatórios sem deps externas) | 8¹ | ~90s |
04_sharing.js | Criação de shares + acesso público + fail2ban | 3+10 | ~70s |
05_auth.js | Tokens, refresh e detecção de reuso | 20 | ~60s |
test_danfe.js | DANFE (NF-e) — relatório com múltiplas bandas, barcode e cálculos, sem datasource | 30¹ | ~3m20s |
test_pokemon_duel.js | Duelo Pokémon com mock /demo/pokeapi + dart_eval | 20–120¹ | ~2m20s |
test_sqlite.js | Relatórios SQLite: vendas agrupadas e catálogo de produtos | 10¹ | ~4m20s |
saturation.js | Rampa agressiva 5→60 VUs — encontra ponto de ruptura | até 60¹ | ~3m |
¹ Ajustável com --env MAX_VUS=N
05_auth.js — autenticação
Requer o servidor iniciado com AUTH_SECRET:
AUTH_SECRET=test-k6-secret fvm dart run bin/example_server.dart
k6 run --env AUTH_SECRET=test-k6-secret k6/scripts/05_auth.jsDependências mockadas
Todos os testes rodam sem serviços externos:
| Dependência | Substituição |
|---|---|
| Email (Resend/MailerSend) | LogEmailProvider — emails vão para stdout |
PokeAPI (pokeapi.co) | /demo/pokeapi/pokemon/:name — mock local |
| Supabase / PostgreSQL | SQLite local em /tmp/k6_test.db |
| Assinatura de relatórios | Desabilitada (sem ENCRYPTION_KEY) |
Para garantir que o .env não ative email real, o run.sh seta RESEND_API_TOKEN="" via Platform.environment, que tem prioridade sobre o arquivo .env.
Resultados de referência — Mac Air M1 8 GB
Medições realizadas com servidor e k6 no mesmo host (macOS 15, Dart 3.x, Relic).
01 — Baseline /health + /info
| Métrica | Valor |
|---|---|
| Throughput | 1.163 req/s |
p(95) /health | 3,4 ms |
p(95) /info | 3,3 ms |
| Erros | 0% |
| Spike 100 VUs | estável, sem degradação |
02 — Listagem de relatórios
| Métrica | Valor |
|---|---|
| Throughput | 486 req/s (30 VUs) |
| p(95) list | 3,6 ms |
| p(95) detail | 4,5 ms |
| Erros | 0% |
03 — Geração mista (PDF/HTML/CSV, 8 VUs)
O setup() descarta automaticamente relatórios com dependências externas e testa apenas os que respondem com 200. Resultado típico: ~19/28 relatórios aprovados.
| Métrica | HTML | CSV | |
|---|---|---|---|
| p(95) | 2,0 s | 150 ms | 1,9 s |
| p(99) | < 10 s | — | — |
| Erros de geração | 0% |
Throughput de geração: ~3,5 iter/s com 8 VUs. O gargalo é o IsolateEngine (um worker Dart por request concorrente).
04 — Sharing (3 criadores + 10 visitantes)
| Métrica | Valor |
|---|---|
| Shares criados | ~87 em 60s |
| p(95) criação | 1,4 s (envolve geração HTML) |
p(95) acesso /s/:slug | 2,8 ms (bytes estáticos) |
| Fail2ban ativado | sim — 330 hits (comportamento esperado em localhost) |
| Erros de criação | 0% |
O fail2ban dispara porque todos os VUs partilham o mesmo IP (localhost). Em produção com IPs distribuídos, o comportamento é diferente. O contador fail2ban_triggered confirma que a proteção funciona.
test_danfe — DANFE NF-e (30 VUs)
O DANFE usa dados inline sem datasource externo. O relatório inclui múltiplas bandas, barcode, layout posicionado e cálculos de totais.
| Métrica | HTML | CSV | |
|---|---|---|---|
| p(95) | 41 ms | 42 ms | 41 ms |
| Throughput | 140 req/s | ||
| Erros | 0% |
test_pokemon_duel — Duelo Pokémon com dart_eval (saturação)
O Duelo Pokémon exercita o pipeline completo: REST datasource (mock), script Dart de transformação via dart_eval, layout com tabelas e gráficos.
Resultados por VUs — saturação progressiva
| VUs | Throughput | PDF p(95) | HTML p(95) | Erros |
|---|---|---|---|---|
| 20 | 20,0 req/s | 1,30 s | 110 ms | 0% |
| 40 | 23,4 req/s | 2,83 s | 510 ms | 0% |
| 60 | 24,8 req/s | 4,34 s | 880 ms | 0% ← throughput plateau |
| 80 | 25,0 req/s | 6,04 s | 1,32 s | 0% |
| 100 | 24,7 req/s | 7,93 s | 1,70 s | 0% |
| 120 | 24,8 req/s | 9,55 s ⚠️ | 1,83 s | 0% ← threshold violado |
Análise
- Throughput teto: ~25 req/s a partir de 60 VUs —
IsolateEnginesaturou em CPU, fila cresce mas não recusa conexões - Zero erros até 120 VUs: o servidor enfileira as requisições e as serve até o timeout de 30s; a degradação é graceful
- HTML ~5–10× mais rápido que PDF em todos os níveis de carga — a diferença é o layout de página
- Ponto de ruptura do threshold: ~110–120 VUs (PDF p95 cruza 8 s)
- dart_eval não é o gargalo dominante — a renderização PDF consome mais CPU do que a execução do script Dart
test_sqlite — Relatórios SQLite (10 VUs)
Dois cenários sequenciais: vendas agrupadas por região (5.000 linhas, GROUP BY) e catálogo de produtos (100 itens).
| Métrica | Valor |
|---|---|
| Throughput | 65.4 req/s |
| p(50) (mediana) | 14.5 ms |
| p(95) | 29.2 ms |
| Erros | 0% (17.013 requests) |
O SQLite local elimina variabilidade de rede. A latência reflete apenas renderização + I/O de disco local. Esses números foram obtidos com o RateLimiterStore assíncrono e o servidor compilado via AOT.
Nota sobre AOT Ao executar com 10+ VUs em relatórios com tabelas e gráficos, recomenda-se compilar o servidor nativamente (
dart compile exe bin/example_server.dart). Em modo JIT (dart run), sob taxa elevada de exceptions internas no layout (ex:package:pdf), o OSR/Unwinder da VM Dart pode gerar crashes de segmentação no M1.
Interpretação dos resultados
Gargalos identificados
| Cenário | Gargalo | Evidência |
|---|---|---|
| Geração PDF/HTML | IsolateEngine (CPU) | ~3,5 iter/s com 8 VUs, sem erros |
| Duelo Pokémon | Renderização PDF (não dart_eval) | throughput teto ~25 req/s a partir de 60 VUs |
| Sharing (criação) | Geração do output do share | p(95) = 1,4s |
| Baseline | Event loop puro | p(95) = 3,4ms em 100 VUs |
Saturação — comportamento sob carga extrema
| Teste | Comportamento ao saturar | Ponto de ruptura |
|---|---|---|
| Geração mista (60 VUs) | Timeouts (45 s) → 94,7% de erros | ~10–15 VUs para PDF pesado |
| Duelo Pokémon (120 VUs) | Fila cresce, latência sobe, zero erros | ~60 VUs (throughput plateau), threshold p95 violado em ~120 VUs |
O IsolateEngine não recusa conexões — ele enfileira. O comportamento de degradação depende do timeout do cliente:
- Timeout curto (< latência da fila) → erros visíveis
- Timeout generoso (30 s+) → zero erros, latência alta até ~120 VUs concorrentes
Fail2ban em testes locais
O fail2ban (15 req/min por IP em /s/) sempre dispara em testes locais porque todos os VUs usam 127.0.0.1. Em produção, com IPs de clientes distintos, o comportamento é correto. Para desabilitar nos testes, não inicialize o BannedIpStore no buildApp() ou aumente o threshold via configuração.
Escalabilidade
O Dart executa em um único isolate por default. O IsolateEngine distribui geração entre um pool de workers (IsolatedObject do Relic), utilizando os núcleos disponíveis para escalabilidade vertical.
Para escalabilidade horizontal (múltiplas instâncias atrás de um load balancer), o RateLimiterStore tem suporte a backends externos (PostgreSQL e Redis), permitindo que instâncias paralelas compartilhem estado de rate limiting, sessões OTP e proteção contra ataques.
Comportamento sob saturação de I/O de rede
Em ciclos ininterruptos do k6/reproduce.sh com o backend AOT, a ~65 req/s gerando relatórios pesados, o limite de saturação foi atingido no subsistema de I/O de rede (Unix Sockets), não em CPU ou memória. Com clientes cancelando conexões forçadamente sob alta concorrência, o kernel do macOS colocou os processos do servidor em Uninterruptible Sleep, tornando-os não responsivos a sinais externos (kill -9). Esse comportamento é do sistema operacional, não do servidor em si.
Adicionando novos testes
// k6/scripts/meu_teste.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
ramp: {
executor: 'ramping-vus',
stages: [
{ duration: '15s', target: 10 },
{ duration: '60s', target: 10 },
{ duration: '15s', target: 0 },
],
},
},
thresholds: {
http_req_duration: ['p(95)<2000'],
http_req_failed: ['rate<0.01'],
},
};
const BASE = __ENV.BASE_URL || 'http://localhost:8090';
export function setup() {
// Descobre IDs dinâmicamente — nunca hardcode
const res = http.get(`${BASE}/api/v1/reports`);
const reports = res.json();
return { id: reports[0].id };
}
export default function ({ id }) {
const res = http.post(
`${BASE}/api/v1/reports/${id}/generate?format=html`,
'{}',
{ headers: { 'Content-Type': 'application/json' } },
);
check(res, { 'status 200': (r) => r.status === 200 });
}