Тестирование схем валидации

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

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

Ошибка в схеме приводит не только к неверной проверке данных, но и к повреждению бизнес-логики, падениям приложения или скрытым багам.

Тестирование схем в Zod позволяет:

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

Базовые принципы тестирования Zod-схем

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

  1. Валидные данные успешно проходят проверку.
  2. Невалидные данные отклоняются с ожидаемыми ошибками.

Простейшая схема:

import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(2),
  age: z.number().int().positive(),
});

Тесты:

import { describe, expect, it } from "vitest";

describe("UserSchema", () => {
  it("валидирует корректные данные", () => {
    const data = {
      name: "Alex",
      age: 25,
    };

    const result = UserSchema.safeParse(data);

    expect(result.success).toBe(true);
  });

  it("отклоняет некорректные данные", () => {
    const data = {
      name: "A",
      age: -5,
    };

    const result = UserSchema.safeParse(data);

    expect(result.success).toBe(false);
  });
});

Почему safeParse() удобнее в тестах

Метод parse() выбрасывает исключение:

UserSchema.parse(data);

При ошибке:

ZodError

Для тестирования чаще используется safeParse():

const result = UserSchema.safeParse(data);

Результат:

{
  success: true,
  data
}

или:

{
  success: false,
  error
}

Это упрощает проверки и делает тесты более читаемыми.


Проверка успешной валидации

Проверка флага success

const result = UserSchema.safeParse({
  name: "John",
  age: 30,
});

expect(result.success).toBe(true);

Проверка итоговых данных

Особенно важно для схем с transform().

const schema = z.string().transform((v) => v.trim());

const result = schema.safeParse("  hello  ");

expect(result.success).toBe(true);

if (result.success) {
  expect(result.data).toBe("hello");
}

Проверка ошибок валидации

Проверка факта ошибки

const result = UserSchema.safeParse({
  name: "",
  age: -10,
});

expect(result.success).toBe(false);

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

if (!result.success) {
  expect(result.error.issues.length).toBe(2);
}

Проверка конкретной ошибки

if (!result.success) {
  expect(result.error.issues[0].path).toEqual(["name"]);

  expect(result.error.issues[0].message).toBe(
    "String must contain at least 2 character(s)"
  );
}

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

Каждая ошибка содержит объект:

{
  code,
  path,
  message
}

Пример:

if (!result.success) {
  expect(result.error.issues).toEqual([
    expect.objectContaining({
      path: ["email"],
      code: "invalid_string",
    }),
  ]);
}

Проверка flatten()

Метод flatten() преобразует ошибки в удобный формат.

Схема:

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

Тест:

const result = schema.safeParse({
  email: "wrong",
  password: "123",
});

if (!result.success) {
  const errors = result.error.flatten();

  expect(errors.fieldErrors.email).toContain(
    "Invalid email"
  );

  expect(errors.fieldErrors.password).toContain(
    "String must contain at least 8 character(s)"
  );
}

Проверка format()

if (!result.success) {
  const formatted = result.error.format();

  expect(formatted.email?._errors).toContain(
    "Invalid email"
  );
}

Тестирование сложных схем

Вложенные объекты

const AddressSchema = z.object({
  city: z.string(),
  zip: z.string().length(6),
});

const UserSchema = z.object({
  name: z.string(),
  address: AddressSchema,
});

Тест:

const result = UserSchema.safeParse({
  name: "Alex",
  address: {
    city: "Moscow",
    zip: "123",
  },
});

expect(result.success).toBe(false);

if (!result.success) {
  expect(result.error.issues[0].path).toEqual([
    "address",
    "zip",
  ]);
}

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

const schema = z.array(
  z.string().min(3)
);

Тест:

const result = schema.safeParse([
  "ok",
  "hello",
]);

expect(result.success).toBe(false);

if (!result.success) {
  expect(result.error.issues[0].path).toEqual([0]);
}

Тестирование union-схем

const schema = z.union([
  z.string(),
  z.number(),
]);

Корректные значения:

expect(schema.safeParse("hello").success)
  .toBe(true);

expect(schema.safeParse(100).success)
  .toBe(true);

Некорректные:

expect(schema.safeParse(true).success)
  .toBe(false);

Тестирование discriminated union

const schema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("user"),
    name: z.string(),
  }),

  z.object({
    type: z.literal("admin"),
    permissions: z.array(z.string()),
  }),
]);

Тест:

const result = schema.safeParse({
  type: "admin",
  permissions: [],
});

expect(result.success).toBe(true);

Проверка ошибок:

const result = schema.safeParse({
  type: "admin",
});

expect(result.success).toBe(false);

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

Простая проверка

