Type something to search...
Unit Testing Next.js Components with Jest and React Testing Library

Unit Testing Next.js Components with Jest and React Testing Library

Unit tests are the fastest feedback loop you can have on a Next.js project. They run in milliseconds, don't need a browser or a running server, and tell you exactly which component broke. Jest and React Testing Library are still the most common pairing for this job, and Next.js ships a preset (next/jest) that removes most of the configuration pain.

This guide sets up Jest in a Next.js 16 App Router project and then walks through the tests you'll write most often: rendering components, simulating user interaction, mocking next/navigation, testing components that call Server Actions, handling Server Components, and snapshot tests. I'll also cover the limits, mainly async Server Components, and where end-to-end tests should take over.

Installing the Packages

Install Jest, the jsdom environment, React Testing Library, and the type packages:

npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event ts-node @types/jest

What each one does:

  • jest: the test runner and assertion library.
  • jest-environment-jsdom: a simulated browser DOM, so components can render outside a real browser.
  • @testing-library/react and @testing-library/dom: render components and query the DOM the way a user would.
  • @testing-library/jest-dom: extra matchers like toBeInTheDocument() and toHaveValue().
  • @testing-library/user-event: realistic user interactions (typing, clicking, tabbing).
  • ts-node: lets Jest read a TypeScript config file (jest.config.ts).
  • @types/jest: types for describe, it, expect, and jest.fn().

Configuring Jest with next/jest

Create jest.config.ts at the project root:

// jest.config.ts
import type { Config } from "jest";
import nextJest from "next/jest.js";

const createJestConfig = nextJest({
  // Path to your Next.js app, used to load next.config and .env files
  dir: "./",
});

const config: Config = {
  coverageProvider: "v8",
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/$1",
  },
};

// Exported this way so next/jest can load the async Next.js config
export default createJestConfig(config);

next/jest does a lot behind the scenes:

  • Transforms TypeScript and JSX with the Next.js compiler (SWC), so you don't need Babel.
  • Auto-mocks CSS, CSS Modules, image imports, and next/font.
  • Loads .env files into process.env.
  • Ignores node_modules and .next when resolving tests.

The moduleNameMapper entry maps the @/ path alias to the project root. Adjust it to match the paths in your tsconfig.json; if your code lives in src/, use "<rootDir>/src/$1".

Then create the setup file, which runs before each test file:

// jest.setup.ts
import "@testing-library/jest-dom";

This registers the jest-dom matchers globally. Because the file is TypeScript and imports the package, the matcher types are picked up automatically.

Finally, add scripts to package.json:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Where to Put Test Files

Jest finds files ending in .test.tsx or .spec.tsx, plus anything in a __tests__ folder. Both of these layouts work:

# Separate folder
__tests__/
  counter.test.tsx
components/
  counter.tsx

# Colocated
components/
  counter.tsx
  counter.test.tsx

Colocating tests inside the app directory is safe too. Only special files like page.tsx and route.ts become routes, so app/dashboard/stats.test.tsx won't be served. I prefer colocation, because the test sits next to what it tests and moves with it.

Your First Test

Start with a simple presentational component:

// components/greeting.tsx
export function Greeting({ name }: { name?: string }) {
  return <h1>{name ? `Hello, ${name}!` : "Hello, stranger!"}</h1>;
}
// components/greeting.test.tsx
import { render, screen } from "@testing-library/react";
import { Greeting } from "./greeting";

describe("Greeting", () => {
  it("greets the user by name", () => {
    render(<Greeting name="Maria" />);
    expect(
      screen.getByRole("heading", { level: 1, name: "Hello, Maria!" }),
    ).toBeInTheDocument();
  });

  it("falls back when no name is given", () => {
    render(<Greeting />);
    expect(screen.getByRole("heading")).toHaveTextContent("Hello, stranger!");
  });
});

Run npm test, and both tests should pass.

render mounts the component into a jsdom container. screen exposes queries bound to document.body. Note that this component has no "use client" directive. It's a synchronous Server Component by default, but since it doesn't use any server-only APIs, it renders fine in a test.

