Интеграционные тесты с валидацией

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

Типичные проблемы, выявляемые интеграционными тестами:

  • несовпадение DTO и схемы Zod;
  • неправильная сериализация данных;
  • потеря обязательных полей;
  • некорректные преобразования типов;
  • расхождение между frontend и backend;
  • нарушение контрактов API;
  • неправильная обработка ошибок валидации.

Zod в интеграционных тестах выступает одновременно:

  • механизмом валидации входящих данных;
  • валидатором ответов API;
  • источником единых контрактов;
  • инструментом контроля регрессий.

Проверка HTTP API с использованием Zod

Базовая структура тестируемого обработчика

import express fr om "express";
import { z } fr om "zod";

const app = express();

app.use(express.json());

const CreateUserSchema = z.object({
  email: z.string().email(),
  age: z.number().int().positive(),
});

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

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

  res.status(201).json({
    id: 1,
    ...result.data,
  });
});

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

Установка зависимостей

npm install --save-dev jest supertest

Первый интеграционный тест

import request fr om "supertest";
import { app } from "./app";

describe("POST /users", () => {
  test("создание пользователя с валидными данными", async () => {
    const response = await request(app)
      .post("/users")
      .send({
        email: "admin@test.com",
        age: 25,
      });

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

    expect(response.body).toMatchObject({
      email: "admin@test.com",
      age: 25,
    });
  });
});

Тест проверяет:

  • корректность прохождения схемы Zod;
  • правильность HTTP-статуса;
  • структуру ответа;
  • совместимость сериализации.

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

Тест невалидного email

test("ошибка при некорректном email", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      email: "invalid-email",
      age: 20,
    });

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

  expect(response.body.error.fieldErrors.email).toBeDefined();
});

Проверка отсутствующего поля

test("ошибка при отсутствии age", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      email: "user@test.com",
    });

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

  expect(response.body.error.fieldErrors.age).toBeDefined();
});

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

Схема заказа

const OrderSchema = z.object({
  customer: z.object({
    name: z.string(),
    email: z.string().email(),
  }),

  items: z.array(
    z.object({
      productId: z.number(),
      quantity: z.number().positive(),
    })
  ),

  shipping: z.object({
    city: z.string(),
    zip: z.string(),
  }),
});

Интеграционный тест заказа

test("создание заказа", async () => {
  const response = await request(app)
    .post("/orders")
    .send({
      customer: {
        name: "Alex",
        email: "alex@test.com",
      },

      items: [
        {
          productId: 10,
          quantity: 2,
        },
      ],

      shipping: {
        city: "Berlin",
        zip: "10115",
      },
    });

  expect(response.status).toBe(201);
});

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

Интеграционные тесты должны покрывать не только успешные кейсы, но и все возможные нарушения схемы.

Неверный тип данных

test("ошибка при строковом quantity", async () => {
  const response = await request(app)
    .post("/orders")
    .send({
      customer: {
        name: "Alex",
        email: "alex@test.com",
      },

      items: [
        {
          productId: 10,
          quantity: "2",
        },
      ],

      shipping: {
        city: "Berlin",
        zip: "10115",
      },
    });

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

Пустой массив

const OrderSchema = z.object({
  items: z
    .array(ItemSchema)
    .min(1),
});

Тест:

test("ошибка при пустом массиве товаров", async () => {
  const response = await request(app)
    .post("/orders")
    .send({
      items: [],
    });

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

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

Схема с преобразованием

const UserSchema = z.object({
  age: z.string().transform(Number),
});

Проверка результата transform

app.post("/users", (req, res) => {
  const parsed = UserSchema.parse(req.body);

  res.json(parsed);
});

Тест:

test("transform преобразует строку в число", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      age: "42",
    });

  expect(response.body.age).toBe(42);
  expect(typeof response.body.age).toBe("number");
});

Интеграционный тест здесь критически важен, поскольку проверяется не только схема, но и итоговое поведение HTTP API.


Проверка preprocess

Схема preprocess

const Schema = z.object({
  age: z.preprocess(
    value => Number(value),
    z.number()
  ),
});

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

test("preprocess приводит строку к числу", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      age: "55",
    });

  expect(response.status).toBe(200);
  expect(response.body.age).toBe(55);
});

Проверка refine и superRefine

refine

const PasswordSchema = z.object({
  password: z.string().min(8),
}).refine(
  data => /[A-Z]/.test(data.password),
  {
    message: "Password must contain uppercase letter",
    path: ["password"],
  }
);

