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:
- CyCT vs RTL examples - Tour of Heroes (ToH)
- Comparison of low level spies & mocks: Sinon vs Jest
- Comparison of network spies & mocks:
cy.intercept
vsMSW
- CyCT vs RTL examples - Epic React
- Wrapping up
- Addendum: Gleb Bahmutov's The Missing Comparison Part video: a comparison of the developer experience
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):
June 2023:
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.
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", "/");
});
});
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("/");
});
});
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}
/>
);
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");
});
});
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");
});
});
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
.
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);
});
});
});
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");
});
});
});
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.
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
vstoHaveBeenCalled
,have.been.calledTwice
vstoHaveBeenCalledTimes(2)
Matchers:
sinon.match.string
vsexpect.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"));
-
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
andawait 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 withPromise.all
and have to usetoHaveBeenNthCalledWith
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]);
});
// 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]);
});
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();
Replace obj.method() with a stubbed function:
cy.stub(obj, "method");
jest.spyOn(obj, "foo").mockImplementation(jest.fn());
Force obj.method() to return a value:
cy.stub(obj, "method").returns("Cliff");
jest.spyOn(obj, "method").mockReturnValue("Cliff");
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";
});
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");
});
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"));
});
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");
});
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");
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!");
});
});
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!");
});
});
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");
});
});
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");
});
});
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
);
});
});
});
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();
});
});
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
});
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();
});
});
// 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");
});
});
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.
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.
Testing with context: cyct vs rtl
Mocking http (intercept vs msw) : cyct vs rtl, another 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>
Compare how you would diagnose this failure in RTL and CyCT.
Here is the RTL failure:
Here is the CyCT failure:
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>
CyCT executes the same, with a slight delay retrying.
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.
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);
};
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):
Looking at the CyCT failure, we can easily tell that the 500 network call never happened:
Top comments (7)
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...
Thanks @muratkeremozcan ! And about performance, what did you think was best?
Doesn't matter what I think, we can all pull the repo and experiment
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.
Cypress component testing FTW ! One tool to rule them all :)
Comprehensive research that on par with published paper. Kudo!
@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?
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...