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 vs MSW

  • 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...

Simple counter: cyct vs rtl

Testing with context: cyct vs rtl

Simple redux: cyct vs rtl

A11y: cyct vs rtl

Geolocation: cyct vs rtl

Router-redirect: cyct vs rtl

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

React-router: cyct vs rtl

Modal: cyct vs rtl

Stub window fetch: cyct vs rtl

Simple redux: cyct vs rtl

/** 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')
})

A11y: cyct vs rtl

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();
})

Geolocation: cyct vs rtl

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 vs MSW

  • 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 vs MSW

  • 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 vs MSW

  • 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 vs MSW

  • 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

  • 382