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

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

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

class-validator требует полноценного покрытия тестами, особенно в проектах с:

  • REST API;
  • GraphQL;
  • микросервисами;
  • NestJS;
  • сложными DTO;
  • пользовательскими валидаторами.

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

Для примеров используется Jest.

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

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

npx ts-jest config:init

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

{
  "preset": "ts-jest",
  "testEnvironment": "node"
}

Базовый DTO для тестирования

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 символов')
})

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

whitelist удаляет лишние свойства.

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)
})

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

DTO

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: true

class 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)
})

Тестирование optional-полей

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)
})

Проверка skipMissingProperties

class 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'
  }
}

DTO

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
  }
}

Тест с mock

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
        }
      }
    })
  }
}

DTO

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)
})

Проверка групп валидации

DTO

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

  @IsEmail({}, {
    groups: ['update']
  })
  email: string
}

Тест группы create

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')
})

Тест группы update

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')
})

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

DTO

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)
})

Проверка null и undefined

Поведение class-validator различается для null и undefined.

class UserDto {
  @IsString()
  name: string
}

undefined

it('should fail on undefined', async () => {
  const dto = new UserDto()

  const errors = await validate(dto)

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

null

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)
})

Проверка enum

DTO

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.

DTO

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

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')
})

Snapshot-тестирование

Полезно при сложных 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",
  },
]
`

Проверка большого количества сценариев через table-driven tests

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)
})

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

DTO

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)
  })
})

Проверка ValidationPipe

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')

Игнорирование вложенных children

Ошибки вложенных DTO находятся в children.

expect(errors[0].children?.length)
  .toBeGreaterThan(0)

Практика построения качественных тестов

Эффективные тесты валидации обладают несколькими характеристиками:

  • проверяют как успешные, так и неуспешные сценарии;
  • изолируют конкретное правило;
  • тестируют edge-cases;
  • проверяют реальные payload из API;
  • не зависят от порядка ошибок;
  • покрывают кастомные валидаторы;
  • учитывают трансформацию данных;
  • проверяют nested DTO;
  • тестируют pipe-конфигурацию;
  • валидируют поведение в production-режиме.

Edge-case сценарии

Пустая строка

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)
})

NaN

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)
})

Infinity

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

Для каждого DTO желательно проверять:

Сценарий Проверка
Валидные данные Ошибок нет
Невалидные данные Ошибки есть
Пустые значения Корректная реакция
Лишние поля whitelist / forbidNonWhitelisted
Nested DTO children
Массивы each: true
Кастомные валидаторы validate
Трансформация plainToInstance
Группы groups
Optional-поля IsOptional
Pipe Integration tests