Cypress Component Testing vs React Testing Library
Murat K. Ozcan
Staff Engineer, Test Architect
Ambassador
Combinatorial Testing Comittee
Contents
-
CyCT vs RTL examples
-
Low level spies & mocks: Sinon vs Jest
-
Network spies & mocks:
cy.intercept
vsMSW
-
Comparison of the developer experience
Our experience at Extend
Jan 1st 2023
October 13th 2023
Wrapper Hell
HeaderBarBrand Component
RTL vs Cy CT
Same test, different APIs.
InputDetail Component
InputDetail
NavBar Component
html vs the browser...
/** 5 imports **/
it('can render with redux with defaults', () => {
cy.mount(
<Provider store={store}>
<Counter />
</Provider>
)
cy.getByCy('+').click()
cy.findByLabelText('count').should('have.text', '1')
})
it('can render with redux with custom initial state', () => {
const store = createStore(reducer, { count: 3 })
cy.mount(
<Provider store={store}>
<Counter />
</Provider>
)
cy.getByCy('-').click()
cy.findByLabelText('count').should('have.text', '2')
})
/* 8 imports */
test('can render with redux with defaults', () => {
render(
<Provider store={store}>
<Counter />
</Provider>,
)
userEvent.click(screen.getByText('+'))
expect(screen.getByLabelText(/count/i)).toHaveTextContent(1)
})
test('can render with redux with custom initial state', () => {
const store = createStore(reducer, {count: 3})
render(
<Provider store={store}>
<Counter />
</Provider>,
)
userEvent.click(screen.getByText('-'))
expect(screen.getByLabelText(/count/i)).toHaveTextContent('2')
})
import 'cypress-axe'
/* import InaccessibleForm */
it('checks all violations', () => {
cy.mount(<InaccessibleForm />)
cy.injectAxe()
cy.checkAlly()
})
it('checks filtered violations', () => {
cy.mount(<InaccessibleForm />)
cy.injectAxe()
cy.checkA11y(null, {
includedImpacts: ['moderate', 'serious']
})
})
import {axe} from 'jest-axe'
/* import InaccessibleForm */
test('all violations', async () => {
const {container} = render(<InaccessibleForm />)
expect(await axe(container)).toHaveNoViolations()
})
it('checks filtered violations', async () => {
const { container } = render(<InaccessibleForm />);
const results = await axe(container, {
runOnly: {
type: 'rule',
values: ['moderate', 'serious']
}
})
expect(results).toHaveNoViolations();
})
it('displays the users current location', () => {
const fakePosition = {
coords: {
latitude: 35,
longitude: 139
}
}
cy.window().then((win) =>
cy
.stub(win.navigator.geolocation, 'getCurrentPosition')
.callsArgWith(0, fakePosition)
.as('getCurrentPosition')
)
cy.mount(<Location />)
cy.get('@getCurrentPosition').should('be.called')
cy.contains(fakePosition.coords.latitude)
cy.contains(fakePosition.coords.longitude)
})
// util to create a promise that you can resolve/reject on demand.
function deferred() {
let resolve, reject
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return {promise, resolve, reject}
}
test('displays the users current location', async () => {
const fakePosition = {
coords: {
latitude: 35,
longitude: 139,
},
}
const {promise, resolve} = deferred()
window.navigator.geolocation = {
getCurrentPosition: jest.fn(),
}
window.navigator.geolocation.getCurrentPosition.mockImplementation(callback =>
promise.then(() => callback(fakePosition)),
)
render(<Location />)
await act(async () => {
resolve()
await promise
})
expect(window.navigator.geolocation.getCurrentPosition).toHaveBeenCalled()
expect(screen.getByText(/latitude/i)).toHaveTextContent(
`Latitude: ${fakePosition.coords.latitude}`,
)
expect(screen.getByText(/longitude/i)).toHaveTextContent(
`Longitude: ${fakePosition.coords.longitude}`,
)
})
Contents
-
CyCT vs RTL examples
-
Low level spies & mocks: Sinon vs Jest
-
Network spies & mocks:
cy.intercept
vsMSW
-
Comparison of the developer experience
to.have.been.called
toHaveBeenCalled
cy.spy(obj, 'method')
jest.spyOn(obj, 'method')
have.been.calledTwice
its('callCount').should('eq', 2)
toHaveBeenCalledTimes(2)
sinon.match.string
expect.any(String)
spies & assertions
custom matchers
const spy = cy.spy(calculator, "add").as("add")
calculator.add(2, 3)
const isEven = (x: number) => x % 2 === 0
const isOdd = (x: number) => x % 2 === 1
const { match } = Cypress.sinon
expect(spy).to.be.calledWith(match(isEven), match(isOdd))
const spy = jest.spyOn(calculator, 'add')
calculator.add(2, 3)
const isEven = (x: number) => x % 2 === 0
const isOdd = (x: number) => x % 2 === 1
expect.extend({
toBeEven(received) {
const pass = isEven(received)
if (pass) {
return {
message: () => `expected ${received} not to be an even number`,
pass: true,
}
} else {
return {
message: () => `expected ${received} to be an even number`,
pass: false,
}
}
},
toBeOdd(received) {
const pass = isOdd(received)
if (pass) {
return {
message: () => `expected ${received} not to be an odd number`,
pass: true,
}
} else {
return {
message: () => `expected ${received} to be an odd number`,
pass: false,
}
}
},
})
expect(spy.mock.calls[0][0]).toBeEven()
expect(spy.mock.calls[0][1]).toBeOdd()
asynchronous testing
it("resolved value (promises)", () => {
const calc = {
add(a: number, b: number) {
return Cypress.Promise.resolve(a + b)
},
}
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])
})
it("resolved value (promises)", async () => {
const calc = {
add(a: number, b: number) {
return new Promise((resolve) => {
resolve(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)
expect(spy).toHaveBeenNthCalledWith(1, 4, 5)
expect(spy).toHaveBeenNthCalledWith(2, 1, 90)
expect(spy).toHaveBeenNthCalledWith(3, -5, -8)
expect(results).toEqual([9, 91, -13])
});
cy.stub(obj, 'method')
jest.spyOn(obj, 'method')
.mockImplementation(jest.fn())
cy.stub()
jest.fn()
cy.stub(obj, 'method')
.returns('foo')
jest.spyOn(obj, 'method')
.mockReturnValue('foo')
stubs / mocks
cy.stub(obj, 'method')
.withArgs('bar')
.returns('foo')
jest.spyOn(obj, 'method')
.mockImplementation(arg => {
if (arg === 'bar') return 'foo'
})
stubs / mocks
cy.stub(obj, 'method')
.resolves('foo')
jest.spyOn(obj, 'method')
.mockImplementation(() =>
Promise.resolve('foo')
)
cy.stub(obj, 'method')
.rejects(new Error('foo'))
jest.spyOn(obj, 'method')
.mockImplementation(() =>
Promise.reject(new Error('foo'))
)
stubs / mocks
Restoring the original method after stub
Matchers: .callThrough(), withArgs(), match.type, match(predicate)
Declarative convenience vs imperative possibility
Calling the original method from the stub
Controlling time cy.clock
vs jest.useFakeTimers
Contents
-
CyCT vs RTL examples
-
Low level spies & mocks: Sinon vs Jest
-
Network spies & mocks:
cy.intercept
vsMSW
-
Comparison of the developer experience
our code
network requests
mock here
less here
+ custom mount
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
})
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();
});
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...", () => {
// test code
})
it("should go through the modal flow, and cover error on DELETE", () => {
// test code
// DELETE mock is unique to this test
// we can define it or change our network mock on the fly
cy.intercept("DELETE", "*", { statusCode: 500 }).as("deleteHero")
invokeHeroDelete()
// test code
})
})
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
rest.delete(
`${process.env.REACT_APP_API_URL}/heroes/${heroes[0].id}`, // use /.*/ for all requests
async (_req, res, ctx) => res(ctx.status(500), ctx.json("expected error"))
),
]
const server = setupServer(...handlers)
beforeAll(() => {
server.listen({
onUnhandledRequest: "warn",
})
})
beforeEach(() => wrappedRender(<Heroes />))
afterEach(server.resetHandlers)
afterAll(server.close)
it("should display the hero list on render...", async () => {
// test code
})
it("should go through the modal flow, and cover error on DELETE", async () => {
// test code
// uses the network definition in the beginning
invokeHeroDelete()
// test code
})
})
Contents
-
CyCT vs RTL examples
-
Low level spies & mocks: Sinon vs Jest
-
Network spies & mocks:
cy.intercept
vsMSW
-
Comparison of the developer experience
DevX comparison
// ./src/components/HeaderBarBrand.ts
<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>
debugging failures
DevX comparison
// ./src/components/InputDetail.ts
<input
name={name}
role={name}
defaultValue={shownValue}
placeholder={placeholder}
// add a setTimeout to simulate an asynchronous delay
onChange={() => setTimeout(onChange, 3000)}
readOnly={readOnly}
className="input"
type="text"
></input>
stability with async processes
DevX comparison
// ./src/heroes/Heroes.ts
const handleDeleteFromModal = () => {
// heroToDelete ? deleteHero(heroToDelete) : null
setShowModal(false)
}
testing in the dark vs testing with lights on
Contents
-
CyCT vs RTL examples
-
Low level spies & mocks: Sinon vs Jest
-
Network spies & mocks:
cy.intercept
vsMSW
-
Comparison of the developer experience
Wrap up
There are many similarities between CyCT & RTL; if we know RTL & some Cypres e2e, we will be very comfortable with CyCT
Declarative convenience vs imperative possibility
The key differentiator is the developer experience;
real browser vs html
full observability of the app vs working in the dark
Links & references
Cypress Component Testing vs React Testing Library
By Murat Ozcan
Cypress Component Testing vs React Testing Library
Cypress Component Testing vs React Testing Library
- 392