При разработке сложных приложений стандартных декораторов
class-validator часто недостаточно. Появляются проверки
уникальности данных, сравнения свойств, обращения к базе данных,
проверки бизнес-логики и интеграции с внешними сервисами. Такие сценарии
требуют создания кастомных валидаторов.
Любой пользовательский валидатор становится частью критической инфраструктуры приложения. Ошибки в нём приводят к:
Поэтому кастомные валидаторы обязательно покрываются тестами.
Типичный кастомный валидатор состоит из двух частей:
Пример проверки совпадения паролей:
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions
} fr om 'class-validator';
@ValidatorConstraint({ name: 'MatchPasswords', async: false })
export class MatchPasswordsConstraint
implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue =
(args.object as any)[relatedPropertyName];
return value === relatedValue;
}
defaultMessage(args: ValidationArguments) {
return 'Пароли не совпадают';
}
}
export function MatchPasswords(
property: string,
options?: ValidationOptions
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [property],
validator: MatchPasswordsConstraint
});
};
}
Наиболее распространённый стек:
| Инструмент | Назначение |
|---|---|
| Jest | Фреймворк тестирования |
| ts-jest | Поддержка TypeScript |
| class-validator | Проверка DTO |
| reflect-metadata | Работа декораторов |
Установка:
npm install --save-dev jest ts-jest @types/jest
Для TypeScript:
npm install reflect-metadata
Конфигурация:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node'
};
Подключение metadata:
import 'reflect-metadata';
Основной способ тестирования кастомных валидаторов — вызов функции
validate.
DTO:
class RegisterDto {
password: string;
@MatchPasswords('password')
confirmPassword: string;
}
Тест:
import { validate } fr om 'class-validator';
describe('MatchPasswordsConstraint', () => {
it('должен пропускать одинаковые пароли', async () => {
const dto = new RegisterDto();
dto.password = '123456';
dto.confirmPassword = '123456';
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
it('должен возвращать ошибку при несовпадении', async () => {
const dto = new RegisterDto();
dto.password = '123456';
dto.confirmPassword = '654321';
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
});
Недостаточно проверять только количество ошибок. Необходимо
анализировать содержимое объекта ValidationError.
Пример:
it('должен содержать сообщение валидатора', async () => {
const dto = new RegisterDto();
dto.password = '111';
dto.confirmPassword = '222';
const errors = await validate(dto);
expect(errors[0].constraints).toHaveProperty(
'MatchPasswords'
);
expect(
errors[0].constraints?.MatchPasswords
).toBe('Пароли не совпадают');
});
Имя валидатора задаётся в декораторе:
@ValidatorConstraint({
name: 'MatchPasswords'
})
Оно попадает в constraints.
Тест:
expect(errors[0].constraints).toEqual({
MatchPasswords: 'Пароли не совпадают'
});
Метод defaultMessage тоже является частью логики.
Проверка:
it('должен возвращать корректное сообщение', () => {
const validator = new MatchPasswordsConstraint();
const message = validator.defaultMessage(
{} as any
);
expect(message).toBe('Пароли не совпадают');
});
Иногда полноценная валидация DTO не нужна. Можно тестировать сам класс валидатора напрямую.
Пример:
describe('validate()', () => {
const validator = new MatchPasswordsConstraint();
it('должен вернуть true', () => {
const result = validator.validate(
'123',
{
constraints: ['password'],
object: {
password: '123'
}
} as any
);
expect(result).toBe(true);
});
it('должен вернуть false', () => {
const result = validator.validate(
'123',
{
constraints: ['password'],
object: {
password: '456'
}
} as any
);
expect(result).toBe(false);
});
});
Многие кастомные валидаторы асинхронны:
Пример:
@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface {
async validate(email: string) {
const user = await User.findOne({
wh ere: { email }
});
return !user;
}
}
describe('IsEmailUniqueConstraint', () => {
it('должен вернуть false если email существует', async () => {
jest.spyOn(User, 'findOne')
.mockResolvedValue({
id: 1
} as any);
const validator =
new IsEmailUniqueConstraint();
const result =
await validator.validate(
'test@mail.com'
);
expect(result).toBe(false);
});
});
Кастомные валидаторы не должны использовать реальную базу данных в unit-тестах.
Плохой вариант:
await prisma.user.findUnique(...)
без моков.
Хороший вариант:
jest.spyOn(prisma.user, 'findUnique')
.mockResolvedValue(null);
Валидатор должен тестироваться независимо от:
Тест проверяет только бизнес-логику.
Иногда важно убедиться, что валидатор не делает лишних запросов.
Пример:
const spy = jest.spyOn(User, 'findOne');
await validator.validate('a@mail.com');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith({
wh ere: {
email: 'a@mail.com'
}
});
Можно тестировать не только валидатор, но и сам декоратор.
Пример DTO:
class UserDto {
password: string;
@MatchPasswords('password')
confirmPassword: string;
}
Тест:
it('декоратор должен регистрировать валидацию', async () => {
const dto = new UserDto();
dto.password = '123';
dto.confirmPassword = '456';
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
Многие кастомные валидаторы используют
ValidationArguments.
Пример:
validate(value: string, args: ValidationArguments) {
console.log(args.property);
console.log(args.constraints);
console.log(args.object);
}
Тестирование:
it('должен получать constraints', () => {
const validator =
new MatchPasswordsConstraint();
const result = validator.validate(
'111',
{
constraints: ['password'],
object: {
password: '111'
}
} as any
);
expect(result).toBe(true);
});
Особое внимание уделяется пограничным случаям.
Например:
null
undefined
''
0
[]
{}
NaN
it('должен вернуть false для undefined', () => {
const validator =
new MatchPasswordsConstraint();
const result = validator.validate(
undefined,
{
constraints: ['password'],
object: {
password: '123'
}
} as any
);
expect(result).toBe(false);
});
it('должен корректно работать без related property', () => {
const validator =
new MatchPasswordsConstraint();
const result = validator.validate(
'123',
{
constraints: ['password'],
object: {}
} as any
);
expect(result).toBe(false);
});
Некоторые валидаторы могут выбрасывать ошибки.
Пример:
async validate(value: string) {
const result = await api.check(value);
if (!result) {
throw new Error('API Error');
}
return true;
}
Тест:
it('должен обрабатывать ошибки API', async () => {
jest.spyOn(api, 'check')
.mockRejectedValue(
new Error('Network Error')
);
const validator = new ApiValidator();
await expect(
validator.validate('123')
).rejects.toThrow('Network Error');
});
Кастомный валидатор не должен ломать приложение.
Иногда лучше возвращать false, чем выбрасывать
исключение.
Пример:
async validate(value: string) {
try {
const user = await service.find(value);
return !!user;
} catch {
return false;
}
}
Тест:
it('должен возвращать false при ошибке сервиса', async () => {
jest.spyOn(service, 'find')
.mockRejectedValue(
new Error('DB Error')
);
const validator =
new UserExistsValidator();
const result =
await validator.validate('1');
expect(result).toBe(false);
});
Jest поддерживает snapshot-тесты.
Пример:
it('должен соответствовать snapshot', async () => {
const dto = new RegisterDto();
dto.password = '111';
dto.confirmPassword = '222';
const errors = await validate(dto);
expect(errors).toMatchSnapshot();
});
Snapshot полезен для:
При использовании ValidateNested:
class AddressDto {
@IsNotEmpty()
city: string;
}
class UserDto {
@ValidateNested()
address: AddressDto;
}
Тест:
it('должен валидировать nested DTO', async () => {
const dto = new UserDto();
dto.address = new AddressDto();
const errors = await validate(dto);
expect(errors.length).toBe(1);
expect(
errors[0].children?.length
).toBeGreaterThan(0);
});
Пример кастомного валидатора массива:
@ValidatorConstraint()
class UniqueArrayConstraint
implements ValidatorConstraintInterface {
validate(values: any[]) {
return values.length ===
new Set(values).size;
}
}
Тест:
it('должен находить дубликаты', () => {
const validator =
new UniqueArrayConstraint();
const result = validator.validate([
1,
2,
2
]);
expect(result).toBe(false);
});
Некоторые валидаторы выполняются очень часто.
Например:
Проверка производительности:
it('должен быстро валидировать массив', () => {
const validator =
new UniqueArrayConstraint();
const array = Array.from(
{ length: 100000 },
(_, i) => i
);
const start = Date.now();
validator.validate(array);
const end = Date.now();
expect(end - start).toBeLessThan(100);
});
Для синхронных валидаторов используется
validateSync.
import { validateSync } from 'class-validator';
const errors = validateSync(dto);
Тест:
it('должен синхронно валидировать DTO', () => {
const dto = new RegisterDto();
dto.password = '1';
dto.confirmPassword = '2';
const errors = validateSync(dto);
expect(errors.length).toBe(1);
});
В NestJS кастомные валидаторы часто используются внутри
ValidationPipe.
Тест DTO:
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true
});
Проверяется:
Unit-тесты проверяют валидатор изолированно.
Интеграционные тесты проверяют:
Пример:
await request(app.getHttpServer())
.post('/users')
.send({
password: '111',
confirmPassword: '222'
})
.expect(400);
| Тип теста | Что проверяет |
|---|---|
| Unit | Логику валидатора |
| Integration | Работу системы целиком |
| E2E | Поведение API |
После исправления бага обязательно создаётся тест.
Пример:
it('не должен падать на null', () => {
...
});
Это защищает код от повторного появления ошибки.
Пример:
@ValidatorConstraint()
class MinWordsConstraint
implements ValidatorConstraintInterface {
validate(value: string, args: ValidationArguments) {
const [min] = args.constraints;
return value.split(' ').length >= min;
}
}
Тест:
it('должен использовать constraints', () => {
const validator =
new MinWordsConstraint();
const result = validator.validate(
'one two three',
{
constraints: [2]
} as any
);
expect(result).toBe(true);
});
@MatchPasswords('password', {
message: 'Неверное подтверждение'
})
Тест:
it('должен использовать custom message', async () => {
const dto = new RegisterDto();
dto.password = '123';
dto.confirmPassword = '456';
const errors = await validate(dto);
expect(
errors[0].constraints?.MatchPasswords
).toBe('Неверное подтверждение');
});
Плохо:
it('test validator', () => {
...
});
Хорошо:
it('должен вернуть false при пустом email', () => {
...
});
Нельзя допускать зависимость тестов друг от друга.
Плохо:
sharedDto.password = '123';
Хорошо:
const dto = new RegisterDto();
let validator: MatchPasswordsConstraint;
beforeEach(() => {
validator =
new MatchPasswordsConstraint();
});
afterEach(() => {
jest.clearAllMocks();
});
Плохо:
expect(errors.length).toBe(1);
Лучше:
expect(errors[0].constraints)
.toHaveProperty('MatchPasswords');
Unit-тесты должны быть быстрыми и изолированными.
Часто ошибки появляются именно на:
null;undefined;Unit-тест не должен запускать HTTP-сервер.
import 'reflect-metadata';
import { validate } from 'class-validator';
describe('MatchPasswordsConstraint', () => {
class RegisterDto {
password: string;
@MatchPasswords('password')
confirmPassword: string;
}
it('должен пропускать одинаковые пароли', async () => {
const dto = new RegisterDto();
dto.password = '123';
dto.confirmPassword = '123';
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
it('должен отклонять разные пароли', async () => {
const dto = new RegisterDto();
dto.password = '123';
dto.confirmPassword = '456';
const errors = await validate(dto);
expect(errors.length).toBe(1);
expect(
errors[0].constraints
).toHaveProperty(
'MatchPasswords'
);
});
it('должен использовать defaultMessage', async () => {
const dto = new RegisterDto();
dto.password = '123';
dto.confirmPassword = '456';
const errors = await validate(dto);
expect(
errors[0].constraints?.MatchPasswords
).toBe('Пароли не совпадают');
});
});