Слой валидации данных часто становится источником скрытых ошибок. Даже при корректной настройке декораторов могут возникать проблемы:
class-validator требует полноценного покрытия тестами,
особенно в проектах с:
Для примеров используется Jest.
npm install --save-dev jest ts-jest @types/jest
Если проект использует TypeScript:
npx ts-jest config:init
Пример конфигурации:
{
"preset": "ts-jest",
"testEnvironment": "node"
}
import {
IsEmail,
IsInt,
IsString,
Length,
Min
} from 'class-validator'
export class CreateUserDto {
@IsString()
@Length(3, 20)
username: string
@IsEmail()
email: string
@IsInt()
@Min(18)
age: number
}
Первый сценарий — объект полностью соответствует требованиям.
import { validate } from 'class-validator'
import { CreateUserDto } from './CreateUserDto'
describe('CreateUserDto', () => {
it('should pass validation', async () => {
const dto = new CreateUserDto()
dto.username = 'alex'
dto.email = 'alex@test.com'
dto.age = 25
const errors = await validate(dto)
expect(errors.length).toBe(0)
})
})
Следующий сценарий — объект содержит некорректные данные.
it('should return validation errors', async () => {
const dto = new CreateUserDto()
dto.username = 'a'
dto.email = 'wrong-email'
dto.age = 10
const errors = await validate(dto)
expect(errors.length).toBe(3)
})
Иногда требуется убедиться, что ошибка относится к определённому полю.
it('should validate email field', async () => {
const dto = new CreateUserDto()
dto.username = 'alex'
dto.email = 'invalid'
dto.age = 22
const errors = await validate(dto)
expect(errors[0].property).toBe('email')
})
constraints содержит список нарушенных правил.
it('should contain isEmail constraint', async () => {
const dto = new CreateUserDto()
dto.username = 'alex'
dto.email = 'invalid'
dto.age = 22
const errors = await validate(dto)
expect(errors[0].constraints).toHaveProperty('isEmail')
})
Одно поле может нарушать несколько правил одновременно.
export class PostDto {
@IsString()
@Length(5, 50)
title: string
}
Тест:
it('should return multiple constraints', async () => {
const dto = new PostDto()
dto.title = 1 as any
const errors = await validate(dto)
expect(errors[0].constraints).toHaveProperty('isString')
expect(errors[0].constraints).toHaveProperty('isLength')
})
Проверка текстов особенно важна для API и frontend-интеграции.
export class ProductDto {
@Length(5, 20, {
message: 'Название должно содержать от 5 до 20 символов'
})
title: string
}
Тест:
it('should return custom message', async () => {
const dto = new ProductDto()
dto.title = 'abc'
const errors = await validate(dto)
expect(
errors[0].constraints?.isLength
).toBe('Название должно содержать от 5 до 20 символов')
})
whitelistwhitelist удаляет лишние свойства.
import { plainToInstance } from 'class-transformer'
import { validate } from 'class-validator'
class UserDto {
@IsString()
name: string
}
Тест:
it('should strip unknown properties', async () => {
const payload = {
name: 'Alex',
role: 'admin'
}
const dto = plainToInstance(UserDto, payload)
await validate(dto, {
whitelist: true
})
expect((dto as any).role).toBeUndefined()
})
forbidNonWhitelistedПри включении этой опции лишние поля вызывают ошибку.
it('should fail on extra properties', async () => {
const payload = {
name: 'Alex',
role: 'admin'
}
const dto = plainToInstance(UserDto, payload)
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true
})
expect(errors.length).toBe(1)
})
import {
IsString,
ValidateNested
} from 'class-validator'
import { Type } from 'class-transformer'
class AddressDto {
@IsString()
city: string
}
class UserDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto
}
it('should validate nested object', async () => {
const dto = new UserDto()
dto.address = {
city: 123
} as any
const errors = await validate(dto)
expect(errors[0].children?.length).toBeGreaterThan(0)
})
class TagDto {
@IsString()
name: string
}
class ArticleDto {
@ValidateNested({ each: true })
@Type(() => TagDto)
tags: TagDto[]
}
Тест:
it('should validate nested array', async () => {
const dto = new ArticleDto()
dto.tags = [
{ name: 'typescript' },
{ name: 123 as any }
]
const errors = await validate(dto)
expect(errors.length).toBeGreaterThan(0)
})
each: trueclass SkillsDto {
@IsString({ each: true })
skills: string[]
}
Тест:
it('should validate every array element', async () => {
const dto = new SkillsDto()
dto.skills = ['nodejs', 123 as any]
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
import {
IsOptional,
IsString
} from 'class-validator'
class UpdateUserDto {
@IsOptional()
@IsString()
bio?: string
}
it('should pass without optional field', async () => {
const dto = new UpdateUserDto()
const errors = await validate(dto)
expect(errors.length).toBe(0)
})
it('should fail with invalid optional field', async () => {
const dto = new UpdateUserDto()
dto.bio = 123 as any
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
skipMissingPropertiesclass PatchUserDto {
@IsString()
name: string
}
Тест:
it('should ignore missing properties', async () => {
const dto = new PatchUserDto()
const errors = await validate(dto, {
skipMissingProperties: true
})
expect(errors.length).toBe(0)
})
import {
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator'
@ValidatorConstraint({ async: true })
export class UserExistsConstraint
implements ValidatorConstraintInterface {
async validate(username: string) {
return username !== 'admin'
}
}
import {
Validate
} from 'class-validator'
class RegisterDto {
@Validate(UserExistsConstraint)
username: string
}
it('should fail if username exists', async () => {
const dto = new RegisterDto()
dto.username = 'admin'
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
@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
}
}
describe('EmailExistsConstraint', () => {
it('should return false when user exists', async () => {
const usersService = {
findByEmail: jest.fn()
}
usersService.findByEmail.mockResolvedValue({
id: 1
})
const validator = new EmailExistsConstraint(
usersService as any
)
const result = await validator.validate(
'admin@test.com'
)
expect(result).toBe(false)
})
})
import {
registerDecorator,
ValidationOptions,
ValidationArguments
} from 'class-validator'
export function IsLongerThan(
property: string,
options?: ValidationOptions
) {
return function(object: Object, propertyName: string) {
registerDecorator({
name: 'isLongerThan',
target: object.constructor,
propertyName,
constraints: [property],
options,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedProperty] = args.constraints
const relatedValue = (args.object as any)[relatedProperty]
return value.length > relatedValue.length
}
}
})
}
}
class PasswordDto {
password: string
@IsLongerThan('password')
confirmPassword: string
}
it('should validate custom decorator', async () => {
const dto = new PasswordDto()
dto.password = '123456'
dto.confirmPassword = '123'
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
class UserDto {
@IsString({
groups: ['create']
})
name: string
@IsEmail({}, {
groups: ['update']
})
email: string
}
it('should validate create group', async () => {
const dto = new UserDto()
const errors = await validate(dto, {
groups: ['create']
})
expect(errors.length).toBe(1)
expect(errors[0].property).toBe('name')
})
it('should validate update group', async () => {
const dto = new UserDto()
const errors = await validate(dto, {
groups: ['update']
})
expect(errors.length).toBe(1)
expect(errors[0].property).toBe('email')
})
import {
ValidateIf,
IsNotEmpty
} from 'class-validator'
class PaymentDto {
method: string
@ValidateIf(o => o.method === 'card')
@IsNotEmpty()
cardNumber: string
}
it('should validate field conditionally', async () => {
const dto = new PaymentDto()
dto.method = 'card'
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
Поведение class-validator различается для
null и undefined.
class UserDto {
@IsString()
name: string
}
it('should fail on undefined', async () => {
const dto = new UserDto()
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
it('should fail on null', async () => {
const dto = new UserDto()
dto.name = null as any
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
import {
IsEnum
} from 'class-validator'
enum Role {
USER = 'user',
ADMIN = 'admin'
}
class UserDto {
@IsEnum(Role)
role: Role
}
it('should validate enum', async () => {
const dto = new UserDto()
dto.role = 'moderator' as any
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
Очень распространённый сценарий в NestJS.
import {
IsInt
} from 'class-validator'
import {
Type
} from 'class-transformer'
class QueryDto {
@Type(() => Number)
@IsInt()
page: number
}
it('should transform value before validation', async () => {
const dto = plainToInstance(QueryDto, {
page: '10'
})
const errors = await validate(dto)
expect(errors.length).toBe(0)
expect(dto.page).toBe(10)
})
validateSyncСинхронная валидация используется для простых сценариев.
import {
validateSync
} from 'class-validator'
Тест:
it('should validate synchronously', () => {
const dto = new CreateUserDto()
dto.email = 'wrong'
const errors = validateSync(dto)
expect(errors.length).toBeGreaterThan(0)
})
ValidationError содержит несколько важных полей:
{
target,
property,
value,
constraints,
children
}
Тест:
it('should contain validation metadata', async () => {
const dto = new CreateUserDto()
dto.email = 'wrong'
const errors = await validate(dto)
expect(errors[0]).toHaveProperty('property')
expect(errors[0]).toHaveProperty('constraints')
expect(errors[0]).toHaveProperty('value')
})
Полезно при сложных DTO.
it('should match validation snapshot', async () => {
const dto = new CreateUserDto()
dto.email = 'wrong'
const errors = await validate(dto)
expect(errors).toMatchSnapshot()
})
Пример snapshot:
exports[`should match validation snapshot 1`] = `
[
{
"constraints": {
"isEmail": "email must be an email",
},
"property": "email",
},
]
`
Jest поддерживает параметризованные тесты.
describe.each([
['wrong-email', false],
['admin@test.com', true],
['test', false]
])('Email validation', (email, expected) => {
it(`should validate ${email}`, async () => {
class EmailDto {
@IsEmail()
email: string
}
const dto = new EmailDto()
dto.email = email
const errors = await validate(dto)
expect(errors.length === 0).toBe(expected)
})
})
При больших DTO важно контролировать время выполнения.
it('should validate fast enough', async () => {
const dto = new CreateUserDto()
dto.username = 'alex'
dto.email = 'alex@test.com'
dto.age = 25
const start = performance.now()
await validate(dto)
const end = performance.now()
expect(end - start).toBeLessThan(50)
})
export class LoginDto {
@IsEmail()
email: string
@Length(6, 20)
password: string
}
@Post('login')
login(@Body() dto: LoginDto) {
return true
}
import * as request from 'supertest'
describe('AuthController', () => {
it('should reject invalid request', async () => {
await request(app.getHttpServer())
.post('/login')
.send({
email: 'wrong',
password: '123'
})
.expect(400)
})
})
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true
})
)
Тест:
it('should reject extra fields', async () => {
await request(app.getHttpServer())
.post('/login')
.send({
email: 'test@test.com',
password: '123456',
role: 'admin'
})
.expect(400)
})
plainToInstanceНекорректно:
const dto = {
page: '1'
}
Корректно:
const dto = plainToInstance(QueryDto, {
page: '1'
})
Без трансформации декораторы @Type() не срабатывают.
Плохой тест:
expect(errors.length).toBe(1)
Хороший тест:
expect(errors[0].property).toBe('email')
expect(errors[0].constraints)
.toHaveProperty('isEmail')
Ошибки вложенных DTO находятся в children.
expect(errors[0].children?.length)
.toBeGreaterThan(0)
Эффективные тесты валидации обладают несколькими характеристиками:
it('should fail on empty string', async () => {
class Dto {
@IsNotEmpty()
value: string
}
const dto = new Dto()
dto.value = ''
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
it('should fail on NaN', async () => {
class Dto {
@IsInt()
value: number
}
const dto = new Dto()
dto.value = NaN
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
it('should fail on Infinity', async () => {
class Dto {
@IsNumber()
value: number
}
const dto = new Dto()
dto.value = Infinity
const errors = await validate(dto)
expect(errors.length).toBe(1)
})
Для каждого DTO желательно проверять:
| Сценарий | Проверка |
|---|---|
| Валидные данные | Ошибок нет |
| Невалидные данные | Ошибки есть |
| Пустые значения | Корректная реакция |
| Лишние поля | whitelist / forbidNonWhitelisted |
| Nested DTO | children |
| Массивы | each: true |
| Кастомные валидаторы | validate |
| Трансформация | plainToInstance |
| Группы | groups |
| Optional-поля | IsOptional |
| Pipe | Integration tests |