Мокирование bcrypt в тестах: когда и зачем

bcrypt используется для криптографического хеширования паролей и построен вокруг дорогой по вычислениям функции, что делает его медленным по своей природе. Именно эта особенность становится ключевой проблемой в тестовой среде: полноценные вызовы bcrypt.hash() и bcrypt.compare() могут существенно замедлять выполнение тестов, нарушать детерминированность и усложнять изоляцию бизнес-логики.

В тестах почти никогда не требуется проверять сам алгоритм хеширования. Его корректность уже гарантирована библиотекой и многолетним использованием в индустрии. Тесты должны проверять поведение приложения, а не криптографические примитивы.

Основная причина замены bcrypt в тестах — контроль над средой выполнения и скоростью.

bcrypt использует вычислительно затратные операции (work factor / salt rounds). Даже при минимальных настройках:

bcrypt.hash(password, 10)

операция остаётся относительно медленной, особенно при массовом запуске тестов.

Проблемы, которые возникают без мокирования:

  • заметное увеличение времени выполнения тестового набора
  • нестабильные тесты при параллельном запуске
  • лишняя нагрузка на CI/CD пайплайны
  • невозможность легко воспроизводить результаты
  • усложнение тестирования негативных сценариев

Кроме того, bcrypt возвращает разные хеши даже для одинакового входа из-за соли. Это делает невозможным прямое сравнение значений в тестах.

Когда мокирование оправдано

Мок bcrypt уместен в следующих случаях:

  • unit-тестирование сервисов аутентификации
  • проверка бизнес-логики регистрации и логина
  • тестирование обработчиков контроллеров
  • проверка сценариев ошибок без реального хеширования
  • тестирование потоков, где пароль не является объектом теста

Когда bcrypt не следует мокировать:

  • интеграционные тесты аутентификации
  • тесты миграции паролей
  • end-to-end сценарии логина/регистрации
  • проверка совместимости с реальной базой пользователей

Иначе можно получить ситуацию, когда тесты проходят, но реальная система ломается.

Подходы к мокированию bcrypt

Существует несколько устойчивых стратегий, каждая из которых подходит под разные архитектурные решения.

1. Прямое мокирование модуля (Jest)

Самый распространённый подход — замена bcryptjs через механизмы тестового фреймворка.

Пример с Jest:

jest.mock('bcryptjs', () => ({
  hash: jest.fn(),
  compare: jest.fn()
}));

Далее задаётся поведение:

const bcrypt = require('bcryptjs');

bcrypt.hash.mockResolvedValue('hashed_password');
bcrypt.compare.mockResolvedValue(true);

Такой подход позволяет:

  • полностью контролировать поведение
  • ускорить тесты до минимального времени выполнения
  • легко симулировать любые сценарии

Минус заключается в жёсткой привязке тестов к реализации модуля.

2. Фейковая реализация (manual mock)

Более стабильный подход — создание собственной упрощённой версии bcrypt.

// __mocks__/bcryptjs.js

module.exports = {
  hash: async (password) => `hashed_${password}`,
  compare: async (password, hash) => hash === `hashed_${password}`
};

Преимущества:

  • простота
  • отсутствие зависимости от фреймворка моков
  • предсказуемое поведение

Недостатки:

  • невозможность эмуляции сложных сценариев
  • риск расхождения с реальной логикой

Этот вариант хорошо подходит для unit-тестов бизнес-логики.

3. Dependency Injection

Наиболее гибкий и архитектурно правильный способ — не мокировать библиотеку напрямую, а абстрагировать её через сервис.

Пример:

class AuthService {
  constructor(hasher) {
    this.hasher = hasher;
  }

  async register(password) {
    const hash = await this.hasher.hash(password);
    return hash;
  }

  async login(password, hash) {
    return this.hasher.compare(password, hash);
  }
}

В реальном коде:

const bcrypt = require('bcryptjs');

const bcryptAdapter = {
  hash: bcrypt.hash,
  compare: bcrypt.compare
};

В тестах:

const fakeHasher = {
  hash: async (p) => `hashed_${p}`,
  compare: async (p, h) => h === `hashed_${p}`
};

Такой подход:

  • полностью отделяет бизнес-логику от криптографии
  • делает тесты независимыми от библиотек
  • упрощает миграцию (например, на argon2)

Типичные ошибки при мокировании bcrypt

1. Мокирование без учёта асинхронности

bcrypt возвращает Promise. Частая ошибка — синхронные моки:

bcrypt.hash.mockReturnValue('hash'); // ошибка

Правильно:

bcrypt.hash.mockResolvedValue('hash');

2. Проверка хеша как строки

Хеш нельзя использовать как стабильное значение:

expect(await bcrypt.hash('123', 10)).toBe('some_hash'); // плохой тест

Причина — соль и внутренние параметры алгоритма.

3. Мокирование только одного метода

Часто мокают hash, но забывают про compare. Это приводит к несоответствию поведения логики аутентификации.

4. Использование реального bcrypt в unit-тестах

Это приводит к:

  • замедлению тестов
  • нестабильности при параллельном запуске
  • ненужной криптографической нагрузке

Баланс между моками и реальной криптографией

Полное исключение bcrypt из тестов тоже является ошибкой. Правильная стратегия — разделение уровней тестирования:

  • unit-тесты: мок bcrypt полностью
  • интеграционные тесты: использовать реальный bcrypt
  • e2e: проверка полной цепочки с реальной базой

Такой подход обеспечивает одновременно:

  • скорость разработки
  • уверенность в корректности системы
  • устойчивость к рефакторингу

Поведение моков в сложных сценариях

Иногда требуется имитировать более сложное поведение bcrypt:

  • ошибки хеширования
  • задержки выполнения
  • несовпадение пароля

Пример эмуляции ошибки:

bcrypt.hash.mockRejectedValue(new Error('hash error'));

Пример задержки:

bcrypt.compare.mockImplementation(
  () => new Promise(resolve => setTimeout(() => resolve(true), 50))
);

Это позволяет тестировать:

  • обработку исключений
  • таймауты
  • устойчивость сервисов

Влияние мокирования на архитектуру

Правильное мокирование часто выявляет архитектурные проблемы. Если bcrypt сложно заменить в тестах, это сигнал о:

  • сильной связности кода
  • отсутствии абстракций
  • нарушении принципа dependency inversion

Решение почти всегда одно — вынести криптографию в отдельный слой.

Сравнение подходов

Прямой мок:

  • быстро внедряется
  • плохо масштабируется
  • привязан к тестовому фреймворку

Manual mock:

  • простая реализация
  • стабильность выше
  • ограниченная гибкость

Dependency Injection:

  • архитектурно корректен
  • требует предварительного проектирования
  • лучший вариант для долгосрочных проектов

Поведение bcrypt и тестовая предсказуемость

bcrypt специально спроектирован так, чтобы исключить предсказуемость выходных данных. Это делает его надёжным с точки зрения безопасности, но неудобным для тестирования.

Мокирование в этом контексте — не обходной путь, а обязательная часть стратегии тестирования, позволяющая отделить криптографию от логики приложения.

При правильной организации тестов bcrypt перестаёт быть источником сложности и превращается в заменяемый инфраструктурный компонент, не влияющий на проверку бизнес-правил.