const schema = z.string().refine(
  (v) => v.startsWith("A"),
  {
    message: "Must start with A",
  }
);

Тест:

expect(
  schema.safeParse("Alex").success
).toBe(true);

expect(
  schema.safeParse("John").success
).toBe(false);

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

const result = schema.safeParse("John");

if (!result.success) {
  expect(result.error.issues[0].message)
    .toBe("Must start with A");
}

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

superRefine() позволяет создавать несколько ошибок.

Схема:

const schema = z.object({
  password: z.string(),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      path: ["confirmPassword"],
      message: "Passwords do not match",
    });
  }
});

Тест:

const result = schema.safeParse({
  password: "123456",
  confirmPassword: "111111",
});

expect(result.success).toBe(false);

if (!result.success) {
  expect(result.error.issues[0].path)
    .toEqual(["confirmPassword"]);
}

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

Проверка преобразования

const schema = z.string()
  .transform((v) => v.toUpperCase());

const result = schema.safeParse("hello");

expect(result.success).toBe(true);

if (result.success) {
  expect(result.data).toBe("HELLO");
}

Проверка цепочек transform

const schema = z.string()
  .transform((v) => v.trim())
  .transform((v) => v.toUpperCase());

const result = schema.safeParse(" hello ");

if (result.success) {
  expect(result.data).toBe("HELLO");
}

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

const schema = z.preprocess(
  (value) => Number(value),
  z.number()
);

Тест:

const result = schema.safeParse("42");

expect(result.success).toBe(true);

if (result.success) {
  expect(result.data).toBe(42);
}

Тестирование optional и nullable

optional

const schema = z.string().optional();

expect(schema.safeParse(undefined).success)
  .toBe(true);

expect(schema.safeParse("hello").success)
  .toBe(true);

nullable

const schema = z.string().nullable();

expect(schema.safeParse(null).success)
  .toBe(true);

nullish

const schema = z.string().nullish();

expect(schema.safeParse(undefined).success)
  .toBe(true);

expect(schema.safeParse(null).success)
  .toBe(true);

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

const schema = z.string().default("guest");

const result = schema.parse(undefined);

expect(result).toBe("guest");

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

const schema = z.coerce.number();

Проверка:

expect(schema.parse("42")).toBe(42);

Некорректные данные:

expect(() => {
  schema.parse("abc");
}).toThrow();

Тестирование async-схем

Асинхронный refine

const schema = z.string().refine(
  async (value) => {
    return value !== "admin";
  },
  {
    message: "Reserved name",
  }
);

Тест:

it("проверяет async refine", async () => {
  const result = await schema.safeParseAsync(
    "admin"
  );

  expect(result.success).toBe(false);
});

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

await expect(
  schema.parseAsync("admin")
).rejects.toThrow();

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

Повторяющиеся проверки удобно выносить в таблицы.

describe("EmailSchema", () => {
  const schema = z.string().email();

  it.each([
    "a@test.com",
    "b@test.com",
    "user@gmail.com",
  ])("валидный email: %s", (email) => {
    expect(
      schema.safeParse(email).success
    ).toBe(true);
  });

  it.each([
    "",
    "abc",
    "wrong-email",
  ])("невалидный email: %s", (email) => {
    expect(
      schema.safeParse(email).success
    ).toBe(false);
  });
});

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

Особое внимание уделяется:

  • пустым строкам;
  • null;
  • undefined;
  • минимальным значениям;
  • максимальным значениям;
  • очень длинным строкам;
  • пустым массивам;
  • NaN;
  • Infinity.

Пример:

const schema = z.number().min(1).max(10);

expect(schema.safeParse(1).success)
  .toBe(true);

expect(schema.safeParse(10).success)
  .toBe(true);

expect(schema.safeParse(0).success)
  .toBe(false);

expect(schema.safeParse(11).success)
  .toBe(false);

Проверка edge-case сценариев

NaN

const schema = z.number();

expect(schema.safeParse(NaN).success)
  .toBe(false);

Infinity

expect(schema.safeParse(Infinity).success)
  .toBe(true);

Запрет Infinity:

const finiteSchema = z.number().finite();

expect(
  finiteSchema.safeParse(Infinity).success
).toBe(false);

Проверка strict-режима

const schema = z.object({
  name: z.string(),
}).strict();

Тест:

const result = schema.safeParse({
  name: "Alex",
  age: 30,
});

expect(result.success).toBe(false);

Проверка passthrough

const schema = z.object({
  name: z.string(),
}).passthrough();

Тест:

const result = schema.parse({
  name: "Alex",
  age: 30,
});

expect(result.age).toBe(30);

Проверка strip

const schema = z.object({
  name: z.string(),
}).strip();

