Моки и стабы для Zod схем

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

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

В контексте Zod под моками и стабами обычно подразумеваются:

Тип Назначение
Mock Генерация фальшивых данных
Stub Упрощённая замена реальной схемы
Fixture Заранее подготовленный набор данных
Fake validator Искусственная схема с контролируемым поведением

Генерация моков на основе Zod-схем

Базовая схема

import { z } from "zod"

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(18),
})

Для тестов понадобится множество валидных объектов:

{
  id: 1,
  name: "Alex",
  email: "alex@test.com",
  age: 25
}

Ручное создание быстро становится проблемой:

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

Использование @anatine/zod-mock

Наиболее популярная библиотека генерации моков — @anatine/zod-mock.

Установка

npm install @anatine/zod-mock faker

Простая генерация объекта

import { z } from "zod"
import { generateMock } from "@anatine/zod-mock"

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
})

const mock = generateMock(UserSchema)

console.log(mock)

Пример результата:

{
  id: 42,
  name: "John Doe",
  email: "test@example.com"
}

Генерация сложных структур

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

const AddressSchema = z.object({
  city: z.string(),
  country: z.string(),
})

const UserSchema = z.object({
  id: z.number(),
  profile: z.object({
    firstName: z.string(),
    lastName: z.string(),
  }),
  address: AddressSchema,
})
const mock = generateMock(UserSchema)

Результат:

{
  id: 10,
  profile: {
    firstName: "Jane",
    lastName: "Smith"
  },
  address: {
    city: "Paris",
    country: "France"
  }
}

Массивы

const PostSchema = z.object({
  title: z.string(),
  tags: z.array(z.string()),
})
const mock = generateMock(PostSchema)

Результат:

{
  title: "Example",
  tags: ["tag1", "tag2"]
}

Настройка генерации данных

Кастомные генераторы

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
})
const mock = generateMock(UserSchema, {
  stringMap: {
    name: () => "ADMIN_USER",
  },
})

Результат:

{
  id: 55,
  name: "ADMIN_USER"
}

Моки для enum

Схемы перечислений

const RoleSchema = z.enum([
  "admin",
  "moderator",
  "user",
])
const mock = generateMock(RoleSchema)

Результат:

"admin"

Enum внутри объектов

const UserSchema = z.object({
  role: RoleSchema,
})
const mock = generateMock(UserSchema)

Моки для union-схем

Простое объединение

const ResponseSchema = z.union([
  z.object({
    status: z.literal("success"),
    data: z.string(),
  }),
  z.object({
    status: z.literal("error"),
    message: z.string(),
  }),
])
const mock = generateMock(ResponseSchema)

Библиотека выберет один из вариантов union.


Моки для discriminatedUnion

Дискриминированные схемы

const ShapeSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("circle"),
    radius: z.number(),
  }),
  z.object({
    type: z.literal("square"),
    size: z.number(),
  }),
])
const mock = generateMock(ShapeSchema)

Работа с optional и nullable

Optional

const UserSchema = z.object({
  name: z.string(),
  nickname: z.string().optional(),
})

Мок может содержать:

{
  name: "Alex"
}

или:

{
  name: "Alex",
  nickname: "Lex"
}

Nullable

const Schema = z.object({
  description: z.string().nullable(),
})

Возможные результаты:

{
  description: null
}

или:

{
  description: "Example"
}

Использование faker совместно с Zod

Генерация реалистичных данных

import { faker } from "@faker-js/faker"

const user = {
  id: faker.number.int(),
  name: faker.person.fullName(),
  email: faker.internet.email(),
}

После генерации данные можно проверить через Zod:

UserSchema.parse(user)

Комбинирование faker и схем

Фабрика моков

