Unit тесты для схем Yup

Схемы в Yup представляют собой декларативное описание правил валидации данных, и их тестирование строится вокруг проверки предсказуемости этих правил. Ключевая цель unit-тестов — гарантировать, что каждая ветка валидации ведёт себя строго детерминированно при одинаковых входных данных.

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

  • проверка валидных значений (positive cases)
  • проверка невалидных значений (negative cases)
  • проверка граничных условий
  • проверка кастомной логики (test, when, трансформации)
  • проверка асинхронной валидации

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


Подготовка тестового окружения

Для тестирования Yup-схем чаще всего используются Jest или Vitest. Обе библиотеки подходят одинаково хорошо, так как Yup возвращает Promise при валидации.

Базовая настройка включает установку тестового раннера и саму библиотеку Yup:

npm install yup
npm install -D jest

При использовании TypeScript дополнительно подключается типизация:

npm install -D @types/jest ts-jest

В Vitest конфигурация обычно проще:

npm install -D vitest

Базовая структура теста Yup-схемы

Любая Yup-схема тестируется через методы:

  • validate
  • isValid
  • validateSync

На практике чаще используется validate, так как он возвращает детализированную ошибку.

Пример базовой схемы:

import * as yup from "yup";

const schema = yup.object({
  email: yup.string().email().required(),
});

Тестирование валидного случая:

test("валидный email проходит проверку", async () => {
  const data = { email: "test@mail.com" };

  await expect(schema.validate(data)).resolves.toEqual(data);
});

Тестирование ошибки:

test("пустой email вызывает ошибку", async () => {
  const data = { email: "" };

  await expect(schema.validate(data)).rejects.toBeTruthy();
});

Проверка обязательных полей

Одна из самых частых проверок — required. В Yup она работает в сочетании с типом данных.

const schema = yup.object({
  username: yup.string().required(),
});

Тесты должны покрывать:

  • undefined
  • null
  • пустую строку
  • строку с пробелами (если применяются trim-правила)
test("username обязателен", async () => {
  await expect(schema.validate({ username: undefined })).rejects.toBeTruthy();
  await expect(schema.validate({ username: null })).rejects.toBeTruthy();
  await expect(schema.validate({ username: "" })).rejects.toBeTruthy();
});

Тестирование строковых правил

Строковые правила часто включают:

  • min
  • max
  • matches
  • email
  • url

Пример схемы:

const schema = yup.object({
  password: yup.string().min(8).max(20),
});

Тестирование граничных значений:

test("password соблюдает длину", async () => {
  await expect(schema.validate({ password: "1234567" })).rejects.toBeTruthy();
  await expect(schema.validate({ password: "12345678" })).resolves.toBeTruthy();
  await expect(schema.validate({ password: "a".repeat(20) })).resolves.toBeTruthy();
  await expect(schema.validate({ password: "a".repeat(21) })).rejects.toBeTruthy();
});

Числовая валидация и пограничные значения

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

const schema = yup.object({
  age: yup.number().min(18).max(60),
});

Типичная ошибка — передача строки:

test("строка не проходит числовую проверку", async () => {
  await expect(schema.validate({ age: "20" })).rejects.toBeTruthy();
});

Если используется преобразование:

age: yup.number().transform((value, originalValue) => Number(originalValue))

Тогда тест должен учитывать корректную трансформацию:

test("строка преобразуется в число", async () => {
  await expect(schema.validate({ age: "20" })).resolves.toEqual({ age: 20 });
});

Кастомные проверки через test()

Метод test() позволяет задавать пользовательскую логику.

const schema = yup.object({
  username: yup.string().test(
    "no-admin",
    "username не может быть admin",
    (value) => value !== "admin"
  ),
});

Тестирование кастомной логики:

test("admin запрещён", async () => {
  await expect(schema.validate({ username: "admin" })).rejects.toBeTruthy();
});

test("другие значения разрешены", async () => {
  await expect(schema.validate({ username: "user" })).resolves.toBeTruthy();
});

