DEV Community

Murat K Ozcan
Murat K Ozcan

Posted on • Edited on

Cypress Component Testing vs React Testing Library - the complete comparison

Cypress component testing was released about a year prior to this blog post and has been a disruptor not only to the de-facto JS framework component testing solutions but also initiated a paradigm shift in developing frontend components. At the core of this is the ability to render a component in isolation in the actual browser, and to see and observe how the component behaves in isolation while time-travel debugging via Cypress. This changes the entire development experience, with testing - the actual intent - being the icing on the cake.

Rolling out component testing in a community that is so invested in de-facto component testing solutions can be a daring challenge. In this post, we will analyze the differences between React Testing Library (RTL) and Cypress Component Testing (CyCT), and provide you with resources that can help with adopting component testing in your organization.

Toc:

Our experience at Extend

Our first component test at Extend was at the end of 2022, and we have been piloting it in one of our apps for about six months. Comparing the Cypress Cloud reports between then and now, we can see a preference towards component testing.

Bear in mind that we are migrating from Enzyme to CyCT, to be able to upgrade React beyond version 16. If we had RTL tests already in this app - as we do in a second UI app which had already migrated out of Enzyme before CyCT rollout - then the RTL tests and CyCT could coexist. We have about 130 Enzyme tests remaining to migrate in our pilot app. This means we will potentially have around 300 Cypress component tests once the migration is complete. This is aligned with the proportion of e2e to CT we have observed in other apps; usually it is anywhere between 1:3 to 1:5.

December 2022 (first component test commit):

Image description

June 2023:

Image description

If you are curious about how the e2e execution time became so low with more tests, check out the video Improve Cypress e2e test latency by a factor of 20!!

CyCT vs RTL examples - Tour of Heroes (ToH)

ToH is the final app built in the book CCTDD: Cypress Component Test Driven Design. It has a few dozen Cypress component tests and their RTL mirrors. We will cover a few examples to showcase the main differences.

HeaderBarBrand component

The way the component is mounted is very similar between CyCT and RTL. So are custom mounts / renders.

There are less imports in CyCT, because either these are built-in or come with the browser.

The API is the primary difference; with Cypress we have a left-to-right chain style, with RTL we have a right-to-left variable assignment style.

28 lines CyCT vs 35 lines in RTL.

HeaderBarBrand

HeaderBarBrand.cy.tsx



import HeaderBarBrand from "./HeaderBarBrand";
import { BrowserRouter } from "react-router-dom";

describe("HeaderBarBrand", () => {
  beforeEach(() => {
    cy.mount(
      <BrowserRouter>
        <HeaderBarBrand />
      </BrowserRouter>
    );
  });

  it("should verify external link attributes", () => {
    cy.get("a")
      .should("have.attr", "href", "https://reactjs.org/")
      .and("have.attr", "target", "_blank")
      .and("have.attr", "rel", "noopener noreferrer");
    cy.getByCy("header-bar-brand").within(() => cy.get("svg"));
  });

  it("should verify internal link spans and navigation", () => {
    cy.getByCy("navLink").within(() =>
      ["TOUR", "OF", "HEROES"].map((part: string) => cy.contains("span", part))
    );
    cy.getByCy("navLink").click();
    cy.url().should("contain", "/");
  });
});


Enter fullscreen mode Exit fullscreen mode

HeaderBarBrand.test.tsx



import HeaderBarBrand from "./HeaderBarBrand";
import { render, screen, within } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";

describe("HeaderBarBrand", () => {
  beforeEach(() => {
    render(
      <BrowserRouter>
        <HeaderBarBrand />
      </BrowserRouter>
    );
  });
  it("should verify external link attributes", async () => {
    const link = await screen.findByTestId("header-bar-brand-link");
    expect(link).toHaveAttribute("href", "https://reactjs.org/");
    expect(link).toHaveAttribute("target", "_blank");
    expect(link).toHaveAttribute("rel", "noopener noreferrer");

    // not easy to get a tag with RTL, needed to use a test id
    within(await screen.findByTestId("header-bar-brand")).getByTestId(
      "react-icon-svg"
    );
  });

  it("should verify internal link spans and navigation", async () => {
    const navLink = await screen.findByTestId("navLink");
    const withinNavLink = within(navLink);
    ["TOUR", "OF", "HEROES"].forEach((part) => withinNavLink.getByText(part));

    await userEvent.click(navLink);
    expect(window.location.pathname).toBe("/");
  });
});


