DEV Community

Clément Fassot for Zenika

Posted on • Updated on • Originally published at blog.zenika.com

A few ways to approach Cypress Component Testing with React Components

Table of contents

What is Cypress Component Testing ?

A few months ago, Cypress released their component test runner in beta. I have been using it since then, as part of a real world project, why ?

The main advantage of Cypress Component Testing over the competition is the fact that it runs in the browser, just like their end-to-end product. This is a big deal because your components will run on browsers as-well once you ship them to your users ! This alone makes writing component tests much easier for the developer because they have access to the same APIs they do when building their apps and components compared to others node-based test runners.

Today we won't talk about how to install, configure or approach the conception process of your tests, they have great documentation on the subject here, but rather talk about a few of the pitfalls you might get into when getting started with Cypress Component Testing.

Wrapping components to provide their external dependencies

Components might need external dependencies, context, props, or anything really to work properly, this is a recurrent problem for all test runners and cypress has its own way of dealing with it. Let's have an easy to understand example:

This is a simple button which will toogle a darkMode context set somewhere else in your application, it calls the hook useDarkMode which is defined in another folder

DarkModeSelector.jsx

import { useDarkMode } from "../utils/useDarkMode";

export const DarkModeSelector = () => {
  const { darkMode, setDarkMode } = useDarkMode();
  return (
    <button onClick={() => setDarkMode(!darkMode)}>
      {darkMode ? "Switch to lightmode" : "Switch to darkmode"}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

useDarkMode.jsx

import { createContext, useContext } from "react";

const DarkModeContext = createContext(undefined);

export const DarkModeProvider = ({
  children,
  value: { darkMode, setDarkMode },
}) => (
  <DarkModeContext.Provider value={{ darkMode, setDarkMode }}>
    {children}
  </DarkModeContext.Provider>
);

export const useDarkMode = () => {
  const context = useContext(DarkModeContext);
  if (!context) {
    throw new Error("useDarkMode must be used within a DarkModeProvider");
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

If you tried to mount this component as is, you'd get this error: useDarkMode must be used within a DarkModeProvider, because DarkModeSelector tries to use a context that hasn't been defined prior to its mounting.

The easiest, most simple way to fix that is to define a wrapper and then wrap our component in the mount function

DarkModeSelector.cy.jsx

const DarkModeWrapper = ({ children }) => {
  const [darkMode, setDarkMode] = useState(false);
  return (
    <DarkModeProvider
      value={{
        darkMode,
        setDarkMode,
      }}
    >
      {children}
    </DarkModeProvider>
  );
};

describe("<DarkModeSelector />", () => {
  it("mounts with a wrapper", () => {
    cy.mount(
      <DarkModeWrapper>
        <DarkModeSelector />
      </DarkModeWrapper>
    );
    cy.contains("Switch to darkmode");
  });
});
Enter fullscreen mode Exit fullscreen mode

Good ! Now your test is passing and your component mounts.

At the moment you might want to pass an initial value to your context, so you can pass a setting set somewhere else in your application:

We can add an initialValue to the props of the wrapper

DarkModeSelector.cy.jsx

const DarkModeWrapper = ({ children, initialValue }) => {
  const [darkMode, setDarkMode] = useState(initialValue || false);
  return (
    <DarkModeProvider
      value={{
        darkMode,
        setDarkMode,
      }}
    >
      {children}
    </DarkModeProvider>
  );
};

describe("<DarkModeSelector />", () => {
  it("mounts with a wrapper", () => {
    cy.mount(
      <DarkModeWrapper>
        <DarkModeSelector />
      </DarkModeWrapper>
    );
    cy.contains("Switch to darkmode");
  });
  it("renders the 'Switch to lightmode' label when initiated with darkmode=true value", () => {
    cy.mount(
      <DarkModeWrapper initialValue={true}>
        <DarkModeSelector />
      </DarkModeWrapper>
    );
    cy.contains("Switch to lightmode");
  });
});
Enter fullscreen mode Exit fullscreen mode

This is a good start, however we're only checking that the component is reacting properly to changes to the dark mode context, but how do you test that the button calls setDarkMode properly ?
Cypress has an utility called "spy", you can find more info here and here, it allows you to capture and then assert that a function was called. Let's see how we can use it in component tests together with our wrapper:

DarkModeSelector.cy.jsx

const initDarkModeWrapper = () => {
  const setDarkModeSpy = cy.spy();
  const DarkModeWrapper = ({ children, initialValue }) => {
    const [darkMode, setDarkMode] = useState(initialValue);
    return (
      <DarkModeProvider
        value={{
          darkMode,
          setDarkMode: (value) => {
            setDarkMode(value);
            setDarkModeSpy(value);
          },
        }}
      >
        {children}
      </DarkModeProvider>
    );
  };
  return [setDarkModeSpy, DarkModeWrapper];
};

describe("<DarkModeSelector />", () => {
  it("mounts with a wrapper and a spy", () => {
    const [setDarkModeSpy, DarkModeWrapper] = initDarkModeWrapper();
    cy.mount(
      <DarkModeWrapper initialValue={true}>
        <DarkModeSelector />
      </DarkModeWrapper>
    );
    cy.contains("Switch to lightmode");
  });
});
Enter fullscreen mode Exit fullscreen mode

Here we define a function which will define a spy, a wrapper that calls the spy and return them both. Right now it just tests that the component mounts with this wrapper but we can add more tests that will expect the spy to have been called with a certain value:

it("changes context value to 'true' once clicked when initiated with darkmode=false value", () => {
  const [setDarkModeSpy, DarkModeWrapper] = initDarkModeWrapper();
  cy.mount(
    <DarkModeWrapper initialValue={false}>
      <DarkModeSelector />
    </DarkModeWrapper>
  );
  cy.get("button")
    .click()
    .then(() => expect(setDarkModeSpy).to.have.been.calledWith(true));
});
Enter fullscreen mode Exit fullscreen mode
it("changes context value to 'false' once clicked when initiated with darkmode=true value", () => {
  const [setDarkModeSpy, DarkModeWrapper] = initDarkModeWrapper();
  cy.mount(
    <DarkModeWrapper initialValue={true}>
      <DarkModeSelector />
    </DarkModeWrapper>
  );
  cy.get("button")
    .click()
    .then(() => expect(setDarkModeSpy).to.have.been.calledWith(false));
});
Enter fullscreen mode Exit fullscreen mode

After that we can be fairly confident that when we click on the button, the context will be updated with the new value !

Applying this concept to entire pages

One of the advantages of Cypress Component Testing compared to E2E testing is its speed, it allows you to mount components and test them very quickly. In React, pages are components as well, so what's stopping you from testing pages just as we tested components in the last section ? It might get a little more complicated, but the same principles apply. Let's have an example:

Most app have routing, localization, a css framework, and data fetching. So let's use react-i18next, react-router, talwindcss and react-query.

You can find an example application with all of the files here to follow along, I'm not going to go into details of the implementation but it handles dark mode, translations and a few API calls so we can demonstrate what we need to make it work.

Let's start with the simple mount test and see what happens

ProductsByCategoryPage.cy.jsx

import { ProductsByCategoryPage } from "../../src/pages/ProductsByCategoryPage";

describe("<ProductsByCategoryPage />", () => {
  it("mounts", () => {
    cy.mount(<ProductsByCategoryPage />);
  });
});
Enter fullscreen mode Exit fullscreen mode

Result:

Error: useLoaderData must be used within a data router

We get a lot of errors related to hooks not used withing context providers, as seen in the last section. We could build wrappers like before, or we could try another approach ! Let's build a custom mount command:
You can find more info/examples about custom mount commands here

cypress/support/component.jsx

Cypress.Commands.add("mountApplication", (component, options = {}) => {
  return mount(<ApplicationStub>{component}</ApplicationStub>, options);
});
Enter fullscreen mode Exit fullscreen mode

Cypress already imports a custom mount function import { mount } from "cypress/react18";, so we're going to use it for our own mount command. Note that you need to rename component.js to component.jsx otherwise cypress will fail because there is jsx code in the file.

We can also define some options we might want to pass to the tests down the line:

cypress/support/component.jsx

const mountApplicationDefaultOptions = {
  viewport: [1920, 1080],
  applicationStubProps: {
    darkMode: false,
    lang: "en",
    loader: () => {},
  },
};

Cypress.Commands.add("mountApplication", (component, options) => {
  const consolidatedOptions = {
    ...mountApplicationDefaultOptions,
    ...options,
  };
  cy.viewport(...consolidatedOptions.viewport);
  return mount(
    <ApplicationStub {...consolidatedOptions.applicationStubProps}>
      {component}
    </ApplicationStub>
  );
});
Enter fullscreen mode Exit fullscreen mode

In our ApplicationStub component, we import and use every provider and css file we need to make sure the app mounts and works properly. As it is a React component the possibilities are endless

cypress/support/ApplicationStub.jsx

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { App } from "../../src/App";
import "../../src/i18n";
import "../../src/index.css";

export const ApplicationStub = ({ children, darkMode, lang, loader }) => {
  const router = createMemoryRouter([
    {
      path: "/",
      element: <App darkModeParameter={darkMode} />,
      children: [
        { path: "/", element: children },
        {
          path: "/:category",
          element: children,
          loader,
        },
      ],
    },
    ,
  ]);
  const { i18n } = useTranslation();
  if (lang) {
    i18n.changeLanguage(lang);
  }
  const queryClient = new QueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here we can easily mount our whole page and pass default arguments like dark mode or language

ProductsByCategoryPage.cs.jsx

import {
  loader,
  ProductsByCategoryPage,
} from "../../src/pages/ProductsByCategoryPage";

describe("<ProductsByCategoryPage />", () => {
  it("mounts", () => {
    cy.mountApplication(<ProductsByCategoryPage />, {
      applicationStubProps: { loader },
    });
    cy.contains("Switch");
  });
  it("mounts with darkMode", () => {
    cy.mountApplication(<ProductsByCategoryPage />, {
      applicationStubProps: { darkMode: true, loader },
    });
    cy.get("body").should("have.class", "bg-gray-800");
  });
  it("mounts with french language", () => {
    cy.mountApplication(<ProductsByCategoryPage />, {
      applicationStubProps: { lang: "fr", loader },
    });
    cy.contains("Passer au darkmode");
  });
});
Enter fullscreen mode Exit fullscreen mode

You might have noted that the page calls the API during its tests, it might be fine for you but this could break your components test if the API fails and testing both your web app and your API is supposed to be done during E2E tests. So we'll add a way to stub those API calls to make sure these tests are only impacted by the webapp. To do that we're going to use Cypress' intercept:

You could use those intercepts directly in the tests

it("intercepts /products/categories calls", () => {
  cy.intercept("GET", "/products/categories", {
    statusCode: 200,
    body: ["electronics", "jewelery", "men's clothing", "women's clothing"],
  });
  cy.mountApplication(<ProductsByCategoryPage />, {
    applicationStubProps: { loader },
  });
});
Enter fullscreen mode Exit fullscreen mode

But it might get quite repetitive with big pages, you could add it to the beforeEach handler as well

describe("<ProductsByCategoryPage />", () => {
  beforeEach(() => {
    cy.intercept("GET", "/products/categories", {
      statusCode: 200,
      body: ["electronics", "jewelery", "men's clothing", "women's clothing"],
    });
    cy.intercept("GET", "/products", {
      statusCode: 200,
      body: [
        {
          id: 1,
          title: "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops",
          price: 109.95,
          description:
            "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday",
          category: "men's clothing",
          image: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg",
          rating: { rate: 3.9, count: 120 },
        },
        ... // there might be a lot of products to stub
        {
          id: 20,
          title: "DANVOUY Womens T Shirt Casual Cotton Short",
          price: 12.99,
          description:
            "95%Cotton,5%Spandex, Features: Casual, Short Sleeve, Letter Print,V-Neck,Fashion Tees, The fabric is soft and has some stretch., Occasion: Casual/Office/Beach/School/Home/Street. Season: Spring,Summer,Autumn,Winter.",
          category: "women's clothing",
          image: "https://fakestoreapi.com/img/61pHAEJ4NML._AC_UX679_.jpg",
          rating: { rate: 3.6, count: 145 },
        },
      ],
    });
  });
Enter fullscreen mode Exit fullscreen mode

Or you can create json fixture files in cypress/fixtures and use them to declare your intercepts

describe("<ProductsByCategoryPage />", () => {
  beforeEach(() => {
    cy.intercept("GET", "/products/categories", {
      statusCode: 200,
      body: ["electronics", "jewelery", "men's clothing", "women's clothing"],
    });
    cy.intercept("GET", "/products", {
      statusCode: 200,
      fixture: "products.json",
    });
  });
  it("intercepts all the calls", () => {
    cy.mountApplication(<ProductsByCategoryPage />, {
      applicationStubProps: { loader },
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

I hope this article was helpful, I tried to compile some of the hurdles I went through while learning Cypress Component Testing. The example app is not perfect but should help get an idea of how to test components and whole pages with this tool.

I want to thanks the Cypress Team for helping me understanding this, you can join the discussions on Discord and go throught all of their documentation here

You can find me on Github and on Twitter

Top comments (0)