React Testing Components: Difference between revisions
(22 intermediate revisions by the same user not shown) | |||
Line 4: | Line 4: | ||
'''Testing Exactly what you are trying to test and nothing more''' | '''Testing Exactly what you are trying to test and nothing more''' | ||
<br> | <br> | ||
'''Never test through a UI component what can be tested some other way''' | |||
<br> | |||
'''Never test the same thing twice''' | |||
=DOM Overview= | =DOM Overview= | ||
==Definition== | ==Definition== | ||
Line 82: | Line 86: | ||
==React Test Utilities== | ==React Test Utilities== | ||
This library provides the function act() which provides a more realistic environment for rendering and updating componenemts. | This library provides the function act() which provides a more realistic environment for rendering and updating componenemts. | ||
==Example Test Camera== | ==Example Test Camera (React Test Utilities)== | ||
This example is testing a combobox<br> | This example is testing a combobox<br> | ||
[[File:Test camera.png|300px]] | [[File:Test camera.png|300px]] | ||
Line 136: | Line 140: | ||
}); | }); | ||
}); | }); | ||
</syntaxhighlight> | |||
==Test Render== | |||
Test Render does not use the DOM but instead test the javaScript objects. One of the great points which was made is you can identify the component is too complex when looking at testing and it may show opportunity to refactor unnecessarily complete code. | |||
==Example Test (Test Render)== | |||
Here we create an instance of the component and then look for if the checkbox is checked. It did appear to rely on data-testid prior to writing of testing. | |||
<syntaxhighlight lang="js"> | |||
describe('none selected', ()=>{ | |||
it('should select no rovers', ()=> { | |||
const none = {spirit: false, opportunity: false, curiosity: false}; | |||
const tr = TestRenderer.create(<RoverSelector roversActive={none} rovers={rovers} roverSelection={none} onRoverSelection={()=>{}} />); | |||
const inputs = tr.root.findAllByProps({"data-testid": 'rover-selected'}); | |||
inputs.forEach((input) => { | |||
expect(input.props.checked).toBe(false); | |||
}); | |||
}); | |||
}); | |||
</syntaxhighlight> | </syntaxhighlight> | ||
=Testing Component Events= | =Testing Component Events= | ||
==Approach== | |||
For test events take four steps | |||
*Set props and render | |||
*Find elements you need | |||
*Trigger events on elements | |||
*Make assertions against events produced | |||
==Testing Library== | |||
We can use this to test events. Here is a simple component which increments a count on press. | |||
<syntaxhighlight lang="js"> | |||
import React, { useState } from 'react'; | |||
const Counter = () => { | |||
const [count, setCount] = useState(0); | |||
return ( | |||
<p onClick={()=> { setCount(count+1); }}> | |||
{count} ah ah ah | |||
</p> | |||
); | |||
}; | |||
export default Counter; | |||
</syntaxhighlight> | |||
==Example Test Counter (Testing Library)== | |||
Put this here to not scare people and show simple testing is easy. | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
import { render, fireEvent } from '@testing-library/react'; | |||
import Counter from './Counter'; | |||
describe('Counter', () => { | |||
it("should start at zero", () => { | |||
const { queryByText } = render( | |||
<Counter /> | |||
); | |||
const paragraph = queryByText(/ah ah ah/); | |||
expect(paragraph).toBeTruthy(); | |||
expect(paragraph.textContent).toBe('0 ah ah ah'); | |||
}); | |||
it("should increment on click", () => { | |||
const { queryByText } = render( | |||
<Counter /> | |||
); | |||
const paragraph = queryByText(/ah ah ah/); | |||
expect(paragraph.textContent).toBe('0 ah ah ah'); | |||
fireEvent.click(paragraph); | |||
expect(paragraph.textContent).toBe('1 ah ah ah'); | |||
fireEvent.click(paragraph); | |||
expect(paragraph.textContent).toBe('2 ah ah ah'); | |||
}); | |||
}); | |||
</syntaxhighlight> | |||
==Asyc Testing and Jest== | |||
Testing Asynchronous code with Jest '''requires test methods to return a promose''' or they will always pass. Here is the counter again but using a timeout to simulate Async | |||
<syntaxhighlight lang="js"> | |||
import React, { useState } from 'react'; | |||
const CounterAsync = () => { | |||
const [count, setCount] = useState(0); | |||
return ( | |||
<p onClick={()=> { setTimeout(()=> { setCount(count+1); }, 1000)}}> | |||
{count} ah ah ah | |||
</p> | |||
); | |||
}; | |||
export default CounterAsync; | |||
</syntaxhighlight> | |||
==Example Test Counter Async (Testing Library)== | |||
To test async we need to wait for the results. We do this by using the wait function and await. | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
import { render, fireEvent, wait } from '@testing-library/react'; | |||
import CounterAsync from './Counter.async'; | |||
describe('Counter', () => { | |||
it("should start at zero", () => { | |||
const { queryByText } = render( | |||
<CounterAsync /> | |||
); | |||
const paragraph = queryByText(/ah ah ah/); | |||
expect(paragraph).toBeTruthy(); | |||
expect(paragraph.textContent).toBe('0 ah ah ah'); | |||
}); | |||
it("should increment on click", async () => { | |||
const { queryByText } = render( | |||
<CounterAsync /> | |||
); | |||
const paragraph = queryByText(/ah ah ah/); | |||
await wait(() => { | |||
expect(paragraph.textContent).toBe('0 ah ah ah'); | |||
}); | |||
fireEvent.click(paragraph); | |||
await wait(() => { | |||
expect(paragraph.textContent).toBe('1 ah ah ah'); | |||
}); | |||
fireEvent.click(paragraph); | |||
await wait(() => { | |||
expect(paragraph.textContent).toBe('2 ah ah ah'); | |||
}); | |||
}); | |||
}); | |||
</syntaxhighlight> | |||
==Mocking== | |||
Jest provides mocking functions. Mocks functons | |||
*can be called but don't return anything | |||
*can record they were called and the arguments passed | |||
<syntaxhighlight lang="js"> | |||
const f = jest.fn(); | |||
f(1,2,3) | |||
f('a') | |||
expect(f.mock.calls).toEqual( | |||
[ | |||
[1,2,3], | |||
['a'] | |||
] | |||
) | |||
</syntaxhighlight> | |||
We can provide an implement and test that e.g. | |||
<syntaxhighlight lang="js"> | |||
const f = jest.fn((a,b) => {a + b}); | |||
expect(f(4,8)).toBe(12); | |||
expect(f.mocks.calls).toEqual( | |||
[ | |||
[4,8] | |||
] | |||
) | |||
</syntaxhighlight> | |||
We can fake the results to | |||
<syntaxhighlight lang="js"> | |||
const f = jest().fn() | |||
f.mockReturnValueOnce(12).mockReturnValue(0); | |||
// Subsequent calls will then be 0 e.g. | |||
expect(f()).toBe(12); | |||
expect(f()).toBe(0); | |||
expect(f()).toBe(0); | |||
expect(f()).toBe(0); | |||
</syntaxhighlight> | |||
==Back to Date Slider Example== | |||
Not we can fire the event using the a mock function. | |||
*Use jest.fn to capture the event parameters when date changes | |||
*We get the input slider using getByTestId | |||
*fire the event change with fireEvent.change | |||
===Using React Testing Library=== | |||
<syntaxhighlight lang="js"> | |||
describe('change', () => { | |||
it('should publish the selected date', () => { | |||
const fn = jest.fn(); | |||
const { getByTestId } = render( | |||
<DateSlider earth_date="2017-5-13" onDateChanged={fn} /> | |||
); | |||
const input = getByTestId("date-slider"); | |||
fireEvent.change(input, { target: { value: '3877' } }); | |||
expect(fn.mock.calls).toEqual([ | |||
[solToDate(3877)] | |||
]); | |||
}); | |||
}); | |||
</syntaxhighlight> | |||
===Using the Test Renderer=== | |||
<syntaxhighlight lang="js"> | |||
describe('DateSlider', () => { | |||
describe('change', () => { | |||
it('should publish the selected date', () => { | |||
const fn = jest.fn(); | |||
const tr = TestRenderer.create( | |||
<DateSlider earth_date="2017-5-13" onDateChanged={fn} /> | |||
); | |||
const input = tr.root.findByProps({"data-testid": "date-slider"}); | |||
TestRenderer.act(() => { | |||
input.props.onChange({ target: { value: '3877' } }); | |||
}); | |||
expect(fn.mock.calls).toEqual([ | |||
[solToDate(3877)] | |||
]); | |||
}); | |||
}); | |||
}); | |||
</syntaxhighlight> | |||
=Testing States and Effects= | =Testing States and Effects= | ||
Nice demonstration using a simple idea of a list we have two components one with a list with state and one with a list with reducer. | |||
=Using List with State= | |||
==Implementation== | |||
<syntaxhighlight lang="jsx"> | |||
import React, {useState} from 'react'; | |||
export const inputPlaceholder = "Enter new item"; | |||
const List = () => { | |||
const [newItem, setNewItem] = useState(''); | |||
const [items, setItems] = useState([]); | |||
const handleSubmit = (e) => { | |||
setItems(items.concat([newItem])); | |||
setNewItem(''); | |||
e.preventDefault(); | |||
}; | |||
return ( | |||
<div> | |||
<h2>List</h2> | |||
<ul> | |||
{items.map((item) => <li key={item}>{item}</li>)} | |||
</ul> | |||
<form onSubmit={handleSubmit} data-testid='form'> | |||
<div className="form-group"> | |||
<input | |||
type="text" | |||
value={newItem} | |||
onChange={(e) => {setNewItem(e.target.value)}} | |||
className="form-control" | |||
id="newItem" | |||
placeholder={inputPlaceholder} /> | |||
</div> | |||
<input type="submit" value="Add" className="btn btn-primary" /> | |||
</form> | |||
</div> | |||
); | |||
}; | |||
export default List; | |||
</syntaxhighlight> | |||
==Test== | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
import { render, fireEvent } from '@testing-library/react'; | |||
import List, {inputPlaceholder} from './List'; | |||
describe('List', () => { | |||
describe('adding items', () => { | |||
it('should add items', ()=>{ | |||
const { getByText, getByPlaceholderText, getByTestId } = render( | |||
<List /> | |||
); | |||
const input = getByPlaceholderText(inputPlaceholder); | |||
const form = getByTestId('form'); | |||
fireEvent.change(input, { | |||
target: { value: 'one'} | |||
}); | |||
fireEvent.submit(form); | |||
const firstItem = getByText('one'); | |||
expect(firstItem).toBeDefined(); | |||
expect(firstItem.tagName).toBe('LI'); | |||
expect(input.value).toBe(''); | |||
fireEvent.change(input, { | |||
target: { value: 'two'} | |||
}); | |||
fireEvent.submit(form); | |||
const secondItem = getByText('two'); | |||
expect(secondItem).toBeDefined(); | |||
expect(secondItem.tagName).toBe('LI'); | |||
expect(secondItem.parentNode.childElementCount).toBe(2); | |||
expect(input.value).toBe(''); | |||
}); | |||
}); | |||
}); | |||
</syntaxhighlight> | |||
=Using List with Reducer= | |||
==Implementation== | |||
<syntaxhighlight lang="jsx"> | |||
import React, {useReducer} from 'react'; | |||
export const inputPlaceholder = "Enter new item"; | |||
export const defaultState = { | |||
newItem: '', | |||
items: [] | |||
}; | |||
export const reducer = (state, action) => { | |||
switch (action.type) { | |||
case 'newItemChange': | |||
return { | |||
...state, | |||
newItem: action.value | |||
}; | |||
case 'add': | |||
return { | |||
items: state.items.concat([state.newItem]), | |||
newItem: '' | |||
}; | |||
default: return state; | |||
} | |||
}; | |||
const ListReducer = () => { | |||
const [state, dispatch] = useReducer(reducer, defaultState); | |||
const handleSubmit = (e) => { | |||
e.preventDefault(); | |||
dispatch({ | |||
type: 'add' | |||
}); | |||
}; | |||
return ( | |||
<div> | |||
<h2>List</h2> | |||
<ul> | |||
{state.items.map((item) => <li key={item}>{item}</li>)} | |||
</ul> | |||
<form onSubmit={handleSubmit} data-testid='form'> | |||
<div className="form-group"> | |||
<input | |||
type="text" | |||
value={state.newItem} | |||
onChange={(e) => {dispatch({type: 'newItemChange', value: e.target.value});}} | |||
className="form-control" | |||
id="newItem" | |||
placeholder={inputPlaceholder} /> | |||
</div> | |||
<input type="submit" value="Add" className="btn btn-primary" /> | |||
</form> | |||
</div> | |||
); | |||
}; | |||
export default ListReducer; | |||
</syntaxhighlight> | |||
==Test== | |||
<syntaxhighlight lang="js"> | |||
import React from 'react'; | |||
import { render, fireEvent } from '@testing-library/react'; | |||
import ListReducer, {inputPlaceholder, reducer, defaultState} from './ListReducer'; | |||
describe('ListReducer', () => { | |||
describe('adding items', () => { | |||
it('should add items', ()=>{ | |||
const { getByText, getByPlaceholderText, getByTestId } = render( | |||
<ListReducer /> | |||
); | |||
const input = getByPlaceholderText(inputPlaceholder); | |||
const form = getByTestId('form'); | |||
fireEvent.change(input, { | |||
target: { value: 'one'} | |||
}); | |||
fireEvent.submit(form); | |||
const firstItem = getByText('one'); | |||
expect(firstItem).toBeDefined(); | |||
expect(firstItem.tagName).toBe('LI'); | |||
expect(input.value).toBe(''); | |||
fireEvent.change(input, { | |||
target: { value: 'two'} | |||
}); | |||
fireEvent.submit(form); | |||
const secondItem = getByText('two'); | |||
expect(secondItem).toBeDefined(); | |||
expect(secondItem.tagName).toBe('LI'); | |||
expect(secondItem.parentNode.childElementCount).toBe(2); | |||
expect(input.value).toBe(''); | |||
}); | |||
}); | |||
describe('reducer', () => { | |||
describe('starting state', () => { | |||
it('should have no items', () => { | |||
const state = reducer(defaultState, {type: 'does not matter'}); | |||
expect(state.items).toEqual([]); | |||
}); | |||
it('should have an empty new item input', () => { | |||
const state = reducer(defaultState, {type: 'does not matter'}); | |||
expect(state.newItem).toEqual(''); | |||
}); | |||
}); | |||
describe('typing characters', () => { | |||
it('should add the characters to the newItem property', () => { | |||
let state = reducer(defaultState, {type: 'newItemChange', value: 'a'}); | |||
expect(state.newItem).toEqual('a'); | |||
state = reducer(defaultState, {type: 'newItemChange', value: 'ab'}); | |||
expect(state.newItem).toEqual('ab'); | |||
}); | |||
it('should not change the items array', () => { | |||
let state = reducer(defaultState, {type: 'newItemChange', value: 'a'}); | |||
expect(state.items).toEqual([]); | |||
state = reducer(defaultState, {type: 'newItemChange', value: 'ab'}); | |||
expect(state.items).toEqual([]); | |||
}); | |||
}); | |||
describe('add items', () => { | |||
it('should add the items', () => { | |||
let state = reducer( | |||
{ items: [], newItem: 'aurora'}, | |||
{type: 'add'}); | |||
expect(state.items).toEqual(['aurora']); | |||
state = reducer( | |||
{ ...state, newItem: 'borealis'}, | |||
{type: 'add'}); | |||
expect(state.items).toEqual(['aurora', 'borealis']); | |||
}); | |||
it('should clear newItem', () => { | |||
let state = reducer( | |||
{ items: [], newItem: 'aurora'}, | |||
{type: 'add'}); | |||
expect(state.newItem).toEqual(''); | |||
state = reducer( | |||
{ ...state, newItem: 'borealis'}, | |||
{type: 'add'}); | |||
expect(state.newItem).toEqual(''); | |||
}); | |||
}); | |||
}); | |||
}); | |||
</syntaxhighlight> | |||
=Integration Testing= | |||
When Redux or other state management is used then the testing of the components becomes harder to test. Integration testing is perhaps a better choice. So below is an example of how you might approach this. Render the app, find the controls and test the expectation. Fire Events and then repeat. | |||
<syntaxhighlight lang="js"> | |||
import { render, fireEvent, wait } from '@testing-library/react'; | |||
import { App } from './App'; | |||
import { dateToSol } from './services/sols'; | |||
describe('App', () => { | |||
describe('initial render', () => { | |||
it('should select Sep 28 2019', () => { | |||
const { queryByTestId } = render( | |||
App | |||
); | |||
const dateLabel = queryByTestId('date-label'); | |||
const sliderInput = queryByTestId('date-slider'); | |||
expect(dateLabel).toBeDefined(); | |||
expect(dateLabel.textContent).toBe('2019-09-28'); | |||
expect(sliderInput).toBeDefined(); | |||
expect(sliderInput.value).toBe(dateToSol('2019-09-28').toString()); | |||
}); | |||
}); | |||
describe('selecting a pre-curiosity day and changing criteria', () => { | |||
it('should show the correct number of images', async () => { | |||
jest.setTimeout(10000); | |||
const day = '2007-11-28'; | |||
const renderResult = render( | |||
App | |||
); | |||
const dateLabel = renderResult.queryByTestId('date-label'); | |||
const sliderInput = renderResult.queryByTestId('date-slider'); | |||
const checkboxes = renderResult.getAllByTestId("rover-selected"); | |||
fireEvent.change( | |||
sliderInput, | |||
{ target: { value: dateToSol(day).toString() } } | |||
); | |||
await wait(() => { | |||
expect(dateLabel.textContent).toBe('2007-11-28'); | |||
expect(sliderInput.value).toBe(dateToSol('2007-11-28').toString()); | |||
const images = renderResult.getAllByTestId('rover-image'); | |||
expect(images.length).toBe(8); | |||
}); | |||
// deselect opportunity | |||
fireEvent.click(checkboxes[1]); | |||
await wait(() => { | |||
const images = renderResult.getAllByTestId('rover-image'); | |||
expect(images.length).toBe(2); | |||
}); | |||
// select navigation camera | |||
const cameraSelect = renderResult.queryByTestId('camera-select'); | |||
fireEvent.change( | |||
cameraSelect, | |||
{ target: { value: 'NAVCAM' } } | |||
); | |||
await wait(() => { | |||
const images = renderResult.getAllByTestId('rover-image'); | |||
expect(images.length).toBe(27); | |||
}); | |||
}); | |||
}); | |||
}); | |||
</syntaxhighlight> |
Latest revision as of 01:26, 12 July 2021
Introduction
Guiding principle for testing is
Testing Exactly what you are trying to test and nothing more
Never test through a UI component what can be tested some other way
Never test the same thing twice
DOM Overview
Definition
The Document Object Model (DOM) is a programming interface for HTML and XML documents. It represents the page so that programs can change the document structure, style, and content. The DOM represents the document as nodes and objects. That way, programming languages can connect to the page.
A Web page is a document. This document can be either displayed in the browser window or as the HTML source. But it is the same document in both cases. The Document Object Model (DOM) represents that same document so it can be manipulated. The DOM is an object-oriented representation of the web page, which can be modified with a scripting language such as JavaScript.
Document Element
This is one element in a DOM e.g. a div.
innerHTML vs outterHTML
InnerHTML is used for getting or setting a content of the selected whilst the outerHTML is used for getting or setting content with the selected tag. This is a referred to a lot by Web developers so here is a picture to remind people of the difference.
Testing Rendering
React Testing Library
React Testing Library is one of the most well known. They say,
- The more your tests resemble the way the software is used, the more confidence they can give you.
- Encourages rendering components to DOM nodes,
- Making asserts against DOM nodes
It's API tries to encourage tests to represent how the software is used by the user. For example these tests focus on how the user might access the screen, by label, by text
const r = render(<Message content="Some Stuff" isImportant={true} />);
r.getByLabelText('First Name')
r.getByText('2017-5-13')
r.getByTitle('introduction')
// Frown upon because as a user id are not visible but...
r.getByTestId('target')
Example Test
Here is a full example for testing the Date slider
The tests use the getByTestId and render API.
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect'
import DateSlider from './DateSlider';
import {solToDate, dateToSol} from '../services/sols';
describe('DateSlider', () => {
describe('render', () => {
it("should return a container", () => {
const { container } = render(
<DateSlider earth_date="2017-5-13" onDateChanged={()=>{}} />
);
expect(container).toBeDefined();
expect(container.outerHTML).toBe("<div><div class=\"Dateslider\"><div class=\"row\"><div class=\"col-12\" style=\"text-align: center;\"><label for=\"date\">Earth Day</label><p class=\"Dateslider-date\" data-testid=\"date-label\">2017-5-13</p></div></div><div class=\"row\"><div class=\"col-3\" style=\"text-align: right;\"><small>2004-01-05</small></div><div class=\"col-6 form-group\"><input data-testid=\"date-slider\" type=\"range\" id=\"date\" class=\"form-control\" min=\"1\" max=\"5746\" value=\"4878\"></div><div class=\"col-3\"><small>2019-09-28</small></div></div></div></div>");
});
it('should display the correct date', () => {
const { getByTestId } = render(<DateSlider earth_date="2017-5-13" onDateChanged={()=>{}} />);
const date = getByTestId("date-label");
expect(date).toHaveTextContent("2017-5-13");
});
it('should have the correct slider position', () => {
const { getByTestId } = render(<DateSlider earth_date="2017-5-13" onDateChanged={()=>{}} />);
const input = getByTestId("date-slider");
expect(input).toHaveValue(dateToSol("2017-5-13").toString());
});
});
describe('click', () => {
it('should publish the selected date', () => {
const fn = jest.fn();
const { getByTestId } = render(<DateSlider earth_date="2017-5-13" onDateChanged={fn} />);
const input = getByTestId("date-slider");
fireEvent.change(input, { target: { value: '3877' } });
expect(fn.mock.calls.length).toBe(1);
expect(fn.mock.calls[0][0]).toBe(solToDate(3877));
});
});
});
React Test Utilities
This library provides the function act() which provides a more realistic environment for rendering and updating componenemts.
Example Test Camera (React Test Utilities)
This example is testing a combobox
To use the act API it was noted
- create a container and make sure you add it to the document
- jest provides beforeEach and AferEach to prepare and tear down
- act is the API to render and update components
- remember the ReactDOM.render requires the container as an argument
- in the after each we remove the container and reset to null
The tests will consist of testing
- should render a select element (something selected)
- the selector renders each of the options
- it selects the one we ask it to select
import React from 'react';
import ReactDOM from 'react-dom';
import ReactTestUtils from 'react-dom/test-utils';
import CameraSelection from './CameraSelection';
describe('CameraSelection', () => {
describe('rendering', () => {
let container,select;
const cameras = {
BC: "Big camera",
LC: "Little camera"
};
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
ReactTestUtils.act(()=>{
ReactDOM.render(<CameraSelection camera="LC" cameras={cameras} onCameraSelected={()=>{}} />, container);
});
select = container.querySelector('select');
});
afterEach(() => {
document.body.removeChild(container);
container = null;
});
it("should render a select element", () => {
expect(select).toBeDefined();
});
it("should have 2 options", ()=>{
expect(select.length).toBe(2);
});
it("should have LC (Little Camera) selected", ()=>{
expect(select.value).toBe("LC");
expect(select.selectedOptions[0].text).toBe('Little camera');
});
});
});
Test Render
Test Render does not use the DOM but instead test the javaScript objects. One of the great points which was made is you can identify the component is too complex when looking at testing and it may show opportunity to refactor unnecessarily complete code.
Example Test (Test Render)
Here we create an instance of the component and then look for if the checkbox is checked. It did appear to rely on data-testid prior to writing of testing.
describe('none selected', ()=>{
it('should select no rovers', ()=> {
const none = {spirit: false, opportunity: false, curiosity: false};
const tr = TestRenderer.create(<RoverSelector roversActive={none} rovers={rovers} roverSelection={none} onRoverSelection={()=>{}} />);
const inputs = tr.root.findAllByProps({"data-testid": 'rover-selected'});
inputs.forEach((input) => {
expect(input.props.checked).toBe(false);
});
});
});
Testing Component Events
Approach
For test events take four steps
- Set props and render
- Find elements you need
- Trigger events on elements
- Make assertions against events produced
Testing Library
We can use this to test events. Here is a simple component which increments a count on press.
import React, { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
return (
<p onClick={()=> { setCount(count+1); }}>
{count} ah ah ah
</p>
);
};
export default Counter;
Example Test Counter (Testing Library)
Put this here to not scare people and show simple testing is easy.
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
it("should start at zero", () => {
const { queryByText } = render(
<Counter />
);
const paragraph = queryByText(/ah ah ah/);
expect(paragraph).toBeTruthy();
expect(paragraph.textContent).toBe('0 ah ah ah');
});
it("should increment on click", () => {
const { queryByText } = render(
<Counter />
);
const paragraph = queryByText(/ah ah ah/);
expect(paragraph.textContent).toBe('0 ah ah ah');
fireEvent.click(paragraph);
expect(paragraph.textContent).toBe('1 ah ah ah');
fireEvent.click(paragraph);
expect(paragraph.textContent).toBe('2 ah ah ah');
});
});
Asyc Testing and Jest
Testing Asynchronous code with Jest requires test methods to return a promose or they will always pass. Here is the counter again but using a timeout to simulate Async
import React, { useState } from 'react';
const CounterAsync = () => {
const [count, setCount] = useState(0);
return (
<p onClick={()=> { setTimeout(()=> { setCount(count+1); }, 1000)}}>
{count} ah ah ah
</p>
);
};
export default CounterAsync;
Example Test Counter Async (Testing Library)
To test async we need to wait for the results. We do this by using the wait function and await.
import React from 'react';
import { render, fireEvent, wait } from '@testing-library/react';
import CounterAsync from './Counter.async';
describe('Counter', () => {
it("should start at zero", () => {
const { queryByText } = render(
<CounterAsync />
);
const paragraph = queryByText(/ah ah ah/);
expect(paragraph).toBeTruthy();
expect(paragraph.textContent).toBe('0 ah ah ah');
});
it("should increment on click", async () => {
const { queryByText } = render(
<CounterAsync />
);
const paragraph = queryByText(/ah ah ah/);
await wait(() => {
expect(paragraph.textContent).toBe('0 ah ah ah');
});
fireEvent.click(paragraph);
await wait(() => {
expect(paragraph.textContent).toBe('1 ah ah ah');
});
fireEvent.click(paragraph);
await wait(() => {
expect(paragraph.textContent).toBe('2 ah ah ah');
});
});
});
Mocking
Jest provides mocking functions. Mocks functons
- can be called but don't return anything
- can record they were called and the arguments passed
const f = jest.fn();
f(1,2,3)
f('a')
expect(f.mock.calls).toEqual(
[
[1,2,3],
['a']
]
)
We can provide an implement and test that e.g.
const f = jest.fn((a,b) => {a + b});
expect(f(4,8)).toBe(12);
expect(f.mocks.calls).toEqual(
[
[4,8]
]
)
We can fake the results to
const f = jest().fn()
f.mockReturnValueOnce(12).mockReturnValue(0);
// Subsequent calls will then be 0 e.g.
expect(f()).toBe(12);
expect(f()).toBe(0);
expect(f()).toBe(0);
expect(f()).toBe(0);
Back to Date Slider Example
Not we can fire the event using the a mock function.
- Use jest.fn to capture the event parameters when date changes
- We get the input slider using getByTestId
- fire the event change with fireEvent.change
Using React Testing Library
describe('change', () => {
it('should publish the selected date', () => {
const fn = jest.fn();
const { getByTestId } = render(
<DateSlider earth_date="2017-5-13" onDateChanged={fn} />
);
const input = getByTestId("date-slider");
fireEvent.change(input, { target: { value: '3877' } });
expect(fn.mock.calls).toEqual([
[solToDate(3877)]
]);
});
});
Using the Test Renderer
describe('DateSlider', () => {
describe('change', () => {
it('should publish the selected date', () => {
const fn = jest.fn();
const tr = TestRenderer.create(
<DateSlider earth_date="2017-5-13" onDateChanged={fn} />
);
const input = tr.root.findByProps({"data-testid": "date-slider"});
TestRenderer.act(() => {
input.props.onChange({ target: { value: '3877' } });
});
expect(fn.mock.calls).toEqual([
[solToDate(3877)]
]);
});
});
});
Testing States and Effects
Nice demonstration using a simple idea of a list we have two components one with a list with state and one with a list with reducer.
Using List with State
Implementation
import React, {useState} from 'react';
export const inputPlaceholder = "Enter new item";
const List = () => {
const [newItem, setNewItem] = useState('');
const [items, setItems] = useState([]);
const handleSubmit = (e) => {
setItems(items.concat([newItem]));
setNewItem('');
e.preventDefault();
};
return (
<div>
<h2>List</h2>
<ul>
{items.map((item) => <li key={item}>{item}</li>)}
</ul>
<form onSubmit={handleSubmit} data-testid='form'>
<div className="form-group">
<input
type="text"
value={newItem}
onChange={(e) => {setNewItem(e.target.value)}}
className="form-control"
id="newItem"
placeholder={inputPlaceholder} />
</div>
<input type="submit" value="Add" className="btn btn-primary" />
</form>
</div>
);
};
export default List;
Test
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import List, {inputPlaceholder} from './List';
describe('List', () => {
describe('adding items', () => {
it('should add items', ()=>{
const { getByText, getByPlaceholderText, getByTestId } = render(
<List />
);
const input = getByPlaceholderText(inputPlaceholder);
const form = getByTestId('form');
fireEvent.change(input, {
target: { value: 'one'}
});
fireEvent.submit(form);
const firstItem = getByText('one');
expect(firstItem).toBeDefined();
expect(firstItem.tagName).toBe('LI');
expect(input.value).toBe('');
fireEvent.change(input, {
target: { value: 'two'}
});
fireEvent.submit(form);
const secondItem = getByText('two');
expect(secondItem).toBeDefined();
expect(secondItem.tagName).toBe('LI');
expect(secondItem.parentNode.childElementCount).toBe(2);
expect(input.value).toBe('');
});
});
});
Using List with Reducer
Implementation
import React, {useReducer} from 'react';
export const inputPlaceholder = "Enter new item";
export const defaultState = {
newItem: '',
items: []
};
export const reducer = (state, action) => {
switch (action.type) {
case 'newItemChange':
return {
...state,
newItem: action.value
};
case 'add':
return {
items: state.items.concat([state.newItem]),
newItem: ''
};
default: return state;
}
};
const ListReducer = () => {
const [state, dispatch] = useReducer(reducer, defaultState);
const handleSubmit = (e) => {
e.preventDefault();
dispatch({
type: 'add'
});
};
return (
<div>
<h2>List</h2>
<ul>
{state.items.map((item) => <li key={item}>{item}</li>)}
</ul>
<form onSubmit={handleSubmit} data-testid='form'>
<div className="form-group">
<input
type="text"
value={state.newItem}
onChange={(e) => {dispatch({type: 'newItemChange', value: e.target.value});}}
className="form-control"
id="newItem"
placeholder={inputPlaceholder} />
</div>
<input type="submit" value="Add" className="btn btn-primary" />
</form>
</div>
);
};
export default ListReducer;
Test
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import ListReducer, {inputPlaceholder, reducer, defaultState} from './ListReducer';
describe('ListReducer', () => {
describe('adding items', () => {
it('should add items', ()=>{
const { getByText, getByPlaceholderText, getByTestId } = render(
<ListReducer />
);
const input = getByPlaceholderText(inputPlaceholder);
const form = getByTestId('form');
fireEvent.change(input, {
target: { value: 'one'}
});
fireEvent.submit(form);
const firstItem = getByText('one');
expect(firstItem).toBeDefined();
expect(firstItem.tagName).toBe('LI');
expect(input.value).toBe('');
fireEvent.change(input, {
target: { value: 'two'}
});
fireEvent.submit(form);
const secondItem = getByText('two');
expect(secondItem).toBeDefined();
expect(secondItem.tagName).toBe('LI');
expect(secondItem.parentNode.childElementCount).toBe(2);
expect(input.value).toBe('');
});
});
describe('reducer', () => {
describe('starting state', () => {
it('should have no items', () => {
const state = reducer(defaultState, {type: 'does not matter'});
expect(state.items).toEqual([]);
});
it('should have an empty new item input', () => {
const state = reducer(defaultState, {type: 'does not matter'});
expect(state.newItem).toEqual('');
});
});
describe('typing characters', () => {
it('should add the characters to the newItem property', () => {
let state = reducer(defaultState, {type: 'newItemChange', value: 'a'});
expect(state.newItem).toEqual('a');
state = reducer(defaultState, {type: 'newItemChange', value: 'ab'});
expect(state.newItem).toEqual('ab');
});
it('should not change the items array', () => {
let state = reducer(defaultState, {type: 'newItemChange', value: 'a'});
expect(state.items).toEqual([]);
state = reducer(defaultState, {type: 'newItemChange', value: 'ab'});
expect(state.items).toEqual([]);
});
});
describe('add items', () => {
it('should add the items', () => {
let state = reducer(
{ items: [], newItem: 'aurora'},
{type: 'add'});
expect(state.items).toEqual(['aurora']);
state = reducer(
{ ...state, newItem: 'borealis'},
{type: 'add'});
expect(state.items).toEqual(['aurora', 'borealis']);
});
it('should clear newItem', () => {
let state = reducer(
{ items: [], newItem: 'aurora'},
{type: 'add'});
expect(state.newItem).toEqual('');
state = reducer(
{ ...state, newItem: 'borealis'},
{type: 'add'});
expect(state.newItem).toEqual('');
});
});
});
});
Integration Testing
When Redux or other state management is used then the testing of the components becomes harder to test. Integration testing is perhaps a better choice. So below is an example of how you might approach this. Render the app, find the controls and test the expectation. Fire Events and then repeat.
import { render, fireEvent, wait } from '@testing-library/react';
import { App } from './App';
import { dateToSol } from './services/sols';
describe('App', () => {
describe('initial render', () => {
it('should select Sep 28 2019', () => {
const { queryByTestId } = render(
App
);
const dateLabel = queryByTestId('date-label');
const sliderInput = queryByTestId('date-slider');
expect(dateLabel).toBeDefined();
expect(dateLabel.textContent).toBe('2019-09-28');
expect(sliderInput).toBeDefined();
expect(sliderInput.value).toBe(dateToSol('2019-09-28').toString());
});
});
describe('selecting a pre-curiosity day and changing criteria', () => {
it('should show the correct number of images', async () => {
jest.setTimeout(10000);
const day = '2007-11-28';
const renderResult = render(
App
);
const dateLabel = renderResult.queryByTestId('date-label');
const sliderInput = renderResult.queryByTestId('date-slider');
const checkboxes = renderResult.getAllByTestId("rover-selected");
fireEvent.change(
sliderInput,
{ target: { value: dateToSol(day).toString() } }
);
await wait(() => {
expect(dateLabel.textContent).toBe('2007-11-28');
expect(sliderInput.value).toBe(dateToSol('2007-11-28').toString());
const images = renderResult.getAllByTestId('rover-image');
expect(images.length).toBe(8);
});
// deselect opportunity
fireEvent.click(checkboxes[1]);
await wait(() => {
const images = renderResult.getAllByTestId('rover-image');
expect(images.length).toBe(2);
});
// select navigation camera
const cameraSelect = renderResult.queryByTestId('camera-select');
fireEvent.change(
cameraSelect,
{ target: { value: 'NAVCAM' } }
);
await wait(() => {
const images = renderResult.getAllByTestId('rover-image');
expect(images.length).toBe(27);
});
});
});
});