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

Интеграционное тестирование с использованием class-validator концентрируется на проверке полного контура обработки входных данных: от HTTP-запроса до выполнения бизнес-логики, включая трансформацию DTO и применение правил валидации. Основная цель — подтвердить, что система корректно отклоняет невалидные данные и пропускает корректные на всех уровнях пайплайна.

В типичных архитектурах на базе Express или NestJS валидация выполняется на уровне DTO-классов с декораторами class-validator, а запуск происходит через middleware или pipe (например, ValidationPipe в NestJS). Интеграционные тесты проверяют именно это сочетание компонентов, а не отдельные функции.


Контур валидации и роль class-validator

Библиотека class-validator работает поверх объектов классов, применяя декораторы к свойствам:

  • @IsString()
  • @IsInt()
  • @Length()
  • @IsEmail()
  • @ValidateNested()

Пример DTO:

import { IsString, IsEmail, Length } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @Length(3, 20)
  password: string;
}

На уровне интеграции важно не только наличие этих правил, но и корректное поведение всей цепочки:

  1. Получение HTTP-запроса
  2. Преобразование payload в DTO
  3. Применение class-validator
  4. Генерация ошибки валидации
  5. Формирование HTTP-ответа

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

Интеграционные тесты обычно строятся на полном приложении с поднятым сервером.

В NestJS типичная конфигурация:

import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

let app: INestApplication;

beforeAll(async () => {
  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();

  app = moduleRef.createNestApplication();

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.init();
});

Ключевым элементом является ValidationPipe, который запускает class-validator на входных данных. Без него декораторы не участвуют в обработке запроса.


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

Основная ценность интеграционных тестов проявляется в проверке отклонения некорректных данных.

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

it('отклоняет запрос без email', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      password: 'strongpass123',
    });

  expect(response.status).toBe(400);
  expect(response.body.message).toEqual(
    expect.arrayContaining(['email should not be empty']),
  );
});

При этом важна не только проверка статуса, но и структура ошибки, так как она формируется на основе результатов class-validator.


Проверка типовых ограничений DTO

Интеграционные тесты часто охватывают комбинации декораторов:

it('отклоняет слишком короткий пароль', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'test@mail.com',
      password: '12',
    });

  expect(response.status).toBe(400);
  expect(response.body.message).toEqual(
    expect.arrayContaining([
      'password must be longer than or equal to 3 characters',
    ]),
  );
});

Такие тесты фиксируют не только факт валидации, но и устойчивость бизнес-ограничений.


Проверка whitelist и запрета лишних полей

Одной из ключевых возможностей ValidationPipe является фильтрация лишних данных:

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
  }),
);

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

it('отклоняет запрос с лишними полями', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'test@mail.com',
      password: 'strongpass',
      role: 'admin',
    });

  expect(response.status).toBe(400);
  expect(response.body.message).toContain('property role should not exist');
});

Здесь проверяется согласованность DTO и входного контракта API.


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

class-validator часто используется вместе с class-transformer для сложных объектов:

import { ValidateNested, IsString } from 'class-validator';
import { Type } from 'class-transformer';

class ProfileDto {
  @IsString()
  firstName: string;
}

export class CreateUserDto {
  @ValidateNested()
  @Type(() => ProfileDto)
  profile: ProfileDto;
}

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

it('валидирует вложенные DTO', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'test@mail.com',
      password: 'strongpass',
      profile: {
        firstName: 123,
      },
    });

  expect(response.status).toBe(400);
  expect(response.body.message).toEqual(
    expect.arrayContaining(['firstName must be a string']),
  );
});

Важный аспект: без @Type() вложенная валидация не будет выполнена корректно, что делает такие тесты критичными.


Проверка трансформации данных

При включённом transform: true входные данные приводятся к типам DTO.

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
  }),
);

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

it('преобразует строковые числа в number', async () => {
  const response = await request(app.getHttpServer())
    .post('/orders')
    .send({
      amount: '100',
    });

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

Внутри контроллера amount уже интерпретируется как число, если DTO описан корректно.


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

Кастомные валидаторы через @ValidatorConstraint также должны покрываться интеграционно, поскольку они часто зависят от сервисов или базы данных.

@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint {
  constructor(private userService: UserService) {}

  async validate(email: string) {
    const user = await this.userService.findByEmail(email);
    return !user;
  }
}

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

it('отклоняет дублирующий email', async () => {
  await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'exists@mail.com',
      password: 'strongpass',
    });

  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'exists@mail.com',
      password: 'anotherpass',
    });

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

Такие сценарии фиксируют корректную работу асинхронной валидации и взаимодействие с внешними слоями.


Проверка форматирования ошибок валидации

class-validator возвращает массив ошибок, который часто преобразуется в единый формат ответа API.

Типичная структура:

{
  "statusCode": 400,
  "message": [
    "email must be an email",
    "password must be longer than or equal to 3 characters"
  ],
  "error": "Bad Request"
}

Интеграционные тесты фиксируют стабильность этого контракта:

it('формирует корректный формат ошибок', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'invalid',
      password: '1',
    });

  expect(response.body).toHaveProperty('statusCode', 400);
  expect(Array.isArray(response.body.message)).toBe(true);
});

Связка с базой данных и откатом состояния

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

Пример с очисткой:

afterEach(async () => {
  await userRepository.clear();
});

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


Проверка полного потока создания сущности

Финальный тип интеграционного теста объединяет валидацию и бизнес-логику:

it('создаёт пользователя при валидных данных', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: 'new@mail.com',
      password: 'strongpass123',
    });

  expect(response.status).toBe(201);
  expect(response.body.email).toBe('new@mail.com');
});

Здесь проверяется, что валидатор пропускает данные дальше по цепочке, а система корректно выполняет создание сущности.


Граничные случаи и устойчивость валидации

Интеграционные тесты часто включают проверку нестандартных входов:

  • null вместо строки
  • пустые объекты
  • массивы вместо скаляров
  • неожиданные типы (number вместо string)

Пример:

it('отклоняет null в обязательном поле', async () => {
  const response = await request(app.getHttpServer())
    .post('/users')
    .send({
      email: null,
      password: 'strongpass',
    });

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

Такие сценарии выявляют расхождения между ожиданиями DTO и реальными HTTP-пейлоадами.


Согласованность DTO и контрактов API

Интеграционные тесты с class-validator фактически закрепляют контракт между клиентом и сервером. Любое изменение декораторов влияет на:

  • формат ошибок
  • допустимые значения
  • структуру запроса
  • поведение endpoint’ов

Поэтому тесты выступают точкой фиксации этих ограничений и предотвращают регрессии при изменении DTO или глобальных настроек пайпов.