Interactive Testing in Storybook with Next.js and Sitecore XM Cloud
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.
Why Interactive Testing is Important
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.
Brief Overview of Use Cases
-
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.
Initial Setup
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.
Use Case 1: Form Testing
Objective
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.
Step-by-Step Implementation
1. Create a Form Component
The RegisterForm
component has been implemented with the following features:
- Inputs for Name, Email, and Password: Each input validates user input dynamically.
- Error Messages: Displays error messages for invalid or missing inputs.
- Submission Handling: Calls the
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>
);
}
2. Set Up the Story
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);
},
};
3. Write Interactive Tests
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();
});
};
Outcome
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.
1. FilledForm
- Purpose: To simulate a user filling out all fields correctly and submitting the form.
- Test Steps:
- Enter "John Doe" in the Name field.
- Enter "[email protected]" in the Email field.
- Enter "password123" in the Password field.
- Click the "Register" button.
- Expected Result: The form submits successfully without showing any validation errors.
Result: ✅ Test Passed
-
All fields were filled correctly, and the form submitted successfully.
2. SubmitEmptyForm
- Purpose: To test how the form handles submission when no data is entered.
- Test Steps:
- Leave all fields empty.
- Click the "Register" button.
- Expected Result: The form displays validation errors for all required fields.
Result: ✅Test Passed
- Error messages were displayed for:
- Name: "Name is required"
- Email: "Email is required"
- Password: "Password must be at least 6 characters long"
3. SubmitWithErrors
- Purpose: To validate that error messages appear for invalid data and disappear when the data is corrected.
- Test Steps:
- Submit an empty form to trigger all errors.
- Correct the Name field by entering "John Doe."
- Enter an invalid email (e.g., "invalid-email").
- Enter a short password (e.g., "short").
- Correct the email to "[email protected]" and the password to "validpassword123."
- Submit the form again.
- Expected Result:
- Validation errors appear for invalid fields.
- Errors disappear as fields are corrected.
- The form submits successfully when all fields are valid.
Result:
- Step 1: ❌ Test Failed — Email validation did not trigger for "invalid-email."
- Step 2-6: ✅ Test Passed — Errors cleared and form submitted successfully.
Multiple Interactions:
- Initial submission with errors:
- Corrected Name field:
- Corrected Email and Password:
- Successful Submission:
By testing these three stories, we verified the form’s behavior across different scenarios:
- FilledForm: Ensured successful submission with valid data.
- SubmitEmptyForm: Validated error handling for missing fields.
- SubmitWithErrors: Confirmed dynamic error removal and successful submission after corrections.
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.
Use Case 2: Navigation Menu Hover Interaction
Objective
- Test navigation hover effects with dynamic data fetched via GraphQL from Sitecore.
- Ensure the menu updates visually and functionally on hover, including dropdowns for submenus.
Step-by-Step Implementation
-
Create a Navigation Component:
- A navigation bar with hover interactions (e.g., dropdowns or style changes).
-
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> ); }
2. Set Up the Story
- A Storybook story (
Default
) is defined to render theNavigationMenu
component. - Mocked GraphQL data is used to simulate navigation items for the menu.
3. Write the Test
- Simulate hover interactions using Storybook's Testing API with
userEvent
andwithin
. - Validate that:
- The dropdown menu appears on hover.
- The dropdown menu disappears when hover ends.
- Accessibility attributes (
aria-expanded
) are updated correctly.
Interactions and Test Steps
Step 1: Wait for Navigation Items to Load
- Navigation items are fetched dynamically via a GraphQL query.
- Use
findByText
to wait until the "Services" menu item is visible in the DOM.
const servicesLink = await canvas.findByText(
"Services",
{},
{ timeout: 2000 }
);
Step 2: Hover Over "Services"
- Simulate hovering over the "Services" menu item to display its dropdown.
await userEvent.hover(servicesLink);
Expected Result:
The dropdown for "Services" appears, displaying child items like "Consulting."
Step 3: Assert Dropdown Visibility
- Check that the dropdown menu is visible after hovering.
const consultingLink = await canvas.findByText(
"Consulting",
{},
{ timeout: 2000 }
);
expect(consultingLink).toBeVisible();
Step 4: Hover Out of "Services"
- Simulate moving the mouse away from the "Services" menu item to hide its dropdown.
Action:
await userEvent.unhover(servicesLink);
Expected Result:
The dropdown for "Services" disappears.
Step 5: Assert Dropdown Disappearance
- Verify that the dropdown menu is no longer visible.
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();
};
Interactive Testing Results
After implementing and tuning the tests, here are the updated results:
With the updated and tuned test setup:
- All interactive tests for the
NavigationMenu
component passed. - The dropdown menu behavior, including appearance on hover and disappearance on hover-out, was validated successfully.
- The GraphQL data fetch simulation was confirmed to work as intended with the
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.
Further Reading
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.
Final Thoughts on Interactive Testing in Storybook with Next.js and Sitecore XM Cloud
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
References:
Using MSW in Storybook to Mock Sitecore XM Cloud GraphQL Queries
Integrating Sitecore Data in Next.js XM Cloud Using GraphQL