Ir para o conteúdo
·33 min de leitura

Guia de Testes com Vitest: Do Zero à Confiança em Produção

Testes unitários, testes de integração, testes de componentes com Testing Library, estratégias de mock, configuração de cobertura e a filosofia de testes que realmente produz software melhor.

Compartilhar:X / TwitterLinkedIn

A maioria das equipes escreve testes da mesma forma que a maioria das pessoas faz exercícios: sabem que deveriam, sentem-se culpadas quando não fazem, e quando finalmente fazem, exageram no primeiro dia e desistem na sexta-feira. O código-base acaba com uma porção de testes de snapshot superficiais que quebram toda vez que alguém muda uma classe CSS, alguns testes de integração em que ninguém confia e um badge de cobertura no README que mente.

Já estive dos dois lados. Já lancei projetos sem nenhum teste e suei frio em cada deploy. Também já estive em equipes que perseguiram 100% de cobertura e gastaram mais tempo mantendo testes do que escrevendo funcionalidades. Nenhuma das abordagens funciona. O que funciona é escrever os testes certos, nos lugares certos, com as ferramentas certas.

O Vitest mudou a forma como eu penso sobre testes em JavaScript. Não porque inventou novos conceitos — os fundamentos não mudaram desde que Kent Beck escreveu sobre eles há décadas. Mas porque removeu atrito suficiente para que escrever testes deixasse de parecer uma tarefa chata e passasse a fazer parte do ciclo de desenvolvimento. Quando o seu test runner é tão rápido quanto o seu dev server e usa a mesma configuração, as desculpas evaporam.

Este post é tudo o que eu sei sobre testes com Vitest, desde a configuração inicial até a filosofia que faz tudo valer a pena.

Por Que Vitest em Vez de Jest#

Se você já usou o Jest, já conhece a maior parte da API do Vitest. Isso é proposital — o Vitest é compatível com o Jest a nível de API. describe, it, expect, beforeEach, vi.fn() — tudo funciona. Então por que mudar?

Suporte Nativo a ESM#

O Jest foi construído para CommonJS. Ele consegue lidar com ESM, mas requer configuração, flags experimentais e, às vezes, uma reza. Se você está usando sintaxe import/export (que é tudo que é moderno), provavelmente já brigou com o pipeline de transformação do Jest.

O Vitest roda sobre o Vite. O Vite entende ESM nativamente. Não há etapa de transformação para o seu código-fonte — simplesmente funciona. Isso importa mais do que parece. Metade dos problemas que já depurei no Jest ao longo dos anos se originam da resolução de módulos: SyntaxError: Cannot use import statement outside a module, ou mocks que não funcionam porque o módulo já foi cacheado em um formato diferente.

Mesma Configuração do Seu Dev Server#

Se o seu projeto usa Vite (e se você está construindo uma aplicação React, Vue ou Svelte em 2026, provavelmente usa), o Vitest lê o seu vite.config.ts automaticamente. Seus path aliases, plugins e variáveis de ambiente funcionam nos testes sem nenhuma configuração adicional. Com o Jest, você mantém uma configuração paralela que precisa ficar sincronizada com o setup do seu bundler. Toda vez que você adiciona um path alias no vite.config.ts, precisa se lembrar de adicionar o moduleNameMapper correspondente no jest.config.ts. É algo pequeno, mas coisas pequenas se acumulam.

Velocidade#

O Vitest é rápido. Significativamente rápido. Não "economiza dois segundos" rápido — "muda a forma como você trabalha" rápido. Ele usa o grafo de módulos do Vite para entender quais testes são afetados por uma mudança de arquivo e executa apenas esses. Seu modo watch usa a mesma infraestrutura de HMR que torna o dev server do Vite instantâneo.

Em um projeto com mais de 400 testes, a migração do Jest para o Vitest reduziu o ciclo de feedback do watch-mode de ~4 segundos para menos de 500ms. Essa é a diferença entre "vou esperar o teste passar" e "vou olhar o terminal enquanto meus dedos ainda estão no teclado".

Benchmarking Integrado#

O Vitest inclui bench() de fábrica para testes de desempenho. Nenhuma biblioteca separada necessária:

typescript
import { bench, describe } from "vitest";
 
describe("string concatenation", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("string concat", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

Execute com vitest bench. Não é o evento principal, mas é bom ter testes de desempenho na mesma toolchain sem instalar o benchmark.js e configurar um runner separado.

Configuração#

Instalação#

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Configuração#

Crie vitest.config.ts na raiz do seu projeto:

typescript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    css: false,
  },
});

Ou, se você já tem um vite.config.ts, pode estendê-lo:

typescript
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": "/src",
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

A Decisão do globals: true#

