O que testes end-to-end realmente significam
Testes end-to-end (e2e) operam o sistema real do ponto de vista do usuário. Em vez de chamar uma função ou uma rota isoladamente, um teste e2e abre a aplicação, executa as mesmas etapas que uma pessoa faria e verifica se o resultado que o usuário veria realmente aconteceu.
Essa definição traz duas consequências. Primeiro, o teste atravessa todas as fronteiras do sistema: browser, código frontend, HTTP, API, banco de dados e vice-versa. Segundo, as asserções são sobre comportamento, não sobre implementação. Você valida que uma confirmação está visível, e não que uma função específica foi chamada.
Como toda a stack está envolvida, os testes e2e são a coisa mais próxima de uma garantia de que o usuário consegue concluir uma tarefa. Eles também são os testes mais lentos e caros que você escreverá, e é exatamente por isso que você deve escrever poucos deles e escolhê-los cuidadosamente.
Onde o e2e se encaixa na pirâmide de testes
A pirâmide de testes é uma regra prática sobre a quantidade de cada tipo de teste que você deve escrever. A base é larga: muitos testes unitários rápidos. O meio é mais estreito: testes de integração entre alguns componentes. O topo é pequeno: algumas poucas jornadas de ponta a ponta (end-to-end).
- Testes unitários levam milissegundos, rodam sem infraestrutura e validam a lógica pura e casos de borda (edge cases).
- Testes de integração levam segundos, exercitam uma rota ou um módulo com colaboradores reais e capturam bugs de conexão (wiring bugs).
- Testes end-to-end levam dezenas de segundos, rodam contra uma stack implantada e provam que um usuário consegue completar uma tarefa crítica.
O formato importa porque o custo aumenta conforme você sobe e o feedback torna-se mais lento. Um bug que um teste unitário pode encontrar deve ser encontrado por um teste unitário. Reserve o e2e para as jornadas onde um caminho quebrado significa um produto quebrado: cadastro, login, checkout, publicação, convite. Se um teste não precisa de um navegador para ser significativo, ele provavelmente não deveria usar um.
Uma verificação de sanidade útil: para cada teste e2e, pergunte qual teste unitário ou de integração teria capturado o mesmo bug. Se a resposta for “um que é fácil de escrever”, o teste e2e está na camada errada.
Escolhendo as jornadas que valem a pena automatizar
Você não pode automatizar todos os caminhos, portanto, escolha aqueles onde a falha custa mais caro e onde é mais provável que um bug escape pelas camadas inferiores.
Pontue uma jornada candidata em dois eixos: impacto no negócio e risco de integração. Alto impacto somado a alto risco é onde o e2e prova seu valor.
- Alto impacto, alto risco — cadastro, login, checkout, redefinição de senha. Automatize estes primeiro.
- Alto impacto, baixo risco — a home page de marketing. Cubra-a com um smoke test, não com uma jornada completa.
- Baixo impacto, alto risco — uma exportação de admin usada mensalmente. Um único teste é suficiente, ou uma verificação via script.
- Baixo impacto, baixo risco — toggles de configurações e estados cosméticos. Deixe-os para testes de unidade e de componente.
As jornadas que cruzam mais fronteiras são as mais valiosas, pois são aquelas que um teste de unidade não consegue alcançar. Um checkout toca o carrinho, precificação, pagamento, o serviço de pedidos e e-mail — exatamente a fiação que quebra silenciosamente quando uma equipe renomeia um campo.
Anote a lista e revise-a a cada trimestre. O conjunto de jornadas críticas muda conforme o produto cresce, e o fluxo importante de ontem pode se tornar o código morto de hoje.
Escolhendo uma ferramenta: Playwright ou Cypress
Duas ferramentas dominam os testes de navegador modernos, e ambas são boas.
Playwright controla Chromium, Firefox e WebKit através de uma única API. Por padrão, ele executa testes em processos de worker paralelos, suporta múltiplos projetos de navegador, possui locators com auto-waiting e vem com um trace viewer que grava a execução quadro a quadro. Seu storageState torna a reutilização de autenticação simples, e sua fixture request fornece um cliente de API no mesmo teste. É a escolha padrão comum para novas suítes.
Cypress roda no event loop do navegador, o que torna a depuração imediata: você pode inspecionar o DOM a qualquer momento e fazer “viagem no tempo” através dos comandos no runner. Sua API é amigável e suas mensagens de erro são excelentes. A contrapartida é que o suporte cross-browser e o paralelismo historicamente exigiram mais configuração.
A escolha raramente importa tanto quanto a disciplina. Uma suíte de Playwright bem escrita e uma suíte de Cypress bem escrita funcionam igualmente bem. Escolha uma, aprenda seu modelo de espera e foque sua energia em isolamento e locators.
A anatomia de um teste
Quase todo teste e2e possui as mesmas três partes, geralmente chamadas de arrange, act e assert.
test("a signed-in user can upgrade to Pro", async ({ page }) => {
// arrange: the account and plan already exist
// act: perform the journey
await page.goto("/pricing");
await page.getByRole("button", { name: "Choose Pro" }).click();
// assert: the user sees the result
await expect(
page.getByRole("heading", { name: "You're on Pro" }),
).toBeVisible();
});
Mantenha a etapa de arrange fora da UI sempre que possível. Criar um usuário clicando em um formulário de cadastro adiciona minutos a uma suite e acopla cada teste ao fluxo de signup. Em vez disso, crie-o através da API e inicie o teste já logado.
Outro hábito que vale a pena cultivar é ter apenas uma jornada por teste. Quando um teste cobre cinco coisas e falha, você descobre que uma dessas cinco coisas quebrou. Quando ele cobre apenas uma, o nome do teste que falhou já é o diagnóstico.
Escrevendo uma jornada com boa leitura
Um teste e2e é lido com muito mais frequência do que é escrito. O leitor deve ser capaz de entender o que ele protege sem precisar abrir o app.
Nomeie o teste com base no resultado para o usuário, não na implementação. “um usuário logado pode fazer upgrade para o Pro” é melhor do que “testar clique no botão de checkout”. Use o tempo presente e o vocabulário do usuário.
Estruture o teste como uma sequência de ações do usuário, uma por linha, com linhas em branco entre as fases.
test("an admin can invite a teammate", async ({ page }) => {
await page.goto("/team");
await page.getByRole("button", { name: "Invite" }).click();
await page.getByLabel("Email").fill("[email protected]");
await page.getByRole("button", { name: "Send invite" }).click();
await expect(page.getByText("Invitation sent")).toBeVisible();
});
Extraia jornadas repetidas para helpers, mas mantenha o teste em si legível. Um helper chamado upgradeToPro(page) é bom; um helper que esconde o teste inteiro não é. O teste ainda deve mostrar as etapas.
Por fim, comente o “porquê”, não o “quê”. O código já diz qual botão foi clicado; um comentário só é útil quando explica por que o teste existe ou por que uma etapa parece estranha.
Locators: fale com o usuário, não com o DOM
Um locator é a forma como o teste encontra um elemento. A maior melhoria que você pode fazer em uma suite de e2e é escolher locators da mesma maneira que um usuário faria.
O Playwright os ordena do mais para o menos preferido:
getByRole— botões, links, cabeçalhos e inputs por role acessível e nome.getByLabel— controles de formulário por seu label.getByText— texto visível.getByPlaceholder,getByAltText,getByTitle— outros atributos visíveis.getByTestId— um test id estável quando nada voltado ao usuário for adequado.
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByLabel("Email").fill("[email protected]");
await expect(page.getByRole("heading", { name: "Your cart" })).toBeVisible();
Essa ordenação não é estética. Locators de role e label falham quando um elemento é inacessível, portanto, o teste serve também como uma verificação de acessibilidade. Eles também sobrevivem a refatorações: renomear uma classe CSS não os quebra, mas quebra .btn.btn--primary > span.
Use getByTestId deliberadamente, não por padrão. Um test id é um contrato que você mantém, e uma suite cheia deles não diz nada sobre se a interface faz sentido.
Lidando com a autenticação
Fazer login pela UI antes de cada teste é a causa mais comum de suítes de e2e lentas e instáveis. Um login envolve um formulário, uma requisição de rede e um redirecionamento, repetidos centenas de vezes.
A solução é autenticar apenas uma vez e reutilizar a sessão. O Playwright chama a sessão salva de storageState — cookies e local storage serializados em um arquivo.
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.E2E_EMAIL!);
await page.getByLabel("Password").fill(process.env.E2E_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await page.context().storageState({ path: "playwright/.auth/user.json" });
});
Um projeto de setup escreve o arquivo, e os projetos de browser dependem dele e o carregam através de use.storageState. Testes que precisam de um usuário diferente utilizam um arquivo de estado diferente.
// playwright.config.ts
export default defineConfig({
projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ },
{
name: "chromium",
use: { storageState: "playwright/.auth/user.json" },
dependencies: ["setup"],
},
],
});
Você ainda deve manter um teste que faça o login através da interface. O formulário de login é uma jornada do usuário por si só, e o atalho nunca deve ser a única cobertura para essa funcionalidade.
Tornando os testes determinísticos
Determinismo é a chave de tudo. Um teste que passa às vezes é pior do que um que falha sempre, porque ele ensina a equipe a ignorar o vermelho.
Três regras cobrem a maior parte disso.
Nunca use sleep. waitForTimeout(3000) ou é curto demais e instável, ou longo demais e lento. Em vez disso, aguarde pela condição: await expect(locator).toBeVisible(). Asserções focadas em web tentam novamente até passarem ou expirarem o tempo limite, que é exatamente o que você deseja.
Controle a rede. Faça mock de terceiros e dependências instáveis com page.route, para que um sandbox de pagamento lento não quebre a sua suíte de testes. Faça asserções nas requisições que seu app faz quando esse for o comportamento sob teste.
await page.route("**/api/recommendations", (route) =>
route.fulfill({ json: { items: [] } }),
);
Congele o tempo e a aleatoriedade. Um teste que depende de “hoje” ou de um id aleatório falhará em algum momento limite. Injete um clock ou defina seeds para os valores que o teste lê.
Há também o próprio navegador: animações, autofocus e transições criam condições de corrida (races) que parecem bugs da aplicação. Desabilite as animações no ambiente de teste sempre que possível e prefira asserções sobre o estado final.
Cobertura responsiva e entre navegadores
Uma jornada que funciona no Chromium pode falhar no WebKit, e um layout que funciona em um laptop pode ser inutilizável em um celular. A cobertura de viewports e de diferentes navegadores é uma das poucas coisas que o e2e faz que nenhuma outra ferramenta consegue.
O Playwright transforma isso em um problema de configuração. Defina um projeto por navegador e reutilize os mesmos testes.
// playwright.config.ts
export default defineConfig({
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 13"] } },
],
});
Nem todo teste precisa de todos os navegadores. Execute a suíte completa no Chromium para obter feedback rápido, e execute as jornadas críticas em todos os projetos de forma agendada ou antes do release. Isso mantém os pull requests ágeis sem abrir mão da cobertura.
Quando um bug for específico de um navegador, adicione um teste de regressão no projeto afetado. Um comentário que linka para a issue vale mais do que uma nota vaga dizendo que “o Safari é estranho”.
Gerenciando dados de teste
Os dados são onde as suítes de e2e silenciosamente se tornam instáveis. Dois testes que criam um usuário chamado [email protected] passarão individualmente, mas falharão quando executados juntos.
Faça o seed através da API. É mais rápido do que via UI, falha de forma clara quando o seed quebra e mantém o teste focado na jornada, em vez de focar na configuração.
const res = await request.post("/api/test/users", {
data: { email: `ada+${Date.now()}@example.com` },
});
expect(res.ok()).toBeTruthy();
const user = await res.json();
Isole os dados por teste ou por worker. E-mails únicos, registros com escopo de tenant ou um namespace novo por worker funcionam bem. O que importa é que nenhum teste dependa da mesma linha do banco de dados.
Limpe o que você criar, ou torne a limpeza desnecessária definindo o escopo dos dados para uma execução de teste e deletando todo o escopo ao final. Registros remanescentes se acumulam, deixam o banco de dados lento e, eventualmente, causam falhas que não têm relação com a alteração atual.
Executando contra uma stack real
Um teste e2e precisa de um lugar para rodar: um frontend, uma API e um banco de dados, todos na mesma versão do código. A opção mais reproduzível é uma stack docker compose que inicia tudo com um único comando.
docker compose -f docker-compose.e2e.yml up -d --wait
pnpm exec playwright test
docker compose -f docker-compose.e2e.yml down -v
A flag --wait é fundamental: ela faz com que o Compose retorne apenas quando os health checks passarem, o que elimina a race condition de “conexão recusada no primeiro teste”. O -v na finalização descarta os volumes para que a próxima execução comece do zero.
Ambientes de preview levam isso adiante. Uma plataforma faz o build da branch, a implanta em uma URL temporária e a expõe para o job de teste. Isso é o mais próximo da produção que uma execução de e2e pode chegar, e permite que as equipes de produto e QA naveguem pelo mesmo build que os testes utilizam.
Aponte a suite para o ambiente usando uma variável de ambiente — BASE_URL — e nunca deixe o host hard-coded. A mesma suite deve rodar localmente, em CI e contra um preview.
Quando a jornada é uma API, não um navegador
Nem toda jornada de ponta a ponta precisa de um navegador. Um webhook, um job em segundo plano, uma CLI e um fluxo de serviço para serviço são todos “end-to-end” no sentido que importa: eles exercitam o sistema real.
A fixture request do Playwright é um cliente de API completo, portanto, uma jornada de API parece quase idêntica a uma de navegador.
test("a paid order is fulfilled end to end", async ({ request }) => {
const order = await request.post("/api/orders", {
data: { sku: "pro-plan", quantity: 1 },
});
expect(order.status()).toBe(201);
await request.post("/api/payments/webhook", {
data: { orderId: (await order.json()).id, status: "paid" },
});
const res = await request.get(`/api/orders/${(await order.json()).id}`);
expect(await res.json()).toMatchObject({ status: "fulfilled" });
});
Esses testes são mais rápidos e muito menos instáveis (flaky) do que os testes de navegador, então use-os para os fluxos que genuinamente não precisam de uma UI. Para cobertura de API pura em um nível mais baixo, o Supertest é a ferramenta mais leve.
Executando e2e em CI
Testes E2E devem ser executados em pull requests e antes de um release, não a cada salvamento. Em CI, algumas flags fazem a diferença entre um gate útil e um que é ignorado.
- Execute em modo headless. Não há interface gráfica; Playwright e Cypress já vêm configurados como headless por padrão em CI.
- Faça o sharding da suite. Distribua os arquivos entre máquinas com
--shard=1/3para que o tempo total de execução permaneça constante à medida que a suite cresce. - Tente novamente uma vez (retry). Uma única tentativa de reexecução distingue uma falha genuína de um soluço de infraestrutura, desde que você monitore a taxa de retries em vez de permitir que eles escondam a instabilidade (flakiness).
- Faça o upload de artefatos em caso de falha. Traces, screenshots e vídeos são a maneira de tornar um build vermelho em CI diagnosticável sem a necessidade de uma reprodução local.
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 --retries=1
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: report-${{ matrix.shard }}
path: playwright-report/
Retries são uma rede de segurança, não uma solução. Se um teste precisa de um retry para passar consistentemente, ele possui uma race condition ou um bug de isolamento real; o retry está apenas lhe dando tempo para encontrá-lo.
Depurando uma execução com falha
A primeira pergunta após uma falha é sempre a mesma: o que o navegador realmente viu? Uma ferramenta que consiga responder a isso sem a necessidade de uma reprodução local transforma uma investigação de duas horas em uma de dois minutos.
O Playwright grava um trace — screenshots, snapshots do DOM, rede e console — e o trace viewer reproduz a execução passo a passo. Mantenha os traces ativos em caso de falha, abra o relatório e navegue até o momento exato em que a asserção falhou.
pnpm exec playwright test --trace on-first-retry
pnpm exec playwright show-trace test-results/checkout/trace.zip
Localmente, execute a suíte em modo headed ou no modo UI para observar o teste e pausá-lo. O Cypress oferece a mesma ideia através de seu runner interativo, que mantém um log de comandos pelo qual você pode navegar no tempo.
pnpm exec playwright test --ui
pnpm exec playwright test --headed --debug
O hábito que torna a depuração rápida é fazer a asserção exatamente naquilo que está errado. Uma falha em expect(page).toHaveURL(/\/checkout/) informa que a navegação nunca aconteceu, o que é uma investigação completamente diferente de uma falha no título de confirmação.
Combatendo a instabilidade (flakiness)
A instabilidade (flakiness) é o “imposto” dos testes e2e, e quase sempre é causada por um destes fatores:
- Um
awaitausente ou uma race condition. O teste agiu antes de a aplicação estabilizar. Corrija isso com uma web-first assertion, e não com um sleep. - Dados compartilhados. Dois testes alteraram a mesma linha no banco. Isole os dados por teste ou por worker.
- Uma dependência externa. Um script ou API de terceiros estava lento. Faça o mock ou remova-o do caminho crítico.
- Um locator frágil. O teste estava vinculado a uma marcação que mudou. Migre para um locator de role ou label.
- Uma animação. O elemento estava presente, mas ainda não estava estável. Desative as animações no ambiente de teste.
A maneira de corrigir a instabilidade é tratar cada “flake” como um bug com uma causa, colocá-lo em quarentena e corrigir a origem do problema. Uma suíte que depende de retentativas para ficar “verde” é uma suíte em que ninguém confia, e uma suíte em que ninguém confia é pior do que não ter suíte nenhuma.
Mantendo a suíte saudável ao longo do tempo
Uma suíte de e2e tem uma tendência natural de crescer até se tornar lenta e “vermelha”. Mantê-la saudável é uma prática contínua, não uma configuração única.
Revise a suíte da mesma forma que você revisa o produto. Quando uma funcionalidade é removida, delete o teste correspondente no mesmo pull request. Um teste que não protege mais nada é puro custo.
Acompanhe as métricas que importam: tempo total de execução, taxa de aprovação e a taxa de retry. Uma taxa de retry crescente é o sinal mais precoce de que a instabilidade (flakiness) está surgindo, muito antes de a suíte se tornar visivelmente não confiável.
Assuma a responsabilidade pela suíte. Uma suíte de e2e compartilhada e sem dono tende a se degradar: falhas são apenas reiniciadas, testes são ignorados e, em um trimestre, ninguém mais confia em uma execução vermelha. Atribua um rodízio ou um pequeno grupo para mantê-la verde, e transforme a correção de um teste instável em uma tarefa prioritária, em vez de tratá-la como uma interrupção.
Mantenha a suíte rápida o suficiente para ser executada em cada pull request. Quando ela ultrapassar esse limite, utilize sharding, paralelize a execução ou mova as jornadas menos críticas para uma execução noturna. Um quality gate que leva uma hora é um quality gate que as pessoas tentam contornar.
O que não cobrir com e2e
A tentação é testar tudo através do browser porque parece ser a experiência real. Resista. O E2E é a camada mais cara, portanto, utilize-a apenas onde ela realmente for necessária.
Não cubra com e2e:
- Lógica pura e edge cases. Formatação de datas, cálculo de preços, regras de validação — isso pertence a testes unitários que rodam em milissegundos.
- Todos os caminhos de erro. Uma página 500 vale a pena em uma jornada; as vinte formas pelas quais a API pode falhar são testes de integração.
- Combinações exaustivas de input. O E2E cobre o caminho representativo, não a matriz completa.
- Comportamento de componentes. A abertura de um dropdown pertence a um teste de componente, que é mais rápido e preciso.
- Qualquer coisa já coberta em camadas inferiores. Duplicar um teste unitário na camada de e2e adiciona custo e um novo modo de falha sem adicionar confiança.
A regra de ouro: se o bug puder ser encontrado sem um browser, encontre-o sem um browser.
Melhores práticas
- Escreva poucos testes e2e e faça com que cada um represente uma jornada crítica.
- Popule os dados através da API e inicie os testes já autenticado.
- Use locators de role, label e text; trate test ids como último recurso.
- Aguarde por condições com web-first assertions, nunca com
waitForTimeout. - Isole os dados por teste ou por worker para que a ordem de execução nunca importe.
- Mantenha apenas uma jornada por teste para que a falha identifique a causa.
- Aponte a suite para
BASE_URL; execute-a localmente, em CI e em previews. - Capture traces e vídeos em caso de falha e faça o upload como artefatos de CI.
- Coloque testes instáveis (flakes) em quarentena e corrija-os; nunca normalize retries como solução.
- Mantenha pelo menos um teste de login real, mesmo quando reutilizar
storageState.
Erros comuns
- Fazer login pela UI antes de cada teste.
- Selecionar elementos por classe CSS ou posição no DOM.
- Adicionar
waitForTimeoutpara “corrigir” uma race condition. - Compartilhar um usuário ou registro seedado entre testes.
- Executar contra um servidor local iniciado manualmente em vez de uma stack reprodutível.
- Testar lógica de nível unitário através do navegador.
- Hard-codar
localhost:3000para que a suite execute apenas em uma máquina. - Não fazer upload de artefatos e, depois, não conseguir debugar uma falha de CI.
- Deixar um teste flaky continuar falhando e sendo reiniciado em vez de colocá-lo em quarentena.
Próximos passos
O teste E2E está no topo da pirâmide e é mais eficiente quando as camadas abaixo dele estão saudáveis. Leia sobre Supertest para testes de API rápidos que devem cobrir a maior parte da sua superfície HTTP, e Playwright para um tour mais detalhado sobre a ferramenta. Se você estiver avaliando alternativas, o Cypress cobre o outro principal runner, e o Docker explica como levantar a stack reprodutível da qual sua suíte de testes depende.