Ir para o conteúdo
Framework com Framework1676 palavras

Como usar Jest com Next.js: Guia Completo

Introdução

Testar aplicações Next.js sempre foi um ponto de atrito para muitas equipes. A combinação de Server Components, rotas API, renderização híbrida e integrações com banco de dados cria um cenário onde testes automatizados não são apenas desejáveis: são essenciais para manter a sanidade do time.

O Jest se consolidou como a ferramenta de testes mais popular do ecossistema JavaScript, e por bons motivos. Sua API intuitiva, o sistema de mocks robusto e a integração nativa com TypeScript fazem dele a escolha natural para projetos Next.js. No entanto, configurar tudo corretamente exige atenção a detalhes que a documentação oficial nem sempre cobre com profundidade.

Neste guia, vou apresentar o passo a passo completo para integrar Jest com Next.js usando TypeScript, desde a configuração inicial até padrões avançados que uso em projetos de produção. Se você já tem pelo menos dois anos de experiência com desenvolvimento web e quer elevar a qualidade dos seus testes, este artigo é para você.

Por que usar Jest com Next.js

Benefícios da combinação para projetos reais

O Next.js oferece um conjunto de funcionalidades que demanda cobertura de testes em múltiplas camadas. Pense em um e-commerce típico: você tem Server Components que buscam dados de produtos, Client Components que gerenciam o carrinho, rotas API que processam pagamentos e middlewares que controlam autenticação. Jest cobre funções, handlers e componentes compatíveis. Para Server Components assíncronos, a documentação do Next.js recomenda testes E2E.

A partir da versão 12, o Next.js passou a oferecer suporte oficial ao Jest por meio do pacote next/jest, que simplifica drasticamente a configuração. Isso eliminou a necessidade de configurações manuais de Babel e resolveu problemas históricos com transformação de módulos CSS e imports de imagens.

Casos de uso onde essa combinação brilha

Na prática, existem cenários específicos onde Jest com Next.js se torna indispensável:

Testes de hooks customizados que dependem de contexto do Next.js, como useRouter ou useSearchParams. Validação de lógica de negócio em funções utilitárias compartilhadas entre servidor e cliente. Testes de integração de rotas API que simulam requisições HTTP completas. Verificação de comportamento condicional em componentes que renderizam de forma diferente com base em dados do servidor.

Projetos que lidam com formulários complexos, como plataformas SaaS com múltiplos níveis de permissão, se beneficiam enormemente de testes automatizados para garantir que regras de acesso funcionem corretamente após cada deploy.

Setup completo do Jest com Next.js e TypeScript

Instalação das dependências necessárias

Comece instalando os pacotes necessários no seu projeto Next.js existente:

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

Se o seu projeto já usa TypeScript (e deveria), instale também os tipos correspondentes:

TYPESCRIPT
// npm install --save-dev @types/jest

Configuração do jest.config.ts

Crie o arquivo jest.config.ts na raiz do projeto. Aqui está a configuração que uso como base em projetos de produção:

TYPESCRIPT
import type { Config } from "@jest/types";
import nextJest from "next/jest.js";
 
const createJestConfig = nextJest({
  dir: "./",
});
 
const customConfig: Config.InitialOptions = {
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
  testEnvironment: "jsdom",
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
  },
  testPathIgnorePatterns: [
    "<rootDir>/node_modules/",
    "<rootDir>/.next/",
    "<rootDir>/e2e/",
  ],
  collectCoverageFrom: [
    "src/**/*.{ts,tsx}",
    "!src/**/*.d.ts",
    "!src/**/index.ts",
    "!src/**/*.stories.{ts,tsx}",
  ],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },
};
 
export default createJestConfig(customConfig);

O next/jest cuida automaticamente da transformação de módulos CSS, arquivos estáticos e configurações do compilador SWC do Next.js. Sem ele, você precisaria configurar tudo isso manualmente.

Arquivo de setup global

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

TYPESCRIPT
import "@testing-library/jest-dom";
 
// Mock global do next/navigation para todos os testes
jest.mock("next/navigation", () => ({
  useRouter: () => ({
    push: jest.fn(),
    replace: jest.fn(),
    prefetch: jest.fn(),
    back: jest.fn(),
  }),
  useSearchParams: () => new URLSearchParams(),
  usePathname: () => "/",
}));

Configuração do script no package.json

Adicione os scripts de teste ao seu package.json:

TYPESCRIPT
// No package.json, adicione:
// "scripts": {
//   "test": "jest",
//   "test:watch": "jest --watch",
//   "test:coverage": "jest --coverage",
//   "test:ci": "jest --ci --coverage --maxWorkers=2"
// }

O script test:ci é especialmente importante para pipelines de integração contínua, onde limitar o número de workers evita problemas de memória em containers com recursos limitados.

Exemplo prático: testando um componente de listagem de produtos

Estrutura do componente

