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