Моки и стабы для данных

При разработке приложений с использованием библиотеки Globalize часто возникает необходимость тестировать код, связанный с локализацией, форматированием дат, чисел, валют и сообщений. Прямая зависимость от реальных CLDR-данных делает тесты тяжёлыми, медленными и нестабильными. Для изоляции поведения используются моки (mocks) и стабы (stubs).

Моки и стабы позволяют:

  • изолировать бизнес-логику от реальных локализационных данных;
  • тестировать ошибки и нестандартные сценарии;
  • ускорять выполнение unit-тестов;
  • исключать сетевые запросы и загрузку JSON-файлов;
  • проверять вызовы API библиотеки;
  • имитировать различные локали.

Различие между mock и stub

Stub

Stub заменяет функцию или метод заранее определённым поведением.

Пример:

const stub = sinon.stub(Globalize, "locale");
stub.returns("ru");

В данном случае метод всегда возвращает "ru" независимо от входных параметров.

Основная задача stub:

  • подмена результата;
  • контроль состояния;
  • упрощение окружения теста.

Mock

Mock не только подменяет поведение, но и проверяет взаимодействие:

  • сколько раз вызван метод;
  • с какими аргументами;
  • в каком порядке происходили вызовы.

Пример:

const mock = sinon.mock(Globalize);

mock.expects("locale")
    .once()
    .withArgs("ru");

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


Проблемы тестирования Globalize

Зависимость от CLDR

Библиотека использует большие наборы данных Unicode CLDR:

Globalize.load(require("cldr-data/main/ru/numbers.json"));

Такие данные:

  • увеличивают размер тестов;
  • усложняют настройку окружения;
  • требуют загрузки множества JSON-файлов;
  • создают побочные эффекты между тестами.

Глобальное состояние

Globalize хранит текущую локаль глобально:

Globalize.locale("ru");

Это приводит к проблемам:

  • один тест влияет на другой;
  • локаль может не сбрасываться;
  • возникают race condition при параллельном запуске.

Необходимость имитации ошибок

Реальные CLDR-данные не всегда позволяют легко воспроизвести ошибку:

  • отсутствующая локаль;
  • повреждённые данные;
  • неправильный формат валюты;
  • исключения при форматировании.

Моки позволяют искусственно создавать такие ситуации.


Инструменты для моков и стабов

Sinon

Наиболее популярная библиотека.

Установка

npm install sinon --save-dev

Jest

Встроенные возможности мокирования.

npm install jest --save-dev

testdouble.js

Альтернативная библиотека для подмен.


Стабирование методов Globalize

Подмена locale()

Исходный код

function getCurrentLocale() {
    return Globalize.locale().locale;
}

Тест со stub

const sinon = require("sinon");

describe("Locale", () => {

    let localeStub;

    beforeEach(() => {
        localeStub = sinon.stub(Globalize, "locale");
    });

    afterEach(() => {
        localeStub.restore();
    });

    it("returns ru locale", () => {

        localeStub.returns({
            locale: "ru"
        });

        const result = getCurrentLocale();

        expect(result).toBe("ru");
    });

});

Стабирование numberFormatter

Исходный код

function formatPrice(price) {

    const formatter = Globalize.numberFormatter({
        minimumFractionDigits: 2
    });

    return formatter(price);
}

Тестирование formatter

describe("formatPrice", () => {

    let formatterStub;

    beforeEach(() => {

        formatterStub = sinon.stub(Globalize, "numberFormatter");

    });

    afterEach(() => {

        formatterStub.restore();

    });

    it("formats number", () => {

        formatterStub.returns(value => `formatted:${value}`);

        const result = formatPrice(100);

        expect(result).toBe("formatted:100");

    });

});

Имитация ошибок форматирования

Ошибка отсутствующей локали

sinon.stub(Globalize, "locale").throws(
    new Error("E_MISSING_CLDR")
);

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

function safeLocale() {

    try {

        return Globalize.locale();

    } catch (e) {

        return "fallback";

    }

}

Тест:

it("returns fallback on error", () => {

    sinon.stub(Globalize, "locale")
        .throws(new Error("Locale error"));

    const result = safeLocale();

    expect(result).toBe("fallback");

});

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

Работа с переводами

Globalize поддерживает ICU MessageFormat.

Пример:

const formatter = Globalize.messageFormatter("hello");

Изоляция переводов

Тесты не должны зависеть от реальных JSON-файлов переводов.