Vamos criar um cenário realista: um componente de listagem de produtos que filtra itens por categoria e exibe um estado vazio quando não há resultados.

TYPESCRIPT
// src/components/ProductList/ProductList.tsx
"use client";
 
import { useState, useMemo } from "react";
 
export interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}
 
interface ProductListProps {
  products: Product[];
  initialCategory?: string;
  onProductSelect?: (product: Product) => void;
}
 
export function ProductList({
  products,
  initialCategory = "all",
  onProductSelect,
}: ProductListProps) {
  const [selectedCategory, setSelectedCategory] =
    useState<string>(initialCategory);
 
  const categories = useMemo(() => {
    const unique = new Set(products.map((p) => p.category));
    return ["all", ...Array.from(unique)];
  }, [products]);
 
  const filteredProducts = useMemo(() => {
    if (selectedCategory === "all") return products;
    return products.filter((p) => p.category === selectedCategory);
  }, [products, selectedCategory]);
 
  if (products.length === 0) {
    return (
      <div role="status" aria-label="empty-state">
        <p>Nenhum produto disponível no momento.</p>
      </div>
    );
  }
 
  return (
    <div>
      <nav aria-label="product-filters">
        {categories.map((category) => (
          <button
            key={category}
            onClick={() => setSelectedCategory(category)}
            aria-pressed={selectedCategory === category}
            data-testid={`filter-${category}`}
          >
            {category === "all" ? "Todos" : category}
          </button>
        ))}
      </nav>
 
      <ul aria-label="product-list">
        {filteredProducts.map((product) => (
          <li key={product.id}>
            <button
              onClick={() => onProductSelect?.(product)}
              disabled={!product.inStock}
            >
              <span data-testid={`product-name-${product.id}`}>
                {product.name}
              </span>
              <span data-testid={`product-price-${product.id}`}>
                R$ {product.price.toFixed(2)}
              </span>
              {!product.inStock && <span>Indisponível</span>}
            </button>
          </li>
        ))}
      </ul>
 
      <p data-testid="product-count">
        {filteredProducts.length} produto(s) encontrado(s)
      </p>
    </div>
  );
}

Testes completos do componente

Agora, os testes que cobrem os comportamentos principais:

TYPESCRIPT
// src/components/ProductList/ProductList.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProductList, Product } from "./ProductList";
 
const mockProducts: Product[] = [
  {
    id: "1",
    name: "Camiseta React",
    price: 79.9,
    category: "Roupas",
    inStock: true,
  },
  {
    id: "2",
    name: "Caneca TypeScript",
    price: 45.0,
    category: "Acessórios",
    inStock: true,
  },
  {
    id: "3",
    name: "Moletom Next.js",
    price: 189.9,
    category: "Roupas",
    inStock: false,
  },
  {
    id: "4",
    name: "Adesivo JavaScript",
    price: 12.5,
    category: "Acessórios",
    inStock: true,
  },
];
 
describe("ProductList", () => {
  describe("renderização inicial", () => {
    it("deve exibir todos os produtos quando nenhum filtro é aplicado", () => {
      render(<ProductList products={mockProducts} />);
 
      expect(screen.getByText("Camiseta React")).toBeInTheDocument();
      expect(screen.getByText("Caneca TypeScript")).toBeInTheDocument();
      expect(screen.getByText("Moletom Next.js")).toBeInTheDocument();
      expect(screen.getByText("Adesivo JavaScript")).toBeInTheDocument();
      expect(screen.getByTestId("product-count")).toHaveTextContent(
        "4 produto(s) encontrado(s)"
      );
    });
 
    it("deve exibir estado vazio quando a lista de produtos está vazia", () => {
      render(<ProductList products={[]} />);
 
      expect(
        screen.getByText("Nenhum produto disponível no momento.")
      ).toBeInTheDocument();
      expect(
        screen.queryByLabelText("product-list")
      ).not.toBeInTheDocument();
    });
 
    it("deve marcar produtos fora de estoque como indisponíveis", () => {
      render(<ProductList products={mockProducts} />);
 
      const moletomButton = screen.getByText("Moletom Next.js")
        .closest("button");
      expect(moletomButton).toBeDisabled();
      expect(screen.getByText("Indisponível")).toBeInTheDocument();
    });
  });
 
  describe("filtragem por categoria", () => {
    it("deve filtrar produtos ao clicar em uma categoria", async () => {
      const user = userEvent.setup();
      render(<ProductList products={mockProducts} />);
 
      await user.click(screen.getByTestId("filter-Roupas"));
 
      expect(screen.getByText("Camiseta React")).toBeInTheDocument();
      expect(screen.getByText("Moletom Next.js")).toBeInTheDocument();
      expect(
        screen.queryByText("Caneca TypeScript")
      ).not.toBeInTheDocument();
      expect(screen.getByTestId("product-count")).toHaveTextContent(
        "2 produto(s) encontrado(s)"
      );
    });
 
    it("deve voltar a exibir todos ao clicar em 'Todos'", async () => {
      const user = userEvent.setup();
      render(
        <ProductList
          products={mockProducts}
          initialCategory="Roupas"
        />
      );
 
      expect(screen.getByTestId("product-count")).toHaveTextContent(
        "2 produto(s) encontrado(s)"
      );
 
      await user.click(screen.getByTestId("filter-all"));
 
      expect(screen.getByTestId("product-count")).toHaveTextContent(
        "4 produto(s) encontrado(s)"
      );
    });
  });
 
  describe("interação de seleção", () => {
    it("deve chamar onProductSelect ao clicar em produto disponível", async () => {
      const user = userEvent.setup();
      const handleSelect = jest.fn();
 
      render(
        <ProductList
          products={mockProducts}
          onProductSelect={handleSelect}
        />
      );
 
      await user.click(screen.getByText("Camiseta React"));
 
      expect(handleSelect).toHaveBeenCalledTimes(1);
      expect(handleSelect).toHaveBeenCalledWith(
        expect.objectContaining({
          id: "1",
          name: "Camiseta React",
        })
      );
    });
 
    it("não deve chamar onProductSelect para produto indisponível", async () => {
      const user = userEvent.setup();
      const handleSelect = jest.fn();
 
      render(
        <ProductList
          products={mockProducts}
          onProductSelect={handleSelect}
        />
      );
 
      const moletomButton = screen.getByText("Moletom Next.js")
        .closest("button") as HTMLButtonElement;
      await user.click(moletomButton);
 
      expect(handleSelect).not.toHaveBeenCalled();
    });
  });
});

