Использование Mock Service Worker на большом проекте


Содержание:

В статье я продемонстрирую практическое применение Mock Service Worker (MSW) на примере pet-проекта «Family accounting». Это SPA, которое содержит достаточное количество API-эндпоинтов, чтобы наглядно показать особенности работы MSW в приложениях с 50+ количеством запросов к серверу. В статье рассмотрим несколько аспектов работы с моками:

  • организацию архитектуры генераторов моковых данных;
  • переиспользование написанных моков в unit-тестах.

Проблемы использования MSW на больших проектах

При интеграции MSW в проекты с несколькими десятками или сотнями API-запросов, разработчики сталкиваются с проблемой поддержки актуальности моков. Сложность этой задачи напрямую зависит от количества API-эндпоинтов и сложности серверных моделей данных. Без правильного подхода к организации инфраструктуры моков, команда рискует получить набор неактуальных заглушек, не соответствующих текущему состоянию API. В этой статье я представлю способы, которые позволят сгладить углы и частично решить эту проблему.

Базовая настройка окружения

Для работы потребуется настроить Mock Service Worker в проекте. Базовая конфигурация подробно описана в предыдущей статье цикла. В дополнение к MSW будем использовать библиотеку faker.js для генерации реалистичных тестовых данных.

Организация структуры файлов проекта

Проект построен на Vue 3 с использованием модульной архитектуры. Рассмотрим организацию файловой структуры, уделив особое внимание интеграции MSW:

📂 public/
│   └── 📄 mockServiceWorker.js             # Service Worker для перехвата запросов
📂 src/
├── 📂 app/                                 # Ядро приложения
├── 📂 modules/                             # Модули приложения
├── 📂 mocks/                               # Инфраструктура моков
│   ├── 📂 generators/                      # Генераторы тестовых данных
│   │   ├── 📂 accounts/                    # Генераторы для модуля счетов
│   │   │   ├── 📄 account.ts                # Генераторы данных о счетах
│   │   │   ├── 📄 accountsList.ts           # Генератор данных для списков счетов
│   │   │   ├── 📄 accountsStatistics.ts     # Генератор данных для статистики по счетам
│   │   │   └── 📄 index.ts                  # Публичное апи генератора
│   │   ├── 📂 family/
│   │   ├── 📂 user/
│   │   ├── 📄 changelog.ts
│   │   └── 📄 pagination.ts
│   ├── 📂 handlers/            # Перехватчики запросов
│   │   ├── 📂 accounts/        # Перехватчики запросов для модуля счетов
│   │   │   ├── 📄 getAccount.ts
│   │   │   ├── 📄 getAccountsList.ts
│   │   │   ├── 📄 getAccountsStatistics.ts
│   │   │   ├── 📄 createAccount.ts
│   │   │   ├── 📄 updateAccount.ts
│   │   │   └── 📄 index.ts
│   │   ├── 📂 family/
│   │   └── 📂 user/
│   ├── 📄 index.ts             # Точка входа для моков
│   └── 📄 handlers.ts          # Регистрация перехватчиков
└── 📂 shared/                  # Общие компоненты и утилиты всего приложения

На проекте действует модульная архитектура и сами генераторы данных можно также поместить внутрь соответствующих модулей.

Типизация моков

Ключевым аспектом поддержки моков является строгая типизация данных, имитирующих серверные ответы. В нашем проекте все модели данных хранятся в директории app/types. Рассмотрим пример типизации сущности Account:

interface BaseAccountModel {
  id: string;
  createdAt: string;
  updatedAt: string;
  balance: string;
  title: string;
  description: string;
  isDeposit: boolean;
  currency: CurrencyType;
}

interface DetailAccountModel extends BaseAccountModel {
  user: UserModel;
  changelog: Changelog[];
  family: BaseFamily | null;
  history: TransactionBase[];
}

Строгая типизация предоставляет несколько важных преимуществ:

  • Автоматическое отслеживание изменений API: когда интерфейсы обновляются, TypeScript сигнализирует о несоответствиях в генераторах тестовых данных;
  • Раннее обнаружение ошибок: несоответствия между моками и реальным API выявляются на этапе компиляции;
  • Улучшенный DX: IDE предоставляет автодополнение и подсказки при работе с моками;

Даже если в определенный момент типы TypeScript отстают от изменений в API, система типов поможет постепенно привести моки в соответствие с актуальной структурой данных. Главное, чтобы сами запросы также были типизированы эти ми же интерфейсами. Например:

