Test-Driven UI Development with Cypress Component Testing

March 16, 2023

•

By Adam Stone-Lord

As developers, testing can sometimes feel like extra work that we need to take on after building our UI components. We build a component, manually test it in the app, and then write tests to describe how our component should behave. At least, that’s how I used to think about testing my UI components before I started using Component Testing at Cypress.

Before joining Cypress, I was used to component testing tools that rendered components using a simulated DOM like jsdom. However, this wasn’t an ideal experience for me because I couldn’t see how my component would appear to a user. I could only view the DOM output that resulted from mounting my component. This resulted in a sort of “black box” experience where I would write a test and assume that certain things were happening, but I wasn’t able to actually see how they presented in a UI sense.

With Cypress Component Testing, components are rendered in a real browser, which allows us to view the component and see exactly how it will look to a user. This is powerful because it allows us to turn testing into a way to iterate and develop our component from scratch, in a way that live-reloads the real UI instantly for a rapid test-driven development workflow. It also documents how a component is supposed to behave, which helps future developers who are tasked with making changes to the component that we built.

Oftentimes, when I’m asked to make changes to an existing component, the first place I look is the component spec that mounts it. By running the spec and using the Cypress command log to time travel, I can visually see exactly what the tests are asserting which gives me an idea of what the component is responsible for.

In this blog post, we’ll go through a simple example showing how we might use Component Testing to drive the development of a component for Cypress’s Real World App, a test application that we use to demonstrate how to use Cypress.

Building a Component

In our application, we have a notifications list that shows the user some notifications and allows them to click to dismiss an individual notification. The notification item in this list is a good candidate for a component that we can reuse.


We’ll start with an extremely basic React component and a corresponding Cypress component spec that simply mounts our component in the DOM.

// Notification.jsx

import React from "react";

export function Notification() {
  return <div>This is our notification</div>;
}
// Notification.cy.jsx

import { Notification } from "./Notification";

describe("Notification", () => {
  it("renders", () => {
    cy.mount(<Notification />);
  });
});

It doesn’t look like much, but if we open our spec in Cypress, we’ll see that we’ve effectively created a playground for developing our component. When we save changes to either of these files, Cypress will re-mount the component and re-run the spec almost instantly, giving us immediate feedback about how our component looks and behaves. This lays the foundation for using test-driven development to build our component – where we can actually see what’s going on in a real browser environment.

We get all of this without setting up any application state or navigating to any specific pages, and we can test our component’s inputs and outputs in isolation from the rest of our application.

Driving Development with Assertions

Now that we have an environment set up that will help us build our notification component, we can think about how this component should appear and behave. Then we can write some test cases and assertions before we even build it.

We can break our component up into three logical parts that will help us determine what props our component will accept and what assertions we need in our tests:

  1. On the left we have an icon. Our component will accept a string key to determine which icon to display.
  2. In the middle we have the content of the notification. This can simply be a string prop that our component displays.
  3. On the right there’s a “dismiss” button that the user can click to dismiss the notification. For this, the component will take a click handler function as a prop, and we can write a test case to verify that the function gets called when we click the button.

Because we’re writing our test before developing our component, we are forced to make these decisions about our component’s API before writing the code. Testing in isolation like this also encourages us to create a simple API based on props because we don’t have access to global scope that we might have in a real running application. Testing with this isolated approach leads to loosely-coupled application code.

Based on the parts that we broke the component into, we can write our test cases to look something like this:

// Notification.cy.jsx

import { Notification } from "./Notification";

const notificationTypes = ["REQUEST", "RECEIVE", "LIKE", "COMMENT"];

describe("Notification", () => {
  notificationTypes.forEach((type) => {
    it(`renders with correct icon for ${type} notification`, () => {
      cy.mount(
        <Notification
          type={type}
          message="This is the notification message."
          onDismiss={() => false}
        />
      );

      // Check that everything renders correctly
      cy.get(`[data-cy=${type.toLowerCase()}-icon]`).should("be.visible");
      cy.contains("This is the notification message.").should("be.visible");
      cy.contains("button", "Dismiss").should("be.visible");
    });
  });

  it("calls onDismiss when dismiss button is clicked", () => {
    const onDismissStub = cy.stub();

    cy.mount(
      <Notification
        type="REQUEST"
        message="This is the notification message."
        onDismiss={onDismissStub}
      />
    );

    // Click button and assert that our stub was called
    cy.contains("button", "Dismiss")
      .click()
      .then(() => {
        expect(onDismissStub).to.be.called;
      });
  });
});

In this spec, we’re testing a couple of things:

  1. For each type of notification, the icon with the correct test ID is rendered. This could be better captured with a visual testing tool like Percy rather than relying on test IDs.
  2. When our dismiss button is clicked, our onDismiss function is called.

These tests will all fail because we haven’t built the component yet, so it doesn’t accept any props or meet the requirements described in our spec. Let’s build the component while using the Cypress app to see how it looks in the browser.

0:00
/0:20


Because we had our spec written beforehand, we can be confident that our component is meeting all of the requirements that we laid out now that our tests are passing. At this point, we can integrate the component into the application itself, and write an end-to-end test to ensure that the entire user journey that this component is involved in is running smoothly.

Conclusion

Testing our components with Cypress allows us to easily use a test-driven development pattern to build more readable and reusable components throughout our application.

By testing the component in isolation this way, we’ve created a sort of “contract” – a set of requirements that the component needs to fulfill. This is useful not only for preventing regressions but also for serving as documentation for the desired behavior of the component. With this approach, testing our component isn’t extra work that we need to do after the fact. Instead, it helps us to build our component in the first place by allowing us to view changes immediately without setting up the application state or navigating to any specific route.

For more on test-driven development with Cypress Component Testing, check out this webinar where we break down TDD philosophy and tactics with Cypress:

Our ambassador Murat Ozcan has also created a detailed GitBook about this subject complete with code examples. For a more detailed look into how to write component tests with Cypress in general, check out our documentation for your preferred front-end framework.

Supercharge your component testing capabilities with Cypress Cloud and experience unparalleled testing efficiency. Seamlessly transition from local testing to distributed, scalable, and lightning-fast testing in the cloud. Upgrade today.