Quando globals é true, você não precisa importar describe, it, expect, beforeEach, etc. — eles estão disponíveis em todos os lugares, assim como no Jest. Quando false, você os importa explicitamente:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("math", () => {
  it("adds numbers", () => {
    expect(1 + 1).toBe(2);
  });
});

Eu uso globals: true porque reduz o ruído visual e corresponde ao que a maioria dos desenvolvedores espera. Se você está numa equipe que valoriza importações explícitas, defina como false — não há resposta errada aqui.

Se você usar globals: true, adicione os tipos do Vitest ao seu tsconfig.json para que o TypeScript os reconheça:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Ambiente: jsdom vs happy-dom vs node#

O Vitest permite escolher a implementação de DOM por teste ou globalmente:

  • node — Sem DOM. Para lógica pura, utilitários, rotas de API e qualquer coisa que não toque no navegador.
  • jsdom — O padrão. Implementação completa de DOM. Mais pesado, porém mais completo.
  • happy-dom — Mais leve e rápido que o jsdom, mas menos completo. Alguns casos extremos (como Range, Selection ou IntersectionObserver) podem não funcionar.

Eu defino jsdom globalmente e sobrescrevo por arquivo quando preciso de node:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

Arquivos de Setup#

O arquivo de setup roda antes de cada arquivo de teste. É aqui que você configura os matchers da Testing Library e mocks globais:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// Mock window.matchMedia — needed for responsive components
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

A importação @testing-library/jest-dom/vitest dá acesso a matchers como toBeInTheDocument(), toHaveClass(), toBeVisible() e muitos outros. Esses matchers tornam as asserções em elementos DOM legíveis e expressivas.

Escrevendo Bons Testes#

O Padrão AAA#

Todo teste segue a mesma estrutura: Arrange, Act, Assert (Preparar, Agir, Verificar). Mesmo quando você não escreve comentários explícitos, a estrutura deve ser visível:

typescript
it("calculates total with tax", () => {
  // Arrange
  const items = [
    { name: "Widget", price: 10 },
    { name: "Gadget", price: 20 },
  ];
  const taxRate = 0.08;
 
  // Act
  const total = calculateTotal(items, taxRate);
 
  // Assert
  expect(total).toBe(32.4);
});

Quando vejo um teste que mistura preparação, ação e verificação numa única cadeia de chamadas de métodos, sei que vai ser difícil de entender quando falhar. Mantenha as três fases visualmente distintas, mesmo que você não adicione os comentários.

Nomeação de Testes#

Existem duas escolas: it('should calculate total with tax') e it('calculates total with tax'). O prefixo "should" é verboso sem adicionar informação. Quando o teste falha, você verá:

FAIL  ✕ calculates total with tax

Isso já é uma frase completa. Adicionar "should" só adiciona ruído. Eu prefiro a forma direta: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').

Para blocos describe, use o nome da unidade sendo testada:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

Leia em voz alta: "calculateTotal sums item prices." "calculateTotal applies tax rate." Se a frase funciona, a nomeação funciona.

Uma Asserção Por Teste vs Agrupamento Prático#

A regra purista diz uma asserção por teste. A regra prática diz: um conceito por teste. Essas coisas são diferentes.

typescript
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
  const user = createUser("John");
  expect(user.id).toBeDefined();
 
  const updated = updateUser(user.id, { name: "Jane" });
  expect(updated.name).toBe("Jane");
 
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

O primeiro teste tem três asserções, mas todas verificam uma única coisa: o formato de um nome de exibição. Se qualquer asserção falhar, você sabe exatamente o que está quebrado. O segundo teste são três testes separados amontoados juntos. Se a segunda asserção falhar, você não sabe se a criação ou a atualização está quebrada, e a terceira asserção nunca é executada.

Descrições de Testes como Documentação#

Boas suítes de testes servem como documentação viva. Alguém que não conhece o código deveria poder ler as descrições dos testes e entender o comportamento da funcionalidade:

typescript
describe("PasswordValidator", () => {
  describe("minimum length", () => {
    it("rejects passwords shorter than 8 characters", () => { /* ... */ });
    it("accepts passwords with exactly 8 characters", () => { /* ... */ });
  });
 
  describe("character requirements", () => {
    it("requires at least one uppercase letter", () => { /* ... */ });
    it("requires at least one number", () => { /* ... */ });
    it("requires at least one special character", () => { /* ... */ });
  });
 
  describe("common password check", () => {
    it("rejects passwords in the common passwords list", () => { /* ... */ });
    it("performs case-insensitive comparison", () => { /* ... */ });
  });
});

Quando esta suíte de testes é executada, a saída se lê como uma especificação. Esse é o objetivo.

