Ir para o conteúdo
Framework com Framework1909 palavras

Como usar Jest com React: Guia Completo

Introdução

Testes automatizados não são luxo. São infraestrutura. Assim como você não entregaria um projeto sem controle de versão, não deveria entregar sem uma suíte de testes confiável. E quando falamos de aplicações React, o Jest se consolidou como a ferramenta padrão da indústria para esse trabalho.

Neste guia, vou compartilhar o que aprendi em mais de uma década configurando e mantendo suítes de testes em projetos React de diversos tamanhos: desde MVPs com três telas até plataformas SaaS com centenas de componentes. O objetivo é ir além do "hello world" dos testes e mostrar padrões que realmente funcionam no dia a dia.

Se você já tem pelo menos dois anos de experiência com desenvolvimento front-end e quer elevar a qualidade dos seus projetos React com TypeScript, este artigo foi escrito para você.

Por que usar Jest com React

A combinação que se tornou padrão de mercado

O Jest foi criado pelo Facebook (atual Meta), a mesma equipe por trás do React. Isso não é coincidência: a ferramenta foi projetada desde o início para funcionar bem com o ecossistema React. Enquanto outras ferramentas de teste exigem configurações extensas, plugins adicionais e adaptadores, o Jest oferece uma experiência integrada que simplesmente funciona.

Alguns pontos que tornam essa combinação poderosa:

  • Snapshot testing nativo: ideal para garantir que a renderização de componentes não mude sem intenção.
  • Mocking integrado: simular módulos, funções e até timers sem bibliotecas extras.
  • Execução paralela: testes rodam em workers isolados, aproveitando múltiplos cores do processador.
  • Watch mode inteligente: re-executa apenas os testes afetados pelos arquivos modificados, acelerando o ciclo de desenvolvimento.

Casos de uso reais onde essa combinação brilha

Na prática, a dupla Jest e React se destaca em cenários específicos. Em sistemas de e-commerce, por exemplo, testar o fluxo do carrinho de compras (adicionar itens, calcular frete, aplicar cupons) com testes unitários e de integração evita que bugs cheguem à produção e causem perda de receita.

Em dashboards corporativos, onde componentes exibem dados de APIs complexas, o sistema de mocking do Jest permite simular respostas de API e testar como os componentes se comportam com dados variados: listas vazias, erros de rede, dados parciais.

Outro caso comum é em design systems. Bibliotecas de componentes compartilhadas entre múltiplos projetos precisam de testes robustos para garantir que uma alteração em um botão não quebre formulários em três aplicações diferentes. Snapshot tests do Jest são perfeitos para isso.

Setup completo: Jest com React e TypeScript

Instalação das dependências

Vamos começar do zero. Se você está usando Create React App, o Jest já vem configurado. Mas em projetos com configuração customizada (Vite, Webpack manual, etc.), siga os passos abaixo.

TYPESCRIPT
// Execute no terminal:
// npm install --save-dev jest ts-jest @types/jest
// npm install --save-dev @testing-library/react @testing-library/jest-dom
// npm install --save-dev @testing-library/user-event
// npm install --save-dev identity-obj-proxy jest-environment-jsdom
// npm install --save-dev ts-node

Configuração do jest.config.ts

Crie o arquivo jest.config.ts na raiz do projeto:

TYPESCRIPT
import type { Config } from "jest";
 
