see the presentation JavaScript Testing for an overview
Presentation on testing React / JavaScript by Gabriel Vasile
Enables the creation of isolated component demos
examples:
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
npm run storybook
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} />;
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 };
props with TypeScript:
import { ComponentProps } from 'react';
import { Story } from '@storybook/react/types-6-0';
const RatingStoryTemplate: Story<ComponentProps<
typeof Rating
>> = (args) => <Rating {...args} />;
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"]
}
To avoid ESLint errors (in particular with create-react-app projects):
in package.json:
"eslintConfig": {
// ...
"overrides": [
{
"files": ["cypress/**"],
"rules": {
"jest/valid-expect": 0
}
}
]
},
Start the React application in the background (on port 3000) so Puppeteer can interact with it:
npm run start
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');
});
exercise: test whether the todo application shows the correct number of list items
see also: Rajat S: Testing your React App with Puppeteer and Jest
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)
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
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 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:
aria-label
attributeexample roles (set implicitly or explicitly):
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 });
extra assertions (enabled automatically when using create-react-app):
.toHaveTextContent()
.toHaveAttribute()
.toHaveClass()
.toBeInTheDocument()
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 })
);
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));
});
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 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);
}
});
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:/);
});
getByText
: e.g. for divs, spansgetByLabelText
: for form fieldsgetByAltText
: e.g. for imagesgetByTitle
: e.g. for images / linksgetByTestId
: for explicit test ids via data-testid="..."
useful for divs / spans (no default role)
note: Consider giving the element an appropriate role, and using e.g. getByRole("presentation", { name: "text" })
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)
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 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 with a mocked API:
const data = {
value: 'Chuck Norris counted to infinity. Twice.',
};
globalThis.fetch = () =>
Promise.resolve({ json: () => Promise.resolve(data) });
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');
});
expect(() => getByRole('listitem')).toThrow();
or via queryBy...:
expect(queryByRole('listitem')).toEqual(null);
queryBy... will return null instead of throwing
npm install --save-dev react-test-renderer
with TypeScript:
npm install --save-dev react-test-renderer @types/react-test-renderer
import TestRenderer from 'react-test-renderer';
it('renders a component without crashing', () => {
const instance = TestRenderer.create(<MyComponent />)
.root;
});
instance.find(All)
(receives a test function as an argument)instance.find(All)ByType
instance.find(All)ByProps
instance.props
instance.children
instance.type
Testing a Rating component
import TestRenderer from 'react-test-renderer';
import Rating from './Rating';
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);
});
});
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);
});
});
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');
});
});
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() });
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 />);
});
With jest and enzyme
import { shallow, mount } from 'enzyme';
import Rating from './Rating';
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);
});
});
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');
});
});
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);
});
});
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);
});
});
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'
);
});
});
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
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>
`;
with react-testing-library:
it('matches the snapshot', () => {
render(
<TodoItem
title="foo"
completed={false}
onToggle={() => {}}
onDelete={() => {}}
/>
);
const li = screen.getByRole('listitem');
expect(li).toMatchSnapshot();
});
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.