Mocking#

Mocking é a ferramenta mais poderosa e mais perigosa do seu kit de ferramentas de testes. Bem utilizado, isola a unidade sendo testada e torna os testes rápidos e determinísticos. Mal utilizado, cria testes que passam independentemente do que o código faça.

vi.fn() — Criando Funções Mock#

O mock mais simples é uma função que registra suas chamadas:

typescript
const mockCallback = vi.fn();
 
// Call it
mockCallback("hello", 42);
mockCallback("world");
 
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

Você pode dar a ela um valor de retorno:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

Ou fazer com que retorne valores diferentes em chamadas sucessivas:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — Observando Métodos Reais#

Quando você quer observar um método sem substituir seu comportamento:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOn mantém a implementação original por padrão. Você pode sobrescrevê-la com .mockImplementation() quando necessário, mas restaure a original depois com .mockRestore().

vi.mock() — Mock a Nível de Módulo#

Esse é o principal. vi.mock() substitui um módulo inteiro:

typescript
// Mock the entire module
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

O Vitest eleva (hoists) as chamadas vi.mock() automaticamente para o topo do arquivo. Isso significa que o mock está ativo antes que qualquer importação seja executada. Você não precisa se preocupar com a ordem das importações.

Mock Automático#

Se você simplesmente quer que toda exportação seja substituída por um vi.fn():

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

Sem a factory function, o Vitest faz mock automático de todas as exportações. Cada função exportada se torna um vi.fn() que retorna undefined. Isso é útil para módulos que você quer silenciar (como analytics ou logging) sem especificar cada função.

Clear vs Reset vs Restore#

Isso confunde todo mundo em algum momento:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — resets call history, keeps implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
 
// mockReset — resets call history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
 
// mockRestore — for spies, restores original implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally again

Na prática, use vi.clearAllMocks() no beforeEach para limpar o histórico de chamadas entre testes. Use vi.restoreAllMocks() se você estiver usando spyOn e quiser restaurar os originais:

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

O Perigo do Mock Excessivo#

Este é o conselho mais importante sobre mocking que posso dar: cada mock é uma mentira que você conta para o seu teste. Quando você faz mock de uma dependência, está dizendo "eu confio que essa coisa funciona corretamente, então vou substituí-la por uma versão simplificada." Se a sua suposição estiver errada, o teste passa, mas a funcionalidade está quebrada.

typescript
// Over-mocked — tests nothing useful
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("processes user input", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

Este teste verifica que processInput chama validate e format. Mas e se processInput os chamar na ordem errada? E se ignorar seus valores de retorno? E se a validação deveria impedir a etapa de formatação de ser executada? O teste não sabe. Você fez mock de todo o comportamento interessante.

A regra prática: faça mock nas fronteiras, não no meio. Faça mock de requisições de rede, acesso ao sistema de arquivos e serviços de terceiros. Não faça mock das suas próprias funções utilitárias, a menos que haja um motivo convincente (como serem caras de executar ou terem efeitos colaterais).

Testando Componentes React#

O Básico com Testing Library#

A Testing Library impõe uma filosofia: teste componentes da forma como os usuários interagem com eles. Nada de verificar estado interno, nada de inspecionar instâncias de componentes, nada de renderização superficial. Você renderiza um componente e interage com ele através do DOM, assim como um usuário faria.

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renders with label text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
  });
 
  it("applies variant classes", () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Queries: getBy vs queryBy vs findBy#

É aqui que os iniciantes se confundem. Existem três variantes de query e cada uma tem um caso de uso específico:

getBy* — Retorna o elemento ou lança um erro se não encontrado. Use quando você espera que o elemento exista:

typescript
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — Retorna o elemento ou null se não encontrado. Use quando você está verificando que algo NÃO está presente:

typescript
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — Retorna uma Promise. Use para elementos que aparecem de forma assíncrona:

typescript
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

Queries com Acessibilidade em Primeiro Lugar#

A Testing Library fornece essas queries em uma ordem de prioridade deliberada:

  1. getByRole — A melhor query. Usa papéis ARIA. Se o seu componente não é encontrável por papel, pode ter um problema de acessibilidade.
  2. getByLabelText — Para elementos de formulário. Se o seu input não tem um label, corrija isso primeiro.
  3. getByPlaceholderText — Aceitável, mas mais fraco. Placeholders desaparecem quando o usuário digita.
  4. getByText — Para elementos não interativos. Encontra pelo conteúdo de texto visível.
  5. getByTestId — Último recurso. Use quando nenhuma query semântica funcionar.
typescript
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
 
// Over this
screen.getByPlaceholderText("Enter your email");
 
// And definitely over this
screen.getByTestId("email-input");

A classificação não é arbitrária. Ela corresponde à forma como a tecnologia assistiva navega pela página. Se você consegue encontrar um elemento pelo seu papel e nome acessível, leitores de tela também conseguem. Se você só consegue encontrá-lo por um test ID, pode ter uma lacuna de acessibilidade.

Eventos de Usuário#

Não use fireEvent. Use @testing-library/user-event. A diferença importa:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filters results as user types", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchInput onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "vitest");
 
    // user.type fires keydown, keypress, input, keyup for EACH character
    // fireEvent.change just sets the value — skipping realistic event flow
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("clears input on escape key", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hello");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEvent simula a cadeia completa de eventos que um navegador real dispararia. fireEvent.change é um único evento sintético. Se o seu componente escuta onKeyDown ou usa onInput em vez de onChange, fireEvent.change não vai disparar esses handlers, mas userEvent.type vai.

Sempre chame userEvent.setup() no início e use a instância user retornada. Isso garante a ordem correta dos eventos e o rastreamento de estado.

Testando Interações de Componentes#

Um teste realista de componente se parece com isto:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("adds a new todo item", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /new todo/i });
    const addButton = screen.getByRole("button", { name: /add/i });
 
    await user.type(input, "Write tests");
    await user.click(addButton);
 
    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("marks a todo as completed", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("removes completed items when clear button is clicked", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Done task", done: true },
          { id: "2", text: "Pending task", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /clear completed/i }));
 
    expect(screen.queryByText("Done task")).not.toBeInTheDocument();
    expect(screen.getByText("Pending task")).toBeInTheDocument();
  });
});

