React: Testing and Component Demos

Testing in JavaScript

Testing in JavaScript

see the presentation JavaScript Testing for an overview

Testing and React

Topics

  • component demos with Storybook
  • testing React applications with puppeteer
  • libraries for testing React components
    • react-testing-library
    • react-test-renderer
    • enzyme
  • snapshot tests

Resource

Presentation on testing React / JavaScript by Gabriel Vasile

Storybook

Storybook

Enables the creation of isolated component demos

examples:

Setup

in a Create-React-App project:

npx -p @storybook/cli sb init --type react_scripts

in a regular React project:

npx -p @storybook/cli sb init --type react

Running

npm run storybook

Stories

basic example: Rating.stories.js

import Rating from './Rating';

export default { title: 'Rating', component: Rating };

export const OneStar = () => <Rating stars={1} />;
export const FiveStars = () => <Rating stars={5} />;

Stories

example with template, props (controls) and events (actions)

import Rating from './Rating';

export default { title: 'Rating', component: Rating };

const RatingStoryTemplate = (args) => <Rating {...args} />;

export const OneStar = RatingStoryTemplate.bind({});
OneStar.args = { stars: 1 };
export const FiveStars = RatingStoryTemplate.bind({});
FiveStars.args = { stars: 5 };

Stories

props with TypeScript:

import { ComponentProps } from 'react';
import { Story } from '@storybook/react/types-6-0';
const RatingStoryTemplate: Story<ComponentProps<
  typeof Rating
>> = (args) => <Rating {...args} />;

Cypress

Cypress

configuration in a create-react-app project:

in cypress/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
    // be explicit about types included
    // to avoid clashing with Jest types
    "types": ["cypress"]
  },
  "include": ["../node_modules/cypress", "./**/*.ts"]
}

Cypress

To avoid ESLint errors (in particular with create-react-app projects):

in package.json:

  "eslintConfig": {
    // ...
    "overrides": [
      {
        "files": ["cypress/**"],
        "rules": {
          "jest/valid-expect": 0
        }
      }
    ]
  },

Puppeteer

Testing a React application with Puppeteer

Start the React application in the background (on port 3000) so Puppeteer can interact with it:

npm run start

Testing a React application with Puppeteer

let browser: puppeteer.Browser;
let page: puppeteer.Page;
beforeAll(async () => {
  browser = await puppeteer.launch();
});
beforeEach(async () => {
  page = await browser.newPage();
  await page.goto('http://localhost:3000');
});
afterAll(async () => {
  await browser.close();
});

test("displays page with title 'React App'", async () => {
  const pageTitle = await page.title();
  expect(pageTitle).toEqual('React App');
});

Testing a React application with Puppeteer

exercise: test whether the todo application shows the correct number of list items

Puppeteer

see also: Rajat S: Testing your React App with Puppeteer and Jest

React-Testing-Library basics

React-Testing-Library

React-Testing-Library: project for testing UI components (most significant subproject of the Testing-Library)

tests focus on aspects that are relevant for the end user (and not on the exact DOM structure or implementation details)

React-Testing-Library

run tests in a create-react-app-based app:

npm run test

note: if slow, try "test": "react-scripts test --maxWorkers=1" in package.json

Example

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

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByRole('link', {
    name: /learn react/i,
  });
  expect(linkElement).toBeInTheDocument();
});

Querying elements

Querying by ARIA role and accessible name:

screen.getByRole('button', { name: /delete/i });
screen.getByRole('textbox', { name: /new title/i });
screen.getAllByRole('listitem');

the accessible name could be:

  • text content
  • alt text of an image
  • title text of a link / image
  • label text of an input
  • an explicit aria-label attribute

Querying elements

example roles (set implicitly or explicitly):

  • article
  • button
  • checkbox
  • form
  • heading
  • img
  • link
  • list
  • listitem
  • menuitem
  • presentation
  • textbox
  • ... (see role definitions from W3C)

Querying sub-elements

import { screen, within } from '@testing-library/react';

const todoList = screen.getByRole('list');
const firstTodoItem = within(todoList).getAllByRole(
  'listitem'
)[0];
const firstTodoItemDeleteButton = within(
  firstTodoItem
).getByRole('button', { name: /delete/i });

Assertions

extra assertions (enabled automatically when using create-react-app):

Simulating user interactions

import userEvent from '@testing-library/user-event';

userEvent.type(
  screen.getByRole('textbox', { name: /title/i }),
  'write tests'
);
userEvent.click(
  screen.getByRole('button', { name: /add/i })
);