Choosing the Right Query

React Testing Library encourages querying the way users find things. The recommended priority is roughly:

  1. getByRole with a name: buttons, headings, links, inputs. This also catches accessibility problems.
  2. getByLabelText: form fields.
  3. getByPlaceholderText, getByText, getByDisplayValue.
  4. getByTestId: a last resort when nothing else identifies the element.

Each query also comes in three variants:

VariantNo matchUse it when
getBy...ThrowsThe element should be there right now
queryBy...Returns nullAsserting that something is not there
findBy...Rejects after a timeoutThe element appears asynchronously

Testing Client Components and User Events

Here's a Client Component with state:

// components/counter.tsx
"use client";

import { useState } from "react";

export function Counter({
  initial = 0,
  max = 10,
}: {
  initial?: number;
  max?: number;
}) {
  const [count, setCount] = useState(initial);

  return (
    <div>
      <p aria-live="polite">Count: {count}</p>
      <button onClick={() => setCount((c) => c - 1)} disabled={count <= 0}>
        Decrement
      </button>
      <button onClick={() => setCount((c) => c + 1)} disabled={count >= max}>
        Increment
      </button>
    </div>
  );
}
// components/counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./counter";

describe("Counter", () => {
  it("increments and decrements", async () => {
    const user = userEvent.setup();
    render(<Counter initial={1} />);

    await user.click(screen.getByRole("button", { name: "Increment" }));
    expect(screen.getByText("Count: 2")).toBeInTheDocument();

    await user.click(screen.getByRole("button", { name: "Decrement" }));
    await user.click(screen.getByRole("button", { name: "Decrement" }));
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });

  it("disables buttons at the limits", () => {
    render(<Counter initial={10} max={10} />);
    expect(screen.getByRole("button", { name: "Increment" })).toBeDisabled();
    expect(screen.getByRole("button", { name: "Decrement" })).toBeEnabled();
  });
});

userEvent.setup() creates a user session, and every interaction is awaited. Unlike the lower-level fireEvent, user-event dispatches the full sequence of events a real user would trigger (pointer down, focus, mouse up, click), so behavior that depends on focus or keyboard handling is exercised properly. React state updates triggered by these events are wrapped in act for you.

Mocking next/navigation

Components that use useRouter, usePathname, or useSearchParams will throw in a unit test, because there's no App Router context mounted. Mock the module.

Here's a search input that updates the URL:

// components/search-input.tsx
"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";

export function SearchInput() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [value, setValue] = useState(searchParams.get("q") ?? "");

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const params = new URLSearchParams(searchParams.toString());
    if (value) params.set("q", value);
    else params.delete("q");
    router.push(`${pathname}?${params.toString()}`);
  }

  return (
    <form role="search" onSubmit={onSubmit}>
      <label htmlFor="q">Search</label>
      <input id="q" value={value} onChange={(e) => setValue(e.target.value)} />
      <button type="submit">Go</button>
    </form>
  );
}

And the test:

// components/search-input.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { SearchInput } from "./search-input";

jest.mock("next/navigation", () => ({
  useRouter: jest.fn(),
  usePathname: jest.fn(),
  useSearchParams: jest.fn(),
}));

const push = jest.fn();

beforeEach(() => {
  push.mockClear();
  jest.mocked(useRouter).mockReturnValue({
    push,
    replace: jest.fn(),
    refresh: jest.fn(),
    back: jest.fn(),
    forward: jest.fn(),
    prefetch: jest.fn(),
  } as unknown as ReturnType<typeof useRouter>);
  jest.mocked(usePathname).mockReturnValue("/products");
  jest
    .mocked(useSearchParams)
    .mockReturnValue(
      new URLSearchParams("q=boots&sort=price") as unknown as ReturnType<
        typeof useSearchParams
      >,
    );
});

it("pre-fills the input from the URL", () => {
  render(<SearchInput />);
  expect(screen.getByLabelText("Search")).toHaveValue("boots");
});