Mock formatter

sinon.stub(Globalize, "messageFormatter")
    .returns(name => `mock:${name}`);

Полный пример

function translate(name) {

    const formatter =
        Globalize.messageFormatter("hello");

    return formatter({
        name
    });

}

Тест:

describe("translate", () => {

    beforeEach(() => {

        sinon.stub(Globalize, "messageFormatter")
            .returns(data => `Hello ${data.name}`);

    });

    afterEach(() => {

        Globalize.messageFormatter.restore();

    });

    it("translates message", () => {

        expect(translate("Alex"))
            .toBe("Hello Alex");

    });

});

Моки для CLDR.load

Зачем мокировать загрузку

Метод:

Globalize.load(...)

обычно вызывается при инициализации приложения.

В unit-тестах загрузка реальных файлов:

  • не нужна;
  • замедляет тесты;
  • требует файловой системы.

Подмена load()

sinon.stub(Globalize, "load");

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

it("loads cldr data", () => {

    const loadStub =
        sinon.stub(Globalize, "load");

    initializeI18n();

    expect(loadStub.called).toBe(true);

    loadStub.restore();

});

Изоляция глобального состояния

Проблема locale()

Globalize.locale("ru");

Меняет состояние глобально.


Сброс локали

afterEach(() => {
    Globalize.locale("en");
});

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

Создание sandbox

const sandbox = sinon.createSandbox();

Пример

describe("i18n", () => {

    let sandbox;

    beforeEach(() => {

        sandbox = sinon.createSandbox();

    });

    afterEach(() => {

        sandbox.restore();

    });

    it("uses mocked locale", () => {

        sandbox.stub(Globalize, "locale")
            .returns({
                locale: "de"
            });

    });

});

Sandbox автоматически восстанавливает оригинальные методы.


Jest и Globalize

Использование jest.fn()

Подмена метода

Globalize.locale = jest.fn();

Возврат значения

Globalize.locale.mockReturnValue({
    locale: "fr"
});

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

expect(Globalize.locale)
    .toHaveBeenCalled();

Полный пример на Jest

describe("locale service", () => {

    beforeEach(() => {

        Globalize.locale = jest.fn();

    });

    it("returns current locale", () => {

        Globalize.locale.mockReturnValue({
            locale: "it"
        });

        const result =
            getCurrentLocale();

        expect(result).toBe("it");

    });

});

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

Исходная функция

function formatDate(date) {

    const formatter =
        Globalize.dateFormatter({
            datetime: "medium"
        });

    return formatter(date);

}

Подмена formatter

sinon.stub(Globalize, "dateFormatter")
    .returns(() => "01.01.2025");

Тест

it("formats date", () => {

    const result =
        formatDate(new Date());

    expect(result)
        .toBe("01.01.2025");

});

Стабы для currencyFormatter

Исходный код

function formatCurrency(value) {

    const formatter =
        Globalize.currencyFormatter("USD");

    return formatter(value);

}

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

describe("currency", () => {

    beforeEach(() => {

        sinon.stub(Globalize, "currencyFormatter")
            .returns(value => `$${value}`);

    });

    afterEach(() => {

        Globalize.currencyFormatter.restore();

    });

    it("formats currency", () => {

        expect(formatCurrency(50))
            .toBe("$50");

    });

});

Мокирование асинхронной загрузки

Пример async инициализации

async function init() {

    const data =
        await fetch("/i18n/ru.json");

    const json =
        await data.json();

    Globalize.loadMessages(json);

}

Mock fetch

global.fetch = jest.fn(() =>
    Promise.resolve({
        json: () => Promise.resolve({
            hello: "Привет"
        })
    })
);

Проверка загрузки сообщений

it("loads translations", async () => {

    Globalize.loadMessages = jest.fn();

    await init();

    expect(Globalize.loadMessages)
        .toHaveBeenCalled();

});

Частичные моки

Иногда требуется подменить только часть API.


Пример

const original =
    Globalize.numberFormatter;

sinon.stub(Globalize, "numberFormatter")
    .callsFake((options) => {

        if (options.currency) {

            return () => "$100";

        }

        return original(options);

    });

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

Assert вызова

const stub =
    sinon.stub(Globalize, "locale");

Globalize.locale("ru");

sinon.assert.calledWith(
    stub,
    "ru"
);

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

sinon.assert.calledOnce(stub);

Мокирование цепочек вызовов

Проблема chain API

Globalize.locale("ru")
    .numberFormatter();

