Todo time de software escreve testes. Nem todo time escreve testes que evitam erros, sobrevivem a refatorações e dão confiança para publicar numa sexta-feira à tarde. A diferença entre testes que ajudam e testes que atrapalham não está no framework nem no percentual de cobertura. Está na estratégia: saber o que testar, em que nível e com qual compensação.
A abordagem padrão de muitos times é escrever testes unitários para tudo e dar o assunto por encerrado. O relatório de cobertura mostra 90 por cento, a integração contínua passa e todo mundo se sente bem, até que uma mudança numa função quebra três funcionalidades que nenhum teste unitário pegou. O problema não é falta de disciplina, é testar no nível errado. Um componente que conecta cinco serviços pode ter 100 por cento de cobertura unitária e ainda assim falhar em produção, porque a interação entre esses serviços nunca foi validada.
Este guia percorre todo o espectro das estratégias modernas — dos testes unitários rápidos e isolados aos testes ponta a ponta lentos porém confiáveis — e explica quando cada uma agrega valor e quando só cria peso. O objetivo não é convencer você a escrever mais testes, mas ajudá-lo a escrever testes que justifiquem sua existência: pegam erros que importam, rodam rápido o bastante para serem executados com frequência e sobrevivem à inevitável reorganização do seu código.
Boas práticas de testes unitários e o que testar
Testes unitários são a base da maioria das estratégias porque são rápidos, determinísticos e isolados. Um teste unitário bem escrito roda em milissegundos, cobre um único comportamento lógico e não depende de sistemas externos como bancos de dados, sistemas de arquivos ou APIs de rede. Quando falha, você sabe exatamente qual pedaço de lógica quebrou e por quê.
O erro mais comum é testar detalhes de implementação em vez de comportamento. Verificar que uma função chama certo método interno ou define um campo privado torna o teste frágil: uma refatoração que preserva o comportamento externo vai quebrá-lo do mesmo jeito. O teste deixa de ser rede de segurança e vira passivo. Escreva testes sobre a saída observável ou os efeitos colaterais de uma função, não sobre como ela chega àquele resultado.
São bons candidatos as funções utilitárias puras, a lógica de negócio sem entrada e saída, validação e análise sintática, transformações de dados, cálculos algorítmicos e máquinas de estado. São maus candidatos as funções que fazem chamadas de rede, os componentes que desenham interface e todo código fortemente acoplado às entranhas do framework. Esses se testam melhor no nível de integração ou ponta a ponta.
import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";
describe("calculateDiscount", () => {
it("returns 0 for orders below the threshold", () => {
const order: Order = {
items: [{ price: 30, quantity: 1 }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
it("applies 10 percent for orders over $100", () => {
const order: Order = {
items: [
{ price: 80, quantity: 1 },
{ price: 40, quantity: 1 },
],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(12);
});
it("applies loyalty bonus for customers over 2 years", () => {
const order: Order = {
items: [{ price: 100, quantity: 1 }],
customerSince: new Date("2022-06-01"),
};
expect(calculateDiscount(order)).toBe(15);
});
it("does not apply discount when items are on sale", () => {
const order: Order = {
items: [{ price: 200, quantity: 1, onSale: true }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
});O exemplo acima testa quatro comportamentos distintos de uma mesma função, com entradas e saídas esperadas diferentes. Cada teste é independente, determinístico e roda em menos de 10 milissegundos. Se a lógica de preços mudar, os testes dizem exatamente quais cenários foram afetados. Essa é a proposta de valor dos testes unitários: retorno rápido sobre lógica pura.
- Teste comportamentos, não implementação. Verifique saídas e efeitos colaterais, não chamadas internas nem estado privado.
- Uma afirmação lógica por caso. Um teste deve falhar por exatamente um motivo, para ficar óbvio o que quebrou.
- Use nomes descritivos que se leiam como frases. «retorna 0 para pedidos abaixo do limite» é melhor que «teste_desconto_valor_baixo».
- Evite estado mutável compartilhado entre testes. Cada um prepara seus dados e limpa depois de si.
- Mantenha os valores simples e significativos. Use dados realistas, fiéis aos objetos reais do domínio.
Testes de integração com dependências reais
Testes de integração ficam entre os unitários e os ponta a ponta em velocidade e alcance. Verificam como várias unidades colaboram: um repositório chamando um banco real, um serviço conversando com um endpoint concreto, ou um componente renderizado com um store de verdade. A diferença essencial em relação aos unitários é que usam dependências reais ou em contêiner, não simulações.
Os mais valiosos verificam se o seu código interage corretamente com sistemas externos. Um teste unitário que simula a camada de dados diz se a lógica de consulta está correta isoladamente, mas não se a consulta SQL real funciona contra o esquema real. Testes de integração com Testcontainers ou um banco de testes pegam divergências de esquema, restrições violadas, erros nos limites de transação e problemas de serialização que passam batido nos unitários.
Eles brilham em camadas de repositório, manipuladores de rotas de API, consumidores de filas de mensagens, migrações e qualquer código que serialize ou desserialize dados atravessando uma fronteira. O preço é a velocidade — um teste que sobe um contêiner do Postgres leva segundos em vez de milissegundos — mas os erros que pegam custam proporcionalmente mais se aparecerem em produção.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";
describe("OrderRepository", () => {
let db: Awaited<ReturnType<typeof createDatabase>>;
let repo: OrderRepository;
beforeAll(async () => {
db = await createDatabase();
repo = new OrderRepository(db);
});
afterAll(async () => {
await teardownDatabase(db);
});
it("persists and retrieves an order", async () => {
const order = {
id: "ord_001",
customerId: "cus_001",
total: 1250,
status: "pending" as const,
createdAt: new Date("2025-06-01"),
};
await repo.save(order);
const retrieved = await repo.findById("ord_001");
expect(retrieved).toEqual(order);
});
it("returns null for a non-existent order", async () => {
const result = await repo.findById("ord_nonexistent");
expect(result).toBeNull();
});
it("updates order status", async () => {
await repo.save({
id: "ord_002",
customerId: "cus_001",
total: 500,
status: "pending",
createdAt: new Date(),
});
await repo.updateStatus("ord_002", "shipped");
const updated = await repo.findById("ord_002");
expect(updated?.status).toBe("shipped");
});
});A regra essencial é manter isolado o estado do banco entre execuções. Cada suíte deve criar seu próprio esquema ou banco, aplicar migrações, rodar os testes e desmontar tudo. Trabalhar contra um banco compartilhado gera testes instáveis, em que os dados de um vazam para as verificações de outro. Use Testcontainers, Docker Compose ou o suporte a banco de testes do seu framework para que cada execução comece de um estado conhecido.
Um teste de integração com banco real vale por cem testes unitários que o simulam. A simulação diz o que você espera que o banco faça. O banco real diz o que ele de fato faz. E os dois raramente coincidem depois da primeira migração de esquema.
Testes ponta a ponta com Playwright
Testes ponta a ponta simulam interações reais por toda a pilha: navegador, frontend, API, banco de dados e serviços externos. São os mais lentos e caros, mas pegam os erros mais realistas: navegação quebrada, validação de formulário ausente, respostas de API exibidas de forma errada, falhas no fluxo de autenticação e diferenças de renderização entre navegadores.
O Playwright se tornou a ferramenta dominante por seu suporte a vários navegadores, suas verificações com espera automática, a interceptação de rede e a experiência de uso. Ao contrário de ferramentas anteriores, que exigiam esperas e novas tentativas explícitas, o Playwright aguarda que um elemento esteja realmente acionável antes de interagir, o que elimina a causa mais comum de instabilidade.
import { test, expect } from "@playwright/test";
test("user completes a purchase flow", async ({ page }) => {
await page.goto("/products");
await page.getByRole("link", { name: "Wireless Headphones" }).click();
await page.getByRole("button", { name: "Add to Cart" }).click();
await page.getByRole("button", { name: "View Cart" }).click();
await expect(page.getByText("Wireless Headphones")).toBeVisible();
await expect(page.getByText("$89.99")).toBeVisible();
await page.getByRole("button", { name: "Checkout" }).click();
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Card Number").fill("4242424242424242");
await page.getByLabel("Expiry").fill("12/28");
await page.getByLabel("CVC").fill("123");
await page.getByRole("button", { name: "Pay Now" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
await expect(page.getByText("ord_001")).toBeVisible();
});O maior erro é escrever testes ponta a ponta demais. Cada um acrescenta minutos à esteira de integração, e uma suíte de 200 pode facilmente levar meia hora. A economia deles exige seletividade. Concentre-se nas jornadas decisivas — cadastro, login, compra, busca, pagamento — e deixe que os níveis mais baixos cubram casos limite e estados de erro.
- Priorize fluxos ligados à receita: pagamento, upgrade de assinatura, processamento de cobranças.
- Mantenha os testes independentes. Cada um cria seus próprios dados e limpa depois de si.
- Use chamadas de API no preparo em vez de interações pela interface, para chegar mais rápido ao estado inicial.
- Execute-os contra um ambiente de homologação ou pré-visualização, não em produção. Controle a visibilidade dos dados de teste com sinalizadores de funcionalidade.
- Invista em observabilidade: capturas, rastros e gravações das falhas economizam horas de investigação.
Desenvolvimento guiado por testes na prática
Desenvolvimento guiado por testes não trata de testar, e sim de projetar. O ciclo vermelho, verde, refatorar obriga você a pensar na interface do código antes de implementá-lo. Escrever o teste primeiro exige responder: o que esta função deve aceitar e o que deve retornar? Essa restrição leva a APIs melhores, acoplamento mais frouxo e código mais modular.
A resistência costuma vir de dois lugares. Ou alguém tentou num problema pouco adequado (código de interface, trabalho exploratório), ou aplicou a técnica de forma dogmática e passou mais tempo brigando com a ferramenta do que escrevendo código útil. É uma ferramenta, não uma religião. Funciona melhor em lógica algorítmica, transformações de dados, projeto de APIs e todo código cujo contrato entre entrada e saída já está claro.
Um fluxo prático é assim: escreva um único teste que descreva o próximo comportamento desejado. Rode-o e veja-o falhar — isso confirma que ele é válido e detecta a ausência da funcionalidade. Escreva o mínimo de código para fazê-lo passar, sem exagero de engenharia: a implementação mais simples que o satisfaz é a certa. Depois melhore a estrutura sem mudar o comportamento, apoiando-se nos testes que passam para flagrar regressões.
Desenvolvimento guiado por testes não faz você escrever mais testes. Faz você escrever código melhor. O teste é um efeito colateral do projeto, não o objetivo. Escrever testes depois da implementação é testar. Escrevê-los antes é projetar.
É particularmente eficaz em correções. Quando um erro é relatado, escreva um teste que o reproduza (vermelho), corrija o código (verde) e verifique que a correção não quebra nada existente (todos os testes continuam passando). Esse teste vira uma proteção permanente contra reincidência. Com o tempo, forma-se uma suíte que reflete o histórico real de erros do projeto — muito mais valiosa do que uma criada para bater uma meta de cobertura.
Testes por propriedades e fuzzing
Testes tradicionais por exemplo verificam entradas específicas contra saídas esperadas. Testes por propriedades fazem diferente: definem uma propriedade que deve valer para todas as entradas e então geram centenas ou milhares de entradas aleatórias para verificá-la. Essa técnica pega casos limite que ninguém pensaria em escrever como exemplo.
Por exemplo, em vez de escrever cinco casos específicos para uma função de ordenação, você define uma propriedade: para qualquer lista de elementos comparáveis, o resultado deve conter os mesmos elementos em ordem não decrescente. O framework gera listas aleatórias de tamanhos variados, com duplicatas, listas vazias e valores extremos, e verifica a propriedade em cada uma.
import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";
describe("sortByPrice", () => {
it("returns items in ascending price order for any input", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
for (let j = 1; j < sorted.length; j++) {
expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
}
}
});
it("preserves all original elements", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
expect(sorted).toHaveLength(items.length);
expect(sorted.map((i) => i.name).sort()).toEqual(
items.map((i) => i.name).sort()
);
}
});
});O fast-check é uma biblioteca bastante usada para testes por propriedades em TypeScript, integrada ao Vitest e ao Jest. Traz geradores para os tipos comuns, combinadores para estruturas complexas e redução automática: ao encontrar um caso que falha, tenta reduzi-lo à menor entrada que ainda falha, o que facilita muito a investigação.
A abordagem vale sobretudo em serialização e desserialização, algoritmos de ordenação e filtragem, regras de validação, transições de máquinas de estado e qualquer função com espaço de entradas amplo. Combinada a técnicas de fuzzing que injetam dados malformados ou inesperados, expõe vulnerabilidades e travamentos que de outro modo só apareceriam em produção.
Testes de regressão visual
Testes funcionais verificam se a aplicação se comporta bem. Testes de regressão visual verificam se ela aparece bem. Mudanças de CSS, atualizações de dependências e componentes refatorados podem introduzir deslocamentos sutis, diferenças de cor, trocas de fonte ou problemas nos pontos de quebra que nenhum teste funcional pega. A regressão visual captura imagens de componentes ou páginas, compara com referências e sinaliza qualquer diferença para revisão humana.
Ferramentas como Chromatic, Percy e a captura embutida do Playwright implementam isso em níveis distintos. O Chromatic integra-se ao Storybook e compara pixel a pixel com uma lógica que ignora suavização e variações de subpixel. O Percy oferece recursos semelhantes com suporte mais amplo a frameworks. A verificação toHaveScreenshot do Playwright permite afirmações visuais diretamente nos testes ponta a ponta, sem ferramentas adicionais.
import { test, expect } from "@playwright/test";
test("product page renders correctly", async ({ page }) => {
await page.goto("/products/wireless-headphones");
await expect(page).toHaveScreenshot("product-page.png", {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
fullPage: true,
});
});
test("checkout form is visually stable across states", async ({ page }) => {
await page.goto("/checkout");
await expect(page).toHaveScreenshot("checkout-empty.png");
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveScreenshot("checkout-validation-error.png");
});A principal dificuldade é administrar as imagens de referência. Toda mudança visual intencional exige atualizá-las, e o processo de aprovação pode virar gargalo em times que mexem na interface com frequência. A saída é tratar essa atualização como parte do trabalho: aprovar as diferenças visuais no mesmo ciclo de revisão do código, usando uma plataforma integrada ao fluxo de pull requests.
- Comece pelas páginas críticas: página inicial, pagamento, login, detalhes do produto e qualquer página com layout complexo.
- Defina limiares adequados para ignorar artefatos de suavização e diferenças de fonte entre sistemas operacionais.
- Use capturas em nível de componente (via Storybook) para cobertura dirigida, em vez de capturas de página inteira sensíveis a qualquer mudança.
- Integre a revisão visual ao fluxo de pull requests. Exija aprovação humana para diferenças nas páginas alteradas.
- Rode os testes visuais em um ambiente uniforme (Docker) para eliminar diferenças de renderização entre máquinas.
Testar componentes de servidor do React e o Next.js
Os componentes de servidor do React mudaram o cenário de testes. Eles rodam exclusivamente no servidor e não enviam JavaScript ao cliente. Podem acessar diretamente bancos de dados, sistemas de arquivos e serviços de back-end sem expor nada disso ao navegador. Isso permite testá-los com uma abordagem diferente da dos componentes de cliente.
No fundo são funções assíncronas que retornam JSX. Você pode testá-los como qualquer função assíncrona: chamá-los com propriedades, aguardar o resultado e verificar a saída. Sem ambiente de navegador e sem simular fetch ou chamadas ao banco, se você trabalha contra um banco de testes real. Essa simplicidade é uma das vantagens menos valorizadas dos componentes de servidor: o preparo é bem mais leve do que o de componentes de cliente, que exigem um DOM.
import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";
describe("ProductList (Server Component)", () => {
it("renders products from the database", async () => {
// No mocking needed - the component calls the database directly
const { props } = await ProductList({ category: "electronics" });
expect(props.products.length).toBeGreaterThan(0);
expect(props.products.every((p) => p.category === "electronics")).toBe(
true
);
});
it("shows empty state when no products match", async () => {
const { props } = await ProductList({ category: "nonexistent" });
expect(props.products).toHaveLength(0);
});
});Componentes de cliente que usam useState, useEffect, APIs do navegador ou manipuladores de eventos precisam de um ambiente DOM de teste. O Vitest oferece happy-dom ou jsdom para simular um navegador. A Testing Library fornece utilidades para consultar a saída renderizada e simular interações. A diferença em relação aos componentes de servidor é que você precisa renderizar, esperar os efeitos rodarem e verificar o estado resultante do DOM.
Aplicações Next.js acrescentam roteamento, busca de dados e ações de servidor, que pedem cuidados extras. Em páginas que usam getServerSideProps ou generateStaticParams, teste as funções de busca separadamente do componente. Teste as ações de servidor como funções assíncronas comuns — não são outra coisa. Em layouts e páginas do App Router, teste a camada de dados separada da de apresentação, para não acoplar seus testes às entranhas de renderização do framework.
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";
describe("CheckoutForm", () => {
it("shows validation errors for empty required fields", async () => {
const user = userEvent.setup();
render(<CheckoutForm />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(screen.getByText("Email is required")).toBeVisible();
expect(screen.getByText("Card number is required")).toBeVisible();
});
it("submits the form with valid data", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<CheckoutForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "test@example.com");
await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
await user.type(screen.getByLabelText("Expiry"), "12/28");
await user.type(screen.getByLabelText("CVC"), "123");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
cardNumber: "4242424242424242",
expiry: "12/28",
cvc: "123",
});
});
});A distinção mais importante no novo modelo do React é separar os testes de componentes de servidor dos de cliente. Os de servidor são funções assíncronas puras: teste-os sem DOM. Os de cliente exigem simulação de interação: teste-os com Testing Library e userEvent. Misturar as duas coisas deixa seus testes mais lentos e complicados do que o necessário.
Estratégias de simulação e quando evitá-las
A simulação é um dos temas mais debatidos. Os substitutos trocam dependências reais por versões controladas e permitem testar código isoladamente, sem montar bancos, chamar APIs externas nem esperar temporizadores. Em troca, você verifica seu código contra uma versão simulada da dependência, não contra a real. Se o substituto não reflete fielmente o comportamento real, o teste passa enquanto o código falha.
A orientação geral é simular na fronteira do sistema. Substitua serviços externos que você não controla (APIs de terceiros, processadores de pagamento, envio de e-mail) e infraestrutura cara ou não determinística (bancos, sistemas de arquivos, relógios). Não simule módulos internos, funções utilitárias nem seus próprios objetos de domínio: esses devem ser testados com as implementações reais, em testes de integração.
O Vitest oferece vi.mock, que eleva as declarações ao topo do arquivo e troca implementações de módulos antes da execução. É útil para variáveis de ambiente, SDKs de terceiros e objetos globais. O risco é que a substituição em nível de módulo seja invisível e produza testes que passam isolados mas falham juntos por interferência.
import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";
const mockStripe = {
charges: {
create: vi.fn(),
},
};
vi.mock("stripe", () => ({
default: vi.fn(() => mockStripe),
}));
describe("PaymentService", () => {
it("creates a charge successfully", async () => {
mockStripe.charges.create.mockResolvedValue({
id: "ch_001",
status: "succeeded",
});
const service = new PaymentService();
const result = await service.charge(5000, "tok_visa");
expect(result.status).toBe("succeeded");
expect(mockStripe.charges.create).toHaveBeenCalledWith({
amount: 5000,
currency: "usd",
source: "tok_visa",
});
});
it("throws on failed payment", async () => {
mockStripe.charges.create.mockRejectedValue(
new Error("card_declined")
);
const service = new PaymentService();
await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
"card_declined"
);
});
});Uma alternativa mais confiável são os dublês de teste que implementam a mesma interface da dependência real, porém em memória e de forma simplificada. Em vez de simular o módulo do banco, escreva uma versão em memória do seu repositório que guarda os dados num Map. Esse dublê é leve mas fiel, e percorre os mesmos caminhos de código do banco real, sem o custo de preparação.
Todo substituto abre uma distância entre seus testes e a realidade. É nessa distância que os erros se escondem. Antes de recorrer a um, pergunte: consigo testar isso com a dependência real em um ambiente controlado? Se sim, use a real. Se não — lenta demais, cara demais, não determinística —, simule, mas estritamente na fronteira do sistema.
- Simule nas fronteiras: APIs de terceiros, SDKs, gateways de pagamento, serviços externos que você não consegue rodar localmente.
- Use dublês em memória para suas próprias abstrações (repositórios, caches, filas) em vez de simulá-las.
- Não simule o que não é seu: simular as entranhas de uma biblioteca acopla seus testes a detalhes de implementação.
- Prefira injeção de dependências à substituição em nível de módulo. Passá-las explicitamente torna o preparo transparente.
- Limite o alcance de cada substituto ao teste que precisa dele. Reinicie todos os substitutos entre um teste e outro.
Testes nas esteiras de CI/CD
Uma suíte de testes só vale se roda de forma consistente e dá retorno rápido. Testes lentos, instáveis ou que só rodam nas máquinas dos desenvolvedores são um peso. Integrá-los à esteira garante que rodem em cada pull request, em cada integração ao ramo principal e, nos caminhos críticos, antes de cada publicação.
A pirâmide se traduz naturalmente em etapas da esteira. Os unitários rodam primeiro, a cada envio, porque são rápidos e pegam erros básicos de lógica. Depois vêm os de integração, em paralelo quando possível, com dependências em contêiner. Os ponta a ponta vêm por último, só em pull requests e antes de publicar, porque são lentos e caros. Os visuais rodam ao lado deles, comparando capturas com a referência do ramo principal.
O tempo importa. Uma suíte de 45 minutos incentiva a pular os testes locais e enviar código quebrado. Algumas estratégias: rodar só os testes afetados pela mudança atual, dividir as suítes entre executores paralelos, guardar em cache node_modules e os navegadores do Playwright entre execuções, e agendar os ponta a ponta lentos em vez de rodá-los a cada commit.
# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx vitest run --project unit
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run --project integration
e2e:
needs: [unit, integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright testTestes instáveis são a maior ameaça à confiança na esteira. Um teste que falha de vez em quando, sem relação com mudanças de código, corrói a confiança em todo o conjunto. O time começa a ignorar falhas, a integrar com a esteira vermelha, e a suíte acaba virando ruído. Invista em detectá-los: marque-os, coloque-os em quarentena e priorize corrigi-los ou removê-los. Um teste em que não se pode confiar é pior do que nenhum, porque cria o hábito de ignorar falhas.
O relatório de resultados é outra prática subestimada. Uma saída que diz «3 testes falharam» sem contexto obriga a vasculhar os registros atrás da mensagem de erro. Um bom relatório mostra a afirmação que falhou, os valores esperados e obtidos, o arquivo e a linha, além de contexto útil como capturas e rastros de pilha. Os relatórios em HTML do Vitest e do Playwright, ou as anotações do GitHub Actions, deixam os resultados visíveis dentro do próprio fluxo do pull request.
O debate entre o troféu e a pirâmide
A pirâmide clássica — unitários na base, integração no meio, ponta a ponta no topo — orienta a estratégia de testes há duas décadas. Ela transmite uma ideia simples: muitos testes rápidos e isolados, menos testes de integração mais lentos e pouquíssimos ponta a ponta. O modelo funciona bem em serviços de back-end, onde a unidade de entrega é uma função ou classe com fronteiras claras.
Como alternativa, propôs-se o troféu de testes, que reflete melhor o desenvolvimento frontend moderno. Ele remodela a pirâmide num losango, em que os testes de integração formam a camada maior, com menos unitários e menos ponta a ponta. O argumento é que, no frontend, o maior valor vem de verificar como os componentes colaboram — exatamente o que os testes de integração fazem — e não de testar funções isoladas ou jornadas completas.
O troféu reflete uma realidade prática: os testes mais valiosos de uma aplicação React são os que renderizam um componente, interagem com ele e verificam o resultado. Exercitam juntos o componente, seus filhos, seus hooks, a gestão de estado e as chamadas de API. São mais rápidos que os ponta a ponta e mais realistas que os unitários. Uma suíte de bons testes de integração dá mais confiança por teste do que qualquer um dos outros dois no contexto de frontend.
Na verdade, os dois modelos simplificam uma realidade mais complexa. A estratégia certa depende da sua arquitetura. Um microsserviço com lógica de negócio pesada se beneficia da pirâmide: cobertura unitária profunda do domínio, menos testes de integração para a camada de API e alguns testes de contrato para serviços externos. Uma aplicação frontend com interações complexas se beneficia do troféu: ampla cobertura de integração de componentes e páginas, unitários seletivos para utilidades e ponta a ponta nos caminhos críticos.
A pirâmide e o troféu estão ambos errados se você os seguir com dogmatismo. A estratégia certa é a que pega os seus erros, roda rápido o bastante para o seu time e sobrevive à sua arquitetura. Pode parecer uma pirâmide, um troféu ou uma forma que ninguém batizou ainda. Pare de discutir o formato e olhe para o que seus testes realmente pegam.
A ideia importante por trás dos dois modelos é que cada nível tem seu próprio perfil de custo e benefício. Um teste unitário custa milissegundos para escrever e rodar. Um de integração custa segundos. Um ponta a ponta custa minutos. Distribua o orçamento de testes proporcionalmente ao valor que cada nível traz para a sua aplicação. Se for um painel com muita lógica de negócio, invista em unitários e integração. Se for um site de conteúdo com pouca interatividade, os ponta a ponta nos fluxos críticos e a regressão visual para o layout serão o melhor investimento.
Construir uma cultura de testes
A melhor estratégia fracassa se o time não acredita em testes. Cultura de testes não se impõe com limiares de cobertura nem obrigando TDD. Ela nasce quando escrever testes é o caminho natural de menor resistência. As pessoas escrevem testes quando eles são fáceis de escrever, rápidos de rodar e claramente úteis. Quando são frágeis, lentos e cheios de falsas falhas, todos arrumam um jeito de evitá-los.
Comece tornando a experiência excelente. Invista em infraestrutura: executores rápidos, bancos de teste confiáveis, detecção de testes instáveis. Escreva utilidades que deixem concisas as verificações mais comuns. Documente os padrões em um guia do time, para que qualquer pessoa saiba testar um novo endpoint, um componente React ou uma migração sem começar do zero. Construir boa infraestrutura uma vez custa muito menos do que o desgaste diário de brigar com testes ruins.
As revisões de código devem incluir a revisão dos testes. Quem revisa deveria perguntar: este teste cobre mesmo o comportamento que diz cobrir? Ele passaria mesmo com a implementação errada? Está testando a coisa certa no nível certo? É legível e sustentável? Trate os testes como código de primeira classe, sujeito aos mesmos padrões de revisão, guias de estilo e expectativas de qualidade do código de produção.
Métricas de cobertura são boa ferramenta de diagnóstico e péssima meta. Se você fixar 90 por cento, vai obter 90 por cento — não testes melhores. Escreverão testes triviais sobre métodos de acesso e construtores vazios para bater o número, sem reforçar a rede de segurança. Use os relatórios para achar caminhos não testados, não para impor limiares arbitrários. Setenta por cento com testes de integração bem colocados vale mais que noventa e cinco por cento de unitários rasos.
- Torne fácil escrever testes: utilidades, fábricas e auxiliares que reduzam o código repetido em cada arquivo.
- Celebre melhorias nos testes como celebra a entrega de funcionalidades. Um teste que elimina uma classe de erros é uma funcionalidade.
- Faça rodízio na responsabilidade pela infraestrutura de testes, para que o conhecimento se espalhe em vez de se concentrar numa pessoa.
- Faça sessões periódicas de triagem de testes instáveis. Acompanhe o número deles como indicador de saúde do time e priorize as correções.
- Escreva um manifesto de testes do time, definindo o que testar em cada nível, o que não testar e as compensações acordadas.
O objetivo final de uma cultura de testes não é um número de cobertura nem uma esteira perfeita. É confiança. Confiança de que uma refatoração não vai quebrar a produção. Confiança de que publicar numa sexta à tarde é seguro. Confiança de que, quando um erro for relatado, a correção vai durar. Uma estratégia que dá essa confiança ao time é uma boa estratégia, tenha formato de pirâmide, de troféu ou de algo intermediário.
Juntando tudo
Construir uma estratégia eficaz não é escolher uma abordagem em vez de outra, mas entender as compensações e aplicar a ferramenta certa a cada situação. Os unitários pegam erros de lógica em milissegundos. Os de integração pegam problemas de interação com dependências reais. Os ponta a ponta pegam falhas visíveis ao usuário em toda a pilha. Os por propriedades pegam casos limite cuja existência você desconhecia. A regressão visual pega mudanças de aparência indesejadas.
Os times que entregam com confiabilidade não são os de maior cobertura. São os que pensaram com critério sobre o que cada nível oferece, alinharam o investimento ao próprio perfil de risco e construíram uma cultura em que os testes são escritos porque agregam valor, não porque uma política manda. Comece auditando sua suíte atual. Para cada teste, pergunte: ele justifica a própria existência? Se a resposta não for um sim claro, remova-o ou substitua-o. A melhor suíte não é a maior: é a que dá mais confiança por minuto de execução.