Интеграционный тест refine

test("ошибка при отсутствии заглавной буквы", async () => {
  const response = await request(app)
    .post("/register")
    .send({
      password: "weakpass",
    });

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

  expect(
    response.body.error.fieldErrors.password
  ).toContain(
    "Password must contain uppercase letter"
  );
});

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 do not match",
      path: ["confirmPassword"],
    });
  }
});

Интеграционный тест superRefine

test("ошибка несовпадения паролей", async () => {
  const response = await request(app)
    .post("/register")
    .send({
      password: "secret123",
      confirmPassword: "secret456",
    });

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

  expect(
    response.body.error.fieldErrors.confirmPassword
  ).toContain("Passwords do not match");
});

Валидация query-параметров

Схема query

const QuerySchema = z.object({
  page: z.coerce.number().min(1),
  lim it: z.coerce.number().max(100),
});

Middleware

const validateQuery = (schema) => {
  return (req, res, next) => {
    const result = schema.safeParse(req.query);

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

    req.query = result.data;

    next();
  };
};

Интеграционный тест query

test("валидные query-параметры", async () => {
  const response = await request(app)
    .get("/users?page=1&limit=20");

  expect(response.status).toBe(200);
});

Невалидный query

test("ошибка при отрицательной странице", async () => {
  const response = await request(app)
    .get("/users?page=-1&limit=20");

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

Валидация route params

Схема параметров маршрута

const ParamsSchema = z.object({
  id: z.coerce.number().int().positive(),
});

Middleware параметров

const validateParams = (schema) => {
  return (req, res, next) => {
    const result = schema.safeParse(req.params);

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

    req.params = result.data;

    next();
  };
};

Интеграционный тест route params

test("валидный id", async () => {
  const response = await request(app)
    .get("/users/10");

  expect(response.status).toBe(200);
});

Невалидный id

test("ошибка при нечисловом id", async () => {
  const response = await request(app)
    .get("/users/abc");

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

Проверка response validation

Валидация ответов — важный компонент интеграционных тестов. Она предотвращает случайное изменение контрактов API.


Схема ответа

const UserResponseSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  age: z.number(),
});

Проверка ответа

test("ответ соответствует контракту", async () => {
  const response = await request(app)
    .get("/users/1");

  const parsed = UserResponseSchema.parse(
    response.body
  );

  expect(parsed.id).toBeDefined();
});

Контрактное тестирование API

Zod позволяет строить единые схемы для backend и frontend.

Общая схема

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

Backend

res.json(UserSchema.parse(user));

Frontend

const user = UserSchema.parse(await response.json());

Интеграционный тест контракта

test("backend возвращает совместимый контракт", async () => {
  const response = await request(app)
    .get("/users/1");

  expect(() => {
    UserSchema.parse(response.body);
  }).not.toThrow();
});

Интеграционные тесты middleware валидации

Универсальный middleware

const validate = (schema) => {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);

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

    req.body = result.data;

    next();
  };
};

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

app.post(
  "/users",
  validate(UserSchema),
  controller
);

Интеграционный тест middleware

test("middleware блокирует невалидный запрос", async () => {
  const response = await request(app)
    .post("/users")
    .send({});

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

Проверка асинхронной валидации

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

const UserSchema = z.object({
  email: z.string().email(),
}).refine(
  async data => {
    const exists = await db.userExists(data.email);

    return !exists;
  },
  {
    message: "User already exists",
  }
);

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

const parsed = await UserSchema.parseAsync(req.body);

Интеграционный тест async validation

test("ошибка при существующем email", async () => {
  await createUser({
    email: "admin@test.com",
  });

  const response = await request(app)
    .post("/users")
    .send({
      email: "admin@test.com",
    });

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

Интеграционные тесты с базой данных

Проверка сохранения валидных данных

test("валидный пользователь сохраняется в БД", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      email: "user@test.com",
      age: 20,
    });

  const user = await prisma.user.findUnique({
    wh ere: {
      email: "user@test.com",
    },
  });

  expect(user).not.toBeNull();
});

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

test("невалидный пользователь не сохраняется", async () => {
  await request(app)
    .post("/users")
    .send({
      email: "wrong-email",
      age: 20,
    });

  const user = await prisma.user.findUnique({
    wh ere: {
      email: "wrong-email",
    },
  });

  expect(user).toBeNull();
});

Snapshot-тестирование ошибок Zod

Снимок ошибки

test("snapshot ошибки валидации", async () => {
  const response = await request(app)
    .post("/users")
    .send({});

  expect(response.body).toMatchSnapshot();
});

Такой подход помогает обнаруживать:

  • изменения структуры ошибок;
  • изменения сообщений;
  • нарушение формата API.

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

При использовании zod-openapi интеграционные тесты помогают контролировать актуальность документации.

Проверка схемы OpenAPI

test("OpenAPI содержит UserSchema", () => {
  expect(document.components.schemas.User)
    .toBeDefined();
});

Проверка совместимости frontend и backend

Проблема рассинхронизации

Частая ошибка:

  • backend возвращает snake_case;
  • frontend ожидает camelCase.

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

test("формат ответа соответствует frontend", async () => {
  const response = await request(app)
    .get("/users/1");

  expect(response.body.createdAt)
    .toBeDefined();
});

Проверка strict-схем

strict object

const Schema = z.object({
  email: z.string(),
}).strict();

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

test("лишние поля запрещены", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      email: "user@test.com",
      role: "admin",
    });

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

