CCTDD: Cypress Component Test Driven Design
Murat K. Ozcan
Staff Engineer, Test Architect
Ambassador
Combinatorial Testing Comittee
Contents
-
Test Driven Design
-
Component Testing
-
E2e Testing
-
Recommended Practices
Architecture
JSON server
API e2e
CT
UI integration
UI e2e
15
5 :
1 :
Test Driven Design
Cypress Component Test Example
Start with a placeholder test
// src/components/HeaderBarBrand.cy.tsx
import HeaderBarBrand from './HeaderBarBrand'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should', () => {
cy.mount(<HeaderBarBrand />)
})
})
// src/components/HeaderBarBrand.tsx
export default function HeaderBarBrand() {
return <div>hello</div>
}
Write a failing test
// src/components/HeaderBarBrand.cy.tsx
import HeaderBarBrand from './HeaderBarBrand'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should', () => {
cy.mount(<HeaderBarBrand />)
cy.get('a').should('have.attr', 'href', 'https://reactjs.org/')
})
})
TDD ensures fault-finding tests
for improvements that matter.
Make the test pass
// src/components/HeaderBarBrand.tsx
export default function HeaderBarBrand() {
return (
<div>
<a href="https://reactjs.org/" />
</div>
)
}
Start with something failing,
do the minimum to get it to work,
and then make it better.
Use CT as the design tool to aid RGR
// src/components/HeaderBarBrand.cy.tsx
import HeaderBarBrand from './HeaderBarBrand'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should', () => {
cy.mount(<HeaderBarBrand />)
cy.get('a')
.should('have.attr', 'href', 'https://reactjs.org/')
.should('be.visible')
})
})
Visual feedback inspires testing.
Incremental visual enhancements
// src/components/HeaderBarBrand.tsx
import {FaReact} from 'react-icons/fa'
export default function HeaderBarBrand() {
return (
<div>
<a href="https://reactjs.org/">
<FaReact />
</a>
</div>
)
}
The "Red" can be a visual feedback that's not up to our expectation.
Small incremental steps
// src/components/HeaderBarBrand.tsx
import HeaderBarBrand from './HeaderBarBrand'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should', () => {
cy.mount(<HeaderBarBrand />)
cy.get('a')
.should('have.attr', 'href', 'https://reactjs.org/')
.and('have.attr', 'target', '_blank')
.and('have.attr', 'rel', 'noopener noreferrer')
})
})
// src/components/HeaderBarBrand.tsx
import {FaReact} from 'react-icons/fa'
export default function HeaderBarBrand() {
return (
<div className="navbar-brand">
<a
href="https://reactjs.org/"
target="_blank"
rel="noopener noreferrer"
className="navbar-item"
>
</a>
</div>
)
}
Obvious, but hard; write very small incremental tests at a time.
// src/components/HeaderBarBrand.tsx
import HeaderBarBrand from './HeaderBarBrand'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should', () => {
cy.mount(<HeaderBarBrand />)
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'))
})
})
// src/components/HeaderBarBrand.tsx
import {FaReact} from 'react-icons/fa'
export default function HeaderBarBrand() {
return (
<div data-cy="header-bar-brand" className="navbar-brand">
<a
href="https://reactjs.org/"
target="_blank"
rel="noopener noreferrer"
className="navbar-item"
>
<FaReact />
</a>
</div>
)
}
Build the component iteratively with tests.
A Component Test is a small scale App, so replicate the app wrappers
// src/components/HeaderBarBrand.cy.tsx
import HeaderBarBrand from './HeaderBarBrand'
import {BrowserRouter} from 'react-router-dom'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should', () => {
cy.mount(
<BrowserRouter>
<HeaderBarBrand />
</BrowserRouter>,
)
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'))
})
})
// src/components/HeaderBarBrand.tsx
import {FaReact} from 'react-icons/fa'
import {NavLink} from 'react-router-dom'
export default function HeaderBarBrand() {
return (
<div data-cy="header-bar-brand" className="navbar-brand">
<a
href="https://reactjs.org/"
target="_blank"
rel="noopener noreferrer"
className="navbar-item"
>
<FaReact />
</a>
<NavLink to="/" />
</div>
)
}
TS and ESlint can aid RGR
Wrapper Hell
Use a custom mount
Same logic in RTL
// src/components/HeaderBarBrand.cy.tsx
import HeaderBarBrand from './HeaderBarBrand'
import {BrowserRouter} from 'react-router-dom'
import '../styles.scss'
describe('HeaderBarBrand', () => {
it('should verify external link attributes', () => {
cy.mount(
<BrowserRouter>
<HeaderBarBrand />
</BrowserRouter>,
)
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'))
cy.getByCy('navLink').within(() =>
['TOUR', 'OF', 'HEROES'].forEach((part: string) =>
cy.contains('span', part),
),
)
})
})
back to our test...
// src/components/HeaderBarBrand.tsx
import {FaReact} from 'react-icons/fa'
import {NavLink} from 'react-router-dom'
export default function HeaderBarBrand() {
return (
<div data-cy="header-bar-brand" className="navbar-brand">
<a
href="https://reactjs.org/"
target="_blank"
rel="noopener noreferrer"
className="navbar-item"
data-cy="header-bar-brand-link"
>
<div data-cy="react-icon-svg">
<FaReact />
</div>
</a>
<NavLink data-cy="navLink" to="/" className="navbar-item navbar-home">
<span className="tour">TOUR</span>
<span className="of">OF</span>
<span className="heroes">HEROES</span>
</NavLink>
</div>
)
}
describe('HeaderBarBrand', () => {
it('should verify external link attributes', () => {
cy.mount(
<BrowserRouter>
<HeaderBarBrand />
</BrowserRouter>,
)
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'))
cy.getByCy('navLink').within(() =>
['TOUR', 'OF', 'HEROES'].forEach((part: string) =>
cy.contains('span', part),
),
)
cy.getByCy('navLink').click()
cy.url().should('contain', '/')
})
})
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'].forEach((part: string) =>
cy.contains('span', part),
),
)
cy.getByCy('navLink').click()
cy.url().should('contain', '/')
})
})
What matters is the beginning state of a test;
if reaching that state is common, it is an opportunity for a test enhancement. So long as tests do not duplicate, they are order-independent and stateless, either style is fine.
Break down the test or not?
No need to keep the tests short to have a smaller blast radius.
Cypress runner makes diagnosis easy.
it('should render Add Hero button', ()_ => {
addHero().should('be.visible').contains('Add Hero')
})
it('should open Hero form on click', () => {
addHero.click()
cy.getByCy('add-hero-form').should('be.visible')
})
it('should remove Hero form on Cancel', () => {
addHero().click()
cy.getByCy('add-hero-form').should('be.visible')
cy.getByCy('cancel-button').click()
cy.getByCy('add-hero-form').should('not.exist')
})
it('should toggle Add Hero form', () => {
addHero().should('be.visible').contains('Add Hero').click()
cy.getByCy('add-hero-form').should('be.visible')
cy.getByCy('cancel-button').click()
cy.getByCy('add-hero-form').should('not.exist')
})
RTL vs Cy CT
Same test, different APIs.
Contents
-
Test Driven Design
-
Component Testing
-
E2e Testing
-
Recommended Practices
Component testing
props, wrappers, parent vs child
// src/components/InputDetail.cy.tsx
import InputDetail from './InputDetail'
describe('InputDetail', () => {
it('should', () => {
cy.mount(<InputDetail />)
cy.findByPlaceholderText('Aslaug')
})
})
// src/components/InputDetail.tsx
export default function InputDetail() {
return (
<div>
<input placeholder="Aslaug"></input>
</div>
)
}
...or console.log
Use hard-coding initially
make the test pass
// src/components/InputDetail.cy.tsx
import InputDetail from "./InputDetail";
describe("InputDetail", () => {
it("should", () => {
const placeholder = "Aslaug";
cy.mount(<InputDetail placeholder={placeholder} />);
cy.findByPlaceholderText(placeholder);
});
});
// src/components/InputDetail.tsx
type InputDetailProps = {
placeholder: string
}
export default function InputDetail({placeholder}: InputDetailProps) {
return (
<div>
<input placeholder={placeholder}></input>
</div>
)
}
We either manipulate our components via props or what wraps them.
Then use props (or wrappers)
passing data via prop
cy.wrappedMount(
<VillainsContext.Provider value={villains}>
<VillainList />
</VillainsContext.Provider>
)
cy.wrappedMount(
<VillainList />
)
cy.wrappedMount(
<HeroList heroes={heroes} />
)
cy.wrappedMount(
<HeroList heroes={[]} />
)
vs passing data using Context API
// src/components/InputDetail.cy.tsx
import InputDetail from './InputDetail'
describe('InputDetail', () => {
it('should', () => {
const placeholder = 'Aslaug'
const name = 'name'
cy.mount(<InputDetail name={name} placeholder={placeholder} />)
cy.contains(name)
cy.findByPlaceholderText(placeholder)
})
})
// src/components/InputDetail.tsx
type InputDetailProps = {
name: string
placeholder: string
}
export default function InputDetail({name, placeholder}: InputDetailProps) {
return (
<div>
<label>{name}</label>
<input placeholder={placeholder}></input>
</div>
)
}
Add the prop to the component types.
When adding props...
Add it to the arguments or the component.
Use the prop in the component.
// src/components/InputDetail.cy.tsx
import InputDetail from './InputDetail'
import '../styles.scss'
describe('InputDetail', () => {
it('should', () => {
const placeholder = 'Aslaug'
const name = 'name'
const value = 'some value'
cy.mount(
<InputDetail name={name} value={value} placeholder={placeholder} />,
)
cy.contains(name)
cy.get('input').should('have.value', value)
cy.findByPlaceholderText(placeholder)
})
})
// src/components/InputDetail.tsx
type InputDetailProps = {
name: string
value: string
placeholder: string
}
export default function InputDetail({
name,
value,
placeholder,
}: InputDetailProps) {
return (
<div className="field">
<label className="label" htmlFor={name}>
{name}
</label>
<input
name={name}
defaultValue={value}
placeholder={placeholder}
className="input"
type="text"
></input>
</div>
)
}
keep adding props...
describe('InputDetail', () => {
it('should allow the input field to be modified', () => {
const placeholder = 'Aslaug'
const name = 'name'
const value = 'some value'
const newValue = '42'
cy.mount(
<InputDetail name={name} value={value} placeholder={placeholder} />,
)
cy.contains(name)
cy.findByPlaceholderText(placeholder).clear().type(newValue)
cy.get('input').should('have.value', newValue)
})
it('should not allow the input field to be modified when readonly', () => {
const placeholder = 'Aslaug'
const name = 'name'
const value = 'some value'
cy.mount(
<InputDetail
name={name}
value={value}
placeholder={placeholder}
readOnly={true}
/>,
)
cy.contains(name)
cy.findByPlaceholderText(placeholder).should('have.attr', 'readOnly')
})
})
type InputDetailProps = {
name: string
value: string
placeholder?: string
readOnly?: boolean
}
export default function InputDetail({
name,
value,
placeholder,
readOnly,
}: InputDetailProps) {
return (
<div className="field">
<label className="label" htmlFor={name}>
{name}
</label>
<input
name={name}
defaultValue={value}
placeholder={placeholder}
readOnly={readOnly}
className="input"
type="text"
></input>
</div>
)
}
import {ChangeEvent} from 'react'
type InputDetailProps = {
name: string
value: string
placeholder?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
readOnly?: boolean
}
export default function InputDetail({
name,
value,
placeholder,
onChange,
readOnly,
}: InputDetailProps) {
return (
<div className="field">
<label className="label" htmlFor={name}>
{name}
</label>
<input
name={name}
defaultValue={value}
placeholder={placeholder}
onChange={onChange}
readOnly={readOnly}
className="input"
type="text"
></input>
</div>
)
}
describe('InputDetail', () => {
it('should allow the input field to be modified', () => {
const placeholder = 'Aslaug'
const name = 'name'
const value = 'some value'
const newValue = '42'
cy.mount(
<InputDetail
name={name}
value={value}
placeholder={placeholder}
onChange={cy.stub().as('onChange')}
/>,
)
cy.contains(name)
cy.findByPlaceholderText(placeholder).clear().type(newValue)
cy.get('input').should('have.value', newValue)
cy.get('@onChange').should('have.been.calledTwice')
}) ...
Seek failures through tests:
when we have green tests, we want to prefer adding more tests or refactoring
versus adding additional source code.
Stub events
Parent vs Child
Parent vs Child
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 handle name change', () => {
const newHeroName = 'abc'
cy.getByCy('input-detail-name').type(newHeroName)
cy.findByDisplayValue(newHeroName).should('be.visible')
})
it('should handle description change', () => {
const newHeroDescription = '123'
cy.getByCy('input-detail-description').type(newHeroDescription)
cy.findByDisplayValue(newHeroDescription).should('be.visible')
})
Use data-cy in the top tag of the component
Are we duplicating the test effort covered elsewhere?
Find opportunities to cover different functionalities.
RTL vs Cy CT
Contents
-
Test Driven Design
-
Component Testing
-
E2e Testing
-
Recommended Practices
E2e
Great for E2e + TDD:
- routing
- state management
- application flows
Aim to test at lowest level, we move up when we cannot test confidently
- ui-e2e
- ui-integration
- parent component
- child component
Routing feature with TDD + E2e
// cypress/e2e/routes-nav.cy.ts
describe('e2e sanity', () => {
it('should display header bar', () => {
cy.visit('/')
cy.getByCy('header-bar').should('be.visible')
})
})
// src/App.tsx
import HeaderBar from "components/HeaderBar";
import { BrowserRouter } from "react-router-dom";
import "./styles.scss";
function App() {
return (
<BrowserRouter>
<HeaderBar />
</BrowserRouter>
);
}
export default App;
Take hints from child components.
// src/components/HeaderBar.cy.tsx
import HeaderBar from './HeaderBar'
import '../styles.scss'
import {BrowserRouter} from 'react-router-dom'
describe('HeaderBar', () => {
it('should render', () => {
cy.mount(
<BrowserRouter>
<HeaderBar />
</BrowserRouter>,
)
cy.getByCy('header-bar-brand')
})
})
// cypress/e2e/routes-nav.cy.ts
describe('e2e sanity', () => {
it('should render header bar and nav bar', () => {
cy.visit('/')
cy.getByCy('header-bar').should('be.visible')
cy.getByCy('nav-bar').should('be.visible')
})
})
// src/App.tsx
import HeaderBar from 'components/HeaderBar'
import NavBar from 'components/NavBar'
import {BrowserRouter} from 'react-router-dom'
import './styles.scss'
function App() {
return (
<BrowserRouter>
<HeaderBar />
<NavBar />
</BrowserRouter>
)
}
export default App
Start with something failing,
do the minimum to get it to work,
and then make it better.
TDD flow is the same with E2e
// cypress/e2e/routes-nav.cy.ts
describe('e2e sanity', () => {
it('should render header bar and nav bar', () => {
cy.visit('/')
cy.getByCy('header-bar').should('be.visible')
cy.getByCy('nav-bar').should('be.visible')
})
it('should land on not found for non-existing', () => {
cy.visit('/route48')
cy.getByCy('not-found').should('be.visible')
})
it('should direct-navigate to about', () => {
cy.visit('/about')
cy.getByCy('about').contains('CCTDD')
})
})
// src/App.tsx
import About from 'About'
import HeaderBar from 'components/HeaderBar'
import NavBar from 'components/NavBar'
import NotFound from 'components/NotFound'
import {BrowserRouter, Routes, Route} from 'react-router-dom'
import './styles.scss'
export default function App() {
return (
<BrowserRouter>
<HeaderBar />
<div className="section columns">
<NavBar />
<main className="column">
<Routes>
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}
Incremental steps...
Find out about the application through testing.
// cypress/e2e/routes-nav.cy.ts
describe('e2e sanity', () => {
it('shoul v, () => {
cy.visit('/')
cy.getByCy('header-bar').should('be.visible')
cy.getByCy('nav-bar').should('be.visible')
cy.location('pathname').should('eq', route)
})
it('should land on not found', () => {
const route = '/route48'
cy.visit(route)
cy.location('pathname').should('eq', route)
cy.getByCy('not-found').should('be.visible')
})
it('should direct-navigate to about', () => {
const route = '/about'
cy.visit(route)
cy.location('pathname').should('eq', route)
cy.getByCy('about').contains('CCTDD')
})
})
If we have passing tests,
add tests or refactor before adding more source code.
describe('e2e sanity', () => {
it('should land on baseUrl, redirect to /heroes', () => {
cy.visit('/')
cy.getByCy('header-bar').should('be.visible')
cy.getByCy('nav-bar').should('be.visible')
cy.location('pathname').should('eq', route)
cy.getByCy('heroes').should('be.visible')
})
it('should direct-navigate to /heroes', () => {
const route = '/heroes'
cy.visit(route)
cy.location('pathname').should('eq', route)
cy.getByCy('heroes').should('be.visible')
})
it('should land on not found ', () => {
const route = '/route48'
cy.visit(route)
cy.location('pathname').should('eq', route)
cy.getByCy('not-found').should('be.visible')
})
// ...
})
})
Seek failures: keep adding tests until there is a failure,
and then add the new feature to pass the test.
// src/App.tsx
// ...
export default function App() {
return (
<BrowserRouter>
<HeaderBar />
<div className="section columns">
<NavBar />
<main className="column">
<Routes>
<Route path="/" element={<Navigate replace to="/heroes" />} />
<Route path="/heroes" element={<Heroes />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}
Contents
-
Test Driven Design
-
Component Testing
-
E2e Testing
-
Recommended Practices
Recommended practices
API e2e
UI e2e
UI integration
Error cases
Avoid testing implementation details
Exploit the visual feedback
Use combined code coverage
API e2e
Use an API test client, vs late UI e2e.
We don't get to do naive UI e2e
before there is confidence that the backend works.
Be careful with effort duplication, use modest UI e2e & gap-fill.
API e2e strategy
-
Creation
- API create
- API delete
-
Update
- API create
- API update
- API delete
-
Delete
- API create
- API delete
Update covers it all
API e2e: test your backend throughly
Preferably where the backend code lives.
it('should CRUD a new hero entity', () => {
const newHero = {
id: faker.datatype.uuid(),
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
}
cy.crud('POST', 'heroes', {body: newHero}).its('status').should('eq', 201)
cy.crud('GET', 'heroes')
.its('body')
.then(body => {
expect(body.at(-1)).to.deep.eq(newHero)
})
const editedHero = {...newHero, name: 'Murat'}
cy.crud('PUT', `heroes/${editedHero.id}`, {body: editedHero})
.its('status')
.should('eq', 200)
cy.crud('GET', `heroes/${editedHero.id}`)
.its('body')
.should('deep.eq', editedHero)
cy.crud('DELETE', `heroes/${editedHero.id}`).its('status').should('eq', 200)
cy.crud('GET', `heroes/${editedHero.id}`, {allowedToFail: true})
.its('status')
.should('eq', 404)
})
UI e2e strategy
-
Creation
- API create
- API delete
-
Update
- API create
- API update
- API delete
-
Delete
- API create
- API delete
-
Creation
- UI create
- API delete
-
Update
- API create
- UI update
- API delete
-
Delete
- API create
- UI delete
// cypress/e2e/create-hero.cy.ts
it('should go through the add hero flow (ui-e2e)', () => {
cy.intercept("GET", `${Cypress.env("API_URL")}/heroes`)
.as("getHeroes")
cy.visit("/");
cy.wait("@getHeroes")
navToAddHero()
const newHero = {
name: faker.internet.userName(),
description: `description ${faker.internet.userName()}`,
}
cy.getByCy('input-detail-name').type(newHero.name)
cy.getByCy('input-detail-description').type(newHero.description)
cy.getByCy('save-button').click()
cy.location('pathname').should('eq', '/heroes')
cy.getByCy('heroes').should('be.visible')
cy.getByCyLike('hero-list-item').should('have.length.gt', 0)
cy.getByCy('hero-list')
.should('contain', newHero.name)
.and('contain', newHero.description)
cy.getEntityByProperty('hero', newHero.name).then(myHero =>
cy.crud('DELETE', `heroes/${myHero.id}`),
)
})
Creation example
UI create
API delete
Assertions
Use even smaller steps with
TDD + e2e.
UI-integration vs UI-e2e
Always evaluate if you need the backend to gain confidence in your app's functionality.
You should only use true e2e tests when you need this confidence, and you should not have to repeat the same costly e2e tests everywhere.
If your backend is tested by its own e2e tests, your UI e2e needs at the front end are even less; be careful not to duplicate too much of the backend effort.
Convert UI-e2e to UI-integration
it('should go through the refresh flow', () => {
cy.intercept('GET', `${Cypress.env('API_URL')}/heroes`)
.as('getHeroes')
cy.visit('/')
cy.wait('@getHeroes')
navToAddHero()
cy.getByCy('refresh-button').click()
cy.location('pathname').should('eq', '/heroes')
cy.getByCy('hero-list').should('be.visible')
})
it('should go through the refresh flow (ui-integration)', () => {
cy.intercept('GET', `${Cypress.env('API_URL')}/heroes`, {
fixture: 'heroes',
}).as('stubbedGetHeroes')
cy.visit('/')
cy.wait('@stubbedGetHeroes')
navToAddHero()
cy.getByCy('refresh-button').click()
cy.location('pathname').should('eq', '/heroes')
cy.getByCy('hero-list').should('be.visible')
})
it('should go through the cancel flow', () => {
cy.intercept('GET', `${Cypress.env('API_URL')}/heroes`)
.as('getHeroes')
cy.visit('/heroes/add-hero')
cy.wait('@getHeroes')
cy.getByCy('cancel-button').click()
cy.location('pathname').should('eq', '/heroes')
cy.getByCy('hero-list').should('be.visible')
})
it('should go through the cancel flow (ui-integration)', () => {
cy.intercept('GET', `${Cypress.env('API_URL')}/heroes`, {
fixture: 'heroes',
}).as('stubbedGetHeroes')
cy.visit('/heroes/add-hero')
cy.wait('@stubbedGetHeroes')
cy.getByCy('cancel-button').click()
cy.location('pathname').should('eq', '/heroes')
cy.getByCy('hero-list').should('be.visible')
})
Checking error cases
Any test covering the positive flows with cy.intercept()
is a good branching spot.
Start at the component level,
move up to ui-integration if further negative tests are not possible.
it('should handle Save', () => {
cy.intercept('POST', '*', {statusCode: 200})
.as('postHero')
cy.getByCy('save-button').click()
cy.wait('@postHero')
})
it('should handle non-200 Save', () => {
cy.intercept('POST', '*', {statusCode: 400, delay: 100})
.as('postHero')
cy.getByCy('save-button').click()
cy.getByCy('spinner')
cy.wait('@postHero')
cy.getByCy('error')
})
Checking error cases
Avoid testing implementation details
it('should handle Save (implementaiton details)', () => {
cy.spy(React, 'useState').as('useState')
cy.spy(reactQuery, 'useQueryClient').as('useMutation')
cy.spy(postHook, 'usePostEntity').as('usePostEntity')
cy.getByCy('save-button').click()
cy.get('@useState').should('have.been.called')
cy.get('@useMutation').should('have.been.calledOnce')
cy.get('@usePostEntity').should('have.been.calledOnce')
})
it('should handle Save', () => {
cy.intercept('POST', '*', {statusCode: 200})
.as('postHero')
cy.getByCy('save-button').click()
cy.wait('@postHero')
})
In component tests, lean more towards black box
to avoid testing implementation details.
Same idea in RTL with Mock Service Worker (msw), comparable to cy.intercept.
Take advantage of the visual feedback
// src/components/NavBar.cy.tsx
import NavBar from './NavBar'
import {BrowserRouter} from 'react-router-dom'
import '../styles.scss'
describe('NavBar', () => {
it('should navigate to the correct routes', () => {
cy.mount(
<BrowserRouter>
<NavBar />
</BrowserRouter>,
)
cy.getByCy('nav-bar').within(() => {
cy.contains('p', 'Menu')
cy.getByCy('menu-list').children().should('have.length', 3)
['heroes', 'villains', 'about'].each((route: string) => {
cy.get(`[href="/${route}"]`).contains(route, {matchCase: false}).click()
cy.url().should('contain', route)
})
})
})
})
The quality of the feedback improves TDD and early defect detection.
Ad-hoc is possible in a Cypress Component Test
Use combined code coverage
What have we learned
The quality of the feedback with Cypress component testing improves TDD, component engineering and early defect detection.
Once we move up to routing & state management, TDD with e2e is more relevant.
With TDD the key idea is to start with something failing, do the minimum to get it to work, and then make it better.
Links & references
the book
the app
CCTDD
By Murat Ozcan
CCTDD
CCTDD: Cypress Component Test Driven Design
- 883