Test Angular/React/Vue/Svelte

Components Without Fear

Gleb Bahmutov

Sr Director of Engineering

Shanghai’s skyline then and now

it('should show \'HOME\' and \'LOG IN\' when user is not signed in', async () => { (Object.getOwnPropertyDescriptor(signinServiceSpy, 'isLoggedIn')?.get as jasmine.Spy).and.returnValue(false); fixture.detectChanges(); const buttonEls = await loader.getAllHarnesses(MatButtonHarness); expect(buttonEls).toHaveSize(2); const buttonTexts = await parallel(() => buttonEls.map(btn => btn.getText())); const expected = ['HOME', 'LOG IN']; expect(buttonTexts).toEqual(expected); });

Checks the text in two elements... 🤯

import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NavComponent } from './nav.component'; import { SigninService } from '../signin.service'; import { MatButtonModule } from '@angular/material/button'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { HarnessLoader, parallel } from '@angular/cdk/testing'; import { By } from '@angular/platform-browser'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatButtonHarness } from '@angular/material/button/testing'; describe('NavComponent', () => { let component: NavComponent; let fixture: ComponentFixture<NavComponent>; let loader: HarnessLoader; let signinServiceSpy = jasmine.createSpyObj<SigninService>( ['login', 'logout'], ['isLoggedIn'] ); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, MatButtonModule ], declarations: [ NavComponent ], providers: [ { provide: SigninService, useValue: signinServiceSpy } ] }) .compileComponents(); fixture = TestBed.createComponent(NavComponent); component = fixture.componentInstance; fixture.detectChanges(); loader = TestbedHarnessEnvironment.loader(fixture); }); ... });

Before we get to that test...

🤯🤯🤯 

Let's write a test...

Lots of custom syntax

Slow feedback loop

Cryptic errors

Maintenance

Writing Tests For Your Web UI Framework Components

AGENDA

  • The state of frontend testing
  • Use the browser
  • Examples
  • More examples
  • Even more examples
  • The End

Speaker: Gleb Bahmutov PhD

C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing

Learn how to test the web applications

using my courses at

https://cypress.tips/courses

Join others to fight the climate crisis

Gleb Bahmutov

Sr Director of Engineering

nav.component.ts

$ npm install -D cypress
{ "devDependencies": { "@angular-devkit/build-angular": "^14.1.0", "@angular/cli": "~14.1.0", "@angular/compiler-cli": "^14.1.0", "@types/jasmine": "~4.0.0", "jasmine-core": "~4.2.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.1.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.0.0", "tailwindcss": "^3.1.6", "typescript": "~4.7.2" } }

package.json

{ "devDependencies": { "@angular-devkit/build-angular": "^14.1.0", "@angular/cli": "~14.1.0", "@angular/compiler-cli": "^14.1.0", "cypress": "~10.8.0", "tailwindcss": "^3.1.6", "typescript": "~4.7.2" } }

package.json

it('adds 2 todos', () => { cy.visit('http://localhost:4200') cy.get('.new-todo') .type('learn testing{enter}') .type('be cool{enter}') cy.get('.todo-list li') .should('have.length', 2) })
import { defineConfig } from "cypress"; export default defineConfig({ component: { devServer: { framework: "angular", bundler: "webpack", }, specPattern: "**/*.cy.ts", }, });

cypress.config.ts

import { defineConfig } from "cypress"; export default defineConfig({ component: { devServer: { framework: "angular", bundler: "webpack", }, specPattern: "**/*.cy.ts", }, });

cypress.config.ts

Configuration

Let's test nav.component.ts

import { NavComponent } from './nav.component' describe('NavComponent', () => { it('should create and show the links', () => { cy.mount(NavComponent) cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') .wait(1000).click() cy.contains('a', 'HOME') cy.contains('a', 'PROFILE').should('be.visible') cy.contains('button', 'LOG OUT').should('be.visible') .wait(1000).click() cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') }) })

nav.component.cy.ts

  • speed
  • real browser
  • DOM snapshots
  • DevTools

See it run

import { NavComponent } from './nav.component' describe('NavComponent', () => { it('should create and show the links', () => { cy.mount(NavComponent) cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') .wait(1000).click() cy.contains('a', 'HOME') cy.contains('a', 'PROFILE').should('be.visible') cy.contains('button', 'LOG OUT').should('be.visible') .wait(1000).click() cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') }) })

nav.component.cy.ts

Mount the framework component

import { NavComponent } from './nav.component' describe('NavComponent', () => { it('should create and show the links', () => { cy.mount(NavComponent) cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') .wait(1000).click() cy.contains('a', 'HOME') cy.contains('a', 'PROFILE').should('be.visible') cy.contains('button', 'LOG OUT').should('be.visible') .wait(1000).click() cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') }) })

nav.component.cy.ts

Regular Cypress commands

Batteries included

export class NavComponent { constructor(public signinService: SigninService) { } public login(): void { this.signinService.login(); } public logout(): void { this.signinService.logout(); } }

nav.component.ts

import { NavComponent } from './nav.component' import { SigninService } from '../signin.service' describe('NavComponent', () => { it('should create and show the links', () => { const signinService = new SigninService() cy.spy(signinService, 'login').as('login') cy.spy(signinService, 'logout').as('logout') cy.mount(NavComponent, { componentProperties: { signinService, }, }) cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') .wait(1000).click() cy.get('@login').should('have.been.called') cy.contains('a', 'HOME') cy.contains('a', 'PROFILE').should('be.visible') cy.contains('button', 'LOG OUT').should('be.visible') .wait(1000).click() cy.get('@logout').should('have.been.called') cy.contains('a', 'HOME') cy.contains('button', 'LOG IN').should('be.visible') }) })

nav.component.cy.ts

Test syntax

const template = ` <div class="my-9 bg-zinc-50 border border-slate-400 rounded-lg"> <app-email-subscription (emailSubscription)="onEmailSubscription($event)"></app-email-subscription> </div> ` cy.mount(template, { declarations: [EmailSubscriptionComponent], imports: [ NoopAnimationsModule, FormsModule, MatFormFieldModule, MatInputModule, MatSlideToggleModule, MatButtonModule, MatIconModule, ], componentProperties: { onEmailSubscription: cy.stub().as('onEmailSubscription'), }, }) const email = 'email@email.email' cy.get('input:checkbox[role=switch]').should('be.disabled') cy.get('input[type=email]').type(email) cy.get('input:checkbox[role=switch]').should('be.enabled') // needs a better selector cy.contains('button', 'add_box').click() cy.get('@onEmailSubscription').should('be.calledOnceWithExactly', { email, subscribe: true, })

email-subscription.cy.ts

Email subscription component test

What about React?

import React from 'react' import { Timer } from './Timer' it('shows the time', () => { cy.mount(<Timer />) cy.contains('00:00') })

Timer.cy.ts

import React from 'react' import { Timer } from './Timer' import '../App.css' import { SudokuContext } from '../context/SudokuContext' import moment from 'moment' it('sets the clock to the given value', () => { const timeGameStarted = moment().subtract(900, 'seconds') cy.mount( <SudokuContext.Provider value={{ timeGameStarted }}> <section className="status"> <Timer /> </section> </SudokuContext.Provider>, ) cy.contains('15:00') })

Timer.cy.ts

The Timer component with applied styling

No more framework-specific syntax

import React from "react"; import { render, unmountComponentAtNode } from "react-dom"; import { act } from "react-dom/test-utils"; import Toggle from "./toggle"; let container = null; beforeEach(() => { // setup a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; }); it("changes value when clicked", () => { const onChange = jest.fn(); act(() => { render(<Toggle onChange={onChange} />, container); }); // get a hold of the button element, and trigger some clicks on it const button = document.querySelector("[data-testid=toggle]"); expect(button.innerHTML).toBe("Turn on"); act(() => { button.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onChange).toHaveBeenCalledTimes(1); expect(button.innerHTML).toBe("Turn off"); act(() => { for (let i = 0; i < 5; i++) { button.dispatchEvent(new MouseEvent("click", { bubbles: true })); } }); expect(onChange).toHaveBeenCalledTimes(6); expect(button.innerHTML).toBe("Turn on"); });
import React from "react"; import Toggle from "./toggle"; it("changes value when clicked", () => { cy.mount(<Toggle onChange={cy.stub().as('change')} />); // get a hold of the button element, and trigger some clicks on it cy.contains("[data-testid=toggle]", "Turn on").click() cy.get('@change').should('have.been.calledOnce') cy.contains("[data-testid=toggle]", "Turn off") .click() .click() .click() .click() .click() cy.get('@change').its('callCount').should('eq', 6) cy.contains("[data-testid=toggle]", "Turn on") });

equivalent Cypress component test

https://on.cypress.io/api

No more framework-specific syntax

import React from "react"; import { render, unmountComponentAtNode } from "react-dom"; import { act } from "react-dom/test-utils"; import Toggle from "./toggle"; let container = null; beforeEach(() => { // setup a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { // cleanup on exiting unmountComponentAtNode(container); container.remove(); container = null; }); it("changes value when clicked", () => { const onChange = jest.fn(); act(() => { render(<Toggle onChange={onChange} />, container); }); // get a hold of the button element, and trigger some clicks on it const button = document.querySelector("[data-testid=toggle]"); expect(button.innerHTML).toBe("Turn on"); act(() => { button.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(onChange).toHaveBeenCalledTimes(1); expect(button.innerHTML).toBe("Turn off"); act(() => { for (let i = 0; i < 5; i++) { button.dispatchEvent(new MouseEvent("click", { bubbles: true })); } }); expect(onChange).toHaveBeenCalledTimes(6); expect(button.innerHTML).toBe("Turn on"); });
import React from "react"; import Toggle from "./toggle"; it("changes value when clicked", () => { cy.mount(<Toggle onChange={cy.stub().as('change')} />); // get a hold of the button element, and trigger some clicks on it cy.contains("[data-testid=toggle]", "Turn on").click() cy.get('@change').should('have.been.calledOnce') cy.contains("[data-testid=toggle]", "Turn off") .click() .click() .click() .click() .click() cy.get('@change').its('callCount').should('eq', 6) cy.contains("[data-testid=toggle]", "Turn on") });

equivalent Cypress component test

https://on.cypress.io/api

One Ring API

To Rule

Them All

Follow: Murat Ozcan

Each React component has

  • React Testing Library spec
  • Cypress component spec

A huge collection of various React testing examples with matching Cypress component specs

A Testing

Pyramid of Component Tests

Timer

Numbers

Difficulty

StatusSection

GameSection

Game

App

Overlay

import { Game } from './Game' import { SudokuProvider } from './context/SudokuContext' import { WinProvider } from './context/WinContext' import { starting, solved } from '../cypress/fixtures/sudoku.json' it('plays the game', () => { cy.mount( <SudokuProvider> <WinProvider> <Game initArray={starting} solvedArray={solved} /> </WinProvider> </SudokuProvider>, ) cy.get('.game__cell:not(.game__cell--filled)').should( 'have.length', 3, ) starting.forEach((cell, index) => { if (cell === '0') { cy.get('.game__cell').eq(index).click() cy.contains('.status__number', solved[index]) .click() .wait(500, { log: false }) } }) cy.contains('.overlay__text', 'You solved it').should('be.visible') })

src/Game.cy.js

import { Game } from './Game' import { SudokuProvider } from './context/SudokuContext' import { WinProvider } from './context/WinContext' import { starting, solved } from '../cypress/fixtures/sudoku.json' it('plays the game', () => { cy.mount( <SudokuProvider> <WinProvider> <Game initArray={starting} solvedArray={solved} /> </WinProvider> </SudokuProvider>, ) cy.get('.game__cell:not(.game__cell--filled)').should( 'have.length', 3, ) starting.forEach((cell, index) => { if (cell === '0') { cy.get('.game__cell').eq(index).click() cy.contains('.status__number', solved[index]) .click() .wait(500, { log: false }) } }) cy.contains('.overlay__text', 'You solved it').should('be.visible') })

src/Game.cy.js

import { Game } from './Game' import { SudokuProvider } from './context/SudokuContext' import { WinProvider } from './context/WinContext' import { starting, solved } from '../cypress/fixtures/sudoku.json' it('plays the game', () => { cy.mount( <SudokuProvider> <WinProvider> <Game initArray={starting} solvedArray={solved} /> </WinProvider> </SudokuProvider>, ) cy.get('.game__cell:not(.game__cell--filled)').should( 'have.length', 3, ) starting.forEach((cell, index) => { if (cell === '0') { cy.get('.game__cell').eq(index).click() cy.contains('.status__number', solved[index]) .click() .wait(500, { log: false }) } }) cy.contains('.overlay__text', 'You solved it').should('be.visible') })

src/Game.cy.js

src/Game.cy.js

import React, { useState, useEffect } from 'react' import { formatTime } from './Timer' const useFetch = (url) => { const [data, setData] = useState([]) const [loading, setLoading] = useState(url ? true : false) async function fetchData() { if (url) { const response = await fetch(url) const json = await response.json() setData(json) setLoading(false) } } useEffect(() => { if (!url) { return } fetchData() }, [url]) return { loading, data } } export const Overlay = (props) => { const { loading, data } = useFetch( props.overlay && props.time ? '/times/' + props.time : null, ) const className = props.overlay ? 'overlay overlay--visible' : 'overlay' return ( <div className={className} onClick={props.onClickOverlay}> <h2 className="overlay__text"> <div className="overlay__greeting"> You <span className="overlay__textspan1">solved</span>{' '} <span className="overlay__textspan2">it!</span> </div> {loading && ( <div className="overlay__loading">Loading...</div> )} {data.length > 0 && ( <ul className="overlay__times"> {data.map((item, index) => { return ( <li key={index} className={item.current ? 'overlay__current' : ''} > {formatTime(item)} </li> ) })} </ul> )} </h2> </div> ) }

src/components/Overlay.js

import React, { useState, useEffect } from 'react' import { formatTime } from './Timer' const useFetch = (url) => { const [data, setData] = useState([]) const [loading, setLoading] = useState(url ? true : false) async function fetchData() { if (url) { const response = await fetch(url) const json = await response.json() setData(json) setLoading(false) } } useEffect(() => { if (!url) { return } fetchData() }, [url]) return { loading, data } } export const Overlay = (props) => { const { loading, data } = useFetch( props.overlay && props.time ? '/times/' + props.time : null, ) const className = props.overlay ? 'overlay overlay--visible' : 'overlay' return ( <div className={className} onClick={props.onClickOverlay}> <h2 className="overlay__text"> <div className="overlay__greeting"> You <span className="overlay__textspan1">solved</span>{' '} <span className="overlay__textspan2">it!</span> </div> {loading && ( <div className="overlay__loading">Loading...</div> )} {data.length > 0 && ( <ul className="overlay__times"> {data.map((item, index) => { return ( <li key={index} className={item.current ? 'overlay__current' : ''} > {formatTime(item)} </li> ) })} </ul> )} </h2> </div> ) }

src/components/Overlay.js

Stub window.fetch?

Stub some import?

Stub method in some internal class?

it('shows the loading element', () => { cy.intercept('GET', '/times/90', { delay: 1000, statusCode: 404, body: [], }).as('times') cy.mount(<Overlay overlay={true} time={90} />) cy.contains('.overlay__loading', 'Loading').should('be.visible') cy.wait('@times') cy.get('.overlay__loading').should('not.exist') })
it('shows the loading element', () => { cy.intercept('GET', '/times/90', { delay: 1000, statusCode: 404, body: [], }).as('times') cy.mount(<Overlay overlay={true} time={90} />) cy.contains('.overlay__loading', 'Loading').should('be.visible') cy.wait('@times') cy.get('.overlay__loading').should('not.exist') })
it('shows the top times', () => { cy.intercept('GET', '/times/90', { fixture: 'times.json', }).as('scores') cy.mount(<Overlay overlay={true} time={90} />) cy.wait('@scores') cy.get('.overlay__times li').should('have.length', 4) cy.contains('.overlay__times li', '01:30').should( 'have.class', 'overlay__current', ) })
cy.intercept('GET', '/times/90', { fixture: 'times.json', }).as('scores') cy.mount(<Overlay overlay={true} time={90} />) cy.wait('@scores')

stub the

external APIs

Svelte Example

Svelte Example

import Stepper from './Stepper.svelte' it('when clicking increment and decrement', () => { cy.mount(Stepper, { props: { count: 100 } }) cy.get(counterSelector).should('have.text', '100') cy.get(incrementSelector).click() cy.get(counterSelector).should('have.text', '101') cy.get(decrementSelector).click().click() cy.get(counterSelector).should('have.text', '99') })

Stepper.cy.js

https://docs.cypress.io/

Vue Example

import Stepper from './Stepper.vue' it('shows the initial count', () => { cy.mount(Stepper, { props: { initial: 100 } }) cy.get(counterSelector).should('have.text', '100') cy.mount(<Stepper initial={101} />) cy.get(counterSelector).should('have.text', '101') })

Stepper.cy.jsx

https://docs.cypress.io/

Vue Example

  • Real browser
  • Real component
  • Full Cypress API
  • All Cypress plugins
  • File watching
  • DOM snapshots, videos
  • Running on CI

Cypress Component Testing

  • Real browser
  • Real component
  • Full Cypress API
  • All Cypress plugins
  • File watching
  • DOM snapshots, videos
  • Running on CI

Cypress Component Testing

Cypress

Component

Testing

  • Code coverage
  • Imports stubbing

Cypress Component Testing Downsides

Requires webpack / vite configuration

👏 Thank You 👏

Gleb Bahmutov

Sr Director of Engineering

☝️ these slides ☝️

Test Angular/React/Vue/Svelte Components Without Fear

By Gleb Bahmutov

Test Angular/React/Vue/Svelte Components Without Fear

Writing tests for your components (be it Angular, React, Vue, or Svelte components) is often a horror show. Your code in (js-dom) darkness, there are dangers behind each action, and the most beautiful tests get banged up beyond recognition when they try to cross into the CI realm. In this talk, I will show how Cypress component testing becomes a ray of hope guiding you toward testing nirvana. These tests remove most framework-specific test code while focusing on how the component behaves on the page. I will use examples from different frameworks to teach everyone how to test the modern front-end code without fear or pain.

  • 2,971