Мокирование асинхронных валидаторов

Асинхронные валидаторы в библиотеке class-validator используются в ситуациях, когда проверка значения зависит от внешнего источника данных:

  • базы данных;
  • HTTP API;
  • файловой системы;
  • Redis;
  • очередей сообщений;
  • внешних сервисов авторизации.

Типичный пример — проверка уникальности email пользователя:

@Validate(IsEmailUniqueConstraint)
email: string;

Внутри валидатора может выполняться SQL-запрос или HTTP-вызов. Такие проверки работают асинхронно и требуют специального тестирования.


Почему асинхронные валидаторы сложно тестировать

Асинхронные валидаторы создают несколько проблем:

  1. Появляется зависимость от внешнего сервиса.
  2. Тесты становятся медленнее.
  3. Возможны нестабильные результаты.
  4. Возникает необходимость управлять Promise.
  5. Требуется изоляция логики валидатора.

Пример проблемного теста:

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

Если валидатор делает настоящий запрос в базу данных, тест:

  • зависит от состояния БД;
  • может падать из-за сети;
  • требует подготовленных данных;
  • становится интеграционным, а не unit-тестом.

Поэтому используется мокирование.


Архитектура асинхронного валидатора

Базовый пример

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 библиотека будет считать валидатор синхронным.


Использование валидатора в DTO

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-объект.


Подмена HTTP-клиента

Если валидатор обращается к API:

const response = await axios.get(...);

мокируется axios.


Подмена сервиса через DI

При использовании:

  • NestJS;
  • typedi;
  • inversify;

можно мокировать зависимости через контейнер.


Spy-функции

Подход основан на:

jest.spyOn()

Позволяет отслеживать вызовы и подменять реализацию.


Мокирование репозитория через Jest

Исходный валидатор

@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint
  implements ValidatorConstraintInterface {

  async validate(email: string) {
    const user = await UserRepository.findByEmail(email);

    return !user;
  }
}

Unit-тест

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

mockResolvedValue и mockRejectedValue

mockResolvedValue

Используется для успешного Promise:

jest.spyOn(UserRepository, 'findByEmail')
  .mockResolvedValue(null);

Эквивалент:

mockImplementation(() => Promise.resolve(null))

mockRejectedValue

Используется для ошибок:

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

Мокирование через ручной mock-объект

Репозиторий

export class UserRepository {
  static async findByEmail(email: string) {
    return null;
  }
}

Mock

const mockRepository = {
  findByEmail: jest.fn()
};

Использование

mockRepository.findByEmail.mockResolvedValue({
  id: 1
});

Dependency Injection и мокирование

Проблема жёсткой зависимости

Плохой вариант:

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

Такой код значительно проще тестировать.


Мокирование DI в NestJS

Валидатор

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

Мокирование axios

Валидатор

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

Mock axios

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

Проверка вызовов mock-функций

Проверка аргументов

expect(UserRepository.findByEmail)
  .toHaveBeenCalledWith('admin@mail.com');

Проверка количества вызовов

expect(UserRepository.findByEmail)
  .toHaveBeenCalledTimes(1);

Проверка факта вызова

expect(UserRepository.findByEmail)
  .toHaveBeenCalled();

Мокирование TypeORM

Валидатор

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

Мокирование Prisma

Валидатор

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

Mock Prisma

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

Валидатор

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

Проверка ValidationError

Полный объект ошибки

const errors = await validate(dto);

console.log(errors);

Результат:

[
  {
    property: 'email',
    constraints: {
      IsEmailUniqueConstraint:
        'Email already exists'
    }
  }
]

Проверка constraints

expect(errors[0].constraints)
  .toHaveProperty(
    'IsEmailUniqueConstraint'
  );

Проверка текста ошибки

expect(
  errors[0].constraints?.IsEmailUniqueConstraint
).toBe('Email already exists');

Тестирование нескольких асинхронных валидаторов

DTO

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

Promise.all внутри валидатора

Пример

async validate(value: string) {
  const [user, banned] = await Promise.all([
    this.usersService.find(value),
    this.banService.find(value)
  ]);

  return !!user && !banned;
}

Тестирование Promise.all

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

Такие задержки замедляют тесты.


Fake Timers

Активация

jest.useFakeTimers();

Пример

it('should handle timeout', async () => {
  const promise = validator.validate('test');

  jest.runAllTimers();

  const result = await promise;

  expect(result).toBe(true);
});

Частые ошибки

Отсутствие await

Проблема:

const errors = validate(dto);

errors будет Promise.

Правильно:

const errors = await validate(dto);

Смешивание unit и integration тестов

Неправильно:

await validate(dto);

при реальном подключении к базе данных.

Unit-тесты должны использовать mock.


Неочищенные mock-функции

Проблема:

jest.spyOn(...)

без:

jest.clearAllMocks();

Моки начинают влиять на другие тесты.


Отсутствие async: true

Ошибка:

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