Unit-тестирование классов валидации

Валидация данных — один из наиболее критичных слоёв backend-приложения. Ошибки в правилах проверки приводят к:

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

При использовании библиотеки class-validator unit-тестирование становится обязательным элементом поддержки качества, особенно в крупных проектах с большим количеством DTO, кастомных декораторов и сложных условий валидации.

Главная задача unit-тестов — проверить, что конкретный класс валидации:

  • принимает корректные данные;
  • отклоняет некорректные;
  • возвращает ожидаемые сообщения об ошибках;
  • корректно работает с вложенными объектами;
  • соблюдает условия optional/predicate/group;
  • правильно обрабатывает асинхронные проверки.

Установка зависимостей для тестирования

Наиболее распространённая связка:

npm install --save-dev jest ts-jest @types/jest

Если используется TypeScript:

npm install reflect-metadata

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

src/
 ├── dto/
 ├── validators/
 └── services/

test/
 ├── dto/
 └── validators/

Настройка Jest

Пример конфигурации:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

В tsconfig.json необходимо включить:

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

Базовая проверка DTO

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

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

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

  @Length(6, 20)
  password: string;
}

Тест:

import 'reflect-metadata';
import { validate } from 'class-validator';
import { CreateUserDto } from '../. ./src/dto/create-user.dto';

describe('CreateUserDto', () => {
  it('должен успешно проходить валидацию', async () => {
    const dto = new CreateUserDto();

    dto.email = 'admin@test.com';
    dto.password = '12345678';

    const errors = await validate(dto);

    expect(errors.length).toBe(0);
  });

  it('должен возвращать ошибки', async () => {
    const dto = new CreateUserDto();

    dto.email = 'wrong-email';
    dto.password = '123';

    const errors = await validate(dto);

    expect(errors.length).toBe(2);
  });
});

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

Простая проверка количества ошибок недостаточна. Unit-тесты должны подтверждать содержимое ошибок.

Пример:

it('должен возвращать ошибку email', async () => {
  const dto = new CreateUserDto();

  dto.email = 'abc';
  dto.password = '12345678';

  const errors = await validate(dto);

  expect(errors[0].constraints).toHaveProperty('isEmail');
});

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

expect(errors[0].constraints?.isEmail)
  .toBe('email must be an email');

Тестирование кастомных сообщений

DTO:

import { IsEmail } from 'class-validator';

export class UserDto {
  @IsEmail({}, {
    message: 'Некорректный email'
  })
  email: string;
}

Тест:

it('должен использовать кастомное сообщение', async () => {
  const dto = new UserDto();

  dto.email = 'wrong';

  const errors = await validate(dto);

  expect(errors[0].constraints?.isEmail)
    .toBe('Некорректный email');
});

Проверка нескольких ограничений

DTO:

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

export class ProductDto {
  @IsString()
  @Length(3, 10)
  title: string;
}

Тест:

it('должен возвращать несколько ограничений', async () => {
  const dto = new ProductDto();

  dto.title = 5 as any;

  const errors = await validate(dto);

  expect(errors[0].constraints).toHaveProperty('isString');
  expect(errors[0].constraints).toHaveProperty('isLength');
});

Тестирование вложенных объектов

DTO:

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

import { Type } from 'class-transformer';

class AddressDto {
  @IsString()
  city: string;
}

export class UserDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

Тест:

it('должен валидировать вложенный объект', async () => {
  const dto = new UserDto();

  dto.address = {
    city: 123
  } as any;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);

  const nestedErrors = errors[0].children;

  expect(nestedErrors?.length).toBeGreaterThan(0);
});

Проверка массивов объектов

DTO:

class TagDto {
  @IsString()
  name: string;
}

class PostDto {
  @ValidateNested({ each: true })
  @Type(() => TagDto)
  tags: TagDto[];
}

Тест:

it('должен валидировать массив объектов', async () => {
  const dto = new PostDto();

  dto.tags = [
    { name: 'typescript' },
    { name: 123 as any }
  ];

  const errors = await validate(dto);

  expect(errors.length).toBe(1);

  const children = errors[0].children;

  expect(children?.length).toBeGreaterThan(0);
});

Проверка @IsOptional

DTO:

class UserDto {
  @IsOptional()
  @IsString()
  middleName?: string;
}

Тест отсутствующего поля:

it('не должен выдавать ошибку при отсутствии поля', async () => {
  const dto = new UserDto();

  const errors = await validate(dto);

  expect(errors.length).toBe(0);
});

Тест неправильного значения:

