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

При тестировании модулей, работающих с зашифрованными токенами Iron (@hapi/iron), ключевая проблема заключается в том, что операции seal и unseal зависят от криптографического ключа, настроек безопасности и иногда временных параметров. Прямое использование реальной криптографии в юнит-тестах приводит к нестабильности, медленному выполнению и сложной поддержке тестового окружения.

Библиотека Iron используется для «упаковки» объекта в защищённую строку и последующего восстановления исходного объекта.

Типичный сценарий:

import Iron from '@hapi/iron';

const password = 'super-secure-password';

const data = {
  userId: 42,
  role: 'admin'
};

const sealed = await Iron.seal(data, password, Iron.defaults);
const unsealed = await Iron.unseal(sealed, password, Iron.defaults);

В реальном приложении это полезно для хранения сессионных данных без обращения к базе. Однако в тестах это создаёт избыточную нагрузку и ненужную зависимость от реализации криптографии.


Причины мокирования токенов

Мокирование необходимо в случаях, когда:

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

Стратегии мокирования Iron

Замена реализации seal и unseal

Самый простой подход — подмена функций библиотеки:

jest.mock('@hapi/iron', () => ({
  seal: jest.fn(async (obj) => {
    return `sealed-${JSON.stringify(obj)}`;
  }),

  unseal: jest.fn(async (sealed) => {
    const json = sealed.replace('sealed-', '');
    return JSON.parse(json);
  }),

  defaults: {}
}));

Такой подход делает тесты полностью предсказуемыми и исключает криптографическую зависимость.


Мокирование через dependency injection

Более архитектурно правильный способ — не импортировать Iron напрямую в бизнес-логику.

export function createTokenService({ iron, password }) {
  return {
    async sign(payload) {
      return iron.seal(payload, password, iron.defaults);
    },

    async verify(token) {
      return iron.unseal(token, password, iron.defaults);
    }
  };
}

Тест:

const mockIron = {
  seal: jest.fn(async (obj) => `mocked-${obj.userId}`),
  unseal: jest.fn(async (token) => ({ userId: Number(token.split('-')[1]) })),
  defaults: {}
};

const service = createTokenService({
  iron: mockIron,
  password: 'test'
});

Такой подход полностью отделяет тест от библиотеки.


Использование фиктивных токенов

Иногда достаточно не мокать библиотеку, а заменить сами токены на фиктивные значения:

const fakeToken = 'iron:mock.token.value';

Используется в интеграционных тестах, где важна структура запросов, а не криптография.


Изоляция секретов в тестовой среде

Критическая ошибка — использование реального password или key из production в тестах.

Правильный подход:

const TEST_PASSWORD = 'test-password-only';

или через переменные окружения:

process.env.IRON_PASSWORD = 'test-password';

Проблемы при мокировании Iron

Несовместимость структуры данных

Если мок возвращает не тот формат, что реальный unseal, могут возникнуть ошибки в логике:

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

Рекомендуется сохранять структуру максимально близкой к реальной.


Потеря проверки сериализации

Iron фактически проверяет возможность сериализации объекта. При полном мокировании эта проверка исчезает, и можно случайно пропустить ошибки в данных.

Решение — добавлять отдельные тесты без моков для критических сценариев.


Асинхронность

Оригинальные методы Iron асинхронны. Мок должен сохранять это поведение:

seal: jest.fn(async () => 'token')

Иначе тесты могут давать ложные результаты.


Частичное мокирование (partial mock)

Иногда требуется сохранить оригинальную реализацию, изменив только поведение:

jest.mock('@hapi/iron', () => {
  const actual = jest.requireActual('@hapi/iron');

  return {
    ...actual,
    seal: jest.fn(actual.seal),
    unseal: jest.fn(actual.unseal)
  };
});

Такой подход полезен, когда важно тестировать взаимодействие, но контролировать результат.


Мокирование ошибок и негативных сценариев

Для проверки обработки ошибок необходимо имитировать сбои:

iron.unseal.mockRejectedValue(new Error('Invalid token'));

или

seal: jest.fn(async () => {
  throw new Error('Encryption failed');
});

Это позволяет тестировать устойчивость системы к повреждённым токенам.


Тестирование бизнес-логики без Iron

При правильной архитектуре тесты не должны зависеть от Iron вообще.

Пример:

function isAdmin(session) {
  return session.role === 'admin';
}

Тест:

expect(isAdmin({ role: 'admin' })).toBe(true);

Здесь мокирование токенов уже не требуется — декодирование вынесено за пределы логики.


Разделение уровней тестирования

Мокирование Iron обычно применяется только в:

  • unit-тестах (полная изоляция)
  • частично в integration-тестах (контроль внешних зависимостей)

В e2e-тестах мокирование часто отключается, чтобы проверить реальный поток данных:

  • seal → API → unseal
  • корректность ключей
  • совместимость версий библиотеки

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

  • возврат синхронного значения вместо Promise
  • несоответствие структуры объекта
  • использование разных ключей в seal/unseal
  • случайное изменение алгоритма сериализации
  • отсутствие покрытия негативных сценариев

Практика организации моков

Часто удобно выносить мок в отдельный файл:

// __mocks__/@hapi/iron.js

module.exports = {
  seal: jest.fn(async (obj) => `mock-${obj.userId}`),
  unseal: jest.fn(async (token) => ({ userId: Number(token.replace('mock-', '')) })),
  defaults: {}
};

Это упрощает поддержку тестов и убирает дублирование.


Подходы к балансировке реальности и изоляции

Полное мокирование ускоряет тесты, но снижает достоверность. Полное отсутствие моков повышает точность, но делает тесты медленными и нестабильными.

Практическое решение:

  • мокировать в unit-тестах
  • частично мокировать в сервисных тестах
  • не мокировать в e2e

Так сохраняется баланс между скоростью и надёжностью проверки поведения системы.