it("pushes the new query and keeps other params", async () => {
  const user = userEvent.setup();
  render(<SearchInput />);

  const input = screen.getByLabelText("Search");
  await user.clear(input);
  await user.type(input, "sandals");
  await user.click(screen.getByRole("button", { name: "Go" }));

  expect(push).toHaveBeenCalledWith("/products?q=sandals&sort=price");
});

A few notes on this pattern:

  • jest.mock calls are hoisted above imports, so the component receives the mocked hooks.
  • jest.mocked() gives you a typed mock without manual casts on the function itself. The return values still need a cast, because the real router type has more members than the test cares about.
  • A plain URLSearchParams is a good stand-in for the read-only object useSearchParams returns, since it has the same read methods.
  • Resetting in beforeEach keeps tests independent.

If many components need this, move the mock into a shared helper or into jest.setup.ts so you don't repeat it in every file.

Testing Components That Call Server Actions

A Server Action is a function marked with "use server". In a unit test you don't want to run it (it may hit a database), and Jest can't execute the server boundary anyway. Mock the module that exports it, and test that the component calls it correctly and handles its result.

// app/newsletter/actions.ts
"use server";

export async function subscribe(
  email: string,
): Promise<{ ok: boolean; message: string }> {
  // In a real app: validate, write to the database, send a confirmation email
  if (!email.includes("@")) return { ok: false, message: "Invalid email" };
  return { ok: true, message: "Thanks for subscribing!" };
}
// app/newsletter/newsletter-form.tsx
"use client";

import { useState, useTransition } from "react";
import { subscribe } from "./actions";

export function NewsletterForm() {
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState<string | null>(null);
  const [isPending, startTransition] = useTransition();

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    startTransition(async () => {
      const result = await subscribe(email);
      setMessage(result.message);
      if (result.ok) setEmail("");
    });
  }

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit" disabled={isPending}>
        {isPending ? "Subscribing..." : "Subscribe"}
      </button>
      {message && <p role="status">{message}</p>}
    </form>
  );
}
// app/newsletter/newsletter-form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NewsletterForm } from "./newsletter-form";
import { subscribe } from "./actions";

jest.mock("./actions", () => ({
  subscribe: jest.fn(),
}));

const mockSubscribe = jest.mocked(subscribe);

beforeEach(() => mockSubscribe.mockReset());

it("submits the email and shows the success message", async () => {
  mockSubscribe.mockResolvedValue({
    ok: true,
    message: "Thanks for subscribing!",
  });
  const user = userEvent.setup();
  render(<NewsletterForm />);

  await user.type(screen.getByLabelText("Email"), "maria@example.com");
  await user.click(screen.getByRole("button", { name: "Subscribe" }));

  expect(await screen.findByRole("status")).toHaveTextContent(
    "Thanks for subscribing!",
  );
  expect(mockSubscribe).toHaveBeenCalledWith("maria@example.com");
  expect(screen.getByLabelText("Email")).toHaveValue("");
});

it("keeps the email when the server rejects it", async () => {
  mockSubscribe.mockResolvedValue({ ok: false, message: "Invalid email" });
  const user = userEvent.setup();
  render(<NewsletterForm />);

  await user.type(screen.getByLabelText("Email"), "maria@example.com");
  await user.click(screen.getByRole("button", { name: "Subscribe" }));

  expect(await screen.findByRole("status")).toHaveTextContent("Invalid email");
  expect(screen.getByLabelText("Email")).toHaveValue("maria@example.com");
});

findByRole waits for the status message to appear after the async transition finishes. The test verifies the component's contract (what it sends and how it reacts) without depending on the action's implementation. Test the action's own logic separately, as a plain async function, with its dependencies (database client, email sender) mocked.

Mocking fetch

jsdom doesn't provide fetch, so a Client Component that calls it will fail unless you supply one. For a quick unit test, assign a mock:

// components/weather.test.tsx (excerpt)
beforeEach(() => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ temperature: 21 }),
  }) as unknown as typeof fetch;
});

For larger suites with many endpoints, Mock Service Worker (msw) intercepts requests at the network level and lets you define handlers once, which scales better than per-test fetch mocks.

