Pular para o conteúdo

Testes e TDD no Frontend

A pirâmide de testes clássica prega uma base larga de testes unitários. No frontend, essa forma se distorce: unidades isoladas (um componente sem seus filhos, um reducer sem a store) dão pouca confiança porque o valor da UI está justamente na composição — componentes conversando entre si, com o roteador, com o estado e com a rede.

Kent C. Dodds propôs o Testing Trophy, cuja massa está na integração:

graph TD
    E2E["E2E (Playwright/Cypress)<br/>poucos, caros, alta confiança"] --> INT
    INT["Integration (RTL)<br/>MAIORIA — melhor custo/confiança"] --> UNIT
    UNIT["Unit (Vitest/Jest)<br/>lógica pura, hooks, utils"] --> STATIC
    STATIC["Static (TypeScript + ESLint)<br/>base gratuita"]

A régua permanece a mesma do Princípio FIRST: testes devem ser Fast, Isolated, Repeatable, Self-validating e Timely. O Trophy só reposiciona onde investir mais.

O princípio que orienta a React Testing Library:

“The more your tests resemble the way your software is used, the more confidence they can give you.”

Na prática: consulte o DOM como o usuário faria — por role, label, texto visível — e nunca por classe CSS, nome de componente ou estado interno. Um teste que quebra quando você renomeia um useState mas o usuário não percebe diferença é um teste acoplado à implementação. Isso conecta-se a Testabilidade e Arquitetura: código bem arquitetado é testável pela sua interface pública, não pelas suas vísceras.

  • Vitest / Jest: runner e biblioteca de asserção. Vitest é o padrão em projetos Vite; Jest domina o legado. API praticamente intercambiável.
  • React Testing Library (RTL): renderiza componentes e expõe queries centradas no usuário.
  • MSW (Mock Service Worker): intercepta requisições na camada de rede. É um fake de servidor: em vez de mockar fetch/axios (detalhe de implementação), você define handlers de request e responde como um backend real. Os testes não sabem como a rede é chamada.
  • Playwright / Cypress: E2E de verdade, subindo a aplicação num browser real. Poucos, focados nos fluxos críticos (login, checkout).

Componente que busca um usuário e o exibe. Note que o teste nunca inspeciona estado interno.

UserCard.tsx
import { useEffect, useState } from "react";
type User = { id: string; name: string; email: string };
export function UserCard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
fetch(`/api/users/${userId}`)
.then((r) => {
if (!r.ok) throw new Error("failed");
return r.json();
})
.then((data: User) => active && setUser(data))
.catch(() => active && setError(true));
return () => {
active = false;
};
}, [userId]);
if (error) return <p role="alert">Não foi possível carregar o usuário.</p>;
if (!user) return <p>Carregando…</p>;
return (
<article aria-label="Cartão do usuário">
<h2>{user.name}</h2>
<a href={`mailto:${user.email}`}>{user.email}</a>
</article>
);
}
UserCard.test.tsx
import { render, screen } from "@testing-library/react";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { UserCard } from "./UserCard";
const server = setupServer(
http.get("/api/users/:id", ({ params }) =>
HttpResponse.json({ id: params.id, name: "Ada Lovelace", email: "ada@math.dev" })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("exibe nome e email do usuário carregado", async () => {
render(<UserCard userId="42" />);
expect(screen.getByText("Carregando…")).toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Ada Lovelace" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "ada@math.dev" })).toHaveAttribute(
"href",
"mailto:ada@math.dev"
);
});
test("mostra alerta quando a requisição falha", async () => {
server.use(
http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 }))
);
render(<UserCard userId="42" />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Não foi possível carregar o usuário."
);
});

Exemplo 2 — Custom hook testado isoladamente (unit)

Seção intitulada “Exemplo 2 — Custom hook testado isoladamente (unit)”

Lógica pura de estado, sem UI, testada com renderHook. Este é o andar unit do Trophy (a base é a análise estática): rápido e determinístico.

useCounter.ts
import { useCallback, useState } from "react";
export function useCounter(initial = 0, step = 1) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + step), [step]);
const decrement = useCallback(() => setCount((c) => c - step), [step]);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}
useCounter.test.ts
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
test("incrementa e decrementa respeitando o step", () => {
const { result } = renderHook(() => useCounter(10, 5));
expect(result.current.count).toBe(10);
act(() => result.current.increment());
expect(result.current.count).toBe(15);
act(() => result.current.decrement());
expect(result.current.count).toBe(10);
});
test("reset volta ao valor inicial", () => {
const { result } = renderHook(() => useCounter(3));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(5);
act(() => result.current.reset());
expect(result.current.count).toBe(3);
});

O ciclo red-green-refactor de Test-Driven Development funciona no front, mas com nuance: escrever um teste RTL antes de existir markup é natural quando você parte do comportamento (“o usuário deve ver X ao clicar em Y”). Para lógica pura (hooks, reducers, use cases), o TDD flui como no backend. Para o layout visual em si, TDD tende a atritar — regressão visual e revisão manual servem melhor ali.

  • Testar detalhe de implementação. Buscar por classe CSS, inspecionar state interno, usar wrapper.instance(). Quebra a cada refactor sem ganho de confiança.
  • Excesso de unitários mockando tudo. Mockar todos os filhos de um componente testa a fiação do mock, não o comportamento real. Prefira integração.
  • Mockar fetch/axios diretamente em vez de usar MSW. Amarra o teste ao cliente HTTP concreto; migrar de axios para fetch quebra dezenas de testes sem motivo.
  • E2E para tudo. Playwright é caro e lento; reserve-o para fluxos críticos. Violar isso mata o “F” (Fast) do Princípio FIRST.
  • act() warnings ignorados. Sinal de atualização de estado não aguardada; leva a flakiness — quebra o “R” (Repeatable).
  • waitFor com asserção vazia ou setTimeout fixo. Fonte clássica de teste intermitente.

1. O que é o Testing Trophy e como difere da pirâmide clássica?

Resposta

É o modelo de Kent C. Dodds que concentra a maior parte do investimento em testes de integração (com base de tipagem estática do TypeScript, camada de unit para lógica pura e topo enxuto de E2E). Difere da pirâmide clássica, que prioriza uma base larga de unitários. No front, integração dá o melhor custo/confiança porque o valor da UI está na composição.

2. Por que RTL prega consultar o DOM por role/label/texto em vez de classe ou estado?

Resposta

Porque testes devem espelhar o uso real: o usuário enxerga papéis, rótulos e texto, não classes CSS ou useState. Consultar pela interface pública desacopla o teste da implementação — renomear uma variável interna ou trocar uma classe não deve quebrar o teste se o comportamento visível não mudou.

3. Que tipo de test double é o MSW e qual vantagem ele traz sobre mockar fetch?

Resposta

É um fake de servidor: intercepta na camada de rede e responde como um backend real via handlers. A vantagem sobre mockar fetch/axios é que o teste não depende do cliente HTTP concreto — trocar a biblioteca de rede não quebra os testes, e você exercita o código do jeito que ele roda em produção.

4. Como o ciclo de TDD se aplica (e onde atrita) no frontend?

Resposta

Flui bem para lógica pura (hooks, reducers, use cases) e para comportamento observável (“ao clicar, ver X”) escrito com RTL antes do markup. Atrita no layout visual puro, onde regressão visual e revisão manual funcionam melhor que asserções red-green-refactor.