Моки и стабы для валидации

При работе с валидацией на основе схем важно разделять два слоя логики: декларативное описание правил (схема) и внешние зависимости, которые могут влиять на результат проверки. В экосистеме JavaScript библиотека Yup часто используется для описания правил валидации форм, DTO и API-слоёв. Однако как только схема начинает включать асинхронные проверки или обращение к внешним данным, становится необходимым контролируемое тестирование через моки и стабы.

Моки и стабы позволяют изолировать поведение схемы от внешнего мира, обеспечивая предсказуемость тестов и воспроизводимость сценариев.

Различие между моками и стабами в контексте валидации

Стабы (stubs) используются для подмены конкретной реализации функции с фиксированным результатом. В контексте схем Yup это чаще всего функции, возвращающие промисы: запросы к API, проверки уникальности, обращения к базе данных.

Моки (mocks) идут дальше: они не только подменяют поведение, но и фиксируют факт вызова, его параметры и количество обращений. Это особенно важно при проверке того, как схема взаимодействует с внешними зависимостями.

В тестировании валидации различие проявляется следующим образом:

  • стабы отвечают на вопрос «что вернётся?»
  • моки отвечают на вопрос «как именно происходило взаимодействие?»

Асинхронная валидация и необходимость изоляции

Схемы Yup поддерживают асинхронные проверки через .test() или кастомные методы, возвращающие Promise. Это создаёт зависимость от внешнего мира:

  • проверка уникальности email
  • валидация username через API
  • проверка существования ресурса в базе данных

Без изоляции такие тесты становятся нестабильными: сетевые задержки, изменчивость данных и недоступность сервисов делают результаты непредсказуемыми.

Пример проблемного кода:

const schema = Yup.object({
  email: Yup.string()
    .email()
    .test("unique", "Email уже используется", async (value) => {
      const res = await fetch(`/api/users/check?email=${value}`);
      const data = await res.json();
      return data.unique;
    })
});

Такую схему невозможно тестировать без стабов для fetch.

Подмена внешних зависимостей через стабы

При тестировании обычно используется подмена глобальных функций или модулей. В Jest это реализуется через jest.fn() и jest.mock().

Пример стабирования fetch:

global.fetch = jest.fn();

Далее задаётся поведение:

fetch.mockResolvedValue({
  json: async () => ({ unique: true })
});

Теперь схема Yup получает предсказуемый ответ, не обращаясь к сети.

Тестирование становится детерминированным:

await expect(
  schema.validate({ email: "test@mail.com" })
).resolves.toBeTruthy();

Использование моков для контроля вызовов

Когда важно не только поведение, но и факт взаимодействия, применяются моки.

expect(fetch).toHaveBeenCalledWith(
  "/api/users/check?email=test@mail.com"
);

Это позволяет проверять:

  • корректность формирования URL
  • количество вызовов API
  • отсутствие лишних запросов

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

Тестирование кастомных async-правил Yup

Метод .test() в Yup позволяет создавать собственные правила:

Yup.string().test(
  "is-available",
  "Недоступно",
  async function (value) {
    const exists = await checkExists(value);
    return !exists;
  }
);

Функция checkExists — типичный кандидат на стабирование.

Пример стабирования зависимости

import * as api from "./api";

jest.mock("./api");

api.checkExists.mockResolvedValue(false);

Теперь тест проверяет только логику схемы, а не реальный API.

Стабирование модулей вместо глобальных функций

Глобальные моки подходят для простых случаев, но более масштабируемый подход — модульное мокирование.

Пример:

import { checkEmail } from "./service";
jest.mock("./service");

Далее:

checkEmail.mockResolvedValue(true);

Это позволяет:

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

Комбинация моков и стабов в сложных схемах

В реальных приложениях схема Yup может содержать несколько уровней асинхронных проверок:

  • проверка формата
  • проверка существования пользователя
  • проверка бизнес-ограничений

Пример:

Yup.object({
  username: Yup.string().test(
    "available",
    "Занято",
    async (value) => {
      const exists = await api.checkUsername(value);
      const blocked = await api.checkBlacklist(value);
      return !exists && !blocked;
    }
  )
});

Тестирование требует комбинирования стабов:

api.checkUsername.mockResolvedValue(false);
api.checkBlacklist.mockResolvedValue(false);

И моков для контроля вызовов:

expect(api.checkUsername).toHaveBeenCalledTimes(1);
expect(api.checkBlacklist).toHaveBeenCalledWith("john");

Изоляция тестов валидации от состояния окружения

Одной из ключевых проблем является утечка состояния между тестами. Моки и стабы должны сбрасываться:

afterEach(() => {
  jest.clearAllMocks();
});

Это гарантирует:

  • отсутствие влияния предыдущих тестов
  • корректное количество вызовов
  • чистое состояние зависимостей

Проверка ошибок через стабы

Стабы полезны не только для успешных сценариев, но и для проверки ошибок.

api.checkUsername.mockRejectedValue(new Error("Network error"));

Теперь можно проверить поведение схемы при сбое:

await expect(
  schema.validate({ username: "test" })
).rejects.toThrow();

Это важно для устойчивости валидации в реальных условиях.

Моки для синхронных зависимостей

Не все зависимости асинхронны. Иногда схема использует синхронные утилиты:

const isForbidden = (value) => forbiddenList.includes(value);

Мокирование:

jest.spyOn(utils, "isForbidden").mockReturnValue(true);

Это позволяет моделировать разные бизнес-сценарии без изменения исходного кода.

Проверка количества и последовательности вызовов

В сложных схемах порядок проверок может быть важен.

expect(api.checkUsername.mock.invocationCallOrder[0])

Или упрощённо:

expect(api.checkUsername).toHaveBeenCalledBefore(api.checkBlacklist);

Это помогает выявлять архитектурные проблемы в логике валидации.

Изоляция Yup-схем как чистых функций

При грамотном подходе схема Yup рассматривается как чистая функция:

(input) → validation result

Моки и стабы обеспечивают:

  • контроль внешних эффектов
  • воспроизводимость результатов
  • возможность тестировать крайние случаи

Схема перестаёт зависеть от инфраструктуры и становится тестируемым модулем бизнес-логики.

Использование fake-слоёв вместо мокирования

В более сложных проектах вместо точечного мокирования создаются fake-реализации сервисов:

const fakeApi = {
  checkUsername: jest.fn().mockResolvedValue(false),
  checkBlacklist: jest.fn().mockResolvedValue(false)
};

И передача их в схему через контекст:

schema.validate(data, { context: fakeApi });

Такой подход снижает связность тестов и делает архитектуру более гибкой.

Контроль асинхронных гонок

Асинхронные проверки в Yup могут выполняться параллельно. Моки позволяют выявлять гонки:

api.checkUsername.mockImplementation(
  () => new Promise(resolve => setTimeout(() => resolve(false), 100))
);

Это используется для проверки устойчивости схемы к задержкам и конкурентным вызовам.

Подмена времени выполнения для тестирования дебаунса

Если валидация зависит от debounce-логики, используются таймерные моки:

jest.useFakeTimers();

И управление временем:

jest.advanceTimersByTime(300);

Это позволяет тестировать поведение схемы без реального ожидания.

Итоговая модель тестирования схем Yup через моки и стабы

При структурированном подходе тестирование схем валидации строится на трёх уровнях:

  • изоляция внешних зависимостей через стабы
  • контроль взаимодействий через моки
  • моделирование ошибок и нестабильных сценариев

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