StarterApp Docs
Testing

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);
  });
});

Next Steps