TL;DR Guide to React Native Testing

Photo: https://www.press.bmwgroup.com/deutschland/photo/detail/P90045748

Why Write Tests?

Unit, Integration or E2E?

What Are Good Tests?

Example Todo App

npm install
npm test
node node/app.js
react-native run-ios

Testing Components

// components/Todo.js
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { Checkbox } from './Checkbox';
import { IconButton } from './IconButton';
export const Todo = props => (
<View style={styles.todo}>
<Checkbox
checked={props.complete}
disabled={props.progress}
onChange={complete => props.onComplete(props.id, complete)}
/>
<Text style={[styles.text, props.complete && styles.complete]}>
{props.text}
</Text>
{
props.progress &&
<ActivityIndicator style={styles.progress} color="gray" />
}
<IconButton
icon="delete"
disabled={props.progress}
onPress={() => props.onDelete(props.id)}
/>
</View>
);
const styles = StyleSheet.create({
todo: {
alignItems: 'center',
flexDirection: 'row',
height: 60,
paddingHorizontal: 20,
},
text: {
fontSize: 20,
marginLeft: 10,
flex: 1,
},
complete: {
textDecorationLine: 'line-through',
color: 'gray',
},
progress: {
marginRight: 10,
},
});
import 'react-native';
import React from 'react';
// Test renderer must be imported after react-native
import TestRenderer from 'react-test-renderer';
import { Text, ActivityIndicator } from 'react-native';
import { Todo } from '../../components/Todo';
import { Checkbox } from '../../components/Checkbox';
import { IconButton } from '../../components/IconButton';
const todo = {id: '1', text: 'Todo', complete: true};
describe('Todo', () => {
// Put your tests here
});
it('Renders correctly when completed', () => {
const testRenderer = TestRenderer.create(
<Todo {...todo} />
);
const component = testRenderer.root;
const checkbox = component.findByType(Checkbox);
const text = component.findByProps({children: todo.text});
const style = {textDecorationLine: 'line-through', color: 'gray'};
expect(text.type).toBe(Text);
expect(text.props.style).toContainEqual(style);
expect(checkbox.props.checked).toBe(todo.complete);
});
it('Handles complete actions correctly', () => {
const onComplete = jest.fn();
const testRenderer = TestRenderer.create(
<Todo
{...todo}
onComplete={onComplete}
/>
);
const component = testRenderer.root;
const checkbox = component.findByType(Checkbox);
checkbox.props.onChange(false); expect(onComplete).toHaveBeenCalledWith(todo.id, false);
});
it('Handles delete actions correctly', () => {
const onDelete = jest.fn();
const testRenderer = TestRenderer.create(
<Todo
{...todo}
onDelete={onDelete}
/>
);
const component = testRenderer.root;
const deleteButton = component.findByType(IconButton);
deleteButton.props.onPress(); expect(onDelete).toHaveBeenCalledWith(todo.id);
});
it('Renders correctly when in progress', () => {
const testRenderer = TestRenderer.create(
<Todo
{...todo}
progress={true}
/>
);
const component = testRenderer.root;
const checkbox = component.findByType(Checkbox);
const deleteButton = component.findByType(IconButton);
const activityIndicator = component.findByType(ActivityIndicator);
expect(checkbox.props.disabled).toBe(true);
expect(deleteButton.props.disabled).toBe(true);
});

Testing Actions

// actions/index.js
export const addTodo = text => ({
type: ADD_TODO,
text
});
import { addTodo } from '../../actions';describe('addTodo()', () => {
it('Creates ADD_TODO action', () => {
const text = 'Todo';
const action = {type: ADD_TODO, text};
expect(addTodo(text)).toStrictEqual(action);
});
});

Testing Async Actions (Redux Thunk)

// actions/index.js
export const fetchTodosAsync = () => {
return dispatch => {
dispatch(fetchTodos());
return Api.fetchTodos()
.then(todos => dispatch(fetchTodosSuccess(todos)))
.catch(error => dispatch(fetchTodosError(error)));
}
};
import thunk from 'redux-thunk';
import configureMockStore from 'redux-mock-store';
import {
fetchTodos,
fetchTodosAsync,
fetchTodosSuccess,
} from '../../actions';
import { Api } from '../../api/Api';
// Mocks
const mockStore = configureMockStore([thunk]);
const todo = {id: '1', text: 'Todo', complete: false};
// fetchTodosAsync() is an async thunk action, so we test it differently
describe('fetchTodosAsync()', () => {
it('Handles successful todo fetch correctly', () => {
const store = mockStore();
const todos = [todo];
// Because the action calls an async Api method, we need to mock it
const fetchTodosMock = jest.spyOn(Api, 'fetchTodos')
.mockImplementation(() => Promise.resolve(todos));
const actions = [
fetchTodos(),
fetchTodosSuccess(todos)
];
// If return is omitted, the test will pass by default
return store
// The action is processed by the store thunk middleware
// so it has to be dispatched to take effect
.dispatch(fetchTodosAsync())
// The action async, so it has to complete before running tests
.then(() => {
// Expect the Api method to have been called
expect(fetchTodosMock).toHaveBeenCalled();
// And correct actions to have been dispatched
expect(store.getActions()).toStrictEqual(actions);
// Restore the original method
fetchTodosMock.mockRestore();
});
});
});

Testing Reducers