Enter fullscreen mode Exit fullscreen mode

InputDetail component

We see similar contrast between CyCT and RTL as before; similar mount/render, different API styles, terser syntax on Cypress side. Note that testing library has a Cypress version and can be used to make the examples even more similar.

The key difference we want to point out is stubbing the onChange property the component. cy.stub() vs its counterpart jest.fn(). Cypress comes with Sinon, and Cypress' API allows us to stub in-line.



// CyCT: we can stub the property in-line
cy.mount(
  <InputDetail
    name={name}
    value={value}
    placeholder={placeholder}
    onChange={cy.stub().as("onChange")}
  />
);

// RTL: variable assignment first
const onChange = jest.fn();

render(
  <InputDetail
    name={name}
    value={value}
    placeholder={placeholder}
    onChange={onChange}
  />
);


Enter fullscreen mode Exit fullscreen mode

InputDetailComponent

InputDetail.cy.tsx



import InputDetail from "./InputDetail";
import "@testing-library/cypress/add-commands";

describe("InputDetail", () => {
  const placeholder = "Aslaug";
  const name = "name";
  const value = "some value";
  const newValue = "42";

  it("should allow the input field to be modified", () => {
    cy.mount(
      <InputDetail
        name={name}
        value={value}
        placeholder={placeholder}
        onChange={cy.stub().as("onChange")}
      />
    );

    cy.contains("label", name);
    cy.findByPlaceholderText(placeholder).clear().type(newValue);
    cy.findByDisplayValue(newValue).should("be.visible");
    cy.get("@onChange").its("callCount").should("eq", newValue.length);
  });

  it("should not allow the input field to be modified", () => {
    cy.mount(
      <InputDetail
        name={name}
        value={value}
        placeholder={placeholder}
        readOnly={true}
      />
    );

    cy.contains("label", name);
    cy.findByPlaceholderText(placeholder)
      .should("have.value", value)
      .and("have.attr", "readOnly");
  });
});


Enter fullscreen mode Exit fullscreen mode

InputDetail.test.tsx



import InputDetail from "./InputDetail";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("InputDetail", () => {
  const placeholder = "Aslaug";
  const name = "name";
  const value = "some value";
  const newValue = "42";

  it("should allow the input field to be modified", async () => {
    const onChange = jest.fn();
    render(
      <InputDetail
        name={name}
        value={value}
        placeholder={placeholder}
        onChange={onChange}
      />
    );

    await screen.findByText(name);
    const inputField = await screen.findByPlaceholderText(placeholder);
    await userEvent.clear(inputField);
    await userEvent.type(inputField, newValue);
    expect(inputField).toHaveDisplayValue(newValue);
    expect(onChange).toHaveBeenCalledTimes(newValue.length);
  });

  it("should not allow the input field to be modified", async () => {
    render(
      <InputDetail
        name={name}
        value={value}
        placeholder={placeholder}
        readOnly={true}
      />
    );

    await screen.findByText(name);
    const inputField = await screen.findByPlaceholderText(placeholder);
    expect(inputField).toHaveDisplayValue(value);
    expect(inputField).toHaveAttribute("readOnly");
  });
});


Enter fullscreen mode Exit fullscreen mode

NavBar component

We can observe the same similarities and contrast between CyCT and RTL in this component. The most striking of them all is the API style difference; with Cypress we are able to cover a each link with forEach, to be able to do the same in RTL we have to do a little bit of more work with it.each.

Image description

NavBar.cy.tsx



import NavBar from "./NavBar";
import { BrowserRouter } from "react-router-dom";

const routes = ["heroes", "villains", "boys", "about"];

describe("NavBar", () => {
  it("should navigate to the correct routes", () => {
    cy.mount(
      <BrowserRouter>
        <NavBar />
      </BrowserRouter>
    );

    cy.contains("p", "Menu");
    cy.getByCy("menu-list").children().should("have.length", routes.length);

    routes.forEach((route: string) => {
      cy.get(`[href="/${route}"]`)
        .contains(route, { matchCase: false })
        .click()
        .should("have.class", "active-link")
        .siblings()
        .should("not.have.class", "active-link");

      cy.url().should("contain", route);
    });
  });
});


Enter fullscreen mode Exit fullscreen mode

NavBar.test.tsx



