Валидация данных — один из наиболее критичных слоёв backend-приложения. Ошибки в правилах проверки приводят к:
При использовании библиотеки class-validator unit-тестирование становится обязательным элементом поддержки качества, особенно в крупных проектах с большим количеством DTO, кастомных декораторов и сложных условий валидации.
Главная задача unit-тестов — проверить, что конкретный класс валидации:
Наиболее распространённая связка:
npm install --save-dev jest ts-jest @types/jest
Если используется TypeScript:
npm install reflect-metadata
Типичная структура:
src/
├── dto/
├── validators/
└── services/
test/
├── dto/
└── validators/
Пример конфигурации:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
В tsconfig.json необходимо включить:
{
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
Простейший 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);
});
@IsOptionalDTO:
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);
});
@ValidateIfDTO:
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);
});
Создание валидатора:
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);
});
});
Нельзя допускать:
Все зависимости должны мокироваться.
Неправильный подход:
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');
});
whitelistDTO:
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();
});
forbidNonWhitelistedit('должен запрещать лишние поля', 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);
});
skipMissingPropertiesclass UpdateUserDto {
@IsString()
name: string;
}
Тест:
it('должен пропускать отсутствующие поля', async () => {
const dto = new UpdateUserDto();
const errors = await validate(dto, {
skipMissingProperties: true
});
expect(errors.length).toBe(0);
});
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);
}
);
});
Такой подход существенно сокращает дублирование кода.
Критически важно тестировать:
null;undefined;Пример:
it('должен отклонять null', async () => {
const dto = new UserDto();
dto.email = null as any;
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
Иногда удобно использовать snapshot.
it('должен совпадать snapshot', async () => {
const dto = new UserDto();
dto.email = 'wrong';
const errors = await validate(dto);
expect(errors).toMatchSnapshot();
});
Преимущество:
Недостаток:
Связка 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);
});
Подобные тесты полезны для:
Рекомендуемая структура:
test/
├── dto/
│ ├── user.dto.spec.ts
│ └── post.dto.spec.ts
│
└── validators/
├── email-exists.spec.ts
└── is-even.spec.ts
Один тест — одна проверка.
Плохо:
it('test', async () => {
// 20 expect
});
Хорошо:
it('должен валидировать email');
it('должен валидировать password');
Тест всегда должен давать одинаковый результат.
Недопустимо:
Тест должен содержать только необходимые данные.
Плохо:
dto.name = 'Alex';
dto.email = 'a@test.com';
dto.phone = '123';
dto.city = 'Paris';
Если тестируется только email — остальные поля не нужны.
Тест должен объяснять поведение системы.
Хорошее название:
it('должен отклонять некорректный email');
Плохое:
it('test validation');
errors.lengthНедостаточно:
expect(errors.length).toBe(1);
Нужно дополнительно проверять:
Нужно проверять не только ошибки.
Обязательны тесты:
Вложенные DTO — источник большого количества ошибок.
Особенно важно тестировать:
Unit-тесты не должны становиться integration-тестами.
Для сокращения дублирования удобно создавать 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');
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: trueDTO:
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);
});
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);
});