Testes Automatizados para APIs Next.js: Vitest, MSW e Playwright

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema real: APIs Next.js sem cobertura de teste
Route Handlers do App Router vivem em arquivos route.ts que exportam funções HTTP (GET, POST, PUT, DELETE). Server Actions vivem em arquivos "use server". As duas abstrações parecem simples até você precisar testar: elas dependem de objetos Request/NextRequest, acessam banco, chamam APIs externas e rodam em contexto de servidor Node.js.
O resultado comum: zero testes automatizados, ou testes frágeis que mockam tudo e não pegam nenhum bug real.
A stack que resolve isso sem inventar framework próprio: Vitest para testes unitários e de integração, MSW para interceptar chamadas HTTP a serviços externos, e Playwright para E2E que bate na API real rodando. Cada ferramenta cobre uma camada específica, sem sobreposição.
Onde cada ferramenta entra
| Critério | Vitest | MSW | Playwright |
|---|---|---|---|
| Camada | Unitário e integração | Interceptação de rede | E2E (browser ou API) |
| Velocidade | Milissegundos por teste | Não roda sozinho (acopla ao runner) | Segundos por teste |
| Precisa do servidor Next.js rodando? | Não | Não | Sim |
| Testa lógica de negócio isolada? | Sim | Indiretamente (isola dependências) | Não (testa o sistema inteiro) |
| Testa contrato HTTP real? | Parcialmente (você monta o Request) | Sim (intercepta no nível de rede) | Sim |
| Setup de CI | Trivial | Trivial | Precisa de browser headless |
A regra prática: Vitest cobre 70-80% dos testes. MSW entra quando a Route Handler chama API externa (Stripe, OpenAI, qualquer terceiro). Playwright cobre os fluxos críticos de ponta a ponta.
Configurando Vitest para o App Router
O Vitest precisa resolver os aliases do Next.js (@/) e entender o ambiente Node.js. A configuração mínima funcional:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
test: {
environment: "node", // Route Handlers rodam em Node, não em jsdom
globals: true,
include: ["**/*.test.ts", "**/*.test.tsx"],
setupFiles: ["./tests/setup.ts"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});O arquivo de setup registra o MSW e limpa estado entre testes:
// tests/setup.ts
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./mocks/server";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
// "error" em vez de "warn" força você a declarar todo request externo
// Se um teste faz fetch para URL não mockada, falha imediatamente
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Testando Route Handlers com Vitest
Route Handlers são funções que recebem Request (ou NextRequest) e retornam Response (ou NextResponse). Isso significa que você pode chamá-las diretamente, sem subir o servidor:
// src/app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/database";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const category = searchParams.get("category");
const products = await db.product.findMany({
where: category ? { category } : undefined,
take: 50,
});
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || typeof body.price !== "number") {
return NextResponse.json(
{ error: "name (string) e price (number) são obrigatórios" },
{ status: 400 }
);
}
const product = await db.product.create({
data: { name: body.name, price: body.price, category: body.category },
});
return NextResponse.json(product, { status: 201 });
}O teste unitário chama a função diretamente, injetando um NextRequest construído na mão:
// src/app/api/products/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "./route";
import { NextRequest } from "next/server";
import { db } from "@/lib/database";
// Mock do módulo de banco para isolar a Route Handler da infra
vi.mock("@/lib/database", () => ({
db: {
product: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}));
describe("GET /api/products", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retorna produtos filtrados por category quando o param existe", async () => {
const mockProducts = [
{ id: "1", name: "Teclado Mecânico", price: 450, category: "perifericos" },
];
vi.mocked(db.product.findMany).mockResolvedValue(mockProducts);
const request = new NextRequest(
"http://localhost:3000/api/products?category=perifericos"
);
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(mockProducts);
expect(db.product.findMany).toHaveBeenCalledWith({
where: { category: "perifericos" },
take: 50,
});
});
});
describe("POST /api/products", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rejeita payload sem price com 400", async () => {
const request = new NextRequest("http://localhost:3000/api/products", {
method: "POST",
body: JSON.stringify({ name: "Mouse" }),
headers: { "Content-Type": "application/json" },
});
const response = await POST(request);
expect(response.status).toBe(400);
const data = await response.json();
expect(data.error).toContain("price");
});
it("cria produto e retorna 201", async () => {
const created = { id: "2", name: "Mouse", price: 120, category: null };
vi.mocked(db.product.create).mockResolvedValue(created);
const request = new NextRequest("http://localhost:3000/api/products", {
method: "POST",
body: JSON.stringify({ name: "Mouse", price: 120 }),
headers: { "Content-Type": "application/json" },
});
const response = await POST(request);
expect(response.status).toBe(201);
const data = await response.json();
expect(data.id).toBe("2");
});
});Esse padrão funciona porque Route Handlers do App Router são funções puras do ponto de vista de I/O: entrada é Request, saída é Response. Se você estruturou sua camada de API com separação clara entre handler e lógica de negócio, testar fica trivial.
MSW: isolando dependências externas
Quando a Route Handler chama um serviço externo (gateway de pagamento, API de IA, webhook), mockar o fetch com vi.fn() é frágil. O MSW intercepta no nível de rede, o que significa que seu código de produção usa fetch normalmente e o MSW responde no lugar do servidor real.
// tests/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
// Simula a API do Stripe para criação de checkout
http.post("https://api.stripe.com/v1/checkout/sessions", async ({ request }) => {
const body = await request.text();
// Valida que o request contém o campo obrigatório
if (!body.includes("line_items")) {
return HttpResponse.json(
{ error: { message: "line_items is required" } },
{ status: 400 }
);
}
return HttpResponse.json({
id: "cs_test_abc123",
url: "https://checkout.stripe.com/pay/cs_test_abc123",
status: "open",
});
}),
];// tests/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);Com o MSW registrado no setup (mostrado antes), qualquer fetch para api.stripe.com dentro dos testes é interceptado automaticamente. Se a Route Handler fizer um fetch para uma URL não declarada nos handlers, o teste falha imediatamente graças ao onUnhandledRequest: "error".
Isso é útil quando você precisa testar cenários de erro do terceiro: timeout, 500, resposta malformada. Basta sobrescrever o handler no teste específico:
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
it("retorna 502 quando o Stripe responde com 500", async () => {
// Sobrescreve o handler padrão só para este teste
server.use(
http.post("https://api.stripe.com/v1/checkout/sessions", () => {
return HttpResponse.json(
{ error: { message: "Internal server error" } },
{ status: 500 }
);
})
);
const request = new NextRequest("http://localhost:3000/api/checkout", {
method: "POST",
body: JSON.stringify({ productId: "prod_123", quantity: 1 }),
headers: { "Content-Type": "application/json" },
});
const response = await POST(request);
expect(response.status).toBe(502);
});Se sua API implementa retry e timeout resilientes, o MSW permite testar esses cenários sem depender de rede real.
Playwright para E2E: testando a API com servidor real
Playwright não é só para browser. O request fixture permite testar APIs diretamente, sem renderizar página nenhuma. A diferença dos testes anteriores: aqui o Next.js está rodando de verdade, com banco, middleware, autenticação.
// e2e/api/products.spec.ts
import { test, expect } from "@playwright/test";
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
test.describe("API /api/products", () => {
test("GET retorna lista de produtos com status 200", async ({ request }) => {
const response = await request.get(`${BASE_URL}/api/products`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(Array.isArray(body)).toBe(true);
});
test("POST com payload válido cria produto", async ({ request }) => {
const response = await request.post(`${BASE_URL}/api/products`, {
data: { name: "Monitor 4K", price: 2200, category: "perifericos" },
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.name).toBe("Monitor 4K");
expect(body.id).toBeDefined();
});
test("POST sem campos obrigatórios retorna 400", async ({ request }) => {
const response = await request.post(`${BASE_URL}/api/products`, {
data: { name: "Incompleto" },
});
expect(response.status()).toBe(400);
});
});A configuração do Playwright para esse cenário precisa do webServer apontando para o Next.js:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: {
baseURL: "http://localhost:3000",
},
webServer: {
command: "npm run dev",
port: 3000,
reuseExistingServer: !process.env.CI,
// Em CI, sobe o servidor do zero. Local, reaproveita se já estiver rodando
},
});Para pipelines de CI com Docker e CD configurados, o Playwright precisa de um banco de teste separado. Se você usa Prisma, a abordagem segura é rodar migrations em um banco efêmero antes da suíte E2E.
O que NÃO fazer
Anti-pattern 1: mockar o NextResponse inteiro
Código errado:
// ERRADO: mock que esconde bugs reais
vi.mock("next/server", () => ({
NextResponse: {
json: vi.fn((data, init) => ({ data, status: init?.status || 200 })),
},
NextRequest: vi.fn(),
}));Isso quebra porque o NextResponse.json() real retorna um objeto Response com .headers, .status, .body stream. Quando você substitui por um objeto literal, seus testes passam mas o código pode falhar em runtime: qualquer acesso a .headers.get() ou .clone() explode.
Código correto: use o NextRequest e NextResponse reais. Eles funcionam no ambiente Node.js do Vitest sem nenhum mock. O Next.js exporta essas classes como wrappers da Web API Request/Response, que o Node.js 18+ suporta nativamente.
Anti-pattern 2: testes E2E que dependem de seed específica no banco
// ERRADO: assume que o produto com id "prod_abc" existe no banco
test("GET produto específico", async ({ request }) => {
const response = await request.get("/api/products/prod_abc");
expect(response.status()).toBe(200);
});Se outro teste deletou esse registro, ou se o banco de CI está vazio, o teste falha de forma intermitente. A correção: cada teste E2E que precisa de dado cria o dado no beforeEach ou no próprio teste, via POST na API:
test("GET produto específico retorna o produto criado", async ({ request }) => {
// Cria o dado que o teste precisa
const createResponse = await request.post("/api/products", {
data: { name: "Teste Isolado", price: 100 },
});
const created = await createResponse.json();
const getResponse = await request.get(`/api/products/${created.id}`);
expect(getResponse.status()).toBe(200);
const product = await getResponse.json();
expect(product.name).toBe("Teste Isolado");
});Anti-pattern 3: usar MSW para tudo, incluindo seu próprio banco
MSW intercepta chamadas HTTP. Se você usa Prisma, Drizzle ou qualquer ORM que se conecta via TCP ao banco, o MSW não intercepta essa conexão. Tentar usar MSW para "mockar o banco" é um erro conceitual. Para isolar do banco nos testes unitários, mocke o módulo do ORM com vi.mock(). MSW é exclusivamente para dependências acessadas via HTTP/HTTPS.
Quando usar cada combinação
Se sua API Next.js é um CRUD simples sem dependências externas, Vitest com mock do ORM resolve 90% dos cenários. Playwright entra para validar que o middleware de autenticação, o rate limiting e a serialização funcionam juntos.
Se sua API integra com serviços externos (pagamento, email, IA), MSW é obrigatório nos testes de integração. Sem ele, você precisa de sandbox do fornecedor, que é lento e instável.
Se você implementa feature flags próprias, teste os caminhos de cada flag com Vitest (rápido) e valide o comportamento E2E com Playwright para as flags críticas.
Para APIs na edge, como Cloudflare Workers ou Deno Deploy, o MSW funciona igual porque intercepta fetch no nível de rede. O Vitest precisa do environment adequado (miniflare para Workers, Deno runtime para Deno).
Estrutura de diretórios que escala
src/
app/
api/
products/
route.ts
route.test.ts # Vitest: unitário do handler
checkout/
route.ts
route.test.ts
tests/
mocks/
handlers.ts # MSW: handlers para APIs externas
server.ts # MSW: setup do servidor
setup.ts # Vitest: beforeAll/afterAll global
e2e/
api/
products.spec.ts # Playwright: E2E da API
checkout.spec.ts
playwright.config.ts
vitest.config.tsColocar os testes unitários junto ao arquivo testado (route.test.ts ao lado de route.ts) reduz atrito: quando você altera o handler, o teste está ali. Testes E2E ficam separados porque dependem de infraestrutura diferente (servidor rodando, banco populado).
Essa organização se conecta com a estrutura de DDD prático com TypeScript: a lógica de domínio tem seus próprios testes unitários puros, sem dependência de HTTP. Os testes de Route Handler validam a camada de transporte. Os testes E2E validam o sistema.
FAQ
Preciso de Jest se já uso Vitest?
Não. O Vitest é compatível com a API do Jest (describe, it, expect, vi.mock) e roda significativamente mais rápido por usar o pipeline do Vite (ESBuild para transformação, sem Babel). A migração de Jest para Vitest em projetos Next.js costuma ser trocar o config e renomear jest.fn() para vi.fn().
O MSW funciona com o fetch nativo do Node.js 18+?
Sim. O MSW 2.x usa interceptors que funcionam tanto com undici (fetch nativo do Node) quanto com node-fetch. A versão 2.x é obrigatória para isso: a 1.x só funcionava com node-fetch e XMLHttpRequest.
Devo rodar Playwright no CI para testar só APIs, sem browser? Se você já tem Vitest cobrindo os handlers isolados e MSW cobrindo integrações externas, o Playwright para API pura no CI é opcional. Ele se justifica quando o middleware do Next.js (autenticação, rewrite, headers) participa da lógica e você precisa testar o request passando por toda a stack. Para projetos com Node.js em produção com múltiplas camadas, o E2E no CI pega bugs que testes unitários não alcançam.
Como lidar com variáveis de ambiente nos testes?
Crie um .env.test com valores de teste (banco local, chaves de API fake) e carregue no vitest.config.ts com envFile: '.env.test'. Para Playwright, use dotenv no playwright.config.ts. Nunca use variáveis de produção nos testes.
MSW substitui ferramentas como nock ou sinon para HTTP?
Para interceptação de HTTP, sim. O MSW opera em nível mais baixo (intercepta o ClientRequest do Node.js e o fetch nativo), enquanto nock só intercepta http/https do Node. Para projetos que usam fetch nativo, MSW é a escolha correta. Sinon é uma biblioteca de mocks genérica e continua útil para stubs de funções que não são HTTP.
A posição que defendo
Testar APIs Next.js não exige uma stack exótica. Vitest com MSW cobre a maioria absoluta dos cenários, roda em milissegundos e não precisa de servidor. Playwright entra cirurgicamente para os fluxos críticos de E2E.
O erro mais caro não é escolher a ferramenta errada. É não testar a camada de validação de entrada. A maioria dos bugs em Route Handlers acontece ali: payload sem campo obrigatório, tipo errado, header ausente. Um teste que monta um NextRequest com payload inválido e verifica o status 400 leva 30 segundos para escrever e evita incidentes em produção. Se você sair deste post e escrever só os testes de validação de entrada das suas rotas, já terá mais cobertura útil do que 80% dos projetos Next.js que encontro em code review.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