Важно проверять не только true/false, но и корректность сообщения ошибки через ValidationError.


Асинхронная валидация

Асинхронные проверки часто используются для проверки уникальности значений через API.

const schema = yup.object({
  email: yup.string().test(
    "unique-email",
    "email уже используется",
    async (value) => {
      const exists = await fakeApiCheck(value);
      return !exists;
    }
  ),
});

Тестирование асинхронной логики требует моков:

jest.mock("./api", () => ({
  fakeApiCheck: jest.fn(),
}));

Пример теста:

test("email должен быть уникальным", async () => {
  fakeApiCheck.mockResolvedValue(true);

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

Проверка transform-логики

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

const schema = yup.object({
  name: yup.string().transform((value) => value.trim()),
});

Тест должен проверять именно результат трансформации:

test("trim удаляет пробелы", async () => {
  const result = await schema.validate({ name: "  John  " });

  expect(result.name).toBe("John");
});

Валидация вложенных объектов

Сложные формы часто содержат вложенные структуры:

const schema = yup.object({
  user: yup.object({
    email: yup.string().email().required(),
    profile: yup.object({
      age: yup.number().min(18),
    }),
  }),
});

Тестирование должно покрывать путь к каждому полю:

test("вложенная валидация работает", async () => {
  const data = {
    user: {
      email: "test@mail.com",
      profile: { age: 20 },
    },
  };

  await expect(schema.validate(data)).resolves.toEqual(data);
});

Проверка массивов

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

const schema = yup.object({
  tags: yup.array().of(yup.string().min(2)),
});

Тестирование:

test("массив валидируется по каждому элементу", async () => {
  await expect(schema.validate({ tags: ["ok", "a"] }))
    .rejects
    .toBeTruthy();

  await expect(schema.validate({ tags: ["good", "valid"] }))
    .resolves
    .toBeTruthy();
});

Организация тестов по структуре схем

При росте схемы важно поддерживать читаемую структуру тестов. Практика группировки через describe позволяет отражать структуру объекта.

describe("user schema", () => {
  describe("email", () => {
    test("обязателен", () => {});
    test("валидный формат", () => {});
  });

  describe("password", () => {
    test("минимальная длина", () => {});
  });
});

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


Проверка сообщений об ошибках

Иногда важно не только факт ошибки, но и её текст.

try {
  await schema.validate({ email: "invalid" });
} catch (err) {
  expect(err.message).toContain("email must be a valid email");
}

Это особенно важно в проектах, где сообщения ошибок отображаются пользователю напрямую.


Стратегия покрытия граничных случаев

Наиболее частые проблемные зоны:

  • пустые строки vs undefined
  • строки с пробелами
  • NaN в числах
  • массивы с пустыми элементами
  • неожиданные типы (boolean вместо string)

Тесты должны сознательно включать такие входные данные:

await schema.validate({ age: NaN });
await schema.validate({ email: "   " });
await schema.validate({ tags: [null, "ok"] });

Паттерны устойчивого тестирования схем

Для больших проектов применяется подход выделения фабрик данных:

const validUser = (overrides = {}) => ({
  email: "test@mail.com",
  password: "12345678",
  ...overrides,
});

Это позволяет писать тесты без дублирования структуры:

test("email обязателен", async () => {
  await expect(schema.validate(validUser({ email: "" })))
    .rejects
    .toBeTruthy();
});

Типизация и проверка соответствия TypeScript

При использовании TypeScript схема часто становится источником типов.

type User = yup.InferType<typeof schema>;

Тесты могут дополнительно проверять соответствие структуры:

const user: User = await schema.validate(data);

Ошибки типизации на этом этапе часто выявляют несоответствия между схемой и бизнес-логикой.


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

Схемы Yup должны быть полностью детерминированными. Любая зависимость от внешнего состояния (например, глобальных переменных или кэша API) делает тесты нестабильными.

Поэтому:

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