This feature is in developer preview. Use with caution as it may change unexpectedly. For more information, contact us.
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, React Testing Library and jsdom. All the examples on this page passed with these versions:
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/],
},
},
},
});
Make vitest.setup.ts in the project root:
vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
Add a test script to package.json:
package.json
{
"scripts": {
"test": "vitest run"
}
}
Run the tests:
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.
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
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".
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
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:
components/hero.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
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.
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".
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:
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.
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:
components/resolveComponent.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
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.
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:
"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
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.
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 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.
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.
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 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
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.