const createUserMock = () => {
  const user = {
    id: faker.number.int(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    age: faker.number.int({
      min: 18,
      max: 70,
    }),
  }

  return UserSchema.parse(user)
}

Преимущества:

  • гарантированная валидность;
  • реалистичные данные;
  • единая точка генерации;
  • повторное использование.

Стабы схем

Подмена сложной схемы

Иногда настоящая схема слишком сложная:

const RealSchema = z.object({
  database: z.object({
    host: z.string(),
    port: z.number(),
    credentials: z.object({
      login: z.string(),
      password: z.string(),
    }),
  }),
  services: z.array(
    z.object({
      name: z.string(),
      url: z.string().url(),
    }),
  ),
})

Для unit-теста может быть достаточно упрощённой версии:

const StubSchema = z.object({
  database: z.any(),
  services: z.any(),
})

Стабирование parse

Подмена метода parse

import * as schemaModule from "./schema"

jest.spyOn(schemaModule.UserSchema, "parse")
  .mockImplementation((data) => data)

Теперь parse не выполняет валидацию.


Эмуляция ошибок

jest.spyOn(schemaModule.UserSchema, "parse")
  .mockImplementation(() => {
    throw new Error("Validation failed")
  })

Моки safeParse

Успешная валидация

jest.spyOn(UserSchema, "safeParse")
  .mockReturnValue({
    success: true,
    data: {
      id: 1,
      name: "Test",
    },
  })

Ошибка валидации

jest.spyOn(UserSchema, "safeParse")
  .mockReturnValue({
    success: false,
    error: new z.ZodError([]),
  })

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

Схема с refine

const PasswordSchema = z.string().refine(
  (value) => value.length >= 8,
  {
    message: "Password too short",
  },
)

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

const result = PasswordSchema.safeParse("123")

expect(result.success).toBe(false)

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

Комплексная проверка

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

Тест

const result = RegisterSchema.safeParse({
  password: "12345678",
  confirmPassword: "11111111",
})

expect(result.success).toBe(false)

Создание фабрик данных

Паттерн factory

const createUser = (
  overrides?: Partial<z.infer<typeof UserSchema>>,
) => {
  return {
    id: 1,
    name: "John",
    email: "john@test.com",
    age: 30,
    ...overrides,
  }
}

Проверка фабрики схемой

const createValidatedUser = (
  overrides?: Partial<z.infer<typeof UserSchema>>,
) => {
  return UserSchema.parse({
    id: 1,
    name: "John",
    email: "john@test.com",
    age: 30,
    ...overrides,
  })
}

Частичные моки

partial()

const PartialUserSchema = UserSchema.partial()

Теперь все поля необязательны.


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

const patchData = {
  email: "new@test.com",
}

PartialUserSchema.parse(patchData)

deepPartial

Для вложенных структур:

const SettingsSchema = z.object({
  ui: z.object({
    theme: z.string(),
    language: z.string(),
  }),
})
const PartialSchema = SettingsSchema.deepPartial()

Теперь необязательными становятся все вложенные поля.


Моки API-ответов

Схема ответа

const ApiResponseSchema = z.object({
  success: z.boolean(),
  data: z.array(
    z.object({
      id: z.number(),
      title: z.string(),
    }),
  ),
})

Мок ответа

const responseMock = {
  success: true,
  data: [
    {
      id: 1,
      title: "Post",
    },
  ],
}

ApiResponseSchema.parse(responseMock)

Тестирование негативных сценариев

Неверный тип

const invalidUser = {
  id: "wrong",
}
const result = UserSchema.safeParse(invalidUser)

expect(result.success).toBe(false)

Отсутствующее поле

const invalidUser = {
  id: 1,
}

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

Анализ issues

const result = UserSchema.safeParse({
  id: "abc",
})
if (!result.success) {
  console.log(result.error.issues)
}

Пример:

[
  {
    code: "invalid_type",
    expected: "number",
    received: "string",
    path: ["id"],
    message: "Expected number"
  }
]

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

Проверка ошибок через snapshot

expect(
  UserSchema.safeParse({
    id: "wrong",
  }),
).toMatchSnapshot()

Изоляция схем в unit-тестах

Проблема интеграционной зависимости

Модуль:

export const createUser = (input: unknown) => {
  const data = UserSchema.parse(input)

  return database.save(data)
}

Unit-тест функции не обязан проверять корректность схемы.


Стабирование схемы

jest.spyOn(UserSchema, "parse")
  .mockImplementation((data) => ({
    id: 1,
    name: "Test",
    email: "test@test.com",
    age: 30,
  }))

Теперь тестируется только логика createUser.


Генерация edge-case данных

Экстремальные значения

const EdgeCaseSchema = z.object({
  min: z.number().min(0),
  max: z.number().max(100),
})

Пограничные значения:

[
  { min: 0, max: 100 },
  { min: 1, max: 99 },
]

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

Интеграция с fast-check

npm install fast-check

Генерация случайных данных

import fc from "fast-check"
fc.assert(
  fc.property(
    fc.string(),
    (value) => {
      const result = z.string().safeParse(value)

      return result.success
    },
  ),
)

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

Автоматическая валидация

Каждый мок желательно пропускать через parse:

const mock = createUser()

UserSchema.parse(mock)

Это предотвращает:

  • устаревшие фикстуры;
  • расхождение между тестами и схемой;
  • скрытые ошибки типов.

Типизация моков через infer

Автоматическое получение типа

type User = z.infer<typeof UserSchema>

Типизированная фабрика

const createUser = (
  overrides?: Partial<User>,
): User => ({
  id: 1,
  name: "Alex",
  email: "alex@test.com",
  age: 25,
  ...overrides,
})

Переиспользуемые наборы моков

Каталог фикстур

export const users = {
  admin: {
    id: 1,
    role: "admin",
  },

  guest: {
    id: 2,
    role: "guest",
  },
}

Версионирование моков

Изменение схемы:

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
})

требует обновления:

  • фабрик;
  • snapshot-тестов;
  • фикстур;
  • стабов;
  • генераторов.

Автоматическая проверка через parse помогает выявлять такие изменения мгновенно.


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

Хорошая структура проекта

src/
tests/
  fixtures/
  factories/
  mocks/
  stubs/

Разделение ответственности

Категория Назначение
fixtures Статические данные
factories Генерация объектов
mocks Имитация поведения
stubs Подмена зависимостей

Антипаттерны

Невалидные фикстуры

Плохо:

const user = {
  id: "1",
}

если схема ожидает number.


Дублирование схемы в тестах

Плохо:

expect(user).toEqual({
  id: expect.any(Number),
  name: expect.any(String),
})

Лучше:

UserSchema.parse(user)

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

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

Необходимо тестировать:

  • неверные типы;
  • отсутствующие поля;
  • пустые строки;
  • null;
  • undefined;
  • некорректные enum;
  • нарушенные refine-условия.

Подход schema-first в тестировании

При schema-first подходе Zod становится центральным источником правды:

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

Это устраняет рассинхронизацию между:

  • TypeScript-типами;
  • тестовыми данными;
  • runtime-валидацией;
  • API-контрактами.