Проверка passthrough и strip

passthrough

const Schema = z.object({
  email: z.string(),
}).passthrough();

strip

const Schema = z.object({
  email: z.string(),
}).strip();

Интеграционный тест strip

test("лишние поля удаляются", async () => {
  const response = await request(app)
    .post("/users")
    .send({
      email: "user@test.com",
      role: "admin",
    });

  expect(response.body.role)
    .toBeUndefined();
});

Проверка discriminated unions

Схема

const PaymentSchema = z.discriminatedUnion(
  "type",
  [
    z.object({
      type: z.literal("card"),
      cardNumber: z.string(),
    }),

    z.object({
      type: z.literal("paypal"),
      email: z.string().email(),
    }),
  ]
);

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

test("paypal-платеж проходит валидацию", async () => {
  const response = await request(app)
    .post("/payment")
    .send({
      type: "paypal",
      email: "pay@test.com",
    });

  expect(response.status).toBe(200);
});

Тестирование файлов и multipart/form-data

Схема метаданных

const UploadSchema = z.object({
  title: z.string(),
});

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

test("загрузка файла", async () => {
  const response = await request(app)
    .post("/upload")
    .field("title", "Document")
    .attach("file", "__tests__/file.pdf");

  expect(response.status).toBe(200);
});

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

Иногда API возвращает:

  • Date;
  • BigInt;
  • кастомные классы;
  • Prisma Decimal.

Интеграционные тесты помогают выявлять несовместимости.


Проверка даты

const Schema = z.object({
  createdAt: z.string().datetime(),
});

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

test("дата сериализуется корректно", async () => {
  const response = await request(app)
    .get("/posts/1");

  expect(() => {
    Schema.parse(response.body);
  }).not.toThrow();
});

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

Интеграционные тесты особенно ценны после:

  • изменения DTO;
  • обновления Prisma;
  • миграций БД;
  • изменения middleware;
  • обновления frontend-контрактов;
  • разделения monolith/microservices;
  • перехода на TypeScript strict mode.

Стратегия организации тестов

Рекомендуемая структура

src/
tests/
  integration/
    users/
      create-user.test.ts
      update-user.test.ts
    orders/
      create-order.test.ts

Принцип одного сценария

Каждый тест должен проверять один конкретный контракт.

Плохо:

test("user flow", async () => {
  // десятки проверок
});

Хорошо:

test("ошибка при пустом email", async () => {});
test("ошибка при неверном age", async () => {});
test("успешное создание пользователя", async () => {});

Изоляция тестовых данных

Рекомендуемые практики:

  • отдельная тестовая БД;
  • транзакции;
  • rollback после теста;
  • seed-данные;
  • уникальные email и ID.

Частые ошибки

Использование parse вместо safeParse

Проблема:

schema.parse(data);

При ошибке тест падает исключением до проверки результата.

Для middleware обычно безопаснее:

schema.safeParse(data);

Отсутствие проверки response body

Плохо:

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

Правильно:

expect(() => {
  ResponseSchema.parse(response.body);
}).not.toThrow();

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

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

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

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

Один из наиболее надёжных подходов:

  1. Сначала создаётся схема Zod.
  2. Затем строится API.
  3. После этого пишутся интеграционные тесты.
  4. Схема используется для проверки request и response.
  5. Frontend использует те же схемы.

Такой подход обеспечивает:

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