# Unit tests for components with the Next.js App Router SDK

> Do unit tests on Uniform components with Vitest and React Testing Library: setup, mock props, slots, the component resolver and client hooks.

Source: https://docs.uniform.app/docs/sdk/nextjs-app-router/testing

> **Developer Preview:**
>
> This page documents the developer preview of the SDK, version `20.81.1-alpha.25.sha-f076f9b857`.

A Uniform component is a React component. It gets all its data through props of the type `ComponentProps`. Thus, a unit test does not need a Uniform project, an API key or a network connection. You make the props in the test, and you render the component.

This page shows a setup with [Vitest](https://vitest.dev), [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) and jsdom. All the examples on this page passed with these versions:

| Package | Version |
| --- | --- |
| `next` | 16.4.0 |
| `react`, `react-dom` | 19.2.7 |
| `@uniformdev/next-app-router`, `@uniformdev/context`, `@uniformdev/richtext` | 20.81.1-alpha.25.sha-f076f9b857 |
| `vitest` | 5.0.3 |
| `@vitejs/plugin-react` | 6.1.2 |
| `@testing-library/react` | 16.3.3 |
| `@testing-library/dom` | 10.4.2 |
| `@testing-library/jest-dom` | 7.0.1 |
| `jsdom` | 30.1.2 |
| `typescript` | 7.0.2 |

## Set up Vitest

1. Install the packages:

   ```bash
   npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @types/node
   ```
2. Make `vitest.config.ts` in the project root:

`vitest.config.ts`

```ts
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./", import.meta.url)),
    },
  },
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.ts"],
    server: {
      deps: {
        // The SDK imports "next/navigation" without a file extension.
        // Vite must process the SDK, because Node.js cannot resolve this import.
        inline: [/@uniformdev\/next-app-router/],
      },
    },
  },
});
```

3. Make `vitest.setup.ts` in the project root:

`vitest.setup.ts`

```ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
  cleanup();
});
```

4. Add a `test` script to `package.json`:

   `package.json`

   ```json
   {
     "scripts": {
       "test": "vitest run"
     }
   }
   ```
5. Run the tests:

   ```bash
   npm test
   ```

The configuration has these settings:

- `@vitejs/plugin-react` compiles JSX and TypeScript.
- `resolve.alias` connects the `@/` import path to the project root. Use the same path as `paths` in `tsconfig.json`.
- `environment: "jsdom"` gives the tests a browser DOM.
- `server.deps.inline` tells Vite to process the Uniform SDK. The SDK imports `next/navigation` without a file extension. Node.js cannot resolve this import, because the `next` package has no `exports` map. Without this option, each test that imports a value from `@uniformdev/next-app-router/component` fails with the error `Cannot find module '.../node_modules/next/navigation'`. A test that imports only types from the SDK does not have this problem.
- The setup file adds the jest-dom matchers, for example `toBeInTheDocument()`. It also removes the rendered components after each test. React Testing Library does this automatically only when Vitest has `globals: true`.

> **Note:**
>
> The examples use `"jsx": "react-jsx"` and `"paths": { "@/*": ["./*"] }` in `tsconfig.json`. These are the default values of a new Next.js 16 project. `@types/node` gives the types for `node:url` in `vitest.config.ts`.

## Make mock component props

`ComponentProps` has six props: `type`, `variant`, `parameters`, `slots`, `component` and `context`. All six are required. A helper file makes the props with default values, so that each test gives only the props that it needs.

`test/uniform-props.tsx`

```tsx
import type { ComponentParameter, ComponentProps } from "@uniformdev/next-app-router/component";
import type { ReactNode } from "react";

type SlotDefinition = ComponentProps["slots"][string];
type CompositionContext = ComponentProps["context"];

// Makes a parameter object, as the SDK gives it to a component.
export const param = <T,>(
  parameterId: string,
  type: string,
  value: T,
): ComponentParameter<T> => ({ parameterId, type, value });

// Makes a slot with one item for each React node.
export const slot = (name: string, children: ReactNode[]): SlotDefinition => ({
  name,
  items: children.map((child, index) => ({
    _id: `${name}-${index}`,
    $pzCrit: undefined,
    variantId: undefined,
    component: child,
  })),
});

const defaultContext: CompositionContext = {
  _id: "composition-id",
  type: "page",
  state: 64,
  isContextualEditing: false,
  matchedRoute: "/",
  dynamicInputs: {},
  pageState: {
    compositionState: 64,
    routePath: "/",
    keys: undefined,
    releaseId: undefined,
    defaultConsent: false,
    previewMode: undefined,
    locale: undefined,
    edgeMode: false,
  },
};

// Makes the full ComponentProps object. Give only the props that the test needs.
export const createComponentProps = <
  TParameters extends Record<string, ComponentParameter> | unknown = Record<string, ComponentParameter>,
  TSlotNames extends string = string,
>(
  overrides: Partial<ComponentProps<TParameters, TSlotNames>> = {},
): ComponentProps<TParameters, TSlotNames> => ({
  type: "test-component",
  variant: undefined,
  parameters: {} as TParameters,
  slots: {} as Record<TSlotNames, SlotDefinition>,
  component: {
    _id: "component-id",
    _parentId: null,
    slotName: undefined,
    slotIndex: undefined,
  },
  context: defaultContext,
  ...overrides,
});
```

The helper uses these types from the SDK:

- **`ComponentParameter<T>`** needs `type` and `parameterId`. The `value` field is optional. The fields `locales`, `conditions`, `localesConditions`, `connectedData` and `_contextualEditing` are also optional. Use the parameter type from the component definition as `type`, for example `"text"`, `"richText"` or `"checkbox"`.
- **`SlotDefinition`** has a `name` and an `items` array. Each item has `_id`, `$pzCrit` (the personalization criteria), `variantId` and `component`. The `component` field is the rendered React node of the child component. The type permits `null` items, but `UniformSlot` does not accept them. Thus, the helper makes no `null` items.
- **`ComponentContext`** has `_id`, `_parentId`, `slotName` and `slotIndex`.
- **`CompositionContext`** has `_id`, `type`, `state`, `isContextualEditing`, `matchedRoute`, `dynamicInputs` and `pageState`. The value `64` is the published state, and `0` is the draft state.
- **`PageState`** has `compositionState`, `routePath`, `keys`, `releaseId`, `defaultConsent`, `previewMode`, `locale` and the optional `edgeMode`.

`@uniformdev/next-app-router/component` does not export the types `SlotDefinition`, `CompositionContext` and `PageState`. The helper gets them from `ComponentProps`. For a test of the preview behavior, set `context.pageState.previewMode` to `"editor"` or `"preview"`.

## Test a component that reads parameter values

This `BannerComponent` from [Components and slots in code](https://docs.uniform.app/docs/sdk/nextjs-app-router/components#parameters) reads the `value` of each parameter:

`components/banner.tsx`

```tsx
import type { ComponentParameter, ComponentProps } from "@uniformdev/next-app-router/component";

type BannerParameters = {
  title?: ComponentParameter<string>;
  linkUrl?: ComponentParameter<string>;
  isVisible?: ComponentParameter<boolean>;
};

export const BannerComponent = ({
  parameters: { title, linkUrl, isVisible },
}: ComponentProps<BannerParameters>) => {
  if (isVisible?.value === false) return null;

  return (
    <a href={linkUrl?.value ?? "#"}>
      <h2>{title?.value ?? "Default title"}</h2>
    </a>
  );
};
```

The test gives the parameters to the component. It also does a check of the default values when the parameters are missing:

`components/banner.test.tsx`

```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { createComponentProps, param } from "@/test/uniform-props";
import { BannerComponent } from "./banner";

describe("BannerComponent", () => {
  it("renders the title and the link", () => {
    render(
      <BannerComponent
        {...createComponentProps({
          type: "banner",
          parameters: {
            title: param("title", "text", "Summer sale"),
            linkUrl: param("linkUrl", "text", "/sale"),
          },
        })}
      />,
    );

    expect(screen.getByRole("heading", { name: "Summer sale" })).toBeInTheDocument();
    expect(screen.getByRole("link")).toHaveAttribute("href", "/sale");
  });

  it("uses the default values when the parameters are missing", () => {
    render(<BannerComponent {...createComponentProps({ type: "banner" })} />);

    expect(screen.getByRole("heading", { name: "Default title" })).toBeInTheDocument();
    expect(screen.getByRole("link")).toHaveAttribute("href", "#");
  });

  it("renders nothing when isVisible is false", () => {
    const { container } = render(
      <BannerComponent
        {...createComponentProps({
          type: "banner",
          parameters: { isVisible: param("isVisible", "checkbox", false) },
        })}
      />,
    );

    expect(container).toBeEmptyDOMElement();
  });
});
```

## Test a component with UniformText and UniformRichText

`UniformText` and `UniformRichText` render in jsdom without changes. You do not have to replace them with mocks. This `HeroComponent` is the example from [Components and slots in code](https://docs.uniform.app/docs/sdk/nextjs-app-router/components#parameters):

`components/hero.tsx`

```tsx
import {
  type ComponentParameter,
  type ComponentProps,
  UniformRichText,
  UniformText,
} from "@uniformdev/next-app-router/component";
import type { ParameterRichTextValue } from "@uniformdev/richtext";

type HeroParameters = {
  title?: ComponentParameter<string>;
  description?: ComponentParameter<ParameterRichTextValue>;
};

export const HeroComponent = ({
  parameters: { title, description },
  component,
}: ComponentProps<HeroParameters>) => (
  <section>
    {title ? <UniformText component={component} parameter={title} as="h1" placeholder="Enter a title" /> : null}
    {description ? (
      <UniformRichText component={component} parameter={description} placeholder="Enter a description" />
    ) : null}
  </section>
);
```

`components/hero.test.tsx`

```tsx
import { render, screen } from "@testing-library/react";
import type { ParameterRichTextValue } from "@uniformdev/richtext";
import { describe, expect, it } from "vitest";

import { createComponentProps, param } from "@/test/uniform-props";
import { HeroComponent } from "./hero";

const description: ParameterRichTextValue = {
  root: {
    type: "root",
    version: 1,
    children: [
      {
        type: "paragraph",
        version: 1,
        children: [{ type: "text", version: 1, text: "Fresh deals every week." }],
      },
    ],
  },
};

describe("HeroComponent", () => {
  it("renders the title and the description", () => {
    render(
      <HeroComponent
        {...createComponentProps({
          type: "hero",
          parameters: {
            title: param("title", "text", "Welcome"),
            description: param("description", "richText", description),
          },
        })}
      />,
    );

    expect(screen.getByRole("heading", { level: 1, name: "Welcome" })).toBeInTheDocument();
    expect(screen.getByText("Fresh deals every week.")).toBeInTheDocument();
  });

  it("renders no heading when the title parameter is missing", () => {
    render(<HeroComponent {...createComponentProps({ type: "hero" })} />);

    expect(screen.queryByRole("heading")).not.toBeInTheDocument();
  });
});
```

`UniformText` is a client component. It calls `useQuirks`, which reads the Uniform Context from `window.__UNIFORM_CONTEXT__`. In a test, this value is `undefined`. Then `useQuirks` returns empty quirks, and `UniformText` renders the `value` of the parameter.

`UniformText` applies conditional values only when the quirks match. To do a test of a conditional value, replace `useQuirks` with a mock. Refer to [Test a client component that uses hooks](#test-a-client-component-that-uses-hooks).

> **Warning:**
>
> `UniformText` renders nothing when the `type` of the parameter is not `"text"`. If the text does not show in your test, make sure that the mock parameter has `type: "text"`.

## Test a component with slots

`UniformSlot` renders the `component` of each slot item. Give React nodes to the `slot` helper, and do a check of where they show. This `PageComponent` is the example from [Components and slots in code](https://docs.uniform.app/docs/sdk/nextjs-app-router/components#uniform-slot):

`components/page.tsx`

```tsx
import { type ComponentProps, UniformSlot } from "@uniformdev/next-app-router/component";

type PageSlots = "header" | "content" | "footer";

export const PageComponent = ({ slots }: ComponentProps<unknown, PageSlots>) => (
  <>
    <header>
      <UniformSlot slot={slots.header} />
    </header>
    <main>
      <UniformSlot slot={slots.content} />
    </main>
    <footer>
      <UniformSlot slot={slots.footer} />
    </footer>
  </>
);
```

`components/page.test.tsx`

```tsx
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { createComponentProps, slot } from "@/test/uniform-props";
import { PageComponent } from "./page";

describe("PageComponent", () => {
  it("renders the items of each slot in the correct area", () => {
    render(
      <PageComponent
        {...createComponentProps({
          type: "page",
          slots: {
            header: slot("header", [<nav key="nav">Menu</nav>]),
            content: slot("content", [<p key="a">First</p>, <p key="b">Second</p>]),
            footer: slot("footer", []),
          },
        })}
      />,
    );

    const main = screen.getByRole("main");
    expect(within(main).getAllByText(/First|Second/)).toHaveLength(2);
    expect(within(screen.getByRole("banner")).getByText("Menu")).toBeInTheDocument();
    expect(screen.getByRole("contentinfo")).toBeEmptyDOMElement();
  });
});
```

In a unit test, the slot items are the nodes that you give. The SDK does not resolve the child components. Thus, the test does a check of the layout of the component only. To do a test of a child component, do a separate test on that component.

## Test the component resolver

`resolveComponent` is a function. Call it with a component instance that has only a `type`, and compare the result with the expected React component. This is the resolver from [Step 7](https://docs.uniform.app/docs/sdk/nextjs-app-router/tutorial#step-13-map-uniform-types-to-react-components):

`components/resolveComponent.tsx`

```tsx
import type { ResolveComponentFunction, ResolveComponentResult } from "@uniformdev/next-app-router";
import type { ComponentProps } from "@uniformdev/next-app-router/component";

import { HeroComponent } from "./hero";
import { PageComponent } from "./page";

const componentMap: Record<string, ResolveComponentResult["component"]> = {
  page: PageComponent,
  hero: HeroComponent,
};

const NotFound = ({ type }: ComponentProps) => <div>Component not found: {type}</div>;

export const resolveComponent: ResolveComponentFunction = ({ component }) => ({
  component: componentMap[component.type] ?? NotFound,
});
```

`components/resolveComponent.test.tsx`

```tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { createComponentProps } from "@/test/uniform-props";
import { HeroComponent } from "./hero";
import { PageComponent } from "./page";
import { resolveComponent } from "./resolveComponent";

describe("resolveComponent", () => {
  it.each([
    ["page", PageComponent],
    ["hero", HeroComponent],
  ])("maps the type %s to its React component", (type, expected) => {
    expect(resolveComponent({ component: { type } }).component).toBe(expected);
  });

  it("returns the fallback for an unknown type", () => {
    const { component: Fallback } = resolveComponent({ component: { type: "carousel" } });

    expect(Fallback).not.toBeNull();
    if (!Fallback) return;

    render(<Fallback {...createComponentProps({ type: "carousel" })} />);
    expect(screen.getByText("Component not found: carousel")).toBeInTheDocument();
  });
});
```

The fallback component is not exported. Thus, the test renders it and does a check of its text.

## Test a client component that uses hooks

`useQuirks` and `useUniformContext` read the Uniform Context in the browser. In a test, there is no Context. Replace the hooks with mocks to set the values. These components are from [Client-side context](https://docs.uniform.app/docs/sdk/nextjs-app-router/client-context):

`components/location-banner.tsx`

```tsx
"use client";

import { useQuirks } from "@uniformdev/next-app-router/component";

export const LocationBanner = () => {
  const quirks = useQuirks();
  return <div>Country: {quirks?.country ?? "Unknown"}</div>;
};
```

`components/quirk-button.tsx`

```tsx
"use client";

import { useUniformContext } from "@uniformdev/next-app-router/component";

export const QuirkButton = () => {
  const { context } = useUniformContext();

  return (
    <button
      disabled={!context}
      onClick={() => context?.update({ quirks: { country: "Canada" } })}
    >
      Set the country to Canada
    </button>
  );
};
```

`vi.mock` with `importOriginal` replaces only the two hooks. All other exports, for example `UniformText` and `UniformSlot`, stay real:

`components/client-hooks.test.tsx`

```tsx
import { fireEvent, render, screen } from "@testing-library/react";
import type { Context } from "@uniformdev/context";
import { useQuirks, useUniformContext } from "@uniformdev/next-app-router/component";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { LocationBanner } from "./location-banner";
import { QuirkButton } from "./quirk-button";

// Replace only the hooks. UniformText, UniformSlot and the other exports stay real.
vi.mock("@uniformdev/next-app-router/component", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@uniformdev/next-app-router/component")>()),
  useQuirks: vi.fn(),
  useUniformContext: vi.fn(),
}));

beforeEach(() => {
  vi.mocked(useQuirks).mockReset();
  vi.mocked(useUniformContext).mockReset();
});

describe("LocationBanner", () => {
  it("shows the country quirk", () => {
    vi.mocked(useQuirks).mockReturnValue({ country: "Canada" });

    render(<LocationBanner />);
    expect(screen.getByText("Country: Canada")).toBeInTheDocument();
  });

  it("shows Unknown when the quirk is not set", () => {
    vi.mocked(useQuirks).mockReturnValue({});

    render(<LocationBanner />);
    expect(screen.getByText("Country: Unknown")).toBeInTheDocument();
  });
});

describe("QuirkButton", () => {
  it("is disabled until the browser Context is ready", () => {
    vi.mocked(useUniformContext).mockReturnValue({ context: undefined });

    render(<QuirkButton />);
    expect(screen.getByRole("button")).toBeDisabled();
  });

  it("updates the quirk on click", () => {
    const update = vi.fn().mockResolvedValue(undefined);
    vi.mocked(useUniformContext).mockReturnValue({
      context: { update } as unknown as Context,
    });

    render(<QuirkButton />);
    fireEvent.click(screen.getByRole("button"));

    expect(update).toHaveBeenCalledWith({ quirks: { country: "Canada" } });
  });
});
```

Vitest moves the `vi.mock` call to the top of the file. Thus, the import of `useQuirks` in the test file gets the mock. The test casts the mock Context to the `Context` type from `@uniformdev/context`, because the test gives only the `update` function.

## Test an async server component

React Testing Library cannot render an async server component. React in the browser does not support async components. When you give an async component to `render`, React logs the error `... is an async Client Component. Only Server Components can be async at the moment.` and renders nothing. The [Next.js Vitest guide](https://nextjs.org/docs/app/guides/testing/vitest) also tells you to use end-to-end tests for async server components.

For a simple async component, you can call the component as a function. Then render the element that it returns. This method works only when the component and its children are not async, after the first `await`.

`components/product-list.tsx`

```tsx
import type { ComponentParameter, ComponentProps } from "@uniformdev/next-app-router/component";

type ProductListParameters = {
  category?: ComponentParameter<string>;
};

type Product = { id: string; name: string };

export const getProducts = async (category: string): Promise<Product[]> => {
  const response = await fetch(`https://api.example.com/products?category=${category}`);
  return response.json();
};

export const ProductListComponent = async ({
  parameters: { category },
}: ComponentProps<ProductListParameters>) => {
  const products = await getProducts(category?.value ?? "all");

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
};
```

`components/product-list.test.tsx`

```tsx
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { createComponentProps, param } from "@/test/uniform-props";
import { ProductListComponent } from "./product-list";

afterEach(() => {
  vi.unstubAllGlobals();
});

describe("ProductListComponent", () => {
  it("renders the products of the category", async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      Response.json([
        { id: "1", name: "Tent" },
        { id: "2", name: "Lamp" },
      ]),
    );
    vi.stubGlobal("fetch", fetchMock);

    // Call the async component as a function. Then render the element that it returns.
    const element = await ProductListComponent(
      createComponentProps({
        type: "productList",
        parameters: { category: param("category", "text", "camping") },
      }),
    );
    render(element);

    expect(fetchMock).toHaveBeenCalledWith("https://api.example.com/products?category=camping");
    expect(screen.getAllByRole("listitem").map((item) => item.textContent)).toEqual(["Tent", "Lamp"]);
  });
});
```

> **Note:**
>
> This method does not work with Next.js server APIs, for example `cookies()` or `headers()`. It also does not work for an async child component. For these components, use an end-to-end test.

## End-to-end test for personalization

> **Warning:**
>
> This section is a sketch only. We did not run this test. Change the URL, the query string and the text to the values in your project.

A unit test cannot do a check of personalization, because the middleware and the Uniform Context select the variant. Use an end-to-end test with [Playwright](https://playwright.dev) on an app that runs, for example on `next dev`.

For example, a signal in your project matches the query string `utm_campaign=summer`. The personalized hero shows the text "Summer sale" for this signal:

`e2e/personalization.spec.ts`

```ts
import { expect, test } from "@playwright/test";

test("shows the summer variant for the summer campaign", async ({ page }) => {
  await page.goto("/?utm_campaign=summer");
  await expect(page.getByRole("heading", { level: 1 })).toHaveText("Summer sale");
});

test("shows the default variant without the campaign", async ({ page }) => {
  await page.goto("/");
  await expect(page.getByRole("heading", { level: 1 })).not.toHaveText("Summer sale");
});
```

Each Playwright test starts with a new browser context. Thus, the quirks and scores of one test do not go to the next test. For how the SDK selects variants, refer to [Personalization and A/B tests](https://docs.uniform.app/docs/sdk/nextjs-app-router/personalization).
