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

При разработке сложных приложений стандартных декораторов class-validator часто недостаточно. Появляются проверки уникальности данных, сравнения свойств, обращения к базе данных, проверки бизнес-логики и интеграции с внешними сервисами. Такие сценарии требуют создания кастомных валидаторов.

Любой пользовательский валидатор становится частью критической инфраструктуры приложения. Ошибки в нём приводят к:

  • пропуску невалидных данных;
  • ложным ошибкам валидации;
  • неконсистентности DTO;
  • сбоям API;
  • некорректной работе ORM;
  • проблемам безопасности.

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


Структура кастомного валидатора

Типичный кастомный валидатор состоит из двух частей:

  1. Класса-валидатора;
  2. Декоратора-обёртки.

Пример проверки совпадения паролей:

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

Настройка Jest

Конфигурация:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node'
};

Подключение metadata:

import 'reflect-metadata';

Тестирование через validate

Основной способ тестирования кастомных валидаторов — вызов функции 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

Метод defaultMessage тоже является частью логики.

Проверка:

it('должен возвращать корректное сообщение', () => {
  const validator = new MatchPasswordsConstraint();

  const message = validator.defaultMessage(
    {} as any
  );

  expect(message).toBe('Пароли не совпадают');
});

Unit-тестирование validate

Иногда полноценная валидация 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);
  });

});

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

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

  • проверка email в базе;
  • проверка существования пользователя;
  • запросы к API;
  • проверка токенов;
  • проверка уникальности.

Пример:

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

  async validate(email: string) {
    const user = await User.findOne({
      wh ere: { email }
    });

    return !user;
  }
}

Тестирование async validate

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

});

Mocking базы данных

Кастомные валидаторы не должны использовать реальную базу данных в unit-тестах.

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

await prisma.user.findUnique(...)

без моков.

Хороший вариант:

jest.spyOn(prisma.user, 'findUnique')
  .mockResolvedValue(null);

Изоляция логики валидатора

Валидатор должен тестироваться независимо от:

  • HTTP;
  • Express;
  • NestJS;
  • PostgreSQL;
  • Redis;
  • внешних API.

Тест проверяет только бизнес-логику.


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

Иногда важно убедиться, что валидатор не делает лишних запросов.

Пример:

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

Многие кастомные валидаторы используют 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);
});

Тестирование edge cases

Особое внимание уделяется пограничным случаям.

Например:

null
undefined
''
0
[]
{}
NaN

Пример edge case тестов

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

Snapshot-тестирование ошибок

Jest поддерживает snapshot-тесты.

Пример:

it('должен соответствовать snapshot', async () => {

  const dto = new RegisterDto();

  dto.password = '111';
  dto.confirmPassword = '222';

  const errors = await validate(dto);

  expect(errors).toMatchSnapshot();
});

Snapshot полезен для:

  • сложных DTO;
  • больших ValidationError;
  • вложенных объектов;
  • проверки регрессий.

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

При использовании 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);
});

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

Некоторые валидаторы выполняются очень часто.

Например:

  • валидация больших DTO;
  • обработка массивов;
  • batch API;
  • импорт CSV.

Проверка производительности:

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

Для синхронных валидаторов используется 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

В NestJS кастомные валидаторы часто используются внутри ValidationPipe.

Тест DTO:

const errors = await validate(dto, {
  whitelist: true,
  forbidNonWhitelisted: true
});

Проверяется:

  • совместимость pipe;
  • удаление лишних полей;
  • поведение transform;
  • корректность DTO.

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

Unit-тесты проверяют валидатор изолированно.

Интеграционные тесты проверяют:

  • DTO;
  • pipe;
  • контроллер;
  • HTTP;
  • сериализацию;
  • взаимодействие компонентов.

Пример:

await request(app.getHttpServer())
  .post('/users')
  .send({
    password: '111',
    confirmPassword: '222'
  })
  .expect(400);

Разделение unit и integration тестов

Тип теста Что проверяет
Unit Логику валидатора
Integration Работу системы целиком
E2E Поведение API

Проверка регрессий

После исправления бага обязательно создаётся тест.

Пример:

it('не должен падать на null', () => {
  ...
});

Это защищает код от повторного появления ошибки.


Тестирование кастомных constraint options

Пример:

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

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

let validator: MatchPasswordsConstraint;

beforeEach(() => {
  validator =
    new MatchPasswordsConstraint();
});

Очистка mock

afterEach(() => {
  jest.clearAllMocks();
});

Типичные ошибки тестирования

Проверка только errors.length

Плохо:

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

Лучше:

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

Использование реальной БД

Unit-тесты должны быть быстрыми и изолированными.


Отсутствие edge case тестов

Часто ошибки появляются именно на:

  • null;
  • undefined;
  • пустых строках;
  • пустых массивах.

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

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('Пароли не совпадают');
  });

});