Observe: nenhuma inspeção de estado interno, nenhum component.setState(), nenhuma verificação de detalhes de implementação. Renderizamos, interagimos e verificamos o que o usuário veria. Se o componente refatorar sua gestão de estado interna de useState para useReducer, esses testes continuam passando. Esse é o ponto.

Testando Código Assíncrono#

waitFor#

Quando um componente atualiza de forma assíncrona, waitFor faz polling até que a asserção passe:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  // Initially shows loading
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  // Wait for content to appear
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // Loading indicator should be gone
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

waitFor retenta o callback a cada 50ms (por padrão) até que passe ou expire (1000ms por padrão). Você pode personalizar ambos:

typescript
await waitFor(
  () => expect(screen.getByText("Done")).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

Fake Timers#

Quando se testa código que usa setTimeout, setInterval ou Date, fake timers permitem que você controle o tempo:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("delays execution by the specified time", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
 
  it("resets timer on subsequent calls", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    vi.advanceTimersByTime(200);
 
    debounced(); // reset the timer
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

Importante: sempre chame vi.useRealTimers() no afterEach. Fake timers que vazam entre testes causam as falhas mais confusas que você jamais vai depurar.

Testando com Fake Timers e Renderização Assíncrona#

Combinar fake timers com testes de componentes React requer cuidado. O agendamento interno do React usa timers reais, então frequentemente você precisa avançar timers E descarregar atualizações do React juntos:

typescript
import { render, screen, act } from "@testing-library/react";
 
describe("Notification", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("auto-dismisses after 5 seconds", async () => {
    render(<Notification message="Saved!" autoDismiss={5000} />);
 
    expect(screen.getByText("Saved!")).toBeInTheDocument();
 
    // Advance timers inside act() to flush React updates
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
  });
});

Mock de API com MSW#

Para testar busca de dados, o Mock Service Worker (MSW) intercepta requisições de rede no nível da rede. Isso significa que o código fetch/axios do seu componente roda exatamente como rodaria em produção — o MSW apenas substitui a resposta da rede:

typescript
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
 
const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]);
  }),
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: "Alice",
      email: "alice@example.com",
    });
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("UserList", () => {
  it("displays users from API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("shows error state when API fails", async () => {
    // Override the default handler for this one test
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

O MSW é melhor do que fazer mock do fetch ou axios diretamente porque:

  1. O código real de busca de dados do seu componente é executado — você testa a integração real.
  2. Você pode testar tratamento de erros sobrescrevendo handlers por teste.
  3. Os mesmos handlers funcionam tanto em testes quanto no modo de desenvolvimento do navegador (Storybook, por exemplo).

Testes de Integração#

Testes Unitários vs Testes de Integração#

Um teste unitário isola uma única função ou componente e faz mock de todo o resto. Um teste de integração permite que múltiplas unidades trabalhem juntas e só faz mock de fronteiras externas (rede, sistema de arquivos, bancos de dados).

A verdade é: a maioria dos bugs que já vi em produção acontece nas fronteiras entre unidades, não dentro delas. Uma função funciona perfeitamente isolada, mas falha porque o chamador passa dados em um formato ligeiramente diferente. Um componente renderiza bem com dados mockados, mas quebra quando a resposta real da API tem um nível extra de aninhamento.

Testes de integração capturam esses bugs. São mais lentos que testes unitários e mais difíceis de depurar quando falham, mas dão mais confiança por teste.

Testando Múltiplos Componentes Juntos#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// Only mock the API layer — everything else is real
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Shopping Cart Flow", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("displays item count and total", () => {
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
      { id: "2", name: "Mouse", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 items")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("updates quantity and recalculates total", async () => {
    const user = userEvent.setup();
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
    await user.click(incrementButton);
 
    expect(screen.getByText("$159.98")).toBeInTheDocument();
  });
 
  it("completes checkout flow", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /checkout/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
    expect(screen.getByText("ORD-123")).toBeInTheDocument();
  });
});