const data = await http.get<BaseAccountModel[]>('/account/list');

Декомпозиция генераторов данных

Грамотная организация генераторов тестовых данных и обработчиков запросов — один из ключевых моментов в работе с моками. Давайте разберем, как правильно выстроить эту архитектуру и посмотрим на реальных примерах, как это работает на практике.

Ключевые идеи декомпозиции генераторов данных для MSW

  1. Атомарность: каждый генератор отвечает за создание одной конкретной структуры данных;
  2. Гибкость: генераторы принимают параметры для кастомизации создаваемых данных;
  3. Разделение ответственности: обработчики запросов (handlers) используют генераторы, но не создают данные самостоятельно, при этом сами генераторы данных отвечают только за одну схему данных.

Практическая реализация

Рассмотрим создание генератора на примере сущности «Account» (банковский счёт). Начнём с импорта необходимых зависимостей:

// mocks/generators/accounts/account.ts

import { faker } from '@faker-js/faker';

import type { BaseAccountModel, DetailAccountModel } from '@models/account';
import type { CurrencyType } from '@/app/types/main';

Создадим базовый генератор счёта. Он отвечает за генерацию основных полей сущности:

// mocks/generators/accounts/account.ts

const currencies: CurrencyType[] = ['eur', 'rub', 'tg', 'usd'];

export function generateBaseAccount(id?: string): BaseAccountModel {
  return {
    id: id || faker.database.mongodbObjectId(),
    createdAt: faker.date.between({ from: '2024-10-17', to: Date.now() }).toISOString(),
    updatedAt: faker.date.between({ from: '2024-10-17', to: Date.now() }).toISOString(),
    balance: faker.number.float({ min: 0.0, max: 3200000.0 }).toFixed(2).toString(),
    title: faker.finance.accountName(),
    description: faker.lorem.text(),
    isDeposit: faker.datatype.boolean(),
    currency: faker.helpers.arrayElement(currencies),
  };
}

Для генерации расширенной модели счёта создадим отдельную функцию, которая соединяет результаты работы других генераторов:

// mocks/generators/accounts/account.ts

import { generateUser } from '../user';
import { generateChangelogList } from '../changelog';
import { generateBaseFamily } from '../family';
import { generateBaseTransactionsList } from '../transactions';

export function generateDetailAccount(id?: string): DetailAccountModel {
  return {
    ...generateBaseAccount(id),
    user: generateUser(),
    changelog: generateChangelogList(),
    family: faker.helpers.arrayElement([generateBaseFamily(), null]),
    history: generateBaseTransactionsList(),
  };
}

Такой подход к организации генераторов обеспечивает:

  • возможность переиспользования кода между различными частями тестовой инфраструктуры;
  • простоту поддержки и модификации отдельных генераторов;
  • гибкость в настройке генерируемых данных через параметры;
  • чистоту и понятность кода обработчиков запросов.

Используем генераторы данных без MSW в тестах

Помимо работы с Mock Service Worker, созданные генераторы можно применять и в unit-тестах. Однако для этого требуется добавить возможность контроля над генерируемыми данными т.к. в тестах нам важно контролировать данные, которые мы передаём в тестируемую функцию. Это необходимо для проверки базового функционала, граничных случаев и корректности обработки ошибок.

Доработка генераторов данных для unit-тестов

Доработаем наши генераторы, чтобы сделать их более гибкими. Для этого создадим интерфейс с настройками генерации:

type PartialBaseAccount = Partial<Omit<BaseAccountModel, 'id'>>;

export interface DetailAccountArgs {
  baseAccount?: PartialBaseAccount;
  withFamily?: boolean;
  changelogLimit?: number;
}

Кратко про параметры интерфейса:

  • baseAccount использует утилитарные типы TypeScript:
    • Partial<T> делает все поля типа необязательными, что позволяет передавать только те данные, которые нужны для конкретного теста. Остальные поля будут сгенерированы случайно через faker.js;
    • Omit<T, K> исключает указанные свойства из типа. В нашем случае удаляем id, который передаётся отдельным аргументом функции. Альтернативой было бы объединение всех параметров в один объект настроек, но это потребовало бы рефакторинга существующего кода;
  • withFamily позволяет явно указать, должна ли модель счёта содержать данные о семье;
  • changelogLimit задаёт количество записей в истории изменений счёта.