React-Testing-Library example: todo app

Testing the rendering

test('renders a list item with a given text', () => {
  const title = 'test-title';
  render(
    <TodoItem
      todo={{ id: 1, title: title, completed: false }}
      onDelete={() => {}}
      onCompletedChange={() => {}}
    />
  );
  const listItem = screen.getByRole('listitem');
  expect(listItem).toHaveTextContent(new RegExp(title));
});

Testing events

test('triggers the toggle event', () => {
  const mockFn = jest.fn();
  render(
    <TodoItem
      todo={{ id: 1, title: title, completed: false }}
      onDelete={mockFn}
      onCompletedChange={() => {}}
    />
  );
  const listItem = screen.getByRole('listitem');
  const deleteBtn = within(listItem).getByRole('button', {
    name: /delete/i,
  });
  userEvent.click(deleteBtn);
  expect(mockFn).toHaveBeenCalled();
});

Setting up initial state

Setting up a todo application with three todos before each test:

beforeEach(() => {
  render(<App />);
  const titleInput = screen.getByRole('textbox', {
    name: /title/i,
  });
  const addButton = screen.getByRole('button', {
    name: 'add',
  });
  for (let title of ['first', 'second', 'third']) {
    userEvent.type(titleInput, title);
    userEvent.click(addButton);
  }
});

Testing state changes

test('toggling a todo', () => {
  const todoList = screen.getByRole('list');
  const firstTodoItem = within(todoList).getAllByRole(
    'listitem'
  )[0];
  const firstTodoItemCheckbox = within(
    firstTodoItem
  ).getByRole('checkbox');
  expect(firstTodoItem).toHaveTextContent(/TODO:/);
  userEvent.click(firstTodoItemCheckbox);
  expect(firstTodoItem).toHaveTextContent(/DONE:/);
});

React-Testing-Library intermediate

Other queries

getByText

useful for divs / spans (no default role)

note: Consider giving the element an appropriate role, and using e.g. getByRole("presentation", { name: "text" })

Testing asynchronous interactions and APIs

Use .findByRole, instead of .getByRole to wait for an element to appear

findByRole will repeatedly query for an element until it exists (by default every 0.05 seconds for a maximum of 1 second)

Testing asynchronous code

task: testing a ChuckNorrisJoke component which queries an API:

const URL = 'https://api.chucknorris.io/jokes/random';
const ChuckNorrisJoke = () => {
  const [joke, setJoke] = useState(null);
  async function loadJoke() {
    const res = await fetch(URL);
    const data = await res.json();
    setJoke(data.value);
  }
  useEffect(() => {
    loadJoke();
  }, []);
  if (!joke) {
    return <div role="status">loading...</div>;
  }
  return <article>{joke}</article>;
};

Testing asynchronous interactions

testing with an actual API:

test('load Chuck Norris joke from API', async () => {
  render(<ChuckNorrisJoke />);
  const jokeElement = await screen.findByRole('article');
  // joke should have at least 3 characters
  expect(jokeElement).toHaveTextContent(/.../);
});

Testing asynchronous interactions

Testing with a mocked API:

const data = {
  value: 'Chuck Norris counted to infinity. Twice.',
};
globalThis.fetch = () =>
  Promise.resolve({ json: () => Promise.resolve(data) });

Testing errors

Rating component:

test('throw an error if the rating is invalid', () => {
  const testFn = () => {
    render(<Rating value={-1} />);
  };
  expect(testFn).toThrow('value must be 0 to 5');
});

Testing for non-existence

expect(() => getByRole('listitem')).toThrow();

or via queryBy...:

expect(queryByRole('listitem')).toEqual(null);

queryBy... will return null instead of throwing

Resources

React-Test-Renderer

React-Test-Renderer - installation

npm install --save-dev react-test-renderer

with TypeScript:

npm install --save-dev react-test-renderer @types/react-test-renderer

React-Test-Renderer - Example

import TestRenderer from 'react-test-renderer';

it('renders a component without crashing', () => {
  const instance = TestRenderer.create(<MyComponent />)
    .root;
});

React-Test-Renderer - working with instances

  • instance.find(All) (receives a test function as an argument)
  • instance.find(All)ByType
  • instance.find(All)ByProps
  • instance.props
  • instance.children
  • instance.type

React-Test-Renderer - API reference

https://reactjs.org/docs/test-renderer.html

Example: Testing with Jest and React-Test-Renderer

Testing a Rating component

Test setup

import TestRenderer from 'react-test-renderer';