Neste teste, ShoppingCart e CartProvider e seus componentes internos (linhas de itens, seletores de quantidade, exibição de totais) todos trabalham juntos com código real. O único mock é a chamada de API, porque não queremos fazer requisições de rede reais nos testes.

Quando Usar Integração vs Unitário#

Use testes unitários quando:

  • A lógica é complexa e tem muitos casos extremos (um parser de datas, uma máquina de estados, um cálculo).
  • Você precisa de feedback rápido sobre o comportamento de uma função específica.
  • A unidade é relativamente isolada e não depende muito de outras unidades.

Use testes de integração quando:

  • Múltiplos componentes precisam funcionar juntos corretamente (um formulário com validação e submissão).
  • Os dados fluem por várias camadas (contexto → componente → componente filho).
  • Você está testando um fluxo de usuário, não o valor de retorno de uma função.

Na prática, uma suíte de testes saudável é pesada em testes de integração para funcionalidades e tem testes unitários para utilitários complexos. Os próprios componentes são testados através de testes de integração — você não precisa de um teste unitário separado para cada componente minúsculo se o teste de integração o exercita.

Cobertura#

Executando Cobertura#

bash
vitest run --coverage

Você vai precisar de um provedor de cobertura. O Vitest suporta dois:

bash
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
 
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbul

Configure no seu config do Vitest:

typescript
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/test/**",
        "src/**/*.d.ts",
        "src/**/types.ts",
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Istanbul vs V8#

A cobertura V8 usa a instrumentação integrada do motor V8. É mais rápida porque não há etapa de transformação de código. Mas pode ser menos precisa em alguns casos extremos, especialmente em torno de fronteiras de módulos ES.

O Istanbul instrumenta seu código-fonte com contadores antes de executar os testes. É mais lento, mas mais testado em batalha e oferece cobertura de branches mais precisa. Se você está aplicando thresholds de cobertura no CI, a precisão do Istanbul pode importar.

Eu uso V8 para desenvolvimento local (feedback mais rápido) e Istanbul no CI (aplicação mais precisa). Você pode configurar provedores diferentes por ambiente, se necessário.

O Que a Cobertura Realmente Significa#

A cobertura diz quais linhas de código foram executadas durante os testes. Só isso. Ela não diz se essas linhas foram testadas corretamente. Considere isto:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // No assertion!
});

Este teste dá 100% de cobertura da função divide. Ele também não testa absolutamente nada. O teste passaria se divide retornasse null, lançasse um erro ou lançasse mísseis.

A cobertura é um indicador negativo útil: cobertura baixa significa que definitivamente existem caminhos não testados. Mas cobertura alta não significa que seu código está bem testado. Apenas significa que cada linha foi executada durante algum teste.

Linhas vs Branches#

Cobertura de linhas é a métrica mais comum, mas cobertura de branches é mais valiosa:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

Um teste com getDiscount({ isPremium: true, yearsActive: 10 }) atinge todas as linhas (100% de cobertura de linhas), mas testa apenas dois dos três branches. O caminho isPremium: false e o caminho yearsActive <= 5 não são testados.

A cobertura de branches captura isso. Ela rastreia cada caminho possível através da lógica condicional. Se você vai aplicar um threshold de cobertura, use cobertura de branches.

Ignorando Código Gerado#

Alguns códigos não deveriam ser contados na cobertura. Arquivos gerados, definições de tipo, configuração — isso infla suas métricas sem adicionar valor:

typescript
// vitest.config.ts
coverage: {
  exclude: [
    "src/**/*.d.ts",
    "src/**/types.ts",
    "src/**/*.stories.tsx",
    "src/generated/**",
    ".velite/**",
  ],
}

Você também pode ignorar linhas ou blocos específicos no seu código-fonte:

typescript
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
  console.log("Debug info:", data);
}
/* v8 ignore stop */
 
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }

Use isso com moderação. Se você está ignorando grandes trechos de código, ou esses trechos precisam de testes ou não deveriam estar no relatório de cobertura desde o início.

Testando Next.js#

Mockando next/navigation#

Componentes Next.js que usam useRouter, usePathname ou useSearchParams precisam de mocks:

typescript
import { vi } from "vitest";
 
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => "/en/blog",
  useSearchParams: () => new URLSearchParams("?page=1"),
  useParams: () => ({ locale: "en" }),
}));

Para testes que precisam verificar se a navegação foi chamada:

typescript
import { useRouter } from "next/navigation";
 
vi.mock("next/navigation", () => ({
  useRouter: vi.fn(),
}));
 
describe("LogoutButton", () => {
  it("redirects to home after logout", async () => {
    const mockPush = vi.fn();
    vi.mocked(useRouter).mockReturnValue({
      push: mockPush,
      replace: vi.fn(),
      back: vi.fn(),
      prefetch: vi.fn(),
      refresh: vi.fn(),
      forward: vi.fn(),
    });
 
    const user = userEvent.setup();
    render(<LogoutButton />);
 
    await user.click(screen.getByRole("button", { name: /log out/i }));
 
    expect(mockPush).toHaveBeenCalledWith("/");
  });
});

Mockando next-intl#

Para componentes internacionalizados usando next-intl:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

Esta é a abordagem mais simples — traduções retornam a própria chave, então t("hero.title") retorna "hero.title". Nas asserções, você verifica a chave de tradução em vez da string traduzida real. Isso torna os testes independentes de idioma.

Se você precisa de traduções reais em um teste específico:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => {
    const translations: Record<string, string> = {
      "hero.title": "Welcome to My Site",
      "hero.subtitle": "Building things for the web",
    };
    return (key: string) => translations[key] ?? key;
  },
}));

Testando Route Handlers#

Os Route Handlers do Next.js são funções regulares que recebem um Request e retornam uma Response. São simples de testar:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returns users list", async () => {
    const request = new NextRequest("http://localhost:3000/api/users");
    const response = await GET(request);
    const data = await response.json();
 
    expect(response.status).toBe(200);
    expect(data).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
      ])
    );
  });
 
  it("supports pagination via search params", async () => {
    const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
    const response = await GET(request);
    const data = await response.json();
 
    expect(data.page).toBe(2);
    expect(data.items).toHaveLength(10);
  });
});
 
describe("POST /api/users", () => {
  it("creates a new user", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
    });
 
    const response = await POST(request);
    const data = await response.json();
 
    expect(response.status).toBe(201);
    expect(data.name).toBe("Alice");
  });
 
  it("returns 400 for invalid body", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "" }),
    });
 
    const response = await POST(request);
    expect(response.status).toBe(400);
  });
});

Testando Middleware#

O middleware do Next.js roda na edge e processa cada requisição. Teste-o como uma função:

typescript
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
 
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
  const url = new URL(path, "http://localhost:3000");
  return new NextRequest(url, { headers });
}
 
describe("middleware", () => {
  it("redirects unauthenticated users from protected routes", async () => {
    const request = createRequest("/dashboard");
    const response = await middleware(request);
 
    expect(response.status).toBe(307);
    expect(response.headers.get("location")).toContain("/login");
  });
 
  it("allows authenticated users through", async () => {
    const request = createRequest("/dashboard", {
      cookie: "session=valid-token",
    });
    const response = await middleware(request);
 
    expect(response.status).toBe(200);
  });
 
  it("adds security headers", async () => {
    const request = createRequest("/");
    const response = await middleware(request);
 
    expect(response.headers.get("x-frame-options")).toBe("DENY");
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  });
 
  it("handles locale detection", async () => {
    const request = createRequest("/", {
      "accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
    });
    const response = await middleware(request);
 
    expect(response.headers.get("location")).toContain("/tr");
  });
});

Testando Server Actions#

Server Actions são funções assíncronas que rodam no servidor. Como são apenas funções, você pode testá-las diretamente — mas pode precisar fazer mock de dependências exclusivas do servidor:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
    },
  },
}));
 
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));
 
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
 
