Todo projeto frontend acumula as três camadas de teste mais cedo ou mais tarde. O problema não é falta de ferramenta — é usar a ferramenta errada pra pergunta errada. Testar clique de botão com teste de unidade, ou testar regra de negócio pura com Playwright, é caro e frágil dos dois lados.

Jest, Testing Library e Playwright não competem entre si. Cada um responde uma pergunta diferente sobre o mesmo sistema.

Três perguntas, três ferramentas

Jest pergunta: essa função devolve o valor certo? É unidade pura — sem DOM, sem navegador, roda em milissegundos.

Testing Library pergunta: o componente se comporta do jeito que o usuário espera? Ela roda sobre o Jest (ou Vitest), mas simula DOM e interação.

Playwright pergunta: o sistema inteiro funciona num navegador de verdade, com rede, roteamento e todas as peças integradas? É o mais lento e o mais caro de manter — e o único que pega bug de integração real.

Jest: a base — lógica e mocks

Jest brilha em lógica isolada: cálculo, formatação, transformação de dados. O ganho de “avançado” aqui é dominar mock e controle de tempo.

// desconto.test.js
import { calcularDesconto } from './desconto';
import { buscarRegraDoCliente } from './api/regras';

jest.mock('./api/regras');

test('aplica desconto de fidelidade após 2 anos de cliente', async () => {
  buscarRegraDoCliente.mockResolvedValue({ anosDeCliente: 3, percentual: 0.15 });

  const resultado = await calcularDesconto({ clienteId: '42', valor: 200 });

  expect(resultado).toBe(170);
  expect(buscarRegraDoCliente).toHaveBeenCalledWith('42');
});
// debounce.test.js
test('só dispara a busca depois de 300ms sem digitar', () => {
  jest.useFakeTimers();
  const buscar = jest.fn();
  const buscarComDebounce = debounce(buscar, 300);

  buscarComDebounce('rea');
  buscarComDebounce('reac');
  buscarComDebounce('react');

  jest.advanceTimersByTime(299);
  expect(buscar).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1);
  expect(buscar).toHaveBeenCalledWith('react');
  expect(buscar).toHaveBeenCalledTimes(1);
});

jest.mock isola a função da rede, e useFakeTimers remove a espera real do teste. Sem isso, testar debounce significaria ou esperar 300ms de verdade a cada rodada, ou não testar o debounce de fato.

Testing Library: testar como o usuário usa

A regra de ouro do Testing Library é: se o teste acessa state interno ou nome de método do componente, ele está testando implementação, não comportamento. A biblioteca força isso ao só expor queries por texto, role e label — o que o usuário realmente vê.

// FormularioLogin.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import FormularioLogin from './FormularioLogin';

const server = setupServer(
  rest.post('/api/login', (req, res, ctx) => {
    return res(ctx.status(401), ctx.json({ erro: 'Credenciais inválidas' }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('mostra erro do servidor sem travar o formulário', async () => {
  const user = userEvent.setup();
  render(<FormularioLogin />);

  await user.type(screen.getByLabelText(/e-mail/i), 'beto@exemplo.com');
  await user.type(screen.getByLabelText(/senha/i), 'senha-errada');
  await user.click(screen.getByRole('button', { name: /entrar/i }));

  expect(await screen.findByText(/credenciais inválidas/i)).toBeInTheDocument();
  expect(screen.getByRole('button', { name: /entrar/i })).toBeEnabled();
});

O MSW intercepta a chamada de rede no nível do fetch, então o teste exercita o fluxo completo — clique, requisição, resposta, atualização de tela — sem depender de um backend rodando. findByText já embute o waitFor, então não precisa de setTimeout artificial esperando o estado assíncrono resolver.

Playwright: o navegador de verdade

Playwright entra quando a pergunta é “isso funciona de ponta a ponta, com o build real, no Chrome, Firefox e Safari?”. O exemplo abaixo intercepta rede (pra não depender de dado real em produção) e roda em paralelo por navegador:

// checkout.spec.ts
import { test, expect } from '@playwright/test';

test('finaliza compra mesmo com API de frete lenta', async ({ page }) => {
  await page.route('**/api/frete', async (route) => {
    await new Promise((r) => setTimeout(r, 2000));
    route.fulfill({ json: { valor: 15.9, prazo: 5 } });
  });

  await page.goto('/carrinho');
  await page.getByRole('button', { name: 'Calcular frete' }).click();

  await expect(page.getByText('Calculando frete...')).toBeVisible();
  await expect(page.getByText('R$ 15,90')).toBeVisible({ timeout: 5000 });

  await page.getByRole('button', { name: 'Finalizar compra' }).click();
  await expect(page).toHaveURL(/\/pedido\/\d+/);
});
// playwright.config.ts
export default {
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
};

page.route simula latência real de API sem depender de um ambiente instável. retries no CI existe porque teste de navegador tem flakiness inerente — timing de rede, animação, carregamento de fonte. Isso é diferente de teste quebrado: é o custo de testar o sistema real em vez de uma simulação.

Do commit ao deploy

No dia a dia, o dev roda Jest e Testing Library em modo watch — feedback em segundos, direto no editor. Playwright normalmente não roda local o tempo todo, porque sobe um navegador e uma aplicação inteira; roda antes de abrir PR ou fica só no CI.

No pipeline, a ordem importa porque cada camada é uma barreira mais cara que a anterior:

# .github/workflows/ci.yml
jobs:
  unit-and-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test -- --coverage   # Jest + Testing Library

  e2e:
    needs: unit-and-integration
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build
      - run: npx playwright test           # contra o build real

  deploy:
    needs: e2e
    runs-on: ubuntu-latest
    steps:
      - run: npm run deploy

e2e só roda se unit-and-integration passar — não faz sentido subir navegador pra testar um build que já falhou num cálculo básico. E deploy só acontece depois do Playwright validar o build real, não um mock local.

Times maduros ainda rodam Playwright contra o ambiente de preview (Vercel, Netlify) em vez de contra localhost, porque isso pega problema de variável de ambiente e configuração de build que só aparece fora da máquina do dev. É a diferença entre “passou no CI” e “vai funcionar quando o usuário acessar”.

A pirâmide clássica — muita unidade, menos integração, pouquíssimo E2E — não é dogma estético. É consequência direta de custo: um teste Jest roda em 5ms, um Playwright em 5 segundos. Multiplique isso por mil testes e a escolha de camada deixa de ser preferência e vira orçamento de tempo de CI.