Estratégia de testes
Teste de frontend tem uma armadilha própria: é fácil escrever muitos testes que não protegem nada. Eles verificam que o componente renderiza o texto que o componente renderiza, quebram a cada refatoração e passam mesmo quando o app está quebrado.
A pergunta que separa teste bom de teste ruim é sempre a mesma: que bug esse teste teria pegado? Se não tem resposta, apague.
O que testar em cada camada
As camadas definem o tipo de teste. Não é a mesma técnica em todas:
| Camada | Técnica | O que verifica | Quantidade |
|---|---|---|---|
| Função pura | teste unitário | entrada → saída, incluindo borda | muitos |
| Serviço | unitário com fetch mockado |
monta request, valida resposta, mapeia erro | muitos |
| Hook | renderHook |
transição de estado, efeito, cleanup | muitos |
| Componente UI | render + screen por papel |
o que o usuário vê e consegue fazer | médio |
| Página | render com provider + MSW | a tela junta as peças | poucos |
| Fluxo | Playwright | o caminho crítico ponta a ponta | pouquíssimos |
flowchart TD
A["Funções puras + serviços + hooks<br/>(rápidos, muitos)"] --> B["Componentes<br/>(médios)"]
B --> C["Páginas com MSW<br/>(poucos)"]
C --> D["E2E Playwright<br/>(caminho crítico só)"]
Teste barato primeiro
Um teste de função pura roda em milissegundos e nunca fica instável. Um E2E roda em segundos e quebra por timing. Empurre a verificação pro nível mais barato que consegue provar a coisa — e é a arquitetura em camadas que dá essa opção.
Ferramenta: o que o SDK usa
npm test # vitest watch
npm run test:run # uma passada
npm run test:coverage
npm run typecheck # tsc -b --noEmit (inclui os testes)
Stack: Vitest + @testing-library/react + jsdom, com
fake-indexeddb/auto no setup pra Dexie funcionar em Node. O
template do scaffold já vem com isso.
Números deste repositório, como referência do que é sustentável: 3283 testes em 414 arquivos, ~29 s. Os pisos de cobertura que gateiam o CI:
| Métrica | Piso |
|---|---|
| lines | 98% |
| statements | 97% |
| functions | 96% |
| branches | 94% |
Cobertura é piso, não meta
100% de cobertura com asserts fracos protege menos que 80% com asserts que checam comportamento. O piso serve pra impedir código não exercitado nenhuma vez — ele não mede qualidade de teste.
Componente: consulte por papel, não por classe
A regra que decide se o teste vai sobreviver a uma refatoração:
it("dispara onPay ao clicar em Pagar", async () => {
const onPay = vi.fn();
const user = userEvent.setup();
render(<OrderTable orders={[order]} onPay={onPay} />);
await user.click(screen.getByRole("button", { name: "Pagar" }));
expect(onPay).toHaveBeenCalledWith(order.id);
});
it("renderiza a tabela", () => {
const { container } = render(<OrderTable orders={[order]} onPay={vi.fn()} />);
expect(container.querySelector(".tempest_table_3f9a")).toBeTruthy();
});
O primeiro teste falha quando o comportamento muda. O segundo falha quando o CSS muda — e passa mesmo se o botão parar de funcionar. Ele é pior que nenhum teste, porque dá confiança falsa.
Ordem de preferência de consulta:
getByRole(comname) — é o que o usuário e o leitor de tela veem.getByLabelText— campos de formulário.getByText— conteúdo visível.getByTestId— último recurso, quando não existe papel nem texto estável.
Consultar por papel testa acessibilidade de graça
Se getByRole("button", { name: "Pagar" }) não acha, é porque você usou
<div onClick> ou esqueceu o nome acessível. O teste te avisa de um problema
real de a11y sem você escrever um teste de a11y.
Serviço: mocke fetch, não o serviço
import { describe, expect, it, vi } from "vitest";
import { listOrders } from "./orders.service";
describe("listOrders", () => {
it("valida a resposta e converte createdAt em Date", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify([
{
id: "3f2a…",
code: "PED-1",
status: "paid",
total_cents: 1990,
created_at: "2026-07-28T12:00:00Z",
},
]),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
const orders = await listOrders(1);
expect(orders[0].createdAt).toBeInstanceOf(Date);
expect(orders[0].totalCents).toBe(1990);
});
it("rejeita payload fora do schema", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(JSON.stringify([{ id: 1 }]), { status: 200 })),
);
await expect(listOrders(1)).rejects.toThrow();
});
});
O segundo teste é o que justifica o parseResponse existir. Sem ele, o schema é
decoração.
Página: MSW com createMockHandlers
Teste de página verifica que as peças se encaixam — não repete o que o teste do componente já cobriu:
import { createMockHandlers } from "tempest-react-sdk/testing";
export const orderHandlers = createMockHandlers([
{ method: "GET", path: "/orders", body: [{ id: "1", code: "PED-1", status: "paid" }] },
{ method: "POST", path: "/orders/1/pay", status: 200, body: { id: "1", status: "paid" } },
{ method: "GET", path: "/orders/999", status: 404, body: { detail: "Not found" } },
]);
Um handler por cenário, incluindo o de erro — a tela de erro é a que ninguém testa e a que o usuário mais vê. Detalhes em Testing helpers.
Hook: renderHook sem DOM
import { renderHook } from "@testing-library/react";
import { act } from "react";
it("alterna a seleção de uma linha", () => {
const { result } = renderHook(() => useOrderTable([order]));
act(() => result.current.toggle(order.id));
expect(result.current.selected.has(order.id)).toBe(true);
act(() => result.current.toggle(order.id));
expect(result.current.selected.has(order.id)).toBe(false);
});
É por isso que extrair lógica pra hook compensa: você testa a regra sem montar tabela, sem procurar botão, sem esperar render.
O que não testar
| Não teste | Porque |
|---|---|
| Que o componente do SDK funciona | ele já tem 3283 testes |
| Classe CSS, estrutura de DOM, snapshot grande | quebra em refatoração, não pega bug |
Implementação interna (useState chamado) |
você testou o código, não o comportamento |
| Getter/setter trivial | não existe bug possível ali |
Que zod valida |
é a lib; teste o seu schema com payload errado |
Snapshot grande é dívida disfarçada de cobertura
Um toMatchSnapshot() de 400 linhas passa a ser atualizado com -u sem
ninguém ler o diff. Ele deixa de ser teste e vira ruído no PR. Snapshot vale
pra saída pequena e estável (uma string formatada, um objeto de config).
Acessibilidade e pixel
Duas coisas que teste em jsdom não pega:
- Contraste de cor. O
axedesliga a regracolor-contrastem jsdom porque não há paint. Esse bug só aparece em browser real — aconteceu duas vezes neste repositório com token de texto sobre superfície tingida. - Layout. jsdom não calcula layout:
offsetParenté semprenull, altura é sempre 0.
Por isso o CI tem um smoke Playwright no gallery além do sweep
axe em jsdom. Para o seu app: mudança visual se valida em browser, não em
expect.
Recap
- Todo teste responde "que bug isso pegaria?". Sem resposta, apague.
- Camada define técnica: função/serviço/hook unitário (muitos) → componente (médio) → página com MSW (poucos) → E2E (pouquíssimos).
- Consulte por papel, nunca por classe CSS — e ganhe verificação de a11y de graça.
- Serviço testa com
fetchmockado, incluindo payload inválido. - Cobertura é piso (98/97/96/94 aqui), não meta.
- Contraste e layout só se validam em browser real.
Próxima: Anti-padrões.