Решение

sinon.stub(Globalize, "locale")
    .returns({
        numberFormatter: () => value =>
            `formatted:${value}`
    });

Фикстуры и моки

Fixture-данные

Вместо настоящих CLDR-файлов используются минимальные данные.

Пример

const fakeCldr = {
    main: {
        ru: {
            identity: {
                language: "ru"
            }
        }
    }
};

Создание helper-функций

Универсальный mock formatter

function mockFormatter(result) {

    return sinon.stub()
        .returns(() => result);

}

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

sinon.stub(Globalize, "dateFormatter")
    .callsFake(
        mockFormatter("fake-date")
    );

Тестирование fallback-логики

Исходный код

function getLocale(locale) {

    try {

        Globalize.locale(locale);

        return locale;

    } catch {

        return "en";

    }

}

Тест

it("falls back to en", () => {

    sinon.stub(Globalize, "locale")
        .throws();

    expect(getLocale("unknown"))
        .toBe("en");

});

Антипаттерны

Использование реальных CLDR в unit-тестах

Плохо:

Globalize.load(
    require("cldr-data")
);

Причины:

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

Отсутствие restore()

Опасный код:

sinon.stub(Globalize, "locale");

Без:

restore()

stub остаётся активным для следующих тестов.


Мокирование всего Globalize

Плохо:

global.Globalize = {};

Это ломает внутреннее API библиотеки.

Лучше подменять только необходимые методы.


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

Моки полезны для unit-тестов, однако интеграционные тесты должны использовать реальные данные.


Комбинированный подход

Unit

  • моки;
  • стабы;
  • изоляция.

Integration

  • реальные CLDR;
  • реальные локали;
  • проверка совместимости.

Архитектура тестируемого кода

Обёртка над Globalize

Плохой подход

Globalize.numberFormatter()(100);

во всех местах приложения.


Хороший подход

export function formatNumber(value) {

    return Globalize
        .numberFormatter()(value);

}

Теперь тестируется только wrapper.


Dependency Injection

Передача formatter извне

function createPriceService(formatter) {

    return {

        format(price) {

            return formatter(price);

        }

    };

}

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

const formatter =
    sinon.stub().returns("$100");

const service =
    createPriceService(formatter);

expect(service.format(100))
    .toBe("$100");

Изоляция locale-dependent логики

Проблемный код

if (Globalize.locale().locale === "ru")

Более тестируемый вариант

function isRussian(locale) {

    return locale === "ru";

}

Snapshot-тестирование

Пример

expect(formatPrice(100))
    .toMatchSnapshot();

Проблемы snapshot

Форматирование зависит от:

  • версии ICU;
  • платформы;
  • версии Node.js;
  • локали ОС.

Из-за этого snapshot-тесты могут становиться нестабильными.


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

Структура проекта

tests/
├── mocks/
│   ├── globalize.js
│   ├── cldr.js
│   └── messages.js

Централизованный mock Globalize

Пример

module.exports = {

    locale: jest.fn(),

    numberFormatter: jest.fn(),

    dateFormatter: jest.fn(),

    currencyFormatter: jest.fn()

};

Тестирование pluralization

Mock pluralGenerator

sinon.stub(Globalize, "pluralGenerator")
    .returns(value => {

        return value > 1
            ? "other"
            : "one";

    });

Проверка поведения plural rules

function getPlural(count) {

    const plural =
        Globalize.pluralGenerator();

    return plural(count);

}

Тест:

expect(getPlural(1))
    .toBe("one");

Производительность тестов

Использование моков существенно снижает:

  • объём памяти;
  • время запуска;
  • количество загружаемых JSON;
  • нагрузку на файловую систему.

Особенно заметно это в больших monorepo и CI/CD-системах.


Лучшие практики

Минимальная подмена

Мокируется только необходимое API.


Полная изоляция тестов

Каждый тест:

  • создаёт собственные моки;
  • очищает состояние;
  • не влияет на соседние тесты.

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

Sandbox упрощает:

  • очистку;
  • restore;
  • управление жизненным циклом mock/stub.

Отдельные unit и integration тесты

Не следует смешивать:

  • быстрые unit-тесты;
  • полноценную проверку локализации.

Wrapper-архитектура

Работа с Globalize через собственный abstraction layer:

  • упрощает моки;
  • снижает связность;
  • облегчает миграцию на другую i18n-библиотеку;
  • повышает тестируемость кода.