Асинхронные валидаторы в библиотеке class-validator используются в ситуациях, когда проверка значения зависит от внешнего источника данных:
Типичный пример — проверка уникальности email пользователя:
@Validate(IsEmailUniqueConstraint)
email: string;
Внутри валидатора может выполняться SQL-запрос или HTTP-вызов. Такие проверки работают асинхронно и требуют специального тестирования.
Асинхронные валидаторы создают несколько проблем:
Пример проблемного теста:
it('should validate email uniqueness', async () => {
const dto = new UserDto();
dto.email = 'admin@mail.com';
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
Если валидатор делает настоящий запрос в базу данных, тест:
Поэтому используется мокирование.
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments
} fr om 'class-validator';
@ValidatorConstraint({ async: true })
export class IsUserAlreadyExistConstraint
implements ValidatorConstraintInterface {
async validate(username: string) {
const user = await UserRepository.findByName(username);
return !user;
}
defaultMessage(args: ValidationArguments) {
return 'User already exists';
}
}
Ключевой момент:
@ValidatorConstraint({ async: true })
Без async: true библиотека будет считать валидатор
синхронным.
import { Validate } fr om 'class-validator';
export class CreateUserDto {
@Validate(IsUserAlreadyExistConstraint)
username: string;
}
Проверка:
const dto = new CreateUserDto();
dto.username = 'admin';
const errors = await validate(dto);
Метод validate() всегда возвращает Promise.
Самый распространённый вариант.
Вместо реального репозитория используется mock-объект.
Если валидатор обращается к API:
const response = await axios.get(...);
мокируется axios.
При использовании:
можно мокировать зависимости через контейнер.
Подход основан на:
jest.spyOn()
Позволяет отслеживать вызовы и подменять реализацию.
@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface {
async validate(email: string) {
const user = await UserRepository.findByEmail(email);
return !user;
}
}
import { validate } from 'class-validator';
describe('IsEmailUniqueConstraint', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should fail if email exists', async () => {
jest.spyOn(UserRepository, 'findByEmail')
.mockResolvedValue({
id: 1,
email: 'admin@mail.com'
});
const dto = new CreateUserDto();
dto.email = 'admin@mail.com';
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
it('should pass if email is unique', async () => {
jest.spyOn(UserRepository, 'findByEmail')
.mockResolvedValue(null);
const dto = new CreateUserDto();
dto.email = 'new@mail.com';
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
});
Используется для успешного Promise:
jest.spyOn(UserRepository, 'findByEmail')
.mockResolvedValue(null);
Эквивалент:
mockImplementation(() => Promise.resolve(null))
Используется для ошибок:
jest.spyOn(UserRepository, 'findByEmail')
.mockRejectedValue(new Error('DB Error'));
Асинхронный валидатор обязан корректно реагировать на ошибки внешних сервисов.
async validate(email: string) {
const user = await UserRepository.findByEmail(email);
return !user;
}
Если база данных недоступна — произойдёт unhandled rejection.
async validate(email: string) {
try {
const user = await UserRepository.findByEmail(email);
return !user;
} catch {
return false;
}
}
it('should return false on repository error', async () => {
jest.spyOn(UserRepository, 'findByEmail')
.mockRejectedValue(new Error('Connection error'));
const dto = new CreateUserDto();
dto.email = 'test@mail.com';
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
export class UserRepository {
static async findByEmail(email: string) {
return null;
}
}
const mockRepository = {
findByEmail: jest.fn()
};
mockRepository.findByEmail.mockResolvedValue({
id: 1
});
Плохой вариант:
async validate(email: string) {
return !(await UserRepository.findByEmail(email));
}
Валидатор напрямую зависит от репозитория.
@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface {
constructor(
private readonly usersService: UsersService
) {}
async validate(email: string) {
const user = await this.usersService.findByEmail(email);
return !user;
}
}
Такой код значительно проще тестировать.
@ValidatorConstraint({ async: true })
@Injectable()
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface {
constructor(
private readonly usersService: UsersService
) {}
async validate(email: string) {
const user = await this.usersService.findByEmail(email);
return !user;
}
}
describe('IsEmailUniqueConstraint', () => {
let validator: IsEmailUniqueConstraint;
const usersService = {
findByEmail: jest.fn()
};
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
IsEmailUniqueConstraint,
{
provide: UsersService,
useValue: usersService
}
]
}).compile();
validator = module.get(IsEmailUniqueConstraint);
});
it('should validate unique email', async () => {
usersService.findByEmail.mockResolvedValue(null);
const result = await validator.validate('test@mail.com');
expect(result).toBe(true);
});
});
import axios from 'axios';
@ValidatorConstraint({ async: true })
export class IsUserValidConstraint
implements ValidatorConstraintInterface {
async validate(userId: number) {
const response = await axios.get(
`https://api.test/users/${userId}`
);
return response.data.active;
}
}
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
it('should validate active user', async () => {
mockedAxios.get.mockResolvedValue({
data: {
active: true
}
});
const result = await validator.validate(1);
expect(result).toBe(true);
});
expect(UserRepository.findByEmail)
.toHaveBeenCalledWith('admin@mail.com');
expect(UserRepository.findByEmail)
.toHaveBeenCalledTimes(1);
expect(UserRepository.findByEmail)
.toHaveBeenCalled();
@ValidatorConstraint({ async: true })
export class IsCategoryExistsConstraint
implements ValidatorConstraintInterface {
async validate(id: number) {
const category = await AppDataSource
.getRepository(Category)
.findOne({
wh ere: { id }
});
return !!category;
}
}
Метод:
AppDataSource
.getRepository(Category)
.findOne(...)
требует мокирования нескольких уровней.
const findOneMock = jest.fn();
jest.spyOn(AppDataSource, 'getRepository')
.mockReturnValue({
findOne: findOneMock
} as any);
it('should validate category existence', async () => {
findOneMock.mockResolvedValue({
id: 10
});
const result = await validator.validate(10);
expect(result).toBe(true);
});
@ValidatorConstraint({ async: true })
export class IsUserExistsConstraint
implements ValidatorConstraintInterface {
constructor(
private readonly prisma: PrismaService
) {}
async validate(id: number) {
const user = await this.prisma.user.findUnique({
wh ere: { id }
});
return !!user;
}
}
const prismaMock = {
user: {
findUnique: jest.fn()
}
};
it('should validate existing user', async () => {
prismaMock.user.findUnique.mockResolvedValue({
id: 1
});
const validator = new IsUserExistsConstraint(
prismaMock as any
);
const result = await validator.validate(1);
expect(result).toBe(true);
});
defaultMessage(args: ValidationArguments) {
return `Email ${args.value} already exists`;
}
it('should return proper message', () => {
const message = validator.defaultMessage({
value: 'admin@mail.com'
} as any);
expect(message)
.toBe('Email admin@mail.com already exists');
});
const errors = await validate(dto);
console.log(errors);
Результат:
[
{
property: 'email',
constraints: {
IsEmailUniqueConstraint:
'Email already exists'
}
}
]
expect(errors[0].constraints)
.toHaveProperty(
'IsEmailUniqueConstraint'
);
expect(
errors[0].constraints?.IsEmailUniqueConstraint
).toBe('Email already exists');
export class RegisterDto {
@Validate(IsEmailUniqueConstraint)
email: string;
@Validate(IsUsernameUniqueConstraint)
username: string;
}
it('should validate multiple async validators', async () => {
jest.spyOn(UserRepository, 'findByEmail')
.mockResolvedValue({
id: 1
});
jest.spyOn(UserRepository, 'findByUsername')
.mockResolvedValue(null);
const dto = new RegisterDto();
dto.email = 'admin@mail.com';
dto.username = 'new_user';
const errors = await validate(dto);
expect(errors.length).toBe(1);
});
async validate(value: string) {
const [user, banned] = await Promise.all([
this.usersService.find(value),
this.banService.find(value)
]);
return !!user && !banned;
}
it('should validate parallel requests', async () => {
usersService.find.mockResolvedValue({
id: 1
});
banService.find.mockResolvedValue(null);
const result = await validator.validate('admin');
expect(result).toBe(true);
});
Некоторые валидаторы содержат:
await new Promise(resolve =>
setTimeout(resolve, 1000)
);
Такие задержки замедляют тесты.
jest.useFakeTimers();
it('should handle timeout', async () => {
const promise = validator.validate('test');
jest.runAllTimers();
const result = await promise;
expect(result).toBe(true);
});
Проблема:
const errors = validate(dto);
errors будет Promise.
Правильно:
const errors = await validate(dto);
Неправильно:
await validate(dto);
при реальном подключении к базе данных.
Unit-тесты должны использовать mock.
Проблема:
jest.spyOn(...)
без:
jest.clearAllMocks();
Моки начинают влиять на другие тесты.
Ошибка:
@ValidatorConstraint()
В этом случае Promise может интерпретироваться некорректно.
Правильно:
@ValidatorConstraint({ async: true })
Плохой подход:
async validate(value: string) {
const user = await this.repo.find(value);
const permissions = await this.permissions.load(user.id);
const groups = await this.groups.load(user.id);
return permissions.includes('ADMIN');
}
async validate(value: string) {
return this.authService.canRegister(value);
}
Тогда мокируется только сервис.
Каждый валидатор должен тестироваться отдельно.
Нежелательно:
validate(dto)
если тестируется только один constraint.
const result = await validator.validate(
'admin@mail.com'
);
Такой подход:
@ValidatorConstraint({ async: true })
export class IsUsernameFreeConstraint
implements ValidatorConstraintInterface {
constructor(
private readonly usersService: UsersService
) {}
async validate(username: string) {
try {
const user = await this.usersService
.findByUsername(username);
return !user;
} catch {
return false;
}
}
defaultMessage() {
return 'Username already taken';
}
}
describe('IsUsernameFreeConstraint', () => {
let validator: IsUsernameFreeConstraint;
const usersService = {
findByUsername: jest.fn()
};
beforeEach(() => {
jest.clearAllMocks();
validator = new IsUsernameFreeConstraint(
usersService as any
);
});
it('should return true for free username', async () => {
usersService.findByUsername
.mockResolvedValue(null);
const result = await validator.validate('new_user');
expect(result).toBe(true);
});
it('should return false for busy username', async () => {
usersService.findByUsername
.mockResolvedValue({
id: 1
});
const result = await validator.validate('admin');
expect(result).toBe(false);
});
it('should return false on error', async () => {
usersService.findByUsername
.mockRejectedValue(new Error());
const result = await validator.validate('admin');
expect(result).toBe(false);
});
it('should return proper message', () => {
expect(
validator.defaultMessage()
).toBe('Username already taken');
});
});