🧪 Успешное тестирование: основы и передовые приемы Jest. Часть 1
Знаком с ситуацией, когда после деплоя все падает, а ты не знаешь почему? В этой статье я показываю, как из обычного разработчика стать профи в тестировании с Jest. Разобрал все от и до: настройка проекта, базовые тесты, моки и даже асинхронщина. Плюс весь код доступен на GitHub – бери и используй! Если тестирование вызывает у тебя дрожь – этот гайд изменит твою жизнь.
Привет, друзья! Я Кирилл Мыльников, frontend-разработчик в ГК Юзтех. Сегодня хочу поделиться своей экспертизой в тестировании с использованием фреймворка Jest. Полностью настроенный проект будет доступен на GitHub, где вы сможете ознакомиться с ним и клонировать его. Мы рассмотрим широкий спектр тем – от базовых до продвинутых приёмов тестирования. Статья будет полезна как для тех, кто хочет освежить свои знания, так и для новичков, только начинающих погружаться в мир тестирования. Также отмечу, что статья состоит из нескольких частей.
В серии статей разберем следующие темы:
Jest основы:
- структура тестов;
- виды проверок;
- настройка и запуск.
Особенности Jest:
- моки и для чего они нужны;
- тестирование ошибок и покрытие кода.
Прежде чем приступим к практике, давайте вспомним, какие виды тестирования существуют.
Виды тестирования
Unit-тесты
Unit-тесты — это форма тестирования, которая направлена на проверку правильности работы отдельных небольших компонентов кода, таких как функции, классы, модули, компоненты и другие. Они помогают выявлять ошибки и оперативно их устранять при внесении изменений или добавлении нового функционала.
Интеграционное тестирование
Интеграционное тестирование — это тестирование на работоспособность двух или более модулей системы. Они решают проблему после покрытия кода unit-тестов, проверяют, как ведут себя связные модули.
End to End
End to End — это метод тестирования, который проверяет полностью функциональности ПО от начала до конца, включает в себя проверку всех уровней системы, начиная от пользовательского интерфейса и заканчивая базой данных и серверной логикой. В основном, это робот, имитирующий пользователя.
UI тестирование
UI тестирование — это метод тестирования, направленный на корректность работы и удобства пользовательского интерфейса, например: внешний вид интерфейса, отзывчивость, элементы управления и так далее.
Основная стратегия тестирования представлена в виде пирамиды, которая показывает, что начинать следует с юнит-тестов, затем переходить к интеграционным тестам, чтобы сочетать их, и, наконец, проводить end-to-end тесты. Идея пирамиды заключается в том, что чем выше находится тестовый уровень в пирамиде, тем тесты дороже. Ну и этот подход обычно подходит новым проектам, где сразу пишем тесты.
Когда проект уже существует, стратегия тестирования может быть адаптирована. В таких случаях часто начинают с проведения end-to-end тестов для проверки работы приложения в целом. Затем переходят к интеграционным тестам, чтобы убедиться в правильном взаимодействии компонентов. И только после этого фокусируются на unit-тестах, чтобы покрыть отдельные части логики приложения. Такой подход помогает обеспечить надёжное тестирование всего приложения в целом перед более детальной проверкой отдельных компонентов.
Итак, мы с вами разобрали, какие виды тестирования существуют. Пора переходить к практике, а именно, к Jest и его основам.
Jest
Jest — это фреймворк для тестирования JavaScript, который обладает удобным синтаксисом для написания и запуска тестов. Он хорошо документирован, легко настраивается и может быть легко расширен по мере необходимости.
Основные возможности:
- Детальное описание причин падения тестов;
- Наличие полноценного набора инструментов из коробки;
- Быстрый и надёжный запуск тестов в параллельных потоках;
- Работа с проектами, использующими Babel, Rect, Vue, Angular, TypeScript, Node;
- Можно писать как и unit-тесты, так и интеграционные;
- Инструменты для имитации.
Вообще, у нас будет два отдельных проекта-примера, один естественно с React (реализация будет в другой статье), а другой просто настроим с нуля.
Создаём папку, где у вас будет проект, и инициализируем проект, в моём случае с помощью команды:
yarn init
Должна появиться такая структура:
После чего нужно будет установить сам пакет Jest с помощью команды:
yarn add -D jest
Как говорится в документации Jest, для расширенных настроек понадобится установить ещё ряд пакетов:
yarn add -D babel-jest @babel/core @babel/preset-env
После установки всех необходимых пакетов создаём файл babel.config.js в корневой директории проекта и указываем необходимые конфигурационные параметры. Если мы хотим использовать TypeScript, мы должны установить соответствующий пакет и добавить соответствующую зависимость в наш файл конфигурации:
yarn add -D @babel/preset-typescript yarn add -D typescript yarn add -D ts-node
module.exports = { presets: [ ["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript", ], };
Не забывайте про важную деталь: чтобы Jest проверял типы по мере их выполнения, нужно установить ещё один пакет ts-jest и для предоставления глобальных типов @types/jest
yarn add -D ts-jest yarn add -D @types/jest
Давайте пока переключимся на конфигурацию Jest. В корне проекта создаём jest.config.ts и указываем следующую настройку:
import type { Config } from "jest"; const config: Config = { verbose: true, preset: "ts-jest" }; export default config;
Опция verbose: true обеспечивает более подробный вывод информации при запуске тестов в случае возникновения проблем.
Используем preset: “ts-jest” для преобразования TypeScript в JavaScript перед запуском тестов.
Также импортируем тип Config для получения подсказок о других доступных настройках в целом.
Можно теперь проверить, запускаются ли тесты вообще. В package-json указываем команду:
"scripts": { "start": "jest" },
Создаём файл utils, в котором содержится функция, выполняющая деление двух чисел. Нам необходимо протестировать эту функцию, поэтому создаём файл utils.test.ts и пишем первый тест.
import { divideNumbers } from "./utils"; test("should divide numbers", () => { const res = divideNumbers(10, 5); expect(res).toBe(2); });
И запускаем с помощью команды yarn start. Если всё настроили правильно, то должно быть так:
В одном файле можно создавать произвольное количество тестов, используя директивы test или it. Хорошей практикой является начинать названия тестов с ключевого слова "should", чтобы чётко определить ожидаемое поведение. Это способствует пониманию того, что тестируется, и повышает читаемость кода. Кроме того, использование describe() для группировки тестов помогает упорядочить и структурировать код, делая его более ясным и лёгким для поддержки. Важно быть особенно внимательным к именованию тестов, чтобы обеспечить их эффективную организацию и понимание.
Пример:
describe("utils", () => { test("should divide numbers", () => { const res = divideNumbers(10, 5); expect(res).toBe(2); }); it("should check age positive", () => { const res = checkAge(19); expect(res).toBe(true); }); test("should check age negative", () => { const res = checkAge(15); expect(res).toBe(false); }); });
Результат:
Давайте разберём, что такое expect. Это глобальная функция, в которую мы передаём определённый результат и которая возвращает нам объект с разными вариантами проверок. Основные проверки, которые используются:
toBe() — работает как тройное сравнение, идёт проверка на true/false, хорошо подходит для примитивов:
test("should divide numbers", () => { const res = divideNumbers(10, 5); expect(res).toBe(2); });
toEqual() — работает с объектами, сравнивает и возвращает true, если равны объекты, в противном случае false.
test("should check object", () => { const obj = { b: 2 }; expect(obj).toEqual({ b: 2 }); // true });
toHaveLength() — проверяет длину массива.
test("should check array length", () => { const array = [4, 3, 1]; expect(array).toHaveLength(3); });
toContain() — проверяет, есть ли конкретный элемент в массиве.
test("should check element array", () => { const array = [4, 3, 1, "a"]; expect(array).toContain("a"); });
toBeUndefined() и toBeNull() — проверяют результат на null/undefined.
test("should check null/undefined", () => { expect(undefined).toBeUndefined(); expect(null).toBeNull(); });
Думаю, суть вы поняли, проверок достаточно много. О них можно почитать в официальной документации Jest.
Параметризованные тесты
Параметризованные тесты позволяют эффективно повторно использовать один и тот же тестовый сценарий при различных условиях на каждом запуске. Это оптимизирует управление тестами, уменьшает дублирование кода и делает процесс тестирования более гибким и обслуживаемым.
Возможности test и it включают методы, такие как skip, который позволяет игнорировать определенный тест, и only, который позволяет выполнять только выбранные тесты, оставляя остальные в игноре. Эти функции облегчают управление тестами.
describe("Parameterized utils", () => { test.skip("should divide numbers", () => { const res = divideNumbers(10, 5); expect(res).toBe(2); }); it.only("should check age positive", () => { const res = checkAge(19); expect(res).toBe(true); }); });
only:
skip:
Этих методов у нас достаточно много. Подробнее можете посмотреть в официальной документации Jest.
Особенности Jest
Mocks — это фейковые данные, которые нужны для тестового кейса.
Spies — отслеживание работы методов.
Функции-фейки — нужны для тестирования количества вызовов колбэков.
Что такое моки (mocks)?
Давайте рассмотрим пример тестирования с мок-данными. У нас есть функция, которая фильтрует массив:
export const filterArray = <T>( array: T[], callback: (a: T) => boolean ): T[] => { const newArray: T[] = []; for (let i = 0; i < array.length; i++) { if (callback(array[i])) { newArray.push(array[i]); } } return newArray; };
Пример:
import { mocks_data, MocksDataType, result_mocks_data, } from "./__mocks__/mocks"; import { filterArray } from "./filterArray"; describe("filterArray tests", () => { const cb = jest.fn(); it("should not invoke callback when an array is empty", () => { // mock function filterArray([], cb); expect(cb).not.toHaveBeenCalled(); }); it("should invoke callback function for each element in the array", () => { const array = [1, 2, 3]; filterArray(array, cb); expect(cb).toHaveBeenCalledTimes(array.length); }); it("should filter an array", () => { const hasPrice = ({ price }: MocksDataType) => price > 100; const result = filterArray(mocks_data, hasPrice); expect(result).toEqual(result_mocks_data); }); });
- В первом случае мы проверяем, что функция не была вызвана;
- Во втором случае мы проверяем, что функция была вызвана, получила массив и вернула количество вызовов;
- В третьем случае мы используем предоставленные мок-данные для возвращения отфильтрованного массива.
Те самые мок-данные можно посмотреть тут, где result_mocks_data — результат после фильтрации, mocks_data — просто мок-данные, которые получает функция.
Результат:
Следующее, что мы с вами разберём, это Spies
Spies
Spies используют для отслеживания вызовов конкретных функций с целью убедиться, что они были вызваны или вызваны с определенными параметрами. Рассмотрим следующий случай: мы хотим отследить console.log, который используется в нашем цикле. Для этого мы можем использовать метод spyOn, который принимает два аргумента. Первый аргумент — это объект, за которым мы хотим следить (в данном случае это console), а второй аргумент — это метод объекта, за которым мы хотим следить (например, log).
export const filterArray = <T>( array: T[], callback: (a: any) => boolean ): T[] => { const newArray: T[] = []; for (let i = 0; i < array.length; i++) { console.log("array[i]", array[i]); if (callback(array[i])) { newArray.push(array[i]); } } return newArray; };
Пример:
describe("filterArray spies", () => { it("should logSpy console", () => { const logSpy = jest.spyOn(console, "log"); const hasPrice = ({ price }: MocksDataType) => price > 100; const result = filterArray(mocks_data, hasPrice); expect(result).toEqual(result_mocks_data); expect(logSpy).toHaveBeenCalledTimes(mocks_data.length); }); });
Результат:
Мы видим конкретные элементы массива.
Тестирование асинхронного кода
Давайте создадим асинхронную функцию, которая будет создавать нам что-то:
export async function createTodoAsync(text: string) { const response = await fetch("https://jsonplaceholder.typicode.com/todos", { method: "POST", body: JSON.stringify({ title: text }), headers: { "Content-type": "application/json; charset=UTF-8", }, }); if (!response.ok) { throw new Error("Ошибка создания задачи"); } return response.json(); }
И сразу напишем тесты к данной функции:
Пример:
import { createTodoAsync } from "./createTodo"; import { mockResponseTodo, mockTodo } from "./__mocks__/mocks"; const mockFetch = jest.fn(); mockFetch.mockReturnValueOnce( Promise.resolve({ ok: true, json: () => Promise.resolve(mockTodo), headers: new Headers(), redirected: false, status: 200, statusText: "OK", url: "https://example.com", type: "basic", body: null, bodyUsed: false, }) ); describe("createTodo tests", () => { it("should create todo", async () => { const result = await createTodoAsync("todo"); expect(result).toEqual(mockResponseTodo); }); });
Далее в тесте используем метод mockReturnValueOnce, чтобы задать поведение моковой функции fetch. Мы возвращаем объект с флагом ok: true и методом json, который возвращает объект задачи из тестовых данных.
Мок-данные, которые мы ожидаем, в ответе.
Пример:
export const mockResponseTodo = { id: 201, title: "todo", };
Тестирование ошибок
Теперь нужно проверить негативные кейсы, когда приходит ошибка. Оставляем ту же функцию, но теперь пишем вот такой тест-кейс:
if (!response.ok) { throw new Error("Ошибка создания задачи"); }
В функции createTodoAsync ожидаем, что если ошибка, то выводим текст «Ошибка создания задачи».
Пример:
it("should throw error when response is not ok", () => { mockFetch.mockReturnValueOnce(Promise.resolve({ ok: false })); expect(createTodoAsync("todo")).rejects.toThrow("Ошибка создания задачи"); });
В данном примере изменили мок-данные на значение ok: false и ожидаем появления ошибки с конкретным текстом, который определён в функции.
Покрытие кода тестами
Покрытие кода тестами — это процент кода, который выполняется во время запуска тестов. Чем выше покрытие тестами, тем больше уверенности, что код работает правильно. Статистика покрытия тестами помогает определить, какие части кода нуждаются в дополнительных тестах. Она позволяет улучшить качество кода и обнаружить потенциальные проблемы.
Чтобы начать собирать статистику тестов по проекту, нам нужно прописать дополнительные настройки в файле jest.config.ts
Прописываем такое свойство:
collectCoverage: true,
Этим свойством мы разрешаем сбор информации, по умолчанию — false.
Дальше прописываем:
collectCoverageFrom: [ "<rootDir>/*.{js,ts}", "!**/node_modules/**", "!<rootDir>/*.mock*", "!<rootDir>/*.config.*", ],
Указываем в виде массива пути, которые мы хотим протестировать или не хотим. К примеру, всё, что начинается со знака "!", мы отмечаем как папки или файлы, которые будут проигнорированы.
Если всё сделали правильно, запускаем тесты и проверяем статистику.
Результат:
Теперь мы видим, что протестировано, а что нет. Написано, где именно, например, в файле createTodo забыли протестировать функцию.
export function createTodo(text: string) { return { id: Math.random(), text, completed: false, }; }
Давайте напишем для неё тесты и опять запустим.
Тесты к функции.
describe("createTodo function", () => { it("should return an object with id, text, and completed properties", () => { const todo = createTodo("New todo item"); expect(todo).toHaveProperty("id"); expect(todo).toHaveProperty("text"); expect(todo).toHaveProperty("completed"); }); it("should set the text property to the provided text", () => { const text = "New todo item"; const todo = createTodo(text); expect(todo.text).toBe(text); }); it("should set the completed property to false", () => { const todo = createTodo("New todo item"); expect(todo.completed).toBe(false); }); it("should generate a random id", () => { const todo1 = createTodo("New todo item"); const todo2 = createTodo("Another todo item"); expect(todo1.id).not.toBe(todo2.id); }); });
Запускаем снова тесты.
Результат:
Также обратите внимание, что в корне проекта у нас создалась папка coverage, там находится вся информация о тестах.
Больше всего нас интересует index.html который нам нужно открыть.
Мы увидим таблицу, которая у нас была в console, но тут можем провалиться в файл и посмотреть, что конкретно у нас не протестировано.
Пример:
Также в конфиге можем указать пороговые значения тестов для нашего проекта:
coverageThreshold: { global: { branches: 70, functions: 70, lines: 90, statements: 100, }, },
Запускаем и видим, что мы не прошли порог по тестам.
Результат:
Итак, мы с вами создали проект «с нуля» и прошли базовые темы. Важно, что мы научились настраивать конфигурации для различных задач, работать с jest. Напоминаю, что это лишь первая часть. Впереди нас ждут более сложные и захватывающие задачи.
Материалы
Jest — https://jestjs.io/
GitHub — https://github.com/kirill0202/Jest