Теперь доработаем генераторы, чтобы они учитывали новые параметры. Начнём с функции создания базового счёта:

export function generateBaseAccount(id?: string, options: PartialBaseAccount = {}): BaseAccountModel {
  const now = Date.now();
  const dateParams = { from: '2024-10-17', to: now };
  const balanceParams = { min: 0.0, max: 3200000.0 };

  return {
    id: id ?? faker.database.mongodbObjectId(),
    createdAt: options.createdAt ?? faker.date.between(dateParams).toISOString(),
    updatedAt: options.updatedAt ?? faker.date.between(dateParams).toISOString(),
    balance: options.balance ?? faker.number.float(balanceParams).toFixed(2).toString(),
    title: options.title ?? faker.finance.accountName(),
    description: options.description ?? faker.lorem.text(),
    isDeposit: options.isDeposit ?? faker.datatype.boolean(),
    currency: options.currency ?? faker.helpers.arrayElement(currencies),
  };
}

Обновлённый генератор использует оператор ?? (nullish coalescing, оператор нулевого слияния), который возвращает правый операнд, только если левый равен null или undefined. Это позволяет передавать в options даже «ложные» значения вроде пустой строки или false.

Аналогично модифицируем генератор детальной информации о счёте:

const generateFamilyForAccount = (withFamily: boolean) =>  withFamily ? generateBaseFamily() : null;

export function generateDetailAccount(id?: string, options: DetailAccountArgs = {}): DetailAccountModel {
  return {
    ...generateBaseAccount(id, options.baseAccount),
    user: generateUser(),
    changelog: 'changelogLimit' in options ? generateChangelogList(options.changelogLimit) : generateChangelogList(),
    family: 'withFamily' in options
      ? generateFamilyForAccount(options.withFamily!)
      : faker.helpers.arrayElement([generateBaseFamily(), null]),
    history: generateBaseTransactionsList(),
  };
}

В расширенном генераторе особое внимание стоит обратить на поле family. Если флаг withFamily не указан в опциях, используется случайный выбор через faker.js. Если же флаг передан, то семья либо гарантированно будет добавлена в модель счёта, либо гарантированно будет отсутствовать – это важно для тестирования различных состояний приложения.

Пишем тесты с использованием этих генераторов

На примере тестирования страницы счёта рассмотрим, как использовать наши генераторы на практике. На этой странице есть заголовок, который отображает тип счёта в одном из четырёх вариантов: «Личный депозит», «Личный счёт», «Семейный депозит» или «Семейный счёт».

По частям разберём написанные мною тесты:

Для начала, создаём тип данных для перечисляемого значения возможных кейсов, чтобы потом использовать метод forEach в тестах.

interface AccountTypeTest {
  name: string;                 // Описание теста
  config: {                     // Конфигурация генератора
    baseAccount: {
      isDeposit: boolean;       // Депозит или обычный счёт
    };
    withFamily: boolean;        // Семейный или личный счёт
  };
  expectedTitle: string;        // Ожидаемый заголовок
}

Затем на основе этого типа мы создаём набор тестовых сценариев под каждый из четырёх возможных заголовков:

const accountTypeTests: AccountTypeTest[] = [
  {
    name: 'семейный счёт',
    config: {
      baseAccount: { isDeposit: false },
      withFamily: true
    },
    expectedTitle: 'Семейный счёт'
  },
  // ... остальные сценарии для разных типов счетов
];

И наконец пишем тесты с использованием цикла:

accountTypeTests.forEach(({ name, config, expectedTitle }) => {
  it(`отображает ${name}`, async () => {
    const mockAccount = generateDetailAccount(1, config);

    vi.mocked(AccountsRepository.getAccountDetails).mockResolvedValue(mockAccount);

    const wrapper = mount(AccountPage);
    await flushPromises();

    expect(wrapper.get(`[data-test-id="${testId}"]`).text()).toBe(expectedTitle);
  });
});

При таком подходе к unit-тестированию приложения мы имеем то, что:

  • Тестовые данные генерируются предсказуемо, но с случайными значениями для несущественных полей;
  • Тесты сохраняют свою читабельность и понятность;
  • Легко добавлять тестовые сценарии на основе имеющихся генераторов данных.
На изображении нарисован человек держащий в руках рамку с надписью "to be continued...", а слева от него диалоговое облачко с фразой "А что дальше?"

Автор рисунка: @matomi_ryu

© 2024 Рассудихин Алекс