const config: Config = {
  preset: "ts-jest",
  testEnvironment: "jsdom",
  roots: ["<rootDir>/src"],
  setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
  moduleNameMapper: {
    // Permite que o Jest lide com imports de CSS/SCSS
    "\\.(css|less|scss|sass)$": "identity-obj-proxy",
    // Permite que o Jest lide com imports de imagens e outros assets
    "\\.(jpg|jpeg|png|gif|svg)$": "<rootDir>/__mocks__/fileMock.ts",
    // Suporte a path aliases do TypeScript
    "^@/(.*)$": "<rootDir>/src/$1",
  },
  collectCoverageFrom: [
    "src/**/*.{ts,tsx}",
    "!src/**/*.d.ts",
    "!src/index.tsx",
    "!src/**/*.stories.{ts,tsx}",
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};
 
export default config;

Arquivo de setup para os testes

Crie o arquivo src/setupTests.ts:

TYPESCRIPT
import "@testing-library/jest-dom";

Essa única linha importa matchers customizados como toBeInTheDocument(), toHaveTextContent() e toBeVisible(), que tornam as asserções muito mais expressivas e legíveis.

Exemplo prático 1: Testando um componente de formulário

Vamos a um exemplo realista. Imagine um componente de formulário de contato que valida campos e envia dados para uma API:

TYPESCRIPT
// src/components/ContactForm/ContactForm.tsx
import React, { useState } from "react";
 
interface ContactFormProps {
  onSubmit: (data: { name: string; email: string; message: string }) => Promise<void>;
}
 
export const ContactForm: React.FC<ContactFormProps> = ({ onSubmit }) => {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitSuccess, setSubmitSuccess] = useState(false);
 
  const validate = (): boolean => {
    const newErrors: Record<string, string> = {};
 
    if (!name.trim()) {
      newErrors.name = "Nome é obrigatório";
    }
 
    if (!email.trim()) {
      newErrors.email = "E-mail é obrigatório";
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      newErrors.email = "E-mail inválido";
    }
 
    if (!message.trim()) {
      newErrors.message = "Mensagem é obrigatória";
    }
 
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
 
    if (!validate()) return;
 
    setIsSubmitting(true);
    try {
      await onSubmit({ name, email, message });
      setSubmitSuccess(true);
      setName("");
      setEmail("");
      setMessage("");
    } catch {
      setErrors({ form: "Erro ao enviar. Tente novamente." });
    } finally {
      setIsSubmitting(false);
    }
  };
 
  if (submitSuccess) {
    return <p role="status">Mensagem enviada com sucesso!</p>;
  }
 
  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Nome</label>
        <input
          id="name"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          aria-invalid={!!errors.name}
        />
        {errors.name && <span role="alert">{errors.name}</span>}
      </div>
 
      <div>
        <label htmlFor="email">E-mail</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          aria-invalid={!!errors.email}
        />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
 
      <div>
        <label htmlFor="message">Mensagem</label>
        <textarea
          id="message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          aria-invalid={!!errors.message}
        />
        {errors.message && <span role="alert">{errors.message}</span>}
      </div>
 
      {errors.form && <span role="alert">{errors.form}</span>}
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Enviando..." : "Enviar"}
      </button>
    </form>
  );
};

Agora o arquivo de testes:

TYPESCRIPT
// src/components/ContactForm/ContactForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContactForm } from "./ContactForm";
 
