Interactive Testing in Storybook with Next.js and Sitecore XM Cloud
Enhancing Component Reliability: Interactive Testing in Storybook with Sitecore XM Cloud
Start typing to search...
Interactive testing is a critical aspect of modern front-end development, especially for dynamic projects like Sitecore-based applications using Next.js XM Cloud. In this blog, we’ll dive into how you can leverage Storybook to implement interactive testing, enabling seamless validation of UI behaviors for forms and interactive elements such as navigation menus.
This guide is designed to introduce readers to the fundamentals of interactive testing within a Sitecore Next.js XM Cloud project using Storybook. We’ll focus on building robust UI tests that simulate real user interactions, ensuring components look great and function as intended.
Interactive testing bridges the gap between design and functionality. It ensures that UI components respond correctly to user actions, such as form submissions or menu hover interactions, making them reliable in real-world use cases. Interactive testing is crucial for maintaining high-quality standards for headless CMS implementations like Sitecore XM Cloud, where components often rely on dynamic data and state management. It reduces the risk of broken user experiences and helps developers catch bugs early in the development process.
Form Testing
Verify that forms handle user input, validation, and submission correctly while gracefully displaying success or error states.
Navigation Menu Hover Interactions
Test hover interactions for navigation menus, ensuring they respond dynamically by displaying dropdowns, highlighting active states, and maintaining accessibility.
To enable interactive testing in Storybook, make sure to import the following utilities:
import { within, userEvent, expect } from "@storybook/test";
These utilities are essential for writing and running interactive tests directly within your Storybook setup.
Verify that the form fields handle user input and submission correctly, including error states, while ensuring the RegisterForm component behaves as expected in various scenarios.
The RegisterForm component has been implemented with the following features:
onSubmit callback with valid input data.Here’s the component:
// components/RegisterForm.tsx
import React, { useState, useEffect } from "react";
type RegisterFormProps = {
onSubmit: (data: { name: string; email: string; password: string }) => void;
};
export default function RegisterForm({ onSubmit }: RegisterFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errors, setErrors] = useState({ name: "", email: "", password: "" });
useEffect(() => {
// Clear name error when name changes
if (name.trim() !== "") {
setErrors((prev) => ({ ...prev, name: "" }));
}
}, [name]);
useEffect(() => {
// Clear email error when email changes
if (email.trim() !== "" && /\S+@\S+\.\S+/.test(email)) {
setErrors((prev) => ({ ...prev, email: "" }));
}
}, [email]);
useEffect(() => {
// Clear password error when password changes
if (password.length >= 6) {
setErrors((prev) => ({ ...prev, password: "" }));
}
}, [password]);
const validateForm = () => {
let isValid = true;
const newErrors = { name: "", email: "", password: "" };
if (name.trim() === "") {
newErrors.name = "Name is required";
isValid = false;
}
if (email.trim() === "") {
newErrors.email = "Email is required";
isValid = false;
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = "Email is invalid";
isValid = false;
}
if (password.length < 6) {
newErrors.password = "Password must be at least 6 characters long";
isValid = false;
}
setErrors(newErrors);
return isValid;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit({ name, email, password });
}
};
return (
<form
onSubmit={handleSubmit}
className="max-w-md w- mx-auto p-8 bg-white rounded-lg shadow-md"
>
<div className="mb-6">
<label
htmlFor="name"
className="block mb-2 text-sm font-medium text-gray-700"
>
Name:
</label>
<input
id="name"
data-testid="name-input"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors
${
errors.name
? "border-red-500 bg-red-50 focus:ring-red-500"
: "border-gray-300"
}`}
/>
{errors.name && (
<p className="mt-2 text-sm text-red-600">{errors.name}</p>
)}
</div>
<div className="mb-6">
<label
htmlFor="email"
className="block mb-2 text-sm font-medium text-gray-700"
>
Email:
</label>
<input
id="email"
data-testid="email-input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors
${
errors.email
? "border-red-500 bg-red-50 focus:ring-red-500"
: "border-gray-300"
}`}
/>
{errors.email && (
<p className="mt-2 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div className="mb-6">
<label
htmlFor="password"
className="block mb-2 text-sm font-medium text-gray-700"
>
Password:
</label>
<input
id="password"
data-testid="password-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors
${
errors.password
? "border-red-500 bg-red-50 focus:ring-red-500"
: "border-gray-300"
}`}
/>
{errors.password && (
<p className="mt-2 text-sm text-red-600">{errors.password}</p>
)}
</div>
<button
type="submit"
data-testid="submit-button"
className="w-full px-4 py-2 text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
>
Register
</button>
</form>
);
}
A story is created in RegisterForm.stories.tsx to showcase and test the RegisterForm component.
export default {
title: "Example/RegisterForm",
component: RegisterForm,
} as Meta<typeof RegisterForm>;
const Template: StoryFn<typeof RegisterForm> = (args) => (
<RegisterForm {...args} />
);
export const Default = Template.bind({});
Default.args = {
onSubmit: (data) => {
console.log("Form submitted", data);
},
};
Interactive tests are implemented using Storybook's testing capabilities to simulate user interactions with the Sitecore form. Here are the test cases:
a. Filled Form
Simulates a user filling out the form and submitting it successfully.
FilledForm.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Fill out the fields
await userEvent.type(canvas.getByTestId("name-input"), "John Doe");
await userEvent.type(canvas.getByTestId("email-input"), "[email protected]");
await userEvent.type(canvas.getByTestId("password-input"), "password123");
// Submit the form
await userEvent.click(canvas.getByTestId("submit-button"));
// Verify no error messages appear
await expect(canvas.queryByText("Name is required")).not.toBeInTheDocument();
};
b. Submit Empty Form
Simulates a user attempting to submit an empty form and checks for error messages.
SubmitEmptyForm.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Submit the form without filling it
await userEvent.click(canvas.getByTestId("submit-button"));
// Verify error messages
await waitFor(() => {
expect(canvas.getByText("Name is required")).toBeInTheDocument();
expect(canvas.getByText("Email is required")).toBeInTheDocument();
expect(
canvas.getByText("Password must be at least 6 characters long")
).toBeInTheDocument();
});
};
c. Submit with Errors
Tests error handling by intentionally providing invalid data and then correcting it.
SubmitWithErrors.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Trigger validation errors
await userEvent.click(canvas.getByTestId("submit-button"));
await waitFor(() => {
expect(canvas.getByText("Name is required")).toBeInTheDocument();
});
// Correct the name
await userEvent.type(canvas.getByTestId("name-input"), "John Doe");
await waitFor(() => {
expect(canvas.queryByText("Name is required")).not.toBeInTheDocument();
});
// Submit with other errors
await userEvent.type(canvas.getByTestId("email-input"), "invalid-email");
await userEvent.type(canvas.getByTestId("password-input"), "short");
await userEvent.click(canvas.getByTestId("submit-button"));
// Verify remaining errors
await waitFor(() => {
expect(canvas.getByText("Email is invalid")).toBeInTheDocument();
expect(
canvas.getByText("Password must be at least 6 characters long")
).toBeInTheDocument();
});
// Correct all fields
await userEvent.clear(canvas.getByTestId("email-input"));
await userEvent.type(canvas.getByTestId("email-input"), "[email protected]");
await userEvent.clear(canvas.getByTestId("password-input"));
await userEvent.type(canvas.getByTestId("password-input"), "validpassword123");
// Submit again and verify no errors
await userEvent.click(canvas.getByTestId("submit-button"));
await waitFor(() => {
expect(canvas.queryByText("Email is invalid")).not.toBeInTheDocument();
});
};
In our interactive testing setup, we defined three main stories: FilledForm, SubmitEmptyForm, and SubmitWithErrors. Each story tests a specific aspect of the RegisterForm component, verifying its behavior under different conditions. Below are the results of these tests, with interactions and outcomes.
Result: ✅ Test Passed
All fields were filled correctly, and the form submitted successfully.

Result: ✅Test Passed

Result:
Multiple Interactions:

By testing these three stories, we verified the form’s behavior across different scenarios:
These results demonstrate the importance of interactive testing in catching and addressing component behavior issues early in the development process. Each interaction provides confidence that the RegisterForm component will perform reliably in production.
Create a Navigation Component:
Example: NavigationMenu.tsx
// components/NavigationMenu.tsx
import React, { useState } from "react";
import { gql, useQuery } from "@apollo/client";
type NavigationItem = {
id: string;
title: string;
link: string;
children?: NavigationItem[];
};
export default function NavigationMenu() {
const [activeItem, setActiveItem] = useState<string | null>(null);
const NAVIGATION_QUERY = gql`
query GetNavigation {
navigation {
items {
id
title
link
children {
id
title
link
}
}
}
}
`;
const { data, loading, error } = useQuery(NAVIGATION_QUERY);
const handleMouseEnter = (id: string) => {
setActiveItem(id);
};
const handleMouseLeave = () => {
setActiveItem(null);
};
if (loading) return <p>Loading navigation...</p>;
if (error) return <p>Error loading navigation.</p>;
const items: NavigationItem[] = data?.navigation?.items || [];
return (
<nav className="bg-gray-800 text-white">
<ul className="flex space-x-6 p-4">
{items.map((item) => (
<li
key={item.id}
className="relative group"
onMouseEnter={() => handleMouseEnter(item.id)}
onMouseLeave={handleMouseLeave}
>
<a
href={item.link}
className="hover:text-yellow-400 transition-colors"
aria-expanded={item.children ? activeItem === item.id : undefined}
aria-haspopup={item.children ? "true" : undefined}
>
{item.title}
</a>
{item.children && activeItem === item.id && (
<ul
role="menu"
aria-label={`${item.title} submenu`}
className="absolute left-0 mt-2 bg-gray-700 p-2 rounded-lg shadow-lg"
>
{item.children.map((child) => (
<li key={child.id}>
<a
href={child.link}
className="block px-4 py-2 hover:bg-gray-600 hover:text-yellow-400 rounded"
>
{child.title}
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
);
}
Default) is defined to render the NavigationMenu component.userEvent and within.aria-expanded) are updated correctly.findByText to wait until the "Services" menu item is visible in the DOM.const servicesLink = await canvas.findByText(
"Services",
{},
{ timeout: 2000 }
);
await userEvent.hover(servicesLink);
Expected Result:
The dropdown for "Services" appears, displaying child items like "Consulting."
const consultingLink = await canvas.findByText(
"Consulting",
{},
{ timeout: 2000 }
);
expect(consultingLink).toBeVisible();
Action:
await userEvent.unhover(servicesLink);
Expected Result:
The dropdown for "Services" disappears.
Action:
await expect(canvas.queryByText("Consulting")).not.toBeInTheDocument();
Expected Result:
The "Consulting" submenu item is no longer visible.
Final Code
// stories/NavigationMenu.stories.tsx
import React from "react";
import { Meta, StoryFn } from "@storybook/react";
import NavigationMenu from "@/components/NavigationMenu";
import { within, userEvent, expect } from "@storybook/test";
import { MockedProvider } from "@apollo/client/testing";
import { gql } from "@apollo/client";
interface NavigationMenuProps {
className?: string;
}
export default {
title: "Example/NavigationMenu",
component: NavigationMenu,
args: {
className: "navigation-menu",
},
decorators: [
(Story) => (
<MockedProvider
mocks={[
{
request: {
query: gql`
query GetNavigation {
navigation {
items {
id
title
link
children {
id
title
link
}
}
}
}
`,
},
result: {
data: {
navigation: {
items: [
{
id: "1",
title: "Home",
link: "/home",
},
{
id: "2",
title: "Services",
link: "/services",
children: [
{
id: "2-1",
title: "Consulting",
link: "/services/consulting",
},
{
id: "2-2",
title: "Development",
link: "/services/development",
},
],
},
{
id: "3",
title: "Contact",
link: "/contact",
},
],
},
},
},
},
]}
>
<Story />
</MockedProvider>
),
],
} as Meta<typeof NavigationMenu>;
const Template: StoryFn<NavigationMenuProps> = (args) => (
<NavigationMenu {...args} />
);
export const Default = Template.bind({});
Default.args = {
className: "navigation-menu--default",
};
Default.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the navigation items to be loaded
const servicesLink = await canvas.findByText(
"Services",
{},
{ timeout: 2000 }
);
// Hover over "Services" to display dropdown
await userEvent.hover(servicesLink);
// Assert that the dropdown is visible
const consultingLink = await canvas.findByText(
"Consulting",
{},
{ timeout: 2000 }
);
expect(consultingLink).toBeVisible();
// Hover out and verify dropdown disappears
await userEvent.unhover(servicesLink);
await expect(canvas.queryByText("Consulting")).not.toBeInTheDocument();
};
After implementing and tuning the tests, here are the updated results:

With the updated and tuned test setup:
NavigationMenu component passed.MockedProvider.Interactive testing ensures the reliability of dynamic UI components, particularly in a Sitecore GraphQL-driven environment. This setup is ready for real-world use cases and scalable for further enhancements.
For more details on the techniques and tools used in this implementation, refer to the following resources:
Mocking GraphQL in Storybook:
Learn how to set up and use Mock Service Worker (MSW) to mock GraphQL queries in Storybook. This blog provides an in-depth guide for integrating MSW with Storybook to simulate data fetching scenarios:
Using MSW in Storybook to Mock Sitecore XM Cloud GraphQL Queries
Fetching Data with GraphQL in Sitecore:
Understand how to fetch and manage data from Sitecore XM Cloud using GraphQL in Next.js projects. This article demonstrates best practices for integrating Sitecore's GraphQL API:
These resources provide valuable insights into building scalable and testable solutions for Sitecore XM Cloud projects with GraphQL and Storybook.
Interactive testing is crucial for ensuring the reliability of dynamic UI components, particularly in environments like Sitecore XM Cloud with GraphQL. It allows developers to validate behaviors, simulate real user interactions, and catch potential issues early, reducing bugs in production.
By using tools like Storybook for isolated environments, MockedProvider for GraphQL data mocking, and userEvent for simulating actions, you can thoroughly test and refine complex UI interactions. This improves component quality, scalability, and maintainability, ensuring applications are both functional and user-friendly.
In environments like Sitecore XM Cloud, where the UI is data-driven and interactive, adopting these practices is essential for building robust, accessible, and seamless user experiences. For a complete reference to the implementation, you can view the code repository here:
Storybook Sitecore Interactive Test Repository
Using MSW in Storybook to Mock Sitecore XM Cloud GraphQL Queries
Integrating Sitecore Data in Next.js XM Cloud Using GraphQL