import NavBar from "./NavBar";
import { render, screen, within, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import "@testing-library/jest-dom";

const routes = ["Heroes", "Villains", "Boys", "About"];

describe("NavBar", () => {
  beforeEach(() => {
    render(
      <BrowserRouter>
        <NavBar />
      </BrowserRouter>
    );
  });

  it("should verify route layout", async () => {
    expect(await screen.findByText("Menu")).toBeVisible();

    const menuList = await screen.findByTestId("menu-list");
    expect(within(menuList).queryAllByRole("link").length).toBe(routes.length);

    routes.forEach((route) => within(menuList).getByText(route));
  });

  it.each(routes)("should navigate to route %s", async (route: string) => {
    const link = async (name: string) => screen.findByRole("link", { name });
    const activeRouteLink = await link(route);
    userEvent.click(activeRouteLink);
    await waitFor(() => expect(activeRouteLink).toHaveClass("active-link"));
    expect(window.location.pathname).toEqual(`/${route.toLowerCase()}`);

    const remainingRoutes = routes.filter((r) => r !== route);
    remainingRoutes.forEach(async (inActiveRoute) => {
      expect(await link(inActiveRoute)).not.toHaveClass("active-link");
    });
  });
});


Enter fullscreen mode Exit fullscreen mode

In all these examples we have not said anything about the component code, but instead shared a CyCT screen shot to communicate what the component is about. On one side we are staring at html, on the other we are looking at the component in the browser. The picture demonstrates a bug during development that we caught by just looking at the component.

Image description

Comparison of low level spies & mocks: Sinon vs Jest

We prepared a repository to cover core examples of using Sinon with Cypress, and created mirror tests in Jest.

The original Sinon examples are from Gleb Bahmutov's Cypress Examples.

Sinon vs Jest: Spy

A spy does not modify the behavior of the function - it is left perfectly intact. A spy is most useful when you are testing the contract between multiple functions and you don't care about the side effects the real function may create (if any).

spy-practice.cy.ts vs spy-practice-test.ts are linked for you to view the files in comparison; we will summarize the key differences:

  • Spying on methods: Both libraries allow you to create a spy that wraps around a function and records all calls to it, along with arguments and return values. Both Jest and Sinon allow you to assert if the spy was called, how many times it was called, and with what arguments. They do differ in syntax and some functionalities.

cy.spy(obj, 'method') vs jest.spyOn(obj, 'method')

  • Syntax: have.been.called vs toHaveBeenCalled, have.been.calledTwice vs toHaveBeenCalledTimes(2)

  • Matchers: sinon.match.string vs expect.any(String)

  • Custom matchers: easy with Sinon, not great devex with Jest. We will not share the Jest code, because it is very verbose.



// Cy + sinon
const { match } = Cypress.sinon;
const isEven = (x: number) => x % 2 === 0;
const isOdd = (x: number) => x % 2 === 1;

const spy = cy.spy(calculator, "add").as("add");
calculator.add(2, 3);

// expect the value to pass a custom predicate function
// the second argument to "match(predicate, message)"
// is shown if the predicate does not pass and assertion fails
expect(spy).to.be.calledWith(match(isEven), match(isOdd, "is odd"));


Enter fullscreen mode Exit fullscreen mode
  • Asynchronous testing: In Jest, it is required to explicitly wait for asynchronous operations to complete before assertions are checked. This is illustrated in using the setTimeout and await new Promise combination. Cypress + Sinon does not necessitate this explicit wait. There are certain advantages to Cypress chain style and built-in retry ability. In the below example with Jest we have to gather the promises, await them with Promise.all and have to use toHaveBeenNthCalledWith to specify the order.


// Cy + Sinon
it("resolved value (promises)", () => {
  const calc = {
    async add(a: number, b: number) {
      return /* await */ Cypress.Promise.resolve(a + b).delay(100); // don't use await redundantly
    },
  };

  cy.spy(calc, "add").as("add");
  // wait for the promise to resolve then confirm its resolved value
  cy.wrap(calc.add(4, 5)).should("equal", 9);
  cy.wrap(calc.add(1, 90)).should("equal", 91);
  cy.wrap(calc.add(-5, -8)).should("equal", -13);

  // example of confirming one of the calls used add(4, 5)
  cy.get("@add").should("have.been.calledWith", 4, 5);
  cy.get("@add").should("have.been.calledWith", 1, 90);
  cy.get("@add").should("have.been.calledWith", -5, -8);

  // now let's confirm the resolved values
  // first we need to wait for all promises to resolve
  cy.get("@add")
    .its("returnValues")
    .then((ps) => Promise.all(ps))
    .should("deep.equal", [9, 91, -13]);
});


Enter fullscreen mode Exit fullscreen mode


// Jest
it("resolved value (promises)", async () => {
  const calc = {
    async add(a: number, b: number) {
      return a + b;
    },
  };

  const spy = jest.spyOn(calc, "add");

  // Let's gather the promises first
  const promises = [calc.add(4, 5), calc.add(1, 90), calc.add(-5, -8)];

  // Now we wait for all the promises to resolve
  const results = await Promise.all(promises);

  // We can check if the spy was called with the correct arguments at each call
  expect(spy).toHaveBeenNthCalledWith(1, 4, 5);
  expect(spy).toHaveBeenNthCalledWith(2, 1, 90);
  expect(spy).toHaveBeenNthCalledWith(3, -5, -8);

  // Finally, we can verify the resolved values
  expect(results).toEqual([9, 91, -13]);
});


Enter fullscreen mode Exit fullscreen mode

Sinon vs Jest: Stub/Mock

A stub is a way to modify a function and delegate control over its behavior to you (the programmer).

Create a standalone stub (generally for use in unit test):



cy.stub();
jest.fn();


Enter fullscreen mode Exit fullscreen mode

Replace obj.method() with a stubbed function:



cy.stub(obj, "method");
jest.spyOn(obj, "foo").mockImplementation(jest.fn());


Enter fullscreen mode Exit fullscreen mode

Force obj.method() to return a value:



cy.stub(obj, "method").returns("Cliff");
jest.spyOn(obj, "method").mockReturnValue("Cliff");


Enter fullscreen mode Exit fullscreen mode

Force obj.method() when called with "bar" argument to return "foo":



cy.stub(obj, "method").withArgs("bar").returns("foo");

jest.spyOn(obj, "method").mockImplementation((arg) => {
  if (arg === "bar") return "foo";
});


Enter fullscreen mode Exit fullscreen mode

Force obj.method() to return a promise which resolves to "foo"



cy.stub(obj, "method").resolves("foo");

jest.spyOn(obj, "method").mockImplementation(() => {
  return Promise.resolve("foo");
});


Enter fullscreen mode Exit fullscreen mode

Force obj.method() to return a promise rejected with an error



cy.stub(obj, "method").rejects(new Error("foo"));

jest.spyOn(obj, "method").mockImplementation(() => {
  return Promise.reject(new Error("foo"));
});


Enter fullscreen mode Exit fullscreen mode

It is interesting to note that the equivalent of cy.stub() is jest.fn() but in many of the comparisons we are using jest.spyOn(...).mockImplementation(...)

In Jest, we can use .mockImplementation() to provide a custom implementation for the mock function.
In Sinon, we can use .callsFake() or .returns() to specify custom behavior for the stub.

jest.fn() can be used more in scenarios where you're not spying on or modifying existing object methods, but rather creating standalone mock functions. For instance, when testing if a function passed as a prop or callback is called correctly in a component test or when needing to create a mock implementation for a function from a module that your function under test is calling.

Here is an example scenario where jest.fn() could be used



it("should call the callback", () => {
  const mockCallback = jest.fn();

  function doSomething(callback: (arg: string) => void) {
    callback("test argument");
  }

  doSomething(mockCallback);

  expect(mockCallback).toHaveBeenCalledTimes(1);
  expect(mockCallback).toHaveBeenCalledWith("test argument");
});


Enter fullscreen mode Exit fullscreen mode

Comparing stub-practice.cy.ts vs stub-practice.test.ts, here are some of the other highlights:

Restoring the original method after stub:



const person = {
  getName() {
    return "Joe";
  },
};

/// Cy + Sinon
expect(person.getName()).to.eq("Joe");

const stub = cy.stub(person, "getName").returns("Cliff");
expect(person.getName()).to.eq("Cliff");

// restore the original method
stub.restore();
expect(person.getName()).to.eq("Joe");

/// Jest
expect(person.getName()).toBe("Joe");

const stub = jest.spyOn(person, "getName").mockReturnValue("Cliff");
expect(person.getName()).toBe("Cliff");

// restore the original method
stub.mockRestore();
expect(person.getName()).toBe("Joe");


Enter fullscreen mode Exit fullscreen mode

Matchers: .callThrough(), withArgs(), match.type, match(predicate):



describe("matchers: .callThrough(), withArgs(), match.type, match(predicate)", () => {
  const { match } = Cypress.sinon;

  it("Matching stub depending on arguments", () => {
    const greeter = {
      greet(name: string | number | undefined) {
        return `Hello, ${name}!`;
      },
    };

    const stub = cy.stub(greeter, "greet");

    stub.callThrough(); // if you want non-matched calls to call the real method
    stub.withArgs(match.string).returns("Hi, Joe!");
    stub.withArgs(match.number).throws(new Error("Invalid name"));

    expect(greeter.greet("World")).to.equal("Hi, Joe!");
    expect(() => greeter.greet(42)).to.throw("Invalid name");
    expect(greeter.greet).to.have.been.calledTwice;

    // non-matched calls goes the actual method
    expect(greeter.greet()).to.equal("Hello, undefined!");
  });
});


Enter fullscreen mode Exit fullscreen mode

There is no direct equivalent in Jest, but the below does the same thing



describe("matchers: .mockImplementation()", () => {
  it("Matching stub depending on arguments", () => {
    const greeter = {
      greet(name: string | number) {
        return `Hello, ${name}!`;
      },
    };

    jest
      .spyOn(greeter, "greet")
      .mockImplementation((name: string | number | undefined) => {
        if (typeof name === "string") {
          return "Hi, Joe!";
        } else if (typeof name === "number") {
          throw new Error("Invalid name");
        } else {
          return "Hello, undefined!";
        }
      });

    expect(greeter.greet("World")).toEqual("Hi, Joe!");
    expect(() => greeter.greet(42)).toThrow("Invalid name");
    expect(greeter.greet).toHaveBeenCalledTimes(2);

    expect(greeter.greet()).toEqual("Hello, undefined!");
  });
});


Enter fullscreen mode Exit fullscreen mode

Calling the original method from the stub:



describe("Call the original method from the stub: callsFake(...), wrappedMethod()", () => {
  it("Sometimes you might want to call the original method from the stub and modify it", () => {
    const person = {
      getName() {
        return "Joe";
      },
    };

    cy.stub(person, "getName").callsFake(() => {
      // call the real person.getName()
      return person.getName.wrappedMethod().split("").reverse().join("");
    });

    expect(person.getName()).to.eq("eoJ");
  });
});


Enter fullscreen mode Exit fullscreen mode

There is no direct equivalent in Jest, but the below does the same thing.



describe("Call the original method from the stub: mockImplementation(), originalName", () => {
  it("Sometimes you might want to call the original method from the stub and modify it", () => {
    const person = {
      getName() {
        return "Joe";
      },
    };

    const originalGetName = person.getName.bind(person);

    jest.spyOn(person, "getName").mockImplementation(() => {
      return originalGetName().split("").reverse().join("");
    });

    expect(person.getName()).toEqual("eoJ");
  });
});


Enter fullscreen mode Exit fullscreen mode

Controlling time cy.clock vs jest.useFakeTimers:

When running Cypress tests, the tests themselves are outside the application's iframe. When you use cy.clock() command you change the application clock, and not the spec's clock.



describe("cy.clock", () => {
  it("control the time in the browser", () => {
    const specNow = new Date();
    const now = new Date(Date.UTC(2017, 2, 14)).getTime();

    cy.clock(now) // sets the application clock and pause time
      .then(() => {
        // spec clock keeps ticking
        const specNow2 = new Date();
        // confirm by comparing the timestamps in milliseconds
        expect(+specNow2, "spec timestamps").to.be.greaterThan(+specNow);
      });
    // but the application's time is frozen
    cy.window()
      .its("Date")
      .then((appDate) => {
        const appNow = new appDate();
        expect(+appNow, "application timestamps")
          .to.equal(+now)
          .and.to.equal(1489449600000); // the timestamp in milliseconds
      });
    // we can advance the application clock by 5 seconds
    cy.tick(5000);
    cy.window()
      .its("Date")
      .then((appDate) => {
        const appNow = new appDate();
        expect(+appNow, "timestamp after 5 synthetic seconds").to.equal(
          1489449605000
        );
      })
      // meanwhile the spec clock only advanced by probably less than 200ms
      .then(() => {
        const specNow3 = new Date();
        expect(+specNow3, "elapsed on the spec clock").to.be.lessThan(
          +specNow + 200
        );
      });
  });
});


Enter fullscreen mode Exit fullscreen mode

The Jest mirror cannot be run in the browser window, and has a slightly different approach.



describe("jest.useFakeTimers", () => {
  it("control the time in the browser", () => {
    jest.useFakeTimers();
    const specNow = new Date();
    const now = new Date(Date.UTC(2017, 2, 14)).getTime();

    jest.setSystemTime(now);

    // application time is frozen
    const appNow = new Date();
    expect(appNow.getTime()).toBe(now);
    expect(appNow.getTime()).toBe(1489449600000); // the timestamp in milliseconds

    // we can advance the application clock by 5 seconds
    jest.advanceTimersByTime(5000);
    const appNow2 = new Date();
    expect(appNow2.getTime()).toBe(1489449605000);

    // spec clock only advanced by probably less than 200ms
    const specNow3 = new Date();
    expect(specNow3.getTime()).toBeLessThan(specNow.getTime() + 200);

    jest.useRealTimers();
  });
});


Enter fullscreen mode Exit fullscreen mode

Comparison of network spies & mocks: cy.intercept vs MSW

If you have been through Kent C. Dodd's Epic React, you are already convinced that the farther away from our component the mocking is, the more we are testing our code and having better confidence. The farthest we can mock from our code is mocking the network. To mock the network Cypress has the intercept api, and the exact mirror on RTL side is Mock Service Worker (msw).

Let us compare RTL + MSW Heroes.test.tsx vs CyCT + cy.intercept from Tour of Heroes repo.

Here is a summary of the key points; with msw we have a bit more setup, and a required clean up, on the other side with cy.intercept the syntax is succinct and no clean up is required.



// RTL + MSW
it("should see error on initial load with GET", async () => {
  // have to define handlers, setup server and listen
  const handlers = [
    rest.get(
      `${process.env.REACT_APP_API_URL}/heroes`,
      async (_req, res, ctx) => res(ctx.status(400))
    ),
  ];
  const server = setupServer(...handlers);
  server.listen({
    onUnhandledRequest: "warn",
  });

  // test code...

  // have to clean up
  server.resetHandlers();
  server.close();
});

// CyCT + cy.intercept
it("should see error on initial load with GET", () => {
  // in comparison, this is all we have to do with cyct
  cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
    statusCode: 400,
    delay: 100,
  }).as("notFound");

  // test code...

  // no clean up needed
});