Testing Server Components

Synchronous Server Components are just functions that return JSX, so they render like any other component, as long as they don't import server-only modules. If they do (a database client, server-only, next/headers), mock those imports.

Async Server Components are the hard case. The Next.js docs are explicit: Jest doesn't support them yet, and the recommendation is to cover them with end-to-end tests. You'll see a workaround in the wild that awaits the component as a function and renders the result:

// app/posts/post-list.test.tsx
import { render, screen } from "@testing-library/react";
import { PostList } from "./post-list";
import { getPosts } from "@/lib/posts";

jest.mock("@/lib/posts", () => ({
  getPosts: jest.fn(),
}));

it("renders posts returned by the data layer", async () => {
  jest.mocked(getPosts).mockResolvedValue([
    { id: "1", title: "First post" },
    { id: "2", title: "Second post" },
  ]);

  render(await PostList());

  expect(screen.getAllByRole("listitem")).toHaveLength(2);
});

This works for simple components that only await data and return markup. It breaks as soon as the tree contains nested async components, uses cookies() or headers(), or relies on Suspense and streaming behavior. Treat it as a convenience for leaf components, not a strategy. A better long-term approach is to keep data fetching thin and put logic in plain functions (formatting, filtering, permission checks) that you can unit test directly, then test the full page with Playwright. The end-to-end testing with Playwright guide covers that side.

Snapshot Tests

Snapshots record a component's rendered output and fail when it changes:

// components/greeting.snapshot.test.tsx
import { render } from "@testing-library/react";
import { Greeting } from "./greeting";

it("renders unchanged", () => {
  const { container } = render(<Greeting name="Maria" />);
  expect(container).toMatchSnapshot();
});

The first run writes a __snapshots__ file. Later runs compare against it, and jest -u updates it when a change is intentional.

Snapshots are cheap to write and easy to abuse. Large snapshots of whole pages fail on every markup tweak, people learn to press -u without reading the diff, and the test stops protecting anything. Keep them small, for stable components where any output change genuinely deserves a look.

Common Setup Problems

"Cannot use import statement outside a module." A dependency ships ESM only and Jest isn't transforming it. Add it to transformIgnorePatterns so it gets compiled, for example transformIgnorePatterns: ["/node_modules/(?!(some-esm-package)/)"]. Note that next/jest sets its own ignore patterns, so check the merged config if the override doesn't apply.

"invariant expected app router to be mounted." A component (or something it renders, like a Link wrapper) calls a next/navigation hook. Mock the module as shown above.

Path aliases don't resolve. Make sure moduleNameMapper matches your tsconfig.json paths.

Environment variables are missing. next/jest loads .env files, but .env.local isn't loaded in the test environment. Use .env.test for test values.

TextEncoder or Request is not defined. Some libraries expect Web APIs that jsdom doesn't provide. Polyfill them in jest.setup.ts, or run those specific tests in the Node environment with a @jest-environment node docblock comment at the top of the file.

What to Unit Test (and What Not To)

Unit tests are most valuable for:

  • Client Components with real logic: forms, filters, wizards, conditional rendering.
  • Custom hooks (with renderHook from @testing-library/react).
  • Pure utilities: formatting, validation schemas, permission checks.

They're less useful for:

  • Pages that are mostly layout and data fetching.
  • Async Server Components.
  • Routing, caching, and middleware or proxy.ts behavior, which depend on the framework running for real.

Cover the second list with end-to-end tests. If you're starting a new project and prefer a Vite-based runner, the Vitest guide uses the same Testing Library APIs with a different engine.

Conclusion

With next/jest, setting up Jest in a Next.js project comes down to one config file, one setup file, and a few dev dependencies. From there, React Testing Library pushes you to test components the way users use them: find elements by role and label, interact with user-event, and assert on what's visible.

Mock next/navigation for components that use routing hooks, mock Server Action modules to test the client side of a form, and keep async Server Components for end-to-end tests. That split gives you a fast, reliable unit suite that catches most regressions before they reach a browser.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading