Testing UI Components

@avindrafernando

Invest into testing, but ...

  • not seeing improved quality
  • not seeing improved productivity
  • not seeing improved user sentiment

Who Am I?

@avindrafernando

@avindra1

avindrafernando

Testing Ice Cream Cone ๐Ÿฆ

Testing Hour Glassโณ

Say hello to...

The Testing Pyramid ๐Ÿ”บ

But, isn't Unit Testing going to slow us down?

Conflict Points

  • Conventional Diamond
    • 26 conflict points
  • DCD
    • 14 conflict points

Welcome, Jestย 

import sum from './sum';

it('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

Async Requests???

No Problem...

// user.js
import request from './request';

export function getUserName(userID) {
  return request(`/users/${userID}`).then(user => user.name);
}
// request.js
const http = require('http');

export default function request(url) {
  return new Promise(resolve => {
    // This is an example of an http request, for example to fetch
    // user data from an API.
    // This module is being mocked in __mocks__/request.js
    http.get({path: url}, response => {
      let data = '';
      response.on('data', _data => (data += _data));
      response.on('end', () => resolve(data));
    });
  });
}

Let's mock it

// __mocks__/request.js
const users = {
  4: {name: 'Mark'},
  5: {name: 'Paul'},
};

export default function request(url) {
  return new Promise((resolve, reject) => {
    const userID = parseInt(url.substr('/users/'.length), 10);
    process.nextTick(() =>
      users[userID]
        ? resolve(users[userID])
        : reject({
            error: `User with ${userID} not found.`,
          }),
    );
  });
}

Let's test it

Using async/await

// async/await can be used.
it('works with async/await', async () => {
  expect.assertions(1);
  const data = await user.getUserName(4);
  expect(data).toEqual('Mark');
});
// async/await can also be used with `.resolves`.
it('works with async/await and resolves', async () => {
  expect.assertions(1);
  await expect(user.getUserName(5)).resolves.toEqual('Paul');
});

Testing Components

Smoke Test

Shallow Rendering

Full Rendering

Smoke Test

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

it('renders without crashing', () => {
  const div = document.createElement('div');
  ReactDOM.render(<App />, div);
});

Shallow Rendering

import React from 'react';
import { shallow } from 'enzyme';
import App from './App';

it('renders without crashing', () => {
  shallow(<App />);
});

The Problem

"With shallow rendering, I can refactor my component's implementation and my tests break. With shallow rendering, I can break my application and my tests say everything's still working."

- Kent C. Dodds

React Testing Library ๐Ÿ

The Solution

Testing Library/React ๐Ÿ™

Guding
Principles

DOM Nodes

If it relates to rendering components, it should deal with DOM Nodes over rendered component instances.

Test Like a User

It should be generally useful for testing the application components in the way the user would use it.

Simple APIs

Utility implementations and APIs should be simple and flexible.

Core API

Queries

import {render, screen} from '@testing-library/react' // (or /dom, /vue, ...)

test('should show login form', () => {
  render(<Login />)
  const input = screen.getByLabelText('Username')
  // Events and assertions...
})

Queries

No Match 1 Match 1+ Match Await?
getBy throw return throw No
findBy throw return throw Yes
queryBy null return throw No
getAllBy throw array array No
findAllBy throw array array Yes
queryAllBy [] array array No

Queries by Priority

  • ByRole
  • ByLabelText
  • ByPlaceholderText
  • ByText
  • ByDisplayValue
  • ByAltText
  • ByTitle
  • ByTestId

Screen

import {screen} from '@testing-library/dom'

document.body.innerHTML = `
  <label for="example">Example</label>
  <input id="example" />
`

const exampleInput = screen.getByLabelText('Example')
import {render, screen} from '@testing-library/react'

render(
  <div>
    <label htmlFor="example">Example</label>
    <input id="example" />
  </div>,
)

const exampleInput = screen.getByLabelText('Example')

Debug

import {screen} from '@testing-library/dom'

document.body.innerHTML = `
  <button>test</button>
  <span>multi-test</span>
  <div>multi-test</div>
`

// debug document
screen.debug()
// debug single element
screen.debug(screen.getByText('test'))
// debug multiple elements
screen.debug(screen.getAllByText('multi-test'))

Testing Playground ๐Ÿธ

Firing Events

  • fireEventย fireEvent(node, event)
  • fireEvent.*
    • clickย fireEvent.click(node)
import {render, screen, fireEvent} from '@testing-library/react'

const Button = ({onClick, children}) => (
  <button onClick={onClick}>{children}</button>
)

test('calls onClick prop when clicked', () => {
  const handleClick = jest.fn()
  render(<Button onClick={handleClick}>Click Me</Button>)
  fireEvent.click(screen.getByText(/click me/i))
  expect(handleClick).toHaveBeenCalledTimes(1)
})

User Interactions

npm install --save-dev @testing-library/user-event @testing-library/dom
test('trigger some awesome feature when clicking the button', async () => {
  const user = userEvent.setup()
  render(<MyComponent />)

  await user.click(screen.getByRole('button', {name: /click me!/i}))

  // ...assertions...
})
const user = userEvent.setup()

await user.keyboard('[ShiftLeft>]') // Press Shift (without releasing it)
await user.click(element) // Perform a click with `shiftKey: true`

Appearance and Disappearance Using Async Methods

  • findBy* queries
  • waitFor
  • waitForElementToBeRemoved

Async Methods

test('movie title appears', async () => {
  // element is initially not present...
  // wait for appearance and return the element
  const movie = await findByText('the lion king')
})
test('movie title appears', async () => {
  // element is initially not present...

  // wait for appearance inside an assertion
  await waitFor(() => {
    expect(getByText('the lion king')).toBeInTheDocument()
  })
})
test('movie title no longer present in DOM', async () => {
  // element is removed
  await waitForElementToBeRemoved(() => queryByText('the mummy'))
})
npm install --save-dev @testing-library/react

Render

import { render } from '@testing-library/react';

render(<Login />);
// __tests__/fetch.test.js
import React from 'react'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {render, fireEvent, waitFor, screen} from '@testing-library/react'
import '@testing-library/jest-dom'
import Fetch from '../fetch'

const server = setupServer(
  rest.get('/greeting', (req, res, ctx) => {
    return res(ctx.json({greeting: 'hello there'}))
  }),
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('loads and displays greeting', async () => {
  render(<Fetch url="/greeting" />)

  fireEvent.click(screen.getByText('Load Greeting'))

  await waitFor(() => screen.getByRole('heading'))

  expect(screen.getByRole('heading')).toHaveTextContent('hello there')
  expect(screen.getByRole('button')).toBeDisabled()
})

test('handles server error', async () => {
  server.use(
    rest.get('/greeting', (req, res, ctx) => {
      return res(ctx.status(500))
    }),
  )

  render(<Fetch url="/greeting" />)

  fireEvent.click(screen.getByText('Load Greeting'))

  await waitFor(() => screen.getByRole('alert'))

  expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!')
  expect(screen.getByRole('button')).not.toBeDisabled()
})
// import dependencies
import React from 'react'

// import API mocking utilities from Mock Service Worker
import {rest} from 'msw'
import {setupServer} from 'msw/node'

// import react-testing methods
import {render, fireEvent, waitFor, screen} from '@testing-library/react'

// add custom jest matchers from jest-dom
import '@testing-library/jest-dom'
// the component to test
import Fetch from '../fetch'
test('loads and displays greeting', async () => {
  // Arrange
  // Act
  // Assert
})
// declare which API requests to mock
const server = setupServer(
  // capture "GET /greeting" requests
  rest.get('/greeting', (req, res, ctx) => {
    // respond using a mocked JSON body
    return res(ctx.json({greeting: 'hello there'}))
  }),
)

// establish API mocking before all tests
beforeAll(() => server.listen())
// reset any request handlers that are declared as a part of our tests
// (i.e. for testing one-time error scenarios)
afterEach(() => server.resetHandlers())
// clean up once the tests are done
afterAll(() => server.close())

// ...

test('handlers server error', async () => {
  server.use(
    // override the initial "GET /greeting" request handler
    // to return a 500 Server Error
    rest.get('/greeting', (req, res, ctx) => {
      return res(ctx.status(500))
    }),
  )

  // ...
})
render(<Fetch url="/greeting" />)
fireEvent.click(screen.getByText('Load Greeting'))

// wait until the `get` request promise resolves and
// the component calls setState and re-renders.
// `waitFor` waits until the callback doesn't throw an error

await waitFor(() =>
  // getByRole throws an error if it cannot find an element
  screen.getByRole('heading'),
)
// assert that the alert message is correct using
// toHaveTextContent, a custom matcher from jest-dom.
expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!')

// assert that the button is not disabled using
// toBeDisabled, a custom matcher from jest-dom.
expect(screen.getByRole('button')).not.toBeDisabled()

jest-dom ๐Ÿฆ‰

Custom Jest Matches

{data.posts.map((post, index) => (
    <Grid item lg key={post.id}>
      <Card className={classes.card}>
        <CardActionArea>
          <CardMedia
            className={classes.media}
            image={getSrc(post.id)}
            title={post.author}
          />
          <CardContent>
            <Typography gutterBottom variant="h5" component="h2">
              {post.author}
            </Typography>
...
import React from "react";
import { render, screen } from "@testing-library/react";
import axiosMock from "axios";
import Posts from "./Posts";

jest.mock("axios");

it("Should render the Posts component", async () => {
  // Arrange
  // Act
  // Assert
});
  // Arrange
  const returnData = [
    {
      author: "John Doe",
      author_url: "https://unsplash.com/@johndoe",
      filename: "0000_yC-Yzbqy7PY.jpeg",
      format: "jpeg",
      height: 3744,
      id: 0,
      post_url: "https://unsplash.com/photos/yC-Yzbqy7PY",
      width: 5616
    },
    {
      author: "Jane Doe",
      author_url: "https://unsplash.com/@janedoe",
      filename: "0100_pwaaqfoMibI.jpeg",
      format: "jpeg",
      height: 1656,
      id: 1,
      post_url: "https://unsplash.com/photos/pwaaqfoMibI",
      width: 2500
    }
  ];

axiosMock.get.mockResolvedValueOnce({ data: returnData });
// Act
render(<Posts />);

const [cardNodeForJohnDoe, cardNodeForJaneDoe] = await waitFor(() => [
  screen.getByTitle("John Doe"),
  screen.getByTitle("Jane Doe"),
]);
// Act
render(<Posts />);

const cardNodeForJohnDoe = await screen.findByTitle("John Doe");
const cardNodeForJaneDoe = await screen.findByTitle("Jane Doe");

Using findBy

// Assert
expect(axiosMock.get)
 .toHaveBeenCalledTimes(1);
expect(axiosMock.get)
 .toHaveBeenCalledWith("https://picsum.photos/list");
  
expect(cardNodeForJohnDoe)
  .toHaveStyle(
   "backgroundImage: 'url(\"https://picsum.photos/250/375?image=0\")'"
  );
expect(cardNodeForJaneDoe)
  .toHaveStyle(
   "backgroundImage: 'url(\"https://picsum.photos/250/375?image=1\")'"
  );

It Passed!!!

Batteries Included

By Google [CC BY 4.0], via Wikimedia Commons

Facebook [Public domain or CC BY-SA 1.0], via Wikimedia Commons

By Evan You [CC BY 4.0], via Wikimedia Commons

@testing-library/dom Weekly Downloads

5 Most
Common
Mistakes

const button = screen.getByRole('button', {name: /disabled button/i})

// โŒ
expect(button.disabled).toBe(true)
// error message:
//  expect(received).toBe(expected) // Object.is equality
//
//  Expected: true
//  Received: false

// โœ…
expect(button).toBeDisabled()
// error message:
//   Received element is not disabled:
//     <button />

Using the Wrong Assertion

// โŒ
// assuming you've got this DOM to work with:
// <label>Username</label><input data-testid="username" />
screen.getByTestId('username')

// โœ…
// change the DOM to be accessible by associating the label and setting the type
// <label for="username">Username</label><input id="username" type="text" />
screen.getByRole('textbox', {name: /username/i})

Using the Wrong Query

// โŒ
expect(screen.queryByRole('alert')).toBeInTheDocument()

// โœ…
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()

Using query* variants incorrectly

// โŒ
await waitFor(() => {})
expect(window.fetch).toHaveBeenCalledWith('foo')
expect(window.fetch).toHaveBeenCalledTimes(1)

// โœ…
await waitFor(() => expect(window.fetch).toHaveBeenCalledWith('foo'))
expect(window.fetch).toHaveBeenCalledTimes(1)

Passing an Empty Callback to waitFor

// โŒ
await waitFor(() => {
  fireEvent.keyDown(input, {key: 'ArrowDown'})
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

// โœ…
fireEvent.keyDown(input, {key: 'ArrowDown'})
await waitFor(() => {
  expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

Performing Side-effects in waitFor

Biggest benefit of testing?

"Quality means doing it right even when no one is looking."

-ย Henry Ford

@avindrafernando

Taprobane Consulting, LLC

@avindrafernando