Enter fullscreen mode Exit fullscreen mode

The difference is more clear when we want to structure our tests. RTL + msw ends up being plenty more boilerplate compared to CyCT and cy.intercept. Not only the concise syntax, but also the ability to define or change our network mock on the fly with cy.intercept makes our code easier to understand. Check out the code comments below for precise examples:



// RLT + msw
describe("200 flows", () => {
  const handlers = [
    rest.get(
      `${process.env.REACT_APP_API_URL}/heroes`,
      async (_req, res, ctx) => res(ctx.status(200), ctx.json(heroes))
    ),
    // we have to have all definitions in the handler
    // once declared, these are it;
    // we would need a new describe block to change the mock
    rest.delete(
      `${process.env.REACT_APP_API_URL}/heroes/${heroes[0].id}`, // use /.*/ for all requests
      async (_req, res, ctx) => res(ctx.status(400), ctx.json("expected error"))
    ),
  ];
  const server = setupServer(...handlers);
  beforeAll(() => {
    server.listen({
      onUnhandledRequest: "warn",
    });
  });
  afterEach(server.resetHandlers);
  afterAll(server.close);

  it("should display the hero list on render, and go through hero add & refresh flow", async () => {
    expect(await screen.findByTestId("list-header")).toBeVisible();
    expect(await screen.findByTestId("hero-list")).toBeVisible();

    await userEvent.click(await screen.findByTestId("add-button"));
    expect(window.location.pathname).toBe("/heroes/add-hero");

    await userEvent.click(await screen.findByTestId("refresh-button"));
    expect(window.location.pathname).toBe("/heroes");
  });

  const deleteButtons = async () => screen.findAllByTestId("delete-button");
  const modalYesNo = async () => screen.findByTestId("modal-yes-no");
  const maybeModalYesNo = () => screen.queryByTestId("modal-yes-no");
  const invokeHeroDelete = async () => {
    userEvent.click((await deleteButtons())[0]);
    expect(await modalYesNo()).toBeVisible();
  };

  it("should go through the modal flow, and cover error on DELETE", async () => {
    expect(screen.queryByTestId("modal-dialog")).not.toBeInTheDocument();

    await invokeHeroDelete();
    await userEvent.click(await screen.findByTestId("button-no"));
    expect(maybeModalYesNo()).not.toBeInTheDocument();

    await invokeHeroDelete();
    await userEvent.click(await screen.findByTestId("button-yes"));

    expect(maybeModalYesNo()).not.toBeInTheDocument();
    expect(await screen.findByTestId("error")).toBeVisible();
    expect(screen.queryByTestId("modal-dialog")).not.toBeInTheDocument();
  });
});