describe("updateProfile", () => {
  it("updates user and revalidates profile page", async () => {
    const formData = new FormData();
    formData.set("name", "New Name");
    formData.set("bio", "New bio text");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
 
  it("returns error for invalid data", async () => {
    const formData = new FormData();
    // Missing required fields
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

Padrões Práticos#

Função de Render Personalizada#

A maioria dos projetos precisa dos mesmos providers envolvendo cada componente. Crie um render personalizado:

typescript
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
 
interface CustomRenderOptions extends RenderOptions {
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}
 
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
  children: React.ReactNode;
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}) {
  return (
    <ThemeProvider defaultTheme={theme}>
      <CartProvider initialItems={initialCartItems}>
        {children}
      </CartProvider>
    </ThemeProvider>
  );
}
 
export function renderWithProviders(
  ui: ReactElement,
  options: CustomRenderOptions = {}
) {
  const { theme, initialCartItems, ...renderOptions } = options;
 
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders theme={theme} initialCartItems={initialCartItems}>
        {children}
      </AllProviders>
    ),
    ...renderOptions,
  });
}
 
// Re-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };

Agora cada arquivo de teste importa dos seus utilitários personalizados em vez de @testing-library/react:

typescript
import { render, screen } from "@/test/utils";
 
it("renders in dark mode", () => {
  render(<Header />, { theme: "dark" });
  // Header and all its children have access to ThemeProvider and CartProvider
});

Testando Hooks Personalizados#

O Vitest funciona com o renderHook do @testing-library/react:

typescript
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
 
describe("useCounter", () => {
  it("starts at initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  it("increments", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  it("decrements with floor", () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(0); // doesn't go below min
  });
});

Testando Error Boundaries#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Test explosion");
};
 
describe("ErrorBoundary", () => {
  // Suppress console.error for expected errors
  beforeEach(() => {
    vi.spyOn(console, "error").mockImplementation(() => {});
  });
 
  afterEach(() => {
    vi.restoreAllMocks();
  });
 
  it("displays fallback UI when child throws", () => {
    render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowingComponent />
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Something went wrong")).toBeInTheDocument();
  });
 
  it("renders children when no error", () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <div>All good</div>
      </ErrorBoundary>
    );
 
    expect(screen.getByText("All good")).toBeInTheDocument();
    expect(screen.queryByText("Error")).not.toBeInTheDocument();
  });
});

Testes de Snapshot (Com Cuidado)#

Testes de snapshot têm má reputação porque as pessoas os usam como substituto para asserções reais. Um snapshot da saída HTML inteira de um componente é um fardo de manutenção — quebra toda vez que uma classe CSS muda e ninguém revisa o diff com cuidado.

Mas snapshots direcionados podem ser úteis:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// Good — small, targeted snapshot of a pure function's output
it("formats various currency values consistently", () => {
  expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
  expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
  expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
  expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
 
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // Don't do this
});

Snapshots inline (toMatchInlineSnapshot) são melhores que snapshots de arquivo porque o valor esperado é visível diretamente no teste. Você pode ver de relance o que a função retorna sem abrir um arquivo .snap separado.

A Filosofia de Testes#

Teste Comportamento, Não Implementação#

Este princípio é tão importante que merece uma seção dedicada. Considere dois testes para a mesma funcionalidade:

typescript
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Behavior test — resilient, tests what the user sees
it("increments the displayed count when plus button is clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

O primeiro teste quebra se você trocar de useState para useReducer, mesmo que o componente funcione exatamente da mesma forma. O segundo teste só quebra se o comportamento do componente realmente mudar. Ele não se importa com como o contador é gerenciado internamente — apenas que clicar em "+" faz o número aumentar.

O teste decisivo é simples: você pode refatorar a implementação sem alterar o teste? Se sim, você está testando comportamento. Se não, está testando implementação.

O Troféu de Testes#

Kent C. Dodds propôs o "Troféu de Testes" como alternativa à pirâmide de testes tradicional:

    ╭─────────╮
    │  E2E    │   Poucos — caros, lentos, alta confiança
    ├─────────┤
    │         │
    │ Integr. │   Maioria — boa relação confiança-custo
    │         │
    ├─────────┤
    │  Unit   │   Alguns — rápidos, focados, baixo custo
    ├─────────┤
    │ Static  │   Sempre — TypeScript, ESLint
    ╰─────────╯

A pirâmide tradicional coloca testes unitários na base (muitos deles) e testes de integração no meio (menos). O troféu inverte isso: testes de integração são o ponto ideal. Eis o porquê:

  • Análise estática (TypeScript, ESLint) captura erros de digitação, tipos errados e erros lógicos simples de graça. Você nem precisa executar nada.
  • Testes unitários são ótimos para lógica pura complexa, mas não dizem se as peças funcionam juntas.
  • Testes de integração verificam que componentes, hooks e contextos funcionam juntos. Eles dão mais confiança por teste escrito.
  • Testes end-to-end verificam o sistema inteiro, mas são lentos, instáveis e caros de manter. Você precisa de alguns para caminhos críticos, mas não de centenas.

Eu sigo essa distribuição na prática: TypeScript captura a maioria dos meus erros de tipo, escrevo testes unitários para utilitários complexos e algoritmos, testes de integração para funcionalidades e fluxos de usuário, e um punhado de testes E2E para o caminho crítico (cadastro, compra, fluxo principal).

O Que Dá Confiança vs O Que Desperdiça Tempo#

Testes existem para dar confiança para entregar. Não confiança de que cada linha de código roda — confiança de que a aplicação funciona para os usuários. São coisas diferentes.

Alta confiança, alto valor:

  • Teste de integração de um fluxo de checkout — cobre validação de formulário, chamadas de API, atualizações de estado e UI de sucesso/erro.
  • Teste unitário de uma função de cálculo de preço com casos extremos — ponto flutuante, arredondamento, descontos, valores zero/negativos.
  • Teste de que rotas protegidas redirecionam usuários não autenticados.

Baixa confiança, desperdício de tempo:

  • Teste de snapshot de uma página de marketing estática — quebra toda vez que o texto muda, não captura nada significativo.
  • Teste unitário de que um componente passa uma prop para um filho — testando o próprio React, não o seu código.
  • Teste de que useState é chamado — testando o framework, não comportamento.
  • 100% de cobertura de um arquivo de configuração — são dados estáticos, o TypeScript já valida sua forma.

A pergunta a se fazer antes de escrever um teste: "Se este teste não existisse, que bug poderia escapar para produção?" Se a resposta é "nenhum que o TypeScript não capturaria" ou "nenhum que alguém notaria", o teste provavelmente não vale a pena ser escrito.

Testes como Feedback de Design#

Código difícil de testar geralmente é código mal projetado. Se você precisa fazer mock de cinco coisas para testar uma função, essa função tem dependências demais. Se você não consegue renderizar um componente sem configurar providers de contexto elaborados, o componente está muito acoplado ao seu ambiente.

Testes são um usuário do seu código. Se seus testes têm dificuldade em usar sua API, outros desenvolvedores também terão. Quando você se pega lutando com o setup do teste, tome isso como um sinal para refatorar o código sendo testado, não para adicionar mais mocks.

typescript
// Hard to test — function does too much
async function processOrder(orderId: string) {
  const order = await db.orders.findById(orderId);
  const user = await db.users.findById(order.userId);
  const inventory = await checkInventory(order.items);
  if (!inventory.available) {
    await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
    return { success: false, reason: "out-of-stock" };
  }
  const payment = await chargeCard(user.paymentMethod, order.total);
  if (!payment.success) {
    await sendEmail(user.email, "payment-failed", { error: payment.error });
    return { success: false, reason: "payment-failed" };
  }
  await db.orders.update(orderId, { status: "confirmed" });
  await sendEmail(user.email, "order-confirmed", { orderId });
  return { success: true };
}
 
// Easier to test — separated concerns
function determineOrderAction(
  inventory: InventoryResult,
  payment: PaymentResult
): OrderAction {
  if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
  if (!payment.success) return { type: "payment-failed", error: payment.error };
  return { type: "confirmed" };
}

A segunda versão é uma função pura. Você pode testar cada combinação de resultados de inventário e pagamento sem fazer mock de banco de dados, provedor de pagamento ou serviço de e-mail. A lógica de orquestração (buscar dados, enviar e-mails) vive numa camada fina que é testada a nível de integração.

Este é o verdadeiro valor dos testes: não capturar bugs depois de escritos, mas prevenir designs ruins antes de serem commitados. A disciplina de escrever testes empurra você em direção a funções menores, interfaces mais claras e arquitetura mais modular. Os testes são o efeito colateral. A melhoria do design é o evento principal.

Escreva Testes em Que Você Confia#

A pior coisa que pode acontecer a uma suíte de testes não é que ela tenha lacunas. É que as pessoas parem de confiar nela. Uma suíte de testes com alguns testes instáveis que falham aleatoriamente no CI ensina a equipe a ignorar builds vermelhos. Uma vez que isso acontece, a suíte de testes é pior que inútil — ela ativamente fornece falsa segurança.

Se um teste falha intermitentemente, corrija-o ou delete-o. Se um teste é lento, acelere-o ou mova-o para uma suíte de testes lentos separada. Se um teste quebra a cada mudança não relacionada, reescreva-o para testar comportamento em vez de implementação.

O objetivo é uma suíte de testes onde cada falha significa que algo real está quebrado. Quando os desenvolvedores confiam nos testes, eles os executam antes de cada commit. Quando não confiam nos testes, eles os burlam com --no-verify e fazem deploy de dedos cruzados.

Construa uma suíte de testes na qual você apostaria seu fim de semana. Nada menos que isso vale a pena manter.

Posts Relacionados