Тестирование JavaScript от А до Я (Jest, React Testing Library, e2e, screenshot)
TestingEnd2EndJestReactTestingLibraryScreenshot
Введение. Теория. Пирамида тестирования. Квадрат допустимых значений
Тесты позволяют явно увидеть нам изменения в поведении приложения при изменении его логики (тест выдаст ошибку, если наши изменения в приложении ломают логику другой функции).
Цель тестирования - проверка соответствия ПО предъявляемым требованиям
Виды тестирования:
- Функциональное
- Модульное (unit) (70%)
- Интеграционное (20%)
- end-to-end (10%)
- Нефункциональное
- нагрузочное тестирование
- регрессионное (тестирование старого функционала)
- тестирование безопасности
Unit-тесты. Они пишутся на отдельные, независимые, маленькие кусочки системы (например, на методы). Они выполняют простую функцию - проверить работу отдельного маленького кусочка.
Screenshot-тесты. Они уже позволяют нам проверять интерфейс приложения.
Если мы поменяем шрифт в одном месте и он поменятся в другом, то такой тест нам сообщит, что изменения произошли в разных местах и выдаст результат.
Integration-тестирование. Оно уже позволяет просмотреть работу нескольких методов в связке: один компонент рендерит внутри себя список других компонентов по запросу
E2E-тестирование. Оно уже предназначено для проверки важных модулей системы:
- авторизация
- оплата
- создание сущностей
- удаление записи
Квадрат тестирования - это квадрат, который описывает валидные значения, которые может вернуть функция:
- Валидные значения
- Пограничные значения
- Невалидные значения
И вот пример использования квадрата:
Технологии тестирования:
Jest
- самая популярная библиотека для написания любых тестовReact-testing-library
- библиотека для тестрования React-приложенийWebdriverIO
- E2E-тестыStorybook
+Loki
- скриншотные-тесты
Практика. unit тесты с JEST
Устанавливаем в проект библиотеку для тестирования Jest
npm i -D jest
Далее напишем функцию, которую нужно проверить
validateValue.js
const validateValue = (value) => {
if (value < 0 || value > 100) {
return false;
}
return true;
}
module.exports = validateValue;
И далее нам нужно написать сами тесты для функции:
test()
(мы так же можем писатьit()
который является алиасом дляtest()
) принимает в себя имя и функцию, которая будет исполнять тестированиеexpect()
- основная функция, которая используется в тестах - в ней мы описываем, что мы ожидаем от выполнения операции. Внутрь неё передаём операцию и дальше по чейну нужно выбрать одну из функций-проверкиtoBe()
принимает в себя то значение, которое должно оказаться в expect для успешного прохождения проверки
validateValue.spec.js
const validateValue = require('./validateValue');
test('Валидация значения', () => {
expect(validateValue(50)).toBe(true);
});
Так же мы можем описать сразу несколько тестов для нужного нам функционала:
validateValue.spec.js
const validateValue = require('./validateValue');
describe('validateValue', () => {
test('Валидация значения', () => {
expect(validateValue(50)).toBe(true);
});
it('Валидация значения - -1 - fail', () => {
expect(validateValue(-1)).toBe(false);
});
it('Валидация значения - 101 - fail', () => {
expect(validateValue(101)).toBe(false);
});
});
Так же проверяем пограничные значения
Так же дальше мы можем попробовать сравнить массивы:
mapArrToString.js
const mapArrToString = (arr) => {
return arr.filter((item) => Number.isInteger(item)).map(String);
};
module.exports = mapArrToString;
Тут мы уже используем другие функции jest:
toEqual
проводит глубокое сравнение объектов и позволяет сравнить массивы и объекты (если попробовать черезtoBe
, то он выдаст феил, так как он будет сравнивать ссылки, а не значения)- чейн
not
инвертирует результат следующей операции (если у нас значения не эквивалентны, тоnot
выдаст, что они эквивалентны)
mapArrToString.spec.js
const mapArrToString = require('./mapArrToString');
describe('mapArrToString', () => {
it('Переданы корректные значения - success', () => {
expect(mapArrToString([1, 2, 3])).toEqual(['1', '2', '3']);
});
it('Сместь из значений - fail', () => {
expect(mapArrToString([1, 2, 3, null, undefined, 'asdasd'])).toEqual(['1', '2', '3']);
});
it('Пустой массив - success', () => {
expect(mapArrToString([])).toEqual([]);
});
it('Генерация значений - fail', () => {
expect(mapArrToString([])).not.toEqual([1, 2, 3]);
});
});
Далее реализуем функционал возведения в степень
square.js
const square = (value) => {
return value * value;
};
module.exports = square;
Так же условий сравнения в методе expect
крайне большое количество и все из них можно посмотреть в документации
Так же jest предоставляет нам 4 функции, которые выполняют побочные действия между тестами:
beforeAll
beforeEach
afterAll
afterEach
square.spec.js
const square = require('./square');
describe('validateValue', () => {
let mockValue;
// выполняется перед всеми тестами
beforeAll(() => {
mockValue = 3;
console.log(mockValue);
});
// выполняется перед каждым тестом
beforeEach(() => {
mockValue++;
console.log(mockValue);
});
it('Успешное значение - success', () => {
expect(square(2)).toBe(4);
});
it('Три теста - success', () => {
expect(square(2)).toBeLessThan(5);
expect(square(2)).toBeGreaterThan(3);
expect(square(2)).not.toBeUndefined();
});
// выполняется после всех тестов
afterEach(() => {
mockValue++;
console.log(mockValue);
});
// выполняется после всех тестов
afterAll(() => {
mockValue = 0;
console.log(mockValue);
});
});
И теперь каждый тест триггерит изменение нашего значения вышеописанными методами
Моковые данные
Модифицируем нашу функцию таким образом, чтобы она возводила числов в степень через метод и могла не вызвать эту функцию, если число = 1
square.js
const square = (value) => {
if (value === 1) return 1;
return Math.pow(value, 2);
};
module.exports = square;
Для того, чтобы посмотреть сколько раз вызовется определённая функция, мы можем:
- воспользоваться
jest.spyOn
, куда мы передадим библиотеку, за которой следим и её метод - далее нам нужно вызвать целевую функцию
- и далее в
expect
передать наше моковое значение, где чейном проверяем количество вызовов
square.spec.js
const square = require('./square');
describe('validateValue', () => {
it('Успешное значение - success', () => {
const spyMathPow = jest.spyOn(Math, 'pow');
square(2);
expect(spyMathPow).toBeCalledTimes(1);
});
});
Однако тут нужно сказать, что моковые значения в Jest копятся и не делятся на тесты, поэтому второй шан тест уже выдал ошибку
square.spec.js
const square = require('./square');
describe('validateValue', () => {
it('Успешное значение - success', () => {
const spyMathPow = jest.spyOn(Math, 'pow');
square(2);
expect(spyMathPow).toBeCalledTimes(1);
});
it('Успешное значение - success', () => {
const spyMathPow = jest.spyOn(Math, 'pow');
square(1);
expect(spyMathPow).toBeCalledTimes(0);
});
});
Чтобы определить нормальное поведение, нужно будет после каждого теста чистить моковые данные с помощью jest.clearAllMocks()
square.spec.js
const square = require('./square');
describe('validateValue', () => {
it('Успешное значение - success', () => {
const spyMathPow = jest.spyOn(Math, 'pow');
square(2);
expect(spyMathPow).toBeCalledTimes(1);
});
it('Успешное значение - success', () => {
const spyMathPow = jest.spyOn(Math, 'pow');
square(1);
expect(spyMathPow).toBeCalledTimes(0);
});
afterEach(() => {
jest.clearAllMocks();
});
});
Юнит тестирование асинхронных функций. Мокаем данные. Snapshots
Реализуем функцию, которая принимает в себя колбэк и время ожидания
delay.js
const delay = (callback, ms) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(callback());
}, ms);
});
};
module.exports = delay;
И чтобы протестировать её достаточно просто воспользоваться async/await операторами
delay.spec.js
const delay = require('./delay');
describe('mapArrToString', () => {
it('delay - success', async () => {
const sum = await delay(() => 4 + 4, 1000);
expect(sum).toBe(8);
});
});
Дальше уже пойдёт функция получения данных с сервера, выделения из них id и перевода их в строки
getData.js
const axios = require('axios');
const mapArrToString = require('../mapArrToString/mapArrToString');
const getData = async () => {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/users');
const userIds = response.data.map((user) => user.id);
return mapArrToString(userIds);
} catch (e) {
console.error(e);
}
};
module.exports = getData;
Тут нам потребуется уже замокать результат выполнения функции:
jest.mock()
- мокает модуль, котрый мы используемmockReturnValue()
- передаёт те данные, которые должна вернуть вызываемая нами функция
Конкретно в данном примере
- сначала мокаем модуль
axios
, - затем вызываем его до выполнения нашей функции, которую мы проверяем с передачей замоканных данных
- вызываем функцию, которую мы проверяем (выполнение
axios
внутри функцииgetData
заменяется на то, что мы замокали черезjest.mock()
) - экспектим данные
getData.spec.js
const axios = require('axios');
const getData = require('./getData');
// мокаем использование модуля аксиоса
jest.mock('axios');
describe('Тесты', () => {
let response;
beforeEach(() => {
response = {
data: [
{
id: 1,
name: 'Leanne Graham',
username: 'Bret',
email: 'Sincere@april.biz',
address: {
street: 'Kulas Light',
suite: 'Apt. 556',
city: 'Gwenborough',
zipcode: '92998-3874',
geo: {
lat: '-37.3159',
lng: '81.1496',
},
},
phone: '1-770-736-8031 x56442',
website: 'hildegard.org',
company: {
name: 'Romaguera-Crona',
catchPhrase: 'Multi-layered client-server neural-net',
bs: 'harness real-time e-markets',
},
},
{
id: 2,
name: 'Ervin Howell',
username: 'Antonette',
email: 'Shanna@melissa.tv',
address: {
street: 'Victor Plains',
suite: 'Suite 879',
city: 'Wisokyburgh',
zipcode: '90566-7771',
geo: {
lat: '-43.9509',
lng: '-34.4618',
},
},
phone: '010-692-6593 x09125',
website: 'anastasia.net',
company: {
name: 'Deckow-Crist',
catchPhrase: 'Proactive didactic contingency',
bs: 'synergize scalable supply-chains',
},
},
],
};
});
it('Корректный результат', async () => {
// вставляем в результат работы функции моковые данные
axios.get.mockReturnValue(response);
// вызываем саму функцию getData
const data = await getData();
// проверям количество раз срабатываний функции axios внутри getData
expect(axios.get).toBeCalledTimes(1);
// проверяем полученные данные
expect(data).toEqual(['1', '2']);
});
});
Так же у нас имеется метод toMatchSnapshot
, который позволяет сохранить полученные данные из функции в отдельный файл
Так выглядит снепшот:
Если функция вернёт не те данные, что мы ожидали, то снепшот покажет, какие были валидные данные
Тестирование React приложений. React Testing library
Создадим простой компонент:
App.js
function App() {
return (
<div className='App'>
<h1>Hello</h1>
<button>Button</button>
<input type='text' placeholder={'input value'} />
</div>
);
}
export default App;
И далее познакомимся с некоторыми особенностями тестирования в реакте:
render()
- функция, которая рендерит нужный нам элемент на страницеscreen
- объект, который хранит в себе вёрстку, которая должна пойти на страницуgetByText
- получение элемента по тексту внутри негоgetByRole
- получение элемента по его роли на странице (инпут, кнопка, артикль)getByPlaceholderText
- получение элемента по тексту плейсхолдера
toBeInTheDocument
- проверяет, что элемент должен находиться на странице
App.test.js
import { render, screen } from '@testing-library/react';
import App from './App';
// сам тест
test('renders react', () => {
// сюда передаём компонент, который нужно протестировать
render(<App />);
// тут мы записываем наше ожидание на экране (получаем вёрстку по определённому тексту)
const helloWorldElement = screen.getByText(/hello/i); // ищем по тексту
const btn = screen.getByRole('button'); // ищем по семантическим тегам
const input = screen.getByPlaceholderText(/input value/i); // ищем по тексту в плейсхолдере
// передаём ожидание и проверяем, что это должно быть в документе
expect(helloWorldElement).toBeInTheDocument();
expect(btn).toBeInTheDocument();
expect(input).toBeInTheDocument();
// тут мы так же можем сгенерировать снепшот инпута
expect(input).toMatchSnapshot();
// тут же мы можем вывести разметку в консоль
screen.debug();
});
При использовании screen.debug()
вся вёрстка, которая попадает в screen
, будет находиться в консоли
И примерно таким образом выглядят снепшоты элементов
findBy, getBy, queryBy. Пример с useEffect. Асинхронный код
Функции выборки делятся на следующие группы:
getBy...
- находит и возвращает элемент, но если не находит, то прокидывает ошибку и тест завершается с фейлом (getAll...
возвращает массив элементов)queryBy...
- находит и возвращает элемент, но если элемент не найден, то он нам даёт в этом просто убедиться присвоив в переменнуюnull
(queryAll...
возвращает массив элементов)findBy...
- работает какqueryBy...
, но возвращает промис от поиска, то есть работает асинхронно (findAll...
возвращает массив элементов)
App.test.js
test('renders react', () => {
render(<App />);
const helloWorldElement = screen.queryByText(/helloswrld/i);
expect(helloWorldElement).toBeNull();
});
Уже в данном примере мы проводим поиск через findByText
, который позволяет проверить наш асинхронный код появления текста text
на странице
function App() {
const [data, setData] = useState(null);
useEffect(() => {
setTimeout(() => {
setData({});
}, 100);
}, []);
return (
<div className='App'>
{data && <div>text</div>}
<h1>Hello</h1>
<button>Button</button>
<input type='text' placeholder={'input value'} />
</div>
);
}
describe('Test App', () => {
test('renders react', async () => {
render(<App />);
const text = await screen.findByText(/text/i);
expect(text).toBeInTheDocument();
});
});
Так же, чтобы проверить наличие определённых стилей на элементе, мы можем воспользоваться toHaveStyle()
describe('Test App', () => {
test('renders react', async () => {
render(<App />);
const text = await screen.findByText(/text/i);
expect(text).toBeInTheDocument();
expect(text).toHaveStyle({ color: 'red' });
});
});
Тестирование событий. onClick, onChange, onInput. FireEvent, userEvent
Далее реализуем переключение состояния отображения элемента (если toggle
активен, то будет показываться элемент)
Так же тут будет передан data-testid
, который позволит получить доступ к элементу через присвоенный ему id
import { useEffect, useState } from 'react';
function App() {
const [data, setData] = useState(null);
const [toggle, setToggle] = useState(false);
const onClick = () => setToggle((toggle) => !toggle);
useEffect(() => {
setTimeout(() => {
setData({});
}, 100);
}, []);
return (
<div className='App'>
{toggle && <div data-testid={'toggle-element'}>Toggle text</div>}
{data && <div style={{ color: 'red' }}>text</div>}
<h1>Hello</h1>
<button data-testid={'toggle-button'} onClick={onClick}>
Toggle
</button>
<input type='text' placeholder={'input value'} />
</div>
);
}
export default App;
Далее тут мы будем проверять работу тугглера кнопки:
getByTestId
позволяет получать элементы по ранее описанному атрибутуdata-testid
fireEvent
позволяет триггерить определённые ивенты на выбранных нами элементах- тут мы используем
queryByTestId
потому что элемент пропадает со страницы и значение будет закономерноnull
. Вынести один раз в переменную наш элемент, который исчезает нельзя и нам нужно будет его каждый раз получать через вызовscreen.queryByTestId('toggle-element')
import { fireEvent, render, screen } from '@testing-library/react';
import App from './App';
describe('Test App', () => {
test('Toggle button Event', async () => {
render(<App />);
// тут мы получаем элемент со страницы по data-testid
const btn = screen.getByTestId('toggle-button');
// изначально элемента на странице нет, поэтому тут стоит использовать query
// ожидаем, что элемента на странице пока нет
expect(screen.queryByTestId('toggle-element')).toBeNull();
// проверяем ивент клика по кнопке
fireEvent.click(btn);
// ожидаем, что элемент уже есть на странице после клика по кнопке
expect(screen.queryByTestId('toggle-element')).toBeInTheDocument();
// тут уже проверяем, что элемента на странице нет после клика
fireEvent.click(btn);
expect(screen.queryByTestId('toggle-element')).toBeNull();
});
});
Тут с помощью fireEvent.input
идёт проверка ввода значения в поле ввода. С помощью toContainHTML
можно проверить, содержит ли данный HTML-элемент нужное нам значение
<h1 data-testid={'value-elem'}>{value}</h1>
<input
type='text'
placeholder={'input value...'}
onChange={(e) => setValue(e.target.value)}
/>
describe('Test App', () => {
test('Input Event', async () => {
render(<App />);
// получаем инпут
const input = await screen.getByPlaceholderText(/input value/i);
// ожидаем, что в заголовке нет текста
expect(screen.queryByTestId('value-elem')).toContainHTML('');
// передаём событие
fireEvent.input(input, {
// сюда прилетает инпут
target: { value: '1234' },
});
// ожидаем текст
expect(screen.queryByTestId('value-elem')).toContainHTML('1234');
});
});
Так же у нас имеется модуль userEvent
, который выполняет полноценные действия пользователя на странице (двойной клик, наведение мыши, вставка и так далее). Он уже выполняет не искусственные действия, как fireEvent
, а реальные действия пользователя
И примерно так будет выглядеть данный тест, но уже с userEvent
:
describe('Test App', () => {
test('Input Event', async () => {
render(<App />);
// получаем инпут
const input = await screen.getByPlaceholderText(/input value/i);
// ожидаем, что в заголовке нет текста
expect(screen.queryByTestId('value-elem')).toContainHTML('');
// передаём событие
userEvent.type(input, '1234');
// ожидаем текст
expect(screen.queryByTestId('value-elem')).toContainHTML('1234');
});
});
Тестирование компонента с асинхронной загрузкой данных с сервера
import React, { useEffect, useState } from 'react';
import axios from 'axios';
const Users = () => {
const [users, setUsers] = useState([]);
const loadUsers = async () => {
const { data } = await axios.get('https://jsonplaceholder.typicode.com/users');
setUsers(data);
};
useEffect(() => {
loadUsers();
}, []);
return (
<div>
{users.map((user) => (
<div key={user.id} data-testid={'user-item'}>
{user.name}
</div>
))}
</div>
);
};
export default Users;
import { render, screen } from '@testing-library/react';
import Users from './Users';
import axios from 'axios';
jest.mock('axios');
describe('Users tests', () => {
let response;
beforeEach(() => {
response = {
data: [
{
id: 1,
name: 'Leanne Graham',
},
{
id: 2,
name: 'Ervin Howell',
},
],
};
});
it('should load users', () => {
axios.get.mockReturnValue(response);
render(<Users />);
const users = screen.findAllByTestId('user-item');
expect(users.length).toBe(2);
expect(axios.get).toBeCalledTimes(1);
screen.debug();
});
});
Интеграционное тестирование в связке с react router dom v6
Тестируем переход по ссылкам
Начальная сборка для тестирования роутинга:
- две страницы
- роутинг в
App
BrowserRouter
вindex
const MainPage = () => {
return <div data-testid={'main-page'}>MAIN PAGE</div>;
};
export default MainPage;
const AboutPage = () => {
return <div data-testid={'about-page'}>ABOUT PAGE</div>;
};
export default AboutPage;
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
const App = () => {
return (
<div>
<Link to={'/'} data-testid={'main-link'}>
main
</Link>
<Link to={'/about'} data-testid={'about-link'}>
about
</Link>
<Routes>
<Route path={'/'} element={<MainPage />} />
<Route path={'/about'} element={<AboutPage />} />
</Routes>
</div>
);
};
export default App;
И таким способом мы можем проверить наш роутинг:
- в компонент
MemoryRouter
помещаем рендеримый компонент - далее через
userEvent.click
переходим по страницам
import { render, screen } from '@testing-library/react';
import App from './App';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
describe('Router Tests', () => {
it('should run on links', () => {
render(
<MemoryRouter>
<App />
</MemoryRouter>,
);
const mainLink = screen.getByTestId('main-link');
const aboutLink = screen.getByTestId('about-link');
userEvent.click(aboutLink); // переходим по первой ссылке
// ожидаем элемент страницы
expect(screen.getByTestId('about-page')).toBeInTheDocument();
userEvent.click(mainLink); // переходим по второй ссылке
// ожидаем элемент главной страницы
expect(screen.getByTestId('main-page')).toBeInTheDocument();
});
});
Тестируем попадание на страницу Not Found
Страница ошибки:
const ErrorPage = () => {
return <div data-testid={'not-found-page'}>Error</div>;
};
export default ErrorPage;
Добавляем роут на переход по неизвестной ссылке /*
, по которому будет выпадать страница 404
<Routes>
<Route path={'/'} element={<MainPage />} />
<Route path={'/about'} element={<AboutPage />} />
<Route path={'/*'} element={<ErrorPage />} />
</Routes>
И в тесте мы можем сразу указать страницу, на которой мы должны отрендериться через передачу атрибута initialEntries
в MemoryRouter
Конкретно тут написан любой роут, которого нет в приложении, чтобы отрендерился роут ошибки
it('error page', () => {
render(
<MemoryRouter initialEntries={['/asfd']}>
<App />
</MemoryRouter>,
);
expect(screen.getByTestId('not-found-page')).toBeInTheDocument();
});
Тестирование перехода на сгенерированные страницы
Первым делом, создадим страницу отдельного пользователя (данные для всех будут одинаковыми и статичными)
pages > UserDetailsPage.jsx
import React from 'react';
const UserDetailsPage = () => {
return <div data-testid={'user-page'}>User Details Page</div>;
};
export default UserDetailsPage;
Список пользователей будет сгенерирован ссылкой
Users.js
Добавляем ссылку на нужного пользователя
App.js
И для того, чтобы протестировать пользователя придётся уже вынести внутрь MemoryRouter
оба роута, по которым мы хотим переходить, так как наш компонент App
не рендерится и мы из него не можем перейти на нужную страницу user
. Так же нужно будет указать /user
в путях MemoryRouter
, чтобы сразу попасть на нужную нам страницу
Users.tests.js
import { getByTestId, render, screen } from '@testing-library/react';
import Users from './Users';
import axios from 'axios';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import UserDetailsPage from '../pages/UserDetailsPage';
import React from 'react';
jest.mock('axios');
describe('Users tests', () => {
let response;
beforeEach(() => {
response = {
data: [
{
id: 1,
name: 'Leanne Graham',
},
{
id: 2,
name: 'Ervin Howell',
},
{
id: 3,
name: 'Clementine Bauch',
},
],
};
});
// очищаем моки перед каждым применением axios
afterEach(() => {
jest.clearAllMocks();
});
it('should load users', () => {
axios.get.mockReturnValue(response);
render(<Users />);
const users = screen.findAllByTestId('user-item');
expect(users.length).toBe(3);
expect(axios.get).toBeCalledTimes(1);
screen.debug();
});
// пишем переход на страницу отдельного пользователя
it('test open user page', async () => {
axios.get.mockReturnValue(response);
render(
<MemoryRouter initialEntries={['/users']}>
<Routes>
<Route path={'/users'} element={<Users />} />
<Route path={'/users/:id'} element={<UserDetailsPage />} />
</Routes>
</MemoryRouter>,
);
const users = await screen.findAllByTestId('user-item');
expect(users.length).toBe(3);
userEvent.click(users[2]);
expect(getByTestId('user-page')).toBeInTheDocument();
});
});
Хелпер для удобного тестирования роутинга
Первым делом, вынесем всё наше дерево роутинга в отдельный компонент
src > router > AppRouter.jsx
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import MainPage from '../pages/MainPage';
import Users from '../Users/Users';
import UserDetailsPage from '../pages/UserDetailsPage';
import AboutPage from '../pages/AboutPage';
import ErrorPage from '../pages/ErrorPage';
const AppRouter = () => {
return (
<Routes>
<Route path={'/'} element={<MainPage />} />
<Route path={'/users'} element={<Users />} />
<Route path={'/users/:id'} element={<UserDetailsPage />} />
<Route path={'/about'} element={<AboutPage />} />
<Route path={'/*'} element={<ErrorPage />} />
</Routes>
);
};
export default AppRouter;
Далее создадим хелпер, который будет в себя принимать ту конструкцию, которая требуется для тестирования нужных роутов приложения
`src > test > helpers > renderWithRouter.jsx
import { MemoryRouter } from 'react-router-dom';
import AppRouter from '../../router/AppRouter';
export const renderWithRouter = (component, initialRoute = '/') => {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<AppRouter />
{component}
</MemoryRouter>
);
};
Так выглядит целевой компонент для тестирования:
Users.jsx
const Users = () => {
const [users, setUsers] = useState([]);
const loadUsers = async () => {
const resp = await axios.get('https://jsonplaceholder.typicode.com/users');
setUsers(resp.data);
};
useEffect(() => {
loadUsers();
}, []);
return (
<div data-testid='users-page'>
{users.map((user) => (
<Link to={`/users/${user.id}`} key={user.id} data-testid='user-item'>
{user.name}
</Link>
))}
</div>
);
};
Тут оставляем компонент с роутами
App.jsx
const App = () => {
return (
<div>
<Link to={'/'} data-testid={'main-link'}>
main
</Link>
<Link to={'/about'} data-testid={'about-link'}>
about
</Link>
<Link to={'/users'} data-testid={'about-link'}>
users
</Link>
<AppRouter />
</div>
);
};
Теперь в тестах для запуска тестирования нужного роута, достаточно обернуть нужный компонент в renderWithRouter()
хелпер
Users.test.js
import { render, screen } from '@testing-library/react';
import Users from './Users';
import axios from 'axios';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { renderWithRouter } from '../test/helpers/renderWithRouter';
jest.mock('axios');
describe('USERS TEST', () => {
let response;
beforeEach(() => {
response = {
data: [
{
id: 1,
name: 'Leanne Graham',
},
{
id: 2,
name: 'Ervin Howell',
},
{
id: 3,
name: 'Clementine Bauch',
},
],
};
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders learn react link', async () => {
axios.get.mockReturnValue(response);
render(<Users />);
const users = await screen.findAllByTestId('user-item');
expect(users.length).toBe(3);
expect(axios.get).toBeCalledTimes(1);
screen.debug();
});
test('test redirect to details page', async () => {
axios.get.mockReturnValue(response);
render(renderWithRouter(<Users />));
const users = await screen.findAllByTestId('user-item');
expect(users.length).toBe(3);
userEvent.click(users[0]);
expect(screen.getByTestId('users-page')).toBeInTheDocument();
});
});
Тестирование навбара приложения
Навбар будет хранить просто ссылки по страницам в нашем приложении реакта
Navbar.jsx
import React from 'react';
import { Link } from 'react-router-dom';
const Navbar = () => {
return (
<div>
<Link to={'/'} data-testid={'main-link'}>
main
</Link>
<Link to={'/about'} data-testid={'about-link'}>
about
</Link>
<Link to={'/users'} data-testid={'users-link'}>
users
</Link>
</div>
);
};
export default Navbar;
В основном компоненте приложения добавляем наш навбар
App.js
const App = () => {
return (
<div>
<Navbar />
<AppRouter />
</div>
);
};
И тут пишем отдельные тейки тестирования под срабатывание разных роутов
Navbar.test.js
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { renderWithRouter } from '../../test/helpers/renderWithRouter';
import Navbar from './Navbar';
describe('navbar test', () => {
it('test main link', () => {
render(renderWithRouter(<Navbar />));
const mainLink = screen.getByTestId('main-link');
userEvent.click(mainLink);
expect(screen.getByTestId('main-page')).toBeInTheDocument();
});
it('test users link', () => {
render(renderWithRouter(<Navbar />));
const usersLink = screen.getByTestId('users-link');
userEvent.click(usersLink);
expect(screen.getByTestId('users-page')).toBeInTheDocument();
});
it('test about link', () => {
render(renderWithRouter(<Navbar />));
const aboutLink = screen.getByTestId('about-link');
userEvent.click(aboutLink);
expect(screen.getByTestId('about-page')).toBeInTheDocument();
});
});
Интеграционное тестирование в связке с Redux toolkit
Первым делом, нам нужно создать стор
store > store.js
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import counterReducer from './reducers/counterReducer';
const rootReducer = combineReducers({
counter: counterReducer,
});
export const createReduxStore = (initialState = {}) => {
return configureStore({
reducer: rootReducer,
preloadedState: initialState,
});
};
Далее нужно будет создать срез, который вернёт нам редьюсер и два экшена
`store > selectors > counterReducer.js
import { createSlice } from '@reduxjs/toolkit';
export const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
},
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
Тут мы реализуем селектор, по которому мы будем получать значение
`store > selectors > getCounterValue > getCounterValue.js
// стоит подстраховаться и в селектор доставить проверку через nullish и подставлять 0
export const getCounterValue = (state) => state?.counter?.value || 0;
Тут мы должны вложить всё приложение в провайдер, который уже и будет распространять состояние по проекту
index.js
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={createReduxStore()}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>,
);
Это компонент счётчика
components > Counter > Counter.jsx
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getCounterValue } from '../../store/reducers/selectors/getCounterValue/getCounterValue';
import { increment, decrement } from '../../store/reducers/counterReducer';
const Counter = () => {
const dispatch = useDispatch();
const value = useSelector(getCounterValue);
const onIncrement = () => {
dispatch(increment());
};
const onDecrement = () => {
dispatch(decrement());
};
return (
<div>
<h1 data-testid={'value-title'}>{value}</h1>
<button data-testid={'increment-button'} onClick={onIncrement}>
inc
</button>
<button data-testid={'decrement-button'} onClick={onDecrement}>
dec
</button>
</div>
);
};
export default Counter;
Это компонент, в котором располагается счётчик
pages > MainPage.jsx
const MainPage = () => {
return (
<div data-testid={'main-page'}>
MAIN PAGE
<Counter />
</div>
);
};
Тестируем селектор
Чтобы протестировать селектор, можно просто складывать в него различные значения и на выходе он просо должен вернуть нам то же значение
- пустой массив равен 0, так как в срезе изначально стоит 0
- если вложим значение, то оно и будет находиться в селекторе
`store > selectors > getCounterValue > getCounterValue.test.js
import { getCounterValue } from './getCounterValue';
describe('getCounterValue', () => {
it('empty value', () => {
expect(getCounterValue({})).toBe(0);
});
it('filled value', () => {
expect(
getCounterValue({
counter: {
value: 100,
},
}),
).toBe(100);
});
});
Далее мы тестируем функции из среза.
- если мы ничего не передали в стейт, то значение будет идти от 0 при каждом действии
- если мы что-то передали в стейт, то на него будет действовать экшен (
increment
,decrement
)
`store > selectors > counterReducer.test.js
import counterReducer, { decrement, increment } from './counterReducer';
describe('counterReducer', () => {
it('empty state', () => {
expect(counterReducer(undefined, increment())).toEqual({ value: 1 });
expect(counterReducer(undefined, decrement())).toEqual({ value: -1 });
});
it('increment', () => {
expect(counterReducer({ value: 0 }, increment())).toEqual({ value: 1 });
});
it('decrement', () => {
expect(counterReducer({ value: 0 }, decrement())).toEqual({ value: -1 });
});
});
Далее тестируем сам компонент счётчика.
- Чтобы достучаться до него и проверить редакс, нужно передать компонент внутри провайдера
- В провайдере можно задать начальное значение стейта, если нужно
render()
можно использовать просто как функцию и доставать все методы выборки с помощью screen, а можно достать из render его функции выборки и использовать их без обращения кscreen
(как удобнее, так и делаем, но во втором случае мы не сможем пользоваться выборкой изscreen
)- функция сравнения
toHaveTextContent
позволяет нам найти содержание текста в определённом элементе
`components > Counter > Counter.test.jsx“
import Counter from './Counter';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { createReduxStore } from '../../store/store';
describe('Counter component', () => {
it('increment', () => {
const { getByTestId } = render(
<Provider
store={createReduxStore({
counter: { value: 10 },
})}
>
<Counter />
</Provider>,
);
const incrementButton = getByTestId('increment-button');
expect(getByTestId('value-title')).toHaveTextContent('10');
userEvent.click(incrementButton);
expect(getByTestId('value-title')).toHaveTextContent('11');
});
});
Хелпер для удобного тестирования компонентов, в которых используется Redux
Мы можем создать такой же хелпер, который и создавали для роутинга, но для редакса
Однако, мы можем в хелперах заранее возвращать сгенерированный ответ от render
, а не делать рендер в тесте
test > helpers > renderWithRedux.js
import { createReduxStore } from '../../store/store';
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
export const renderWithRedux = (component, initialState = {}) => {
const store = createReduxStore(initialState);
return render(<Provider store={store}>{component}</Provider>);
};
И использовть его для тестирования
Counter.test.js
describe('Counter component', () => {
it('increment', () => {
const { getByTestId } = renderWithRedux(<Counter />, { counter: { value: 10 } });
const incrementButton = getByTestId('increment-button');
expect(getByTestId('value-title')).toHaveTextContent('10');
userEvent.click(incrementButton);
expect(getByTestId('value-title')).toHaveTextContent('11');
});
});
И так уже будет выглядеть хелпер для тестирования роутинга и редакса одновременно
test > helpers > renderTestApp.js
import { render } from '@testing-library/react';
import { createReduxStore } from '../../store/store';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
export const renderTestApp = (component, options) => {
const store = createReduxStore(options?.initialState);
return render(
<Provider store={store}>
<MemoryRouter initialEntries={[options?.route]}>{component}</MemoryRouter>
</Provider>,
);
};
А так выглядит его применение в приложениях:
Counter.test.js
describe('Counter component', () => {
it('increment', () => {
const { getByTestId } = renderTestApp(<Counter />, {
route: '/',
initialState: {
counter: { value: 10 },
},
});
const incrementButton = getByTestId('increment-button');
expect(getByTestId('value-title')).toHaveTextContent('10');
userEvent.click(incrementButton);
expect(getByTestId('value-title')).toHaveTextContent('11');
});
});
Если нам нужно будет протестировать асинхронные экшены, то мы просто ровно так же мокаем данные и вписываем ожидания
e2e тесты с WebdriverIO
E2E-тесты, в свою очередь, уже будут запускаться на реальных данных в реальном браузере
Для тестирования приложения будет использоваться WebdriverIO, который представляет из себя движок для тестирования веб-приложений
npm init wdio .
Так выглядит спек установки вебдрайвера:
Далее добавляем порт в конфиг
И так же у нас появилась папка с готовыми тестами
Тут появится команда запуска тестов
Создадим страницу, которая будет выполнять функционал тугглера текста
pages > HelloWorld.jsx
import React, { useState } from 'react';
const HelloWorld = () => {
const [value, setValue] = useState('');
const [visible, setVisible] = useState(false);
const toggle = () => value === 'hello' && setVisible((prevState) => !prevState);
const onChange = (e) => setValue(e.target.value);
return (
<div>
<input onChange={onChange} id={'search'} type='text' />
<button id={'toggle'} onClick={toggle}>
Hello World
</button>
{visible && <h1 id={'hello'}>Hello World</h1>}
</div>
);
};
export default HelloWorld;
И добавляем её роут
PageObject паттерн
В данном файле находится главный класс, который будет предоставлять метод открытия компонента в браузере
tests > pages > page.js
module.exports = class Page {
open(path) {
// открываем выбранный браузер
return browser.url(`https://localhost:3000/${path}`);
}
};
Далее пишем текст для страницы приветствия, где мы сначала получаем нужные элементы для взаимодействия, а затем пишем сами тесовые методы, которые должны будут выполниться
Получение элементов происходит через конструкцию $()
, в которую мы, используя селекторы $, #, .
, получаем нужные нам элементы
tests > pages > hello.e2e.js
const Page = require('./page');
class HelloPage extends Page {
// получаем кнопку со страницы
get toggleButton() {
return $('#toggle');
}
// получаем инпут со страницы
get searchInput() {
return $('#search');
}
// получаем тайтл со страницы
get helloTitle() {
return $('#hello');
}
// метод для ввода данных в поиск и запуска тугглера для отображения тайтла
async toggleTitleWithInput(text) {
await this.searchInput.setValue(text);
await this.toggleButton.click();
}
// по этой ссылке мы открываем страницу
open() {
// вызываем метод родительского класса
return super.open('hello');
}
}
module.exports = new HelloPage();
В данном файле уже описываем сам тест, используя методы класса
tests > e2e > hello.e2e.js
const HelloPage = require('../pages/hello.page');
describe('hello page', () => {
it('test', async () => {
// открываем страницу для теста
await HelloPage.open();
// выполняем метод
await HelloPage.toggleTitleWithInput('hello');
// ожидаем, что на странице должен быть тайтл
await expect(HelloPage.helloTitle).toBeExisting();
// если ещё раз тыкнем на тугглер
await HelloPage.toggleButton.click();
// то у нас не должно быть тайтла на странице
await expect(HelloPage.helloTitle).not.toBeExisting();
});
});
Запускаем тест
npm run wdio -- --spec tests/e2e/hello.e2e.js
Пример е2е теста с асинхронным кодом
Пишем компонент получения списка пользователей
components > UsersForTest > UsersForTest.js
import React, { useEffect, useState } from 'react';
import User from './Users';
const UsersForTest = () => {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetch('https://jsonplaceholder.typicode.com/users')
.then((response) => response.json())
.then((json) => {
setTimeout(() => {
setUsers(json);
setIsLoading(false);
}, 1000);
});
}, []);
const onDelete = (id) => {
setUsers(users.filter((user) => user.id !== id));
};
return (
<div>
{isLoading && <h1 id='users-loading'>Идет загрузка...</h1>}
{users.length && (
<div id='users-list'>
{users.map((user) => (
<User onDelete={onDelete} user={user} />
))}
</div>
)}
</div>
);
};
export default UsersForTest;
Далее создаём отдельный компонент пользователя
`components > UsersForTest > User.js
import React from 'react';
const User = ({ user, onDelete }) => {
return (
<div>
{user.name}
<button id='user-delete' onClick={() => onDelete(user.id)}>
delete
</button>
</div>
);
};
export default User;
Далее добавляем путь в роутер
router > AppRouter.jsx
Далее тут создаём метод страницы пользователей
tests > pages > users.page.js
const Page = require('./page');
class UsersPage extends Page {
// получаем тайтл о загрузке пользователей
get loadingTitle() {
return $('#users-loading');
}
// получаем список пользователей
get usersList() {
return $('#users-list');
}
// получаем компоненты пользователей
get usersItems() {
// react$ - получаем один компонент
// react$$ - получаем массив компонентов
return browser.react$$('User');
}
// функция для проверки загрузки данных
async loadData() {
try {
// открываем приложение
await this.open();
// ждём, чтобы получить сначала компонент о загрузке данных с сервера
await this.loadingTitle.waitForDisplayed({ timeout: 2000 });
// далее 2 секунды ждём, чтобы получить выведенный список пользователей
await this.usersList.waitForDisplayed({ timeout: 2000 });
} catch (e) {
throw new Error('Не удалось загрузить пользователей');
}
}
// функция удаления одного пользователя
async deleteUser() {
try {
// получаем список компонентов списка пользователей
const usersCount = await this.usersItems.length;
// если списка нет, то выведем ошибку
if (!usersCount) throw new Error('Пользователи не найдены');
// получаем первый компонент списка пользователей и нажимаем на его кнопку удаления
await this.usersItems[0].$('#user-delete').click();
const usersCountAfterDelete = await this.usersItems.length;
if (usersCount - usersCountAfterDelete !== 1)
throw new Error(
'Пользователь не был удалён, либо был удалён более чем 1 пользователь',
);
} catch (e) {
throw new Error('Не удалось удалить пользователя. ' + e.message);
}
}
open() {
return super.open('users-test');
}
}
module.exports = new UsersPage();
А тут пишем сам тест, который будет проверять выполнение методов страницы
`tests > e2e > users.e2e.js
const UsersPage = require('../pages/users.page');
describe('user list', () => {
it('load users', async () => {
await UsersPage.loadData();
});
it('delete user', async () => {
await UsersPage.loadData();
await UsersPage.deleteUser();
});
});
Скриншотные тесты storybook и loki js
Для начала, установим в проект storybook
npx sb init
Далее создастся подобная структура папки с историей
И тут мы можем запустить сторибук
В интерфейсе сторибука мы можем просматривать отдельно каждый из наших компонентов и их спецификации для изменения