describe("ContactForm", () => {
  const mockOnSubmit = jest.fn();
 
  beforeEach(() => {
    mockOnSubmit.mockReset();
    mockOnSubmit.mockResolvedValue(undefined);
  });
 
  it("deve exibir erros de validação quando o formulário é enviado vazio", async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);
 
    await user.click(screen.getByRole("button", { name: /enviar/i }));
 
    expect(screen.getByText("Nome é obrigatório")).toBeInTheDocument();
    expect(screen.getByText("E-mail é obrigatório")).toBeInTheDocument();
    expect(screen.getByText("Mensagem é obrigatória")).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
 
  it("deve validar formato de e-mail inválido", async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText(/nome/i), "João Silva");
    await user.type(screen.getByLabelText(/e-mail/i), "email-invalido");
    await user.type(screen.getByLabelText(/mensagem/i), "Olá, tudo bem?");
    await user.click(screen.getByRole("button", { name: /enviar/i }));
 
    expect(screen.getByText("E-mail inválido")).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
 
  it("deve enviar o formulário com dados válidos e exibir mensagem de sucesso", async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText(/nome/i), "João Silva");
    await user.type(screen.getByLabelText(/e-mail/i), "[email protected]");
    await user.type(screen.getByLabelText(/mensagem/i), "Olá, tudo bem?");
    await user.click(screen.getByRole("button", { name: /enviar/i }));
 
    await waitFor(() => {
      expect(screen.getByText("Mensagem enviada com sucesso!")).toBeInTheDocument();
    });
 
    expect(mockOnSubmit).toHaveBeenCalledWith({
      name: "João Silva",
      email: "[email protected]",
      message: "Olá, tudo bem?",
    });
    expect(mockOnSubmit).toHaveBeenCalledTimes(1);
  });
 
  it("deve exibir erro quando a API falha", async () => {
    mockOnSubmit.mockRejectedValue(new Error("Network error"));
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText(/nome/i), "João Silva");
    await user.type(screen.getByLabelText(/e-mail/i), "[email protected]");
    await user.type(screen.getByLabelText(/mensagem/i), "Olá!");
    await user.click(screen.getByRole("button", { name: /enviar/i }));
 
    await waitFor(() => {
      expect(screen.getByText("Erro ao enviar. Tente novamente.")).toBeInTheDocument();
    });
  });
 
  it("deve desabilitar o botão durante o envio", async () => {
    // Simula uma API que demora para responder
    mockOnSubmit.mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 1000))
    );
    const user = userEvent.setup();
    render(<ContactForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText(/nome/i), "João Silva");
    await user.type(screen.getByLabelText(/e-mail/i), "[email protected]");
    await user.type(screen.getByLabelText(/mensagem/i), "Olá!");
    await user.click(screen.getByRole("button", { name: /enviar/i }));
 
    expect(screen.getByRole("button", { name: /enviando/i })).toBeDisabled();
  });
});

Observe como os testes cobrem o fluxo completo: validação, envio com sucesso, erro de API e estado de loading. Esse é o nível de cobertura que faz diferença em projetos reais.

Exemplo prático 2: Testando um hook customizado com mock de API

Hooks customizados são uma parte central de aplicações React modernas. Veja como testar um hook que busca dados de uma API:

TYPESCRIPT
// src/hooks/useProducts.ts
import { useState, useEffect } from "react";
 
interface Product {
  id: string;
  name: string;
  price: number;
}
 
interface UseProductsResult {
  products: Product[];
  isLoading: boolean;
  error: string | null;
  refetch: () => void;
}
 
export const fetchProducts = async (): Promise<Product[]> => {
  const response = await fetch("/api/products");
  if (!response.ok) {
    throw new Error("Falha ao carregar produtos");
  }
  return response.json();
};
 
export const useProducts = (): UseProductsResult => {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [trigger, setTrigger] = useState(0);
 
  const refetch = () => setTrigger((prev) => prev + 1);
 
  useEffect(() => {
    let cancelled = false;
 
    const load = async () => {
      setIsLoading(true);
      setError(null);
 
      try {
        const data = await fetchProducts();
        if (!cancelled) {
          setProducts(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err.message : "Erro desconhecido");
        }
      } finally {
        if (!cancelled) {
          setIsLoading(false);
        }
      }
    };
 
    load();
 
    return () => {
      cancelled = true;
    };
  }, [trigger]);
 
  return { products, isLoading, error, refetch };
};

Vamos testar carregamento, sucesso, lista vazia, erros e uma nova busca. O mock substitui apenas fetch; assim, os testes também exercitam fetchProducts, sem chamar uma API real. A resposta esperada é um array, conforme o contrato deste hook.

TYPESCRIPT
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { useProducts } from "./useProducts";
 
// O tipo é derivado do hook: Product não é exportado pelo arquivo publicado.
type Product = ReturnType<typeof useProducts>["products"][number];
 
const products: Product[] = [
  { id: "1", name: "Camiseta React", price: 79.9 },
];
const updatedProducts: Product[] = [
  { id: "2", name: "Caneca TypeScript", price: 45 },
];
 
const originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
const fetchMock = jest.fn() as jest.MockedFunction<typeof fetch>;
 
function response(body: Product[], ok = true): Response {
  // Double parcial: o código publicado só lê ok e chama json().
  return { ok, json: async () => body } as Response;
}
 
function deferred<T>() {
  let resolve!: (value: T) => void;
  const promise = new Promise<T>((done) => {
    resolve = done;
  });
  return { promise, resolve };
}
 
beforeEach(() => {
  fetchMock.mockReset();
  Object.defineProperty(globalThis, "fetch", {
    configurable: true,
    writable: true,
    value: fetchMock,
  });
});
 
afterEach(() => {
  cleanup();
  if (originalFetch) {
    Object.defineProperty(globalThis, "fetch", originalFetch);
  } else {
    Reflect.deleteProperty(globalThis, "fetch");
  }
});
 
describe("useProducts", () => {
  it("começa carregando e entrega os produtos recebidos", async () => {
    const pending = deferred<Response>();
    fetchMock.mockReturnValueOnce(pending.promise);
    const { result } = renderHook(() => useProducts());
 
    expect(result.current.products).toEqual([]);
    expect(result.current.isLoading).toBe(true);
    expect(result.current.error).toBeNull();
    expect(fetchMock).toHaveBeenCalledWith("/api/products");
 
    await act(async () => {
      pending.resolve(response(products));
      await pending.promise;
    });
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.products).toEqual(products);
    expect(result.current.error).toBeNull();
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });
 
  it("aceita uma lista vazia", async () => {
    fetchMock.mockResolvedValueOnce(response([]));
    const { result } = renderHook(() => useProducts());
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.products).toEqual([]);
    expect(result.current.error).toBeNull();
  });
 
  it("informa uma resposta HTTP não OK", async () => {
    fetchMock.mockResolvedValueOnce(response([], false));
    const { result } = renderHook(() => useProducts());
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.error).toBe("Falha ao carregar produtos");
    expect(result.current.products).toEqual([]);
  });
 
  it("preserva a mensagem de um erro de rede", async () => {
    fetchMock.mockRejectedValueOnce(new Error("Sem conexão"));
    const { result } = renderHook(() => useProducts());
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.error).toBe("Sem conexão");
  });
 
  it("trata rejeições que não são instâncias de Error", async () => {
    fetchMock.mockRejectedValueOnce("falha sem objeto Error");
    const { result } = renderHook(() => useProducts());
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.error).toBe("Erro desconhecido");
  });
 
  it("limpa o erro e busca novamente ao chamar refetch", async () => {
    const pending = deferred<Response>();
    fetchMock
      .mockRejectedValueOnce(new Error("Sem conexão"))
      .mockReturnValueOnce(pending.promise);
    const { result } = renderHook(() => useProducts());
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.error).toBe("Sem conexão");
 
    act(() => result.current.refetch());
    expect(result.current.isLoading).toBe(true);
    expect(result.current.error).toBeNull();
 
    await act(async () => {
      pending.resolve(response(updatedProducts));
      await pending.promise;
    });
 
    await waitFor(() => expect(result.current.isLoading).toBe(false));
    expect(result.current.products).toEqual(updatedProducts);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
 
  it("ignora a resposta antiga quando uma nova busca já terminou", async () => {
    const oldRequest = deferred<Response>();
    fetchMock
      .mockReturnValueOnce(oldRequest.promise)
      .mockResolvedValueOnce(response(updatedProducts));
    const { result } = renderHook(() => useProducts());
 
    act(() => result.current.refetch());
    await waitFor(() => expect(result.current.products).toEqual(updatedProducts));
 
    await act(async () => {
      oldRequest.resolve(response(products));
      await oldRequest.promise;
    });
 
    expect(result.current.products).toEqual(updatedProducts);
    expect(result.current.isLoading).toBe(false);
    expect(result.current.error).toBeNull();
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});

Guias Relacionados

Explore mais guias técnicos

Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.

Ver todos os guias