🧪 Успешное тестирование: основы и передовые приемы 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

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