it('должен валидировать optional поле', async () => {
  const dto = new UserDto();

  dto.middleName = 123 as any;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

Тестирование @ValidateIf

DTO:

class PaymentDto {
  @IsString()
  type: string;

  @ValidateIf(o => o.type === 'card')
  @IsString()
  cardNumber: string;
}

Тест:

it('должен валидировать поле по условию', async () => {
  const dto = new PaymentDto();

  dto.type = 'card';

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

Проверка отключения условия:

it('не должен валидировать поле', async () => {
  const dto = new PaymentDto();

  dto.type = 'cash';

  const errors = await validate(dto);

  expect(errors.length).toBe(0);
});

Тестирование групп валидации

DTO:

class UserDto {
  @IsString({
    groups: ['create']
  })
  password: string;
}

Тест:

it('должен использовать группу create', async () => {
  const dto = new UserDto();

  const errors = await validate(dto, {
    groups: ['create']
  });

  expect(errors.length).toBe(1);
});

Проверка отсутствия группы:

it('не должен валидировать без группы', async () => {
  const dto = new UserDto();

  const errors = await validate(dto);

  expect(errors.length).toBe(0);
});

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

Создание валидатора:

import {
  ValidatorConstraint,
  ValidatorConstraintInterface
} from 'class-validator';

@ValidatorConstraint({ name: 'isEven' })
export class IsEvenConstraint
  implements ValidatorConstraintInterface {

  validate(value: number): boolean {
    return value % 2 === 0;
  }

  defaultMessage(): string {
    return 'Число должно быть чётным';
  }
}

DTO:

import { Validate } from 'class-validator';

class NumberDto {
  @Validate(IsEvenConstraint)
  value: number;
}

Тест:

it('должен валидировать кастомный constraint', async () => {
  const dto = new NumberDto();

  dto.value = 3;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);

  expect(errors[0].constraints)
    .toHaveProperty('isEven');
});

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

Асинхронный constraint:

@ValidatorConstraint({ async: true })
export class UserExistsConstraint
  implements ValidatorConstraintInterface {

  async validate(id: number): Promise<boolean> {
    return id === 1;
  }
}

DTO:

class UserDto {
  @Validate(UserExistsConstraint)
  userId: number;
}

Тест:

it('должен поддерживать async validation', async () => {
  const dto = new UserDto();

  dto.userId = 999;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

Мокирование зависимостей в кастомных валидаторах

Реальные приложения часто используют DI-контейнер.

Пример:

@ValidatorConstraint({ async: true })
export class EmailExistsConstraint
  implements ValidatorConstraintInterface {

  constructor(
    private readonly usersService: UsersService
  ) {}

  async validate(email: string) {
    const user = await this.usersService.findByEmail(email);

    return !user;
  }
}

Unit-тест:

describe('EmailExistsConstraint', () => {
  it('должен возвращать false если пользователь существует', async () => {
    const usersService = {
      findByEmail: jest.fn()
        .mockResolvedValue({
          id: 1
        })
    };

    const validator =
      new EmailExistsConstraint(
        usersService as any
      );

    const result =
      await validator.validate('admin@test.com');

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

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

Нельзя допускать:

  • обращений к реальной БД;
  • сетевых запросов;
  • использования Redis;
  • доступа к файловой системе;
  • настоящих HTTP-вызовов.

Все зависимости должны мокироваться.

Неправильный подход:

const connection = await database.connect();

Правильный подход:

const service = {
  findUser: jest.fn()
};

Проверка ValidationError

Структура ошибки:

[
  {
    property: 'email',
    value: 'abc',
    constraints: {
      isEmail: 'email must be an email'
    }
  }
]

Тест:

it('должен содержать property', async () => {
  const dto = new UserDto();

  dto.email = 'abc';

  const errors = await validate(dto);

  expect(errors[0].property)
    .toBe('email');
});

Проверка whitelist

DTO:

class UserDto {
  @IsString()
  name: string;
}

Тест:

it('должен удалять лишние поля', async () => {
  const dto = new UserDto() as any;

  dto.name = 'Alex';
  dto.role = 'admin';

  await validate(dto, {
    whitelist: true
  });

  expect(dto.role).toBeUndefined();
});

Проверка forbidNonWhitelisted

it('должен запрещать лишние поля', async () => {
  const dto = new UserDto() as any;

  dto.name = 'Alex';
  dto.role = 'admin';

  const errors = await validate(dto, {
    whitelist: true,
    forbidNonWhitelisted: true
  });

  expect(errors.length).toBe(1);
});

Проверка skipMissingProperties

class UpdateUserDto {
  @IsString()
  name: string;
}

Тест:

it('должен пропускать отсутствующие поля', async () => {
  const dto = new UpdateUserDto();

  const errors = await validate(dto, {
    skipMissingProperties: true
  });

  expect(errors.length).toBe(0);
});

Parameterized tests

Jest поддерживает параметризованные тесты.

Пример:

describe('email validation', () => {
  it.each([
    ['admin@test.com', true],
    ['user@gmail.com', true],
    ['wrong-email', false],
    ['abc', false],
  ])(
    'email %s',
    async (email, expected) => {
      const dto = new UserDto();

      dto.email = email;

      const errors = await validate(dto);

      expect(errors.length === 0)
        .toBe(expected);
    }
  );
});

Такой подход существенно сокращает дублирование кода.


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

Критически важно тестировать:

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

Пример:

it('должен отклонять null', async () => {
  const dto = new UserDto();

  dto.email = null as any;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

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

Иногда удобно использовать snapshot.

it('должен совпадать snapshot', async () => {
  const dto = new UserDto();

  dto.email = 'wrong';

  const errors = await validate(dto);

  expect(errors).toMatchSnapshot();
});

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

  • быстрое обнаружение изменений структуры ошибок.

Недостаток:

  • snapshots могут становиться трудно поддерживаемыми.

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

Связка class-transformer и class-validator используется практически всегда.

DTO:

class UserDto {
  @IsNumber()
  age: number;
}

Тест:

import { plainToInstance }
  from 'class-transformer';

it('должен валидировать transformed object', async () => {
  const plain = {
    age: '20'
  };

  const dto =
    plainToInstance(UserDto, plain);

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

С implicit conversion:

const dto = plainToInstance(
  UserDto,
  plain,
  {
    enableImplicitConversion: true
  }
);

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

Сложные кастомные проверки могут создавать узкие места.

Пример:

it('валидация должна работать быстро', async () => {
  const dto = new UserDto();

  dto.email = 'admin@test.com';

  const start = Date.now();

  await validate(dto);

  const duration = Date.now() - start;

  expect(duration).toBeLessThan(50);
});

Подобные тесты полезны для:

  • regex-heavy validation;
  • deeply nested validation;
  • async validators;
  • schema migration.

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

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

test/
 ├── dto/
 │    ├── user.dto.spec.ts
 │    └── post.dto.spec.ts
 │
 └── validators/
      ├── email-exists.spec.ts
      └── is-even.spec.ts

Принципы хороших unit-тестов

Изолированность

Один тест — одна проверка.

Плохо:

it('test', async () => {
  // 20 expect
});

Хорошо:

it('должен валидировать email');
it('должен валидировать password');

Детерминированность

Тест всегда должен давать одинаковый результат.

Недопустимо:

  • случайные данные без фиксированного seed;
  • зависимость от времени;
  • реальные API;
  • настоящая БД.

Минимальность

Тест должен содержать только необходимые данные.

Плохо:

dto.name = 'Alex';
dto.email = 'a@test.com';
dto.phone = '123';
dto.city = 'Paris';

Если тестируется только email — остальные поля не нужны.


Читаемость

Тест должен объяснять поведение системы.

Хорошее название:

it('должен отклонять некорректный email');

Плохое:

it('test validation');

Частые ошибки при тестировании class-validator

Проверка только errors.length

Недостаточно:

expect(errors.length).toBe(1);

Нужно дополнительно проверять:

  • property;
  • constraints;
  • children;
  • message.

Отсутствие тестов на позитивные сценарии

Нужно проверять не только ошибки.

Обязательны тесты:

  • valid input;
  • invalid input.

Игнорирование nested validation

Вложенные DTO — источник большого количества ошибок.

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

  • массивы объектов;
  • optional nested DTO;
  • circular structures;
  • deeply nested entities.

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

Unit-тесты не должны становиться integration-тестами.


Вспомогательные helper-функции

Для сокращения дублирования удобно создавать utility helpers.

Пример:

export async function getValidationErrors(
  dto: object
) {
  return validate(dto);
}

Helper для получения constraint:

export function getConstraint(
  errors,
  property
) {
  return errors.find(
    e => e.property === property
  );
}

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

const error =
  getConstraint(errors, 'email');

expect(error.constraints)
  .toHaveProperty('isEmail');

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

DTO:

class BaseDto {
  @IsString()
  name: string;
}

class UserDto extends BaseDto {
  @IsEmail()
  email: string;
}

Тест:

it('должен наследовать validation decorators', async () => {
  const dto = new UserDto();

  dto.name = 123 as any;
  dto.email = 'wrong';

  const errors = await validate(dto);

  expect(errors.length).toBe(2);
});

Проверка each: true

DTO:

class TagsDto {
  @IsString({ each: true })
  tags: string[];
}

Тест:

it('должен валидировать каждый элемент массива', async () => {
  const dto = new TagsDto();

  dto.tags = ['typescript', 123 as any];

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

Проверка enum

DTO:

enum UserRole {
  ADMIN = 'admin',
  USER = 'user'
}

class UserDto {
  @IsEnum(UserRole)
  role: UserRole;
}

Тест:

it('должен валидировать enum', async () => {
  const dto = new UserDto();

  dto.role = 'superadmin' as any;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

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

DTO:

class UserDto {
  @ArrayMinSize(2)
  @ArrayMaxSize(5)
  tags: string[];
}

Тест:

it('должен валидировать размер массива', async () => {
  const dto = new UserDto();

  dto.tags = [];

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});

Проверка числовых ограничений

DTO:

class ProductDto {
  @Min(1)
  @Max(100)
  price: number;
}

Тест:

it('должен валидировать диапазон', async () => {
  const dto = new ProductDto();

  dto.price = 500;

  const errors = await validate(dto);

  expect(errors.length).toBe(1);
});