Interactive Testing in Storybook with Next.js and Sitecore XM Cloud

Enhancing Component Reliability: Interactive Testing in Storybook with Sitecore XM Cloud

November 27, 2024

By Sohrab Saboori

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:
    1. Enter "John Doe" in the Name field.
    2. Enter "[email protected]" in the Email field.
    3. Enter "password123" in the Password field.
    4. 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.

    Screenshot of a test passing using Interactive Testing in Storybook with Sitecore XM Cloud

2. SubmitEmptyForm

  • Purpose: To test how the form handles submission when no data is entered.
  • Test Steps:
    1. Leave all fields empty.
    2. 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"

Screenshot of a test passing using Interactive Testing errors in Storybook with Sitecore XM Cloud

3. SubmitWithErrors

  • Purpose: To validate that error messages appear for invalid data and disappear when the data is corrected.
  • Test Steps:
    1. Submit an empty form to trigger all errors.
    2. Correct the Name field by entering "John Doe."
    3. Enter an invalid email (e.g., "invalid-email").
    4. Enter a short password (e.g., "short").
    5. Correct the email to "[email protected]" and the password to "validpassword123."
    6. 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:

  1. Initial submission with errors:
  2. Corrected Name field:
  3. Corrected Email and Password:
  4. Successful Submission:

Screenshot of a test failing using Interactive Testing in Storybook with Sitecore XM Cloud

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

  1. 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 the NavigationMenu 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 and within.
  • Validate that:
    1. The dropdown menu appears on hover.
    2. The dropdown menu disappears when hover ends.
    3. 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:

Screenshot of a interactive testing results in Storybook with Sitecore XM Cloud

With the updated and tuned test setup:

  1. All interactive tests for the NavigationMenu component passed.
  2. The dropdown menu behavior, including appearance on hover and disappearance on hover-out, was validated successfully.
  3. 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:

  1. 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

  2. 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:

    Integrating Sitecore Data in Next.js XM Cloud Using GraphQL

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

Interaction Testing in Storybook

Storybook Test: A Guide to Streamlined Testing

Photo of Fishtank employee Sohrab Saboori

Sohrab Saboori

Senior Full-Stack Developer

Sohrab is a Senior Front-End Developer with extensive experience in React, Next.js, JavaScript, and TypeScript. Sohrab is committed to delivering outstanding digital solutions that not only meet but exceed clients' expectations. His expertise in building scalable and efficient web applications, responsive websites, and e-commerce platforms is unparalleled. Sohrab has a keen eye for detail and a passion for creating seamless user experiences. He is a problem-solver at heart and enjoys working with clients to find innovative solutions to their digital needs. When he's not coding, you can find him lifting weights at the gym, pounding the pavement on the run, exploring the great outdoors, or trying new restaurants and cuisines. Sohrab believes in a healthy and balanced lifestyle and finds that these activities help fuel his creativity and problem-solving skills.