Enter fullscreen mode Exit fullscreen mode


// CyCT + cy.intercept
context("200 flows", () => {
  beforeEach(() => {
    // the GET is common to both tests
    cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`, {
      fixture: "heroes.json",
    }).as("getHeroes");

    cy.wrappedMount(<Heroes />);
  });

  it("should display the hero list on render, and go through hero add & refresh flow", () => {
    cy.wait("@getHeroes");

    cy.getByCy("list-header").should("be.visible");
    cy.getByCy("hero-list").should("be.visible");

    cy.getByCy("add-button").click();
    cy.location("pathname").should("eq", "/heroes/add-hero");

    cy.getByCy("refresh-button").click();
    cy.location("pathname").should("eq", "/heroes");
  });

  const invokeHeroDelete = () => {
    cy.getByCy("delete-button").first().click();
    cy.getByCy("modal-yes-no").should("be.visible");
  };

  it("should go through the modal flow, and cover error on DELETE", () => {
    cy.getByCy("modal-yes-no").should("not.exist");

    cy.log("do not delete flow");
    invokeHeroDelete();
    cy.getByCy("button-no").click();
    cy.getByCy("modal-yes-no").should("not.exist");

    cy.log("delete flow");
    invokeHeroDelete();

    // DELETE mock is unique to this test
    // we can define it or change our network mock on the fly
    // we could for example have a new GET definition here
    cy.intercept("DELETE", "*", { statusCode: 500 }).as("deleteHero");

    cy.getByCy("button-yes").click();
    cy.wait("@deleteHero");
    cy.getByCy("modal-yes-no").should("not.exist");
    cy.getByCy("error").should("be.visible");
  });
});


Enter fullscreen mode Exit fullscreen mode

In our opinion, the intercept API is simpler and is more flexible compared to MSW. We can observe this in the significant difference in the amount of code we have to write, doing the same thing in RTL + msw vs CyCT + cy.intercept. You can compare them here RTL + MSW Heroes.test.tsx vs CyCT + cy.intercept.

Image description

CyCT vs RTL examples - Epic React

In the interest of brevity, we will share links to 13 Cypress component tests and their RTL mirrors. All CyCT examples are from the repo cypress-react-component-test-examples where you can find 400+ individual CyCT examples.

Simple counter: cyct vs rtl

Testing with context: cyct vs rtl

Simple redux: cyct vs rtl

A11y: cyct vs rtl

Geolocation: cyct vs rtl

Mocking http (intercept vs msw) : cyct vs rtl, another cyct vs rtl

Router-redirect: cyct vs rtl

React-router: cyct vs rtl

Modal: cyct vs rtl

Stub window fetch: cyct vs rtl

Wrapping up

We went through, in detail, 3 examples of CyCT vs RTL from the repo & book CCTDD: Cypress Component Test Driven Design where you can find a few dozen more examples of CyCT vs RTL.

We compared low level mocking in CyCT with Sinon vs mocking in RTL with Jest, with many examples and a sample cheat sheet repo.

We compared network level mocking using cy.intercept vs msw with repository links.

Finally we shared about a dozen more CyCT vs RTL examples from Kent C. Dodds' Epic React.

Equipped with these resources, you have cheat sheets at your fingertips and an information toolset to begin rolling out Cypress component testing in your organizations.

Addendum: Gleb Bahmutov's The Missing Comparison Part video: a comparison of the developer experience

In his video, Gleb covered an important part of comparing RTL and CyCT; the developer experience. "Jest (with RTL) use the terminal JSDom, while Cypress gives you the real browser with time traveling debugger, making debugging errors so so so much simpler in Cypress" We strongly suggest to see the video, and we will cover the three key points below.

1. Compare the devex making a simple breaking change to the source code: HeaderBarBrand

Pull the repo, install and start CyCT with RTL side by side; yarn cy:open-ct , yarn test. Execute the CyCT and RTL tests for HeaderBarBrand.

Make a breaking change in HeaderBarBrand component; on line 20 change the spelling of OF to ON.



<NavLink data-cy="navLink" to="/" className="navbar-item navbar-home">
  <span className="tour">TOUR</span>
  {/* Change OF to ON */}
  <span className="of">ON</span>
  <span className="heroes">HEROES</span>
</NavLink>


Enter fullscreen mode Exit fullscreen mode

Compare how you would diagnose this failure in RTL and CyCT.

Here is the RTL failure:

Image description

Here is the CyCT failure:

Image description

2. Compare test stability by simulating an asynchronous process: InputDetail

Execute the CyCT and RTL tests for InputDetail.

On line 34 of InputDetail component, introduce a setTimeout to simulate an asynchronous process. These are very common in real applications, and it is a solid comparison of the stability of the two tools, especially in CI.



<input
  name={name}
  role={name}
  defaultValue={shownValue}
  placeholder={placeholder}
  // @ts-expect-error add a setTimeout to simulate an asynchronous delay
  onChange={() => setTimeout(onChange, 1000)}
  readOnly={readOnly}
  className="input"
  type="text"
></input>


Enter fullscreen mode Exit fullscreen mode

CyCT executes the same, with a slight delay retrying.

Image description

We cannot make the RTL test pass as is; it is synchronous. We would have to add asynchronous assertions to the test to make it work.

Image description

3. Compare the devex making a "complex" change: Heroes

The test of concern stubs the deleting of a hero with a 500 network response (MSW with RTL, and cy.intercept with CyCT) to simulate a deletion error.

In Heroes component, comment out line 40 so that we are doing nothing upon hero deletion.



const handleDeleteFromModal = () => {
  // heroToDelete ? deleteHero(heroToDelete) : null
  setShowModal(false);
};


Enter fullscreen mode Exit fullscreen mode

We are in the dark, trying to identify why the test did not work looking at RTL results (which are a few times longer than the screenshot):

Image description

Looking at the CyCT failure, we can easily tell that the 500 network call never happened:

Image description

Top comments (7)

Collapse
 
leob profile image
leob

Very impressive article, THIS is the kind of content I like to see on dev.to ... based on this, for a greenfield project I'd seriously consider using just one tool (Cypress) for both e2e and component testing, rather than having to juggle two different tools!

For a similar conclusion see (but that's from the Cypress team, there might be a bias):

cypress.io/blog/2022/08/25/how-com...

Collapse
 
karlaecs profile image
Karla Santos

Thanks @muratkeremozcan ! And about performance, what did you think was best?

Collapse
 
muratkeremozcan profile image
Murat K Ozcan

Doesn't matter what I think, we can all pull the repo and experiment

git clone https://github.com/muratkeremozcan/tour-of-heroes-react-vite-cypress-ts.git
cd tour-of-heroes-react-vite-cypress-ts
yarn 

# use jest to test perf of the entire suite
yarn jest:coverage 

# use cypress to test the pef of the test suite (no video or screenshot)
yarn cy:run-ct-fast

Enter fullscreen mode Exit fullscreen mode

Jest:
tests only: 25.775s
startup & tests: 27.07s

CycT
tests only: 15s
startup & tests: 34.15s

At scale, when there are many more tests, surprisingly CyCT would be faster.
There is cost at the initial startup, but upcoming new tech with Vite / esbuild will benefit the browser to speeed this up. However it will not benefit the node environment / Jest.

For individual tests in Jest watch mode vs Cypress open mode (yarn test vs yarn cy:open-ct), again, surprizingly CyCT is much faster (with passes and with failures) . You can check this out at the video presentation of this blog post at youtube.com/watch?v=QdZuKKK7oD0 after minute 17. Again, the new browser tech with vite & esbuild only benefits Cypress in this case.

Collapse
 
leob profile image
leob

Cypress component testing FTW ! One tool to rule them all :)

Collapse
 
zacrify profile image
Pi Shetshotisak (Pi)

Comprehensive research that on par with published paper. Kudo!

Collapse
 
manikandanm1757 profile image
manikandanm1757

@muratkeremozcan Is it possible to use cypress-axe to perform accessibility testing in CyCT? if possible please share minimum required configuration for this or any article link?

Collapse
 
muratkeremozcan profile image
Murat K Ozcan

Take a look at this. The test is disabled, and would fail if enabled because it fails the accessibility check.

You need to import 'cypress-axe' either at the spec file, or component.tsx file or commands.ts file. Import it at spec so all the plugin imports do not needlessly load before every component and or e2e tests.

github.com/muratkeremozcan/cypress...