const result = schema.parse({
  name: "Alex",
  age: 30,
});

expect(result).toEqual({
  name: "Alex",
});

Сравнение parse и safeParse в тестах

Метод Поведение
parse() Выбрасывает исключение
safeParse() Возвращает объект результата

parse() полезен при проверке выбрасывания ошибок:

expect(() => {
  schema.parse({});
}).toThrow();

safeParse() удобен для детального анализа ошибок:

const result = schema.safeParse({});

Использование snapshot-тестирования

Иногда удобно сохранять структуру ошибок.

const result = schema.safeParse({});

expect(result).toMatchSnapshot();

Пример snapshot:

{
  "success": false,
  "error": ...
}

Недостаток snapshot-подхода — высокая чувствительность к изменениям текста ошибок.


Вспомогательные функции для тестов

Функция успешной проверки

function expectSuccess(result: any) {
  expect(result.success).toBe(true);
}

Использование:

expectSuccess(
  schema.safeParse(validData)
);

Функция проверки ошибки

function expectFailure(result: any) {
  expect(result.success).toBe(false);
}

Тестирование схем API

Валидация тела запроса

const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

Тест:

describe("CreateUserSchema", () => {
  it("принимает корректный payload", () => {
    const result =
      CreateUserSchema.safeParse({
        email: "user@test.com",
        password: "12345678",
      });

    expect(result.success).toBe(true);
  });

  it("отклоняет короткий пароль", () => {
    const result =
      CreateUserSchema.safeParse({
        email: "user@test.com",
        password: "123",
      });

    expect(result.success).toBe(false);
    });
});

Тестирование схем переменных окружения

const EnvSchema = z.object({
  PORT: z.coerce.number(),
  NODE_ENV: z.enum([
    "development",
    "production",
  ]),
});

Тест:

expect(
  EnvSchema.safeParse({
    PORT: "3000",
    NODE_ENV: "production",
  }).success
).toBe(true);

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

Хотя TypeScript проверяет типы на этапе компиляции, тестирование полезно для контроля runtime-поведения.

type User = z.infer<typeof UserSchema>;

Проверка:

const user: User = {
  name: "Alex",
  age: 20,
};

Антипаттерны при тестировании схем

Проверка только success

Плохо:

expect(result.success).toBe(false);

Лучше:

expect(result.error.issues[0].path)
  .toEqual(["email"]);

Слишком крупные схемы в одном тесте

Плохо:

it("валидирует всё", () => {
});

Лучше разбивать:

  • проверка email;
  • проверка пароля;
  • проверка optional-полей;
  • проверка refine;
  • проверка transform.

Отсутствие edge-case тестов

Недостаточно проверять только «обычные» данные.

Особенно важно тестировать:

  • пустые значения;
  • экстремальные числа;
  • неожиданные типы;
  • вложенные ошибки;
  • отсутствующие поля.

Практика организации тестов

Структура файлов

schemas/
  user.schema.ts

tests/
  user.schema.test.ts

Группировка describe

describe("UserSchema", () => {
  describe("name", () => {
  });

  describe("age", () => {
  });

  describe("email", () => {
  });
});

Проверка регрессий

После изменения схемы тесты позволяют быстро обнаружить:

  • изменение сообщений ошибок;
  • изменение обязательности полей;
  • нарушение transform-логики;
  • изменение поведения refine;
  • случайное ослабление ограничений.

Интеграционное тестирование

Схема часто тестируется вместе с HTTP-слоем.

Пример с Express:

app.post("/users", (req, res) => {
  const result =
    CreateUserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      errors: result.error.flatten(),
    });
  }

  res.json(result.data);
});

Тест:

const response = await request(app)
  .post("/users")
  .send({
    email: "wrong",
    password: "123",
  });

expect(response.status).toBe(400);

Property-based тестирование

Для сложных схем применяются генераторы случайных данных.

Пример с fast-check:

import fc from "fast-check";

it("не принимает отрицательный возраст", () => {
  fc.assert(
    fc.property(
      fc.integer({ max: -1 }),
      (age) => {
        const result =
          z.number()
            .positive()
            .safeParse(age);

        return result.success === false;
      }
    )
  );
});

Такой подход помогает находить неожиданные edge-case сценарии.


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

Сложные схемы с множеством refine() могут создавать нагрузку.

Простейший benchmark:

const start = performance.now();

for (let i = 0; i < 10000; i++) {
  schema.safeParse(data);
}

const end = performance.now();

console.log(end - start);

Особенно важно контролировать:

  • deeply nested schemas;
  • большие массивы;
  • асинхронные refine;
  • цепочки transform;
  • сложные union-конструкции.