Exemplo prático: testando uma rota API do Next.js

Testando Route Handlers do App Router

Route Handlers são outro ponto crítico que merece cobertura de testes. Veja como testar uma rota de busca de produtos:

TYPESCRIPT
// src/app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
 
interface ProductQuery {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
}
 
function parseSearchParams(
  searchParams: URLSearchParams
): ProductQuery {
  return {
    category: searchParams.get("category") ?? undefined,
    minPrice: searchParams.has("minPrice")
      ? Number(searchParams.get("minPrice"))
      : undefined,
    maxPrice: searchParams.has("maxPrice")
      ? Number(searchParams.get("maxPrice"))
      : undefined,
  };
}
 
export async function GET(request: NextRequest) {
  try {
    const query = parseSearchParams(request.nextUrl.searchParams);
 
    if (query.minPrice !== undefined && isNaN(query.minPrice)) {
      return NextResponse.json(
        { error: "O parâmetro minPrice deve ser um número válido." },
        { status: 400 }
      );
    }
 
    // Simula busca no banco de dados
    const products = await fetchProductsFromDatabase(query);
 
    return NextResponse.json({
      data: products,
      total: products.length,
    });
  } catch (error) {
    return NextResponse.json(
      { error: "Erro interno ao buscar produtos." },
      { status: 500 }
    );
  }
}
 
async function fetchProductsFromDatabase(
  query: ProductQuery
): Promise<Array<{ id: string; name: string }>> {
  // Implementação real consultaria o banco
  return [];
}

Este primeiro exemplo testa diretamente o handler em ambiente Node. A busca no banco ainda é um stub que sempre devolve uma lista vazia, então o teste de sucesso valida o status e o formato da resposta. Os parâmetros válidos abaixo não comprovam a filtragem: ela ainda precisa ser implementada na camada de dados.

TYPESCRIPT
/** @jest-environment node */
 
import { NextRequest } from "next/server";
import { GET } from "./route";
 
describe("GET /api/products", () => {
  it("retorna a lista vazia do stub e o total", async () => {
    const request = new NextRequest("http://localhost/api/products");
    const response = await GET(request);
 
    expect(response.status).toBe(200);
    await expect(response.json()).resolves.toEqual({ data: [], total: 0 });
  });
 
  it("aceita parâmetros válidos sem mudar a resposta do stub", async () => {
    const request = new NextRequest(
      "http://localhost/api/products?category=Roupas&minPrice=0&maxPrice=100"
    );
    const response = await GET(request);
 
    expect(response.status).toBe(200);
    await expect(response.json()).resolves.toEqual({ data: [], total: 0 });
  });
 
  it("retorna 400 quando minPrice não é numérico", async () => {
    const request = new NextRequest(
      "http://localhost/api/products?minPrice=abc"
    );
    const response = await GET(request);
 
    expect(response.status).toBe(400);
    await expect(response.json()).resolves.toEqual({
      error: "O parâmetro minPrice deve ser um número válido.",
    });
  });
});

Estes testes cobrem a resposta do stub e a validação já implementada de minPrice. Antes de usar o endpoint com dados reais, defina e teste o contrato da camada de consulta e a validação completa dos filtros. O caso de erro do banco fica pendente dessa integração.

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