import Rating from './Rating';

Testing the rendering

describe('rendering', () => {
  it('renders 5 spans', () => {
    const instance = TestRenderer.create(
      <Rating stars={3} />
    ).root;
    expect(instance.findAllByType('span')).toHaveLength(5);
  });

  it('renders 3 active stars', () => {
    const instance = TestRenderer.create(
      <Rating stars={3} />
    ).root;
    expect(
      instance.findAllByProps({ className: 'star active' })
    ).toHaveLength(3);
  });
});

Testing events

describe('events', () => {
  it('reacts to click on the fourth star', () => {
    const mockFn = jest.fn();
    const instance = TestRenderer.create(
      <Rating stars={3} onStarsChange={mockFn} />
    ).root;
    const fourthStar = instance.findAllByType('span')[3];
    fourthStar.props.onClick();
    expect(mockFn).toBeCalledWith(4);
  });
});

Testing exceptions

describe('errors', () => {
  it('throws an error if the number of stars is 0', () => {
    const testFn = () => {
      TestRenderer.create(<Rating stars={0} />);
    };
    expect(testFn).toThrow('number of stars must be 1-5');
  });
});

Enzyme

Enzyme - Installation & Setup

npm install --save-dev enzyme enzyme-adapter-react-16

new file src/setupTests.js:

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

Enzyme - Examples

import { shallow, mount } from 'enzyme';

it('renders a component without crashing', () => {
  const wrapper = shallow(<MyComponent />);
});

it('renders a component tree without crashing', () => {
  const wrapper = mount(<MyComponent />);
});

Enzyme - Cheatsheet

https://devhints.io/enzyme

Example: testing a rating component

With jest and enzyme

Example: testing a rating component

import { shallow, mount } from 'enzyme';

import Rating from './Rating';

Example: testing a rating component

describe('rendering', () => {
  it('renders 5 Star components', () => {
    const wrapper = shallow(<Rating stars={5} />);
    expect(wrapper.find('Star')).toHaveLength(5);
  });

  it('renders 5 stars', () => {
    const wrapper = mount(<Rating stars={5} />);
    expect(wrapper.find('.star')).toHaveLength(5);
  });
});

Example: testing a rating component

describe('rendering', () => {
  it('renders 3 active stars', () => {
    const wrapper = mount(<Rating stars={3} />);
    expect(wrapper.find('.star')).toHaveLength(5);
    expect(
      wrapper.find('.star').get(2).props.className
    ).toEqual('star active');
    expect(
      wrapper.find('.star').get(3).props.className
    ).toEqual('star');
  });
});

Example: testing a rating component

describe('events', () => {
  it('reacts to click on first star', () => {
    const mockFn = fn();
    const wrapper = mount(
      <Rating stars={3} onStarsChange={mockFn} />
    );
    wrapper
      .find('span')
      .at(0)
      .simulate('click');
    expect(mockFn.mock.calls[0][0]).toEqual(1);
  });
});

Example: testing a rating component

Testing a (hypothetical) rating component that has its own internal state:

describe('events', () => {
  it('reacts to click on first star', () => {
    const wrapper = mount(<Rating />);
    wrapper
      .find('span')
      .at(0)
      .simulate('click');
    expect(wrapper.instance.state.count).toEqual(1);
  });
});

Example: testing a rating component

describe('errors', () => {
  it('throws an error if the number of stars is 0', () => {
    const testFn = () => {
      const wrapper = shallow(<Rating stars={0} />);
    };
    expect(testFn).toThrow(
      'number of stars must be positive'
    );
  });
});

Snapshot tests

Snapshot tests

Components are rendered and compared to earlier versions (snapshots)

Snapshots are simple text representations of rendered content

Snapshot tests are usually used for regression tests

Snapshot tests

snapshots are usually stored as text files in a location like __snapshots__/Counter.test.js.snap

exports[`matches the snapshot 1`] = `
<div>
  <div>
    count:
    0
    <button>
      +
    </button>
  </div>
</div>
`;

Creating snapshot tests

with react-testing-library:

it('matches the snapshot', () => {
  render(
    <TodoItem
      title="foo"
      completed={false}
      onToggle={() => {}}
      onDelete={() => {}}
    />
  );
  const li = screen.getByRole('listitem');
  expect(li).toMatchSnapshot();
});

Updating snapshot tests

Once we have changed and and verified the behavior of a component we can update the corresponding tests accordingly:

2 snapshot tests failed in 1 test suite.
Inspect your code changes or press `u` to update them.