Integration Tests
Testing Next.js features with real dependencies
Integration tests verify that different parts of the application work together correctly. While unit tests check individual pieces, integration tests ensure those pieces collaborate as expected. Use *.integration.test.ts[x]
naming convention.
Integration Testing Philosophy
Test complete features with real business logic. Mock only external boundaries like databases and third-party APIs.
Test Setup
Integration tests require mocking external dependencies while using real business logic:
import { vi } from "vitest";
// Mock Next.js navigation (required for redirect tests)
vi.mock("next/navigation", () => ({
redirect: (url: string) => {
throw new Error(`NEXT_REDIRECT: ${url}`);
},
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
}),
usePathname: () => "/",
}));
// Mock Next.js 15 async headers
vi.mock("next/headers", () => ({
headers: async () => new Headers(),
cookies: async () => new Map(),
}));
// Mock auth server
vi.mock("@workspace/app-shell/lib/auth/server", () => ({
getCurrentSession: vi.fn(),
requireUser: vi.fn(),
}));
Heads up
Integration tests MUST use /** @vitest-environment node */
for API routes, middleware, and server actions. Client components use the default jsdom environment.
Server Component Testing
Test Server Components by mocking auth and testing redirects:
import { describe, expect, it, vi, beforeEach } from "vitest";
import { getCurrentSession } from "@workspace/app-shell/lib/auth/server";
vi.mock("@workspace/app-shell/lib/auth/server");
describe("Protected Dashboard Page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders for authenticated users", async () => {
vi.mocked(getCurrentSession).mockResolvedValue({
user: { id: "u1", name: "Test User", email: "test@example.com" },
session: { userDocId: "doc1", userId: "u1" },
});
const { default: DashboardPage } = await import("../page");
// Server components return JSX - testing is limited
expect(DashboardPage).toBeDefined();
});
it("redirects unauthenticated users", async () => {
vi.mocked(getCurrentSession).mockResolvedValue(null);
const { default: DashboardPage } = await import("../page");
await expect(async () => {
await DashboardPage();
}).rejects.toThrow("NEXT_REDIRECT");
});
});
Pro Tip
Server Components cannot be rendered with React Testing Library. Test by mocking dependencies and expecting redirects to throw NEXT_REDIRECT
.
API Route Testing
API routes require Node environment and CSRF validation:
/**
* @vitest-environment node
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
function buildRequest(
origin: string | null,
site: string | null,
pathname = "/api/auth/session"
) {
const headers: Record<string, string> = {};
if (origin) headers.origin = origin;
if (site) headers["sec-fetch-site"] = site;
return new NextRequest(`https://dashboard.example${pathname}`, {
method: "POST",
headers
});
}
describe("dashboard auth route", () => {
beforeEach(() => {
vi.resetModules();
process.env = { ...ORIGINAL_ENV };
process.env.APP_BASE_URL = "https://starter.example";
process.env.DASHBOARD_BASE_URL = "https://dashboard.example";
process.env.CONVEX_SITE_URL = "https://test.convex.site";
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("rejects cross-origin POST requests", async () => {
const handlerMock = vi.fn();
vi.doMock("@convex-dev/better-auth/nextjs", () => ({
nextJsHandler: () => ({ POST: handlerMock })
}));
vi.doMock("../lib/auth", () => ({ betterAuthComponent: {} }), {
virtual: true
});
const { POST } = await import("../app/api/auth/[...all]/route");
const response = await POST(
buildRequest("https://evil.example", "cross-site")
);
expect(response.status).toBe(403);
expect(handlerMock).not.toHaveBeenCalled();
});
it("allows POST requests with same-origin metadata", async () => {
const handlerResponse = new Response(null, { status: 200 });
vi.doMock("@convex-dev/better-auth/nextjs", () => ({
nextJsHandler: () => ({ POST: () => handlerResponse })
}));
vi.doMock("../lib/auth", () => ({ betterAuthComponent: {} }), {
virtual: true
});
const { POST } = await import("../app/api/auth/[...all]/route");
const response = await POST(buildRequest(null, "same-origin"));
expect(response.status).toBe(200);
});
it("preserves auth cookies for same-origin POST", async () => {
const handlerResponse = new Response(null, {
status: 200,
headers: {
"Set-Cookie": "__Host-session=abc; Path=/; Secure; HttpOnly; SameSite=Lax"
}
});
vi.doMock("@convex-dev/better-auth/nextjs", () => ({
nextJsHandler: () => ({ POST: () => handlerResponse })
}));
vi.doMock("../lib/auth", () => ({ betterAuthComponent: {} }), {
virtual: true
});
const { POST } = await import("../app/api/auth/[...all]/route");
const response = await POST(
buildRequest("https://dashboard.example", "same-origin")
);
expect(response.status).toBe(200);
expect(response.headers.get("set-cookie")).toContain("__Host-session=");
expect(response.headers.get("set-cookie")).not.toMatch(/Domain=/i);
});
});
CSRF Protection Pattern
All API routes validate Origin and Sec-Fetch-Site headers. Tests must verify both allowed and rejected scenarios.
Middleware Testing
Middleware tests validate security headers and nonce generation:
/** @vitest-environment node */
import { NextRequest } from "next/server";
import { describe, expect, it, vi } from "vitest";
import middleware from "../middleware";
const dashboardOrigin = "http://localhost:3001";
describe("dashboard middleware", () => {
it("injects nonce-based CSP and disables caching", () => {
const response = middleware(new NextRequest(`${dashboardOrigin}/`));
const csp = response.headers.get("content-security-policy");
expect(csp).toBeTruthy();
expect(csp).toContain("'strict-dynamic'");
expect(csp).toContain("'nonce-");
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
const nonce = response.headers.get("x-nonce");
expect(nonce).toBeTruthy();
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("uses url-safe base64 nonces", () => {
const response = middleware(new NextRequest(`${dashboardOrigin}/`));
const nonce = response.headers.get("x-nonce");
expect(nonce).not.toBeNull();
expect(nonce).toMatch(/^[A-Za-z0-9_-]{22}$/);
});
it("sources entropy from crypto.getRandomValues", () => {
const spy = vi.spyOn(globalThis.crypto, "getRandomValues");
middleware(new NextRequest(`${dashboardOrigin}/`));
expect(spy).toHaveBeenCalledTimes(1);
const [buffer] = spy.mock.calls[0] ?? [];
expect(buffer).toBeInstanceOf(Uint8Array);
expect((buffer as Uint8Array).length).toBe(16);
spy.mockRestore();
});
it("mints unique nonces across many draws", () => {
const seen = new Set<string>();
const total = 1000;
for (let i = 0; i < total; i += 1) {
const response = middleware(
new NextRequest(`${dashboardOrigin}/?iteration=${i}`)
);
const nonce = response.headers.get("x-nonce");
expect(nonce).not.toBeNull();
expect(seen.has(nonce!)).toBe(false);
seen.add(nonce!);
}
expect(seen.size).toBe(total);
});
it("does not override cache headers for static assets", () => {
const response = middleware(
new NextRequest(`${dashboardOrigin}/_next/static/main.js`)
);
expect(response.headers.get("cache-control")).not.toBe("no-store");
});
});
Security Assertions
The framework provides security assertion helpers:
Environment-Specific Configuration
/** @vitest-environment node */
// API routes, middleware, server actions
import { describe, it } from "vitest";
describe("API Integration Tests", () => {
it("processes server-side logic", async () => {
// Node-specific testing
});
});
// No annotation needed - jsdom is default
// Client components, hooks
import { describe, it } from "vitest";
import { render } from "@testing-library/react";
import { TestServicesProvider } from "@workspace/app-shell/lib/test-utils";
describe("Client Component Integration", () => {
it("renders with service providers", () => {
renderWithServices(<Component />);
});
});
// Separate files for different environments
// auth-route.test.ts -> Node
// auth-form.test.tsx -> jsdom
// middleware.test.ts -> Node
// layout.test.tsx -> jsdom
Security Requirements
Every integration test must validate security headers per llms/TESTING.md
:
Dashboard App Pages
// CSP with nonce and strict-dynamic
expect(csp).toContain("'strict-dynamic'");
expect(csp).toContain("'nonce-");
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
// X-Nonce header present
expect(response.headers.get("x-nonce")).toBeTruthy();
// No caching
expect(response.headers.get("cache-control")).toBe("no-store");
// Frame protection
expect(csp).toContain("frame-ancestors 'none'");
Marketing App Pages
// CSP with unsafe-inline (no strict-dynamic)
expect(csp).toContain("'unsafe-inline'");
expect(csp).not.toContain("'strict-dynamic'");
// No X-Nonce header
expect(response.headers.get("x-nonce")).toBeFalsy();
// Caching allowed for static pages
expect(response.headers.get("cache-control")).not.toBe("no-store");
API Routes
// No CSP header
expect(response.headers.get("content-security-policy")).toBeFalsy();
// Correct Cache-Control/Vary
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("vary")).toContain("Origin");
Dependency Injection Pattern
Use renderWithServices
for component integration tests:
import { render } from "@testing-library/react";
import { TestServicesProvider } from "@workspace/app-shell/lib/test-utils";
import { mockBilling } from "tests/setup/integration.setup";
it("renders with billing service", () => {
const mockBilling = {
checkout: vi.fn(),
useCustomer: vi.fn().mockReturnValue({ customer: null, loading: false }),
attach: vi.fn(),
check: vi.fn(),
};
render(
<TestServicesProvider services={{ billing: mockBilling }}>
<BillingComponent />
</TestServicesProvider>
);
// Component has access to billing service
});
Service Override Pattern
Override only the services you need. The test harness provides sensible defaults for all services.
Real-World Example
Complete integration test demonstrating best practices:
/**
* @vitest-environment node
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
function buildRequest(
origin: string | null,
site: string | null,
pathname = "/api/auth/session"
) {
const headers: Record<string, string> = {};
if (origin) headers.origin = origin;
if (site) headers["sec-fetch-site"] = site;
return new NextRequest(`https://dashboard.example${pathname}`, {
method: "POST",
headers
});
}
describe("dashboard auth route", () => {
beforeEach(() => {
vi.resetModules();
process.env = { ...ORIGINAL_ENV };
process.env.DASHBOARD_BASE_URL = "https://dashboard.example";
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("rejects cross-origin POST requests", async () => {
const handlerMock = vi.fn();
vi.doMock("@convex-dev/better-auth/nextjs", () => ({
nextJsHandler: () => ({ POST: handlerMock })
}));
const { POST } = await import("../app/api/auth/[...all]/route");
const response = await POST(
buildRequest("https://evil.example", "cross-site")
);
expect(response.status).toBe(403);
expect(handlerMock).not.toHaveBeenCalled();
});
it("allows callback POST requests without same-origin headers", async () => {
const handlerResponse = new Response(null, { status: 200 });
vi.doMock("@convex-dev/better-auth/nextjs", () => ({
nextJsHandler: () => ({ POST: () => handlerResponse })
}));
const { POST } = await import("../app/api/auth/[...all]/route");
const response = await POST(
buildRequest(null, null, "/api/auth/callback/google")
);
expect(response.status).toBe(200);
});
});