// reducers/index.js
export const reducer = (state = initialState, action) => {
switch (action.type) {
case ADD_TODO_SUCCESS:
return {
...state,
adding: false,
todos: [
...state.todos,
{...action.todo, progress: false}
]
};
}
};
import { reducer, initialState } from '../../reducers';
import { addTodoSuccess } from '../../actions';
const todo = {id: '1', text: 'Todo', complete: false};describe('Main reducer', () => {
it('Changes todos state on ADD_TODO_SUCCESS', () => {
const action = addTodoSuccess(todo);
const state = reducer(initialState, action);
const expectedState = {
...initialState,
todos: [{...todo, progress: false}],
adding: false
};
expect(state).toStrictEqual(expectedState);
});
});

Testing Redux Saga

// sagas/index.js
export function* queueCompleteTodo() {
// actionChannel() buffers multiple actions in a queue
const channel = yield actionChannel(COMPLETE_TODO);
while (true) {
// Get queued actions one at a time, in the same order
const action = yield take(channel);
// Wait for either todo to be completed, or the queue to be canceled, whichever arrives first
const { todo, cancel } = yield race({
todo: call(completeTodo, action),
cancel: take(COMPLETE_TODO_CANCEL)
});
// If canceled, flush the remaining queued actions
if (cancel) {
const actions = yield flush(channel);
}
}
}
import { channel } from 'redux-saga';
import { actionChannel, call, race, take } from 'redux-saga/effects';
import { COMPLETE_TODO, COMPLETE_TODO_CANCEL, completeTodo as completeTodoAction, completeTodoCancel } from '../../actions';
import { completeTodo, queueCompleteTodo } from '../../sagas';
describe('queueCompleteTodo()', () => {
it('Buffers multiple actions and processes them in a queue', () => {
const saga = queueCompleteTodo();
const mockChannel = channel();
const completeAction = completeTodoAction();
const cancelAction = completeTodoCancel();
const yield1 = actionChannel(COMPLETE_TODO);
const yield2 = take(mockChannel);
const yield3 = race({
todo: call(completeTodo, completeAction),
cancel: take(COMPLETE_TODO_CANCEL)
});
const yield4 = flush(mockChannel);
expect(saga.next().value).toStrictEqual(yield1);
// We expect the previous call to return an actionChannel,
// so we pass a channel to the next call
expect(saga.next(mockChannel).value).toStrictEqual(yield2);
// At this point the channel is expecting a complete action,
// so we pass an action to the next call
expect(saga.next(completeAction).value).toStrictEqual(yield3);
// At this point the saga is expecting either a resolved Api call
// or a cancel action, so we pass the latter to expect cancel handling
expect(saga.next({cancel: cancelAction}).value).toStrictEqual(yield4);
// When canceled, the generator flushes queued actions,
// and returns to waiting for new complete actions
expect(saga.next().value).toStrictEqual(yield2);
});
});

Integration Tests

import 'react-native';
import React from 'react';
import { TextInput, Button, LayoutAnimation } from 'react-native';
// Note: test testRenderer must be required after react-native.
import TestRenderer from 'react-test-renderer';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunk from 'redux-thunk';
import { reducer } from '../../reducers';
import { rootSaga } from '../../sagas';
import { Api } from '../../api/Api';
import { App } from '../../components/App';
import { Todo } from '../../components/Todo';
import { AddTodo } from '../../components/AddTodo';
import { TodoFilters, Filters } from '../../components/TodoFilters';
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(thunk, sagaMiddleware));
sagaMiddleware.run(rootSaga);
// Mocks
const todos = [
{id: '1', text: 'First todo', complete: false},
{id: '2', text: 'Second todo', complete: false},
];
// We need this to prevent jest errors related to animation
jest.spyOn(LayoutAnimation, 'configureNext')
.mockImplementation(() => jest.fn());
describe('App', () => {
let testRenderer;
// This mock must be created before the App instance
// because it gets called when the TodoList component mounts
const fetchTodosMock = jest.spyOn(Api, 'fetchTodos')
.mockImplementation(() => Promise.resolve([todos[0]]));
// If this looks weird, check the links for explanation
// https://jestjs.io/docs/en/setup-teardown#one-time-setup
// https://reactjs.org/blog/2019/08/08/react-v16.9.0.html#async-act-for-testing
beforeAll(() => {
return TestRenderer.act(async () => {
testRenderer = TestRenderer.create(
<Provider store={store}>
<App />
</Provider>
);
});
});
afterAll(() => {
fetchTodosMock.mockRestore();
});
// Put your tests here
});
it('Loads todos', () => {
const app = testRenderer.root;
const todoComponents = app.findAllByType(Todo);
expect(fetchTodosMock).toHaveBeenCalledWith();
expect(todoComponents.length).toBe(1);
});
it('Adds todos', async () => {
const addTodoMock = jest.spyOn(Api, 'addTodo')
.mockImplementation(() => Promise.resolve(todos[1]));
const app = testRenderer.root;
const addTodo = app.findByType(AddTodo);
await TestRenderer.act(async () => {
addTodo.props.onAdd(todos[1].text);
});
const todoComponents = app.findAllByType(Todo); expect(addTodoMock).toHaveBeenCalledWith(todos[1].text);
expect(todoComponents.length).toBe(2);
addTodoMock.mockRestore();
});
it('Deletes todos', async () => {
const todo = todos[0];
const deleteTodoMock = jest.spyOn(Api, 'deleteTodo')
.mockImplementation(() => Promise.resolve(todos[0]));
const app = testRenderer.root;
let todoComponents = app.findAllByType(Todo);
await TestRenderer.act(async () => {
todoComponents[0].props.onDelete(todos[0].id);
});
todoComponents = app.findAllByType(Todo); expect(deleteTodoMock).toHaveBeenCalledWith(todos[0].id);
expect(todoComponents.length).toBe(1);
deleteTodoMock.mockRestore();
});

Summary

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store