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

При работе с библиотекой Cleave.js часто возникает необходимость тестировать код, связанный с форматированием пользовательского ввода, не используя полноценный браузерный интерфейс. В таких сценариях применяются моки (mocks), стабы (stubs) и фейковые объекты.

Основные причины использования моков:

  • изоляция тестируемого кода;
  • проверка логики без реального DOM;
  • имитация пользовательского ввода;
  • контроль callback-функций;
  • тестирование edge-case сценариев;
  • ускорение unit-тестов.

Cleave.js активно взаимодействует с:

  • DOM-элементами;
  • событиями клавиатуры;
  • состоянием input-поля;
  • callback-функциями;
  • внутренними методами форматирования.

Из-за этого библиотека требует специального подхода к тестированию.


Разница между mock, stub и fake

Stub

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

Пример:

const getRawValueStub = () => '4111111111111111';

Stub обычно не проверяет количество вызовов и аргументы.


Mock

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

Пример с Jest:

const onValueChangedM ock = jest.fn();

Mock позволяет проверять:

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

Fake

Fake представляет собой упрощённую реализацию настоящего объекта.

Пример fake input:

const fakeInput = {
    value: '',
    addEventListener() {},
    removeEventListener() {}
};

Особенности тестирования Cleave.js

Почему Cleave.js сложно тестировать

Библиотека:

  • использует DOM API;
  • слушает события клавиатуры;
  • модифицирует input.value;
  • управляет позицией курсора;
  • хранит внутреннее состояние;
  • вызывает callbacks во время ввода.

Обычный unit-тест без моков часто приводит к ошибкам:

TypeError: Cannot read property 'selectionEnd' of undefined

или:

document is not defined

Создание моков DOM-элементов

Минимальный mock input-элемента

Для многих тестов достаточно базовой структуры:

const inputMock = {
    value: '',
    selectionStart: 0,
    selectionEnd: 0,

    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),

    setSelectionRange: jest.fn()
};

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

Некоторые свойства input являются readonly.

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

Object.defineProperty(inputMock, 'rawValue', {
    value: '123456',
    writable: true
});

Мокирование callback-функций

Проверка onValueChanged

Одной из наиболее важных функций Cleave.js является callback onValueChanged.

Пример:

const onValueChan ged = jest.fn();

const cleave = new Cleave(input, {
    creditCard: true,
    onValueChanged
});

Проверка:

expect(onValueChanged).toHaveBeenCalled();

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

expect(onValueChanged).toHaveBeenCalledWith(
    expect.objectContaining({
        target: expect.objectContaining({
            value: '4111 1111 1111 1111'
        })
    })
);

Мокирование методов Cleave.js

Подмена getRawValue

Иногда необходимо изолировать код от реального форматирования.

Пример:

jest.spyOn(cleave, 'getRawValue')
    .mockReturnValue('4111111111111111');

Теперь метод всегда возвращает фиксированное значение.


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

jest.spyOn(cleave, 'destroy')
    .mockImplementation(() => {});

Подобный подход полезен при тестировании cleanup-логики.


Jest и Cleave.js

Базовая настройка Jest

Часто используется связка:

npm install jest jsdom --save-dev

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

module.exports = {
    testEnvironment: 'jsdom'
};

jsdom обеспечивает имитацию браузерного окружения.


Создание input через jsdom

Реальный DOM внутри тестов

document.body.innerHTML = `
    <input id="card" />
`;

const input = document.getElementById('card');

После этого Cleave.js может работать почти как в браузере.


Стабирование событий

Имитация input-события

const event = new Event('input');

input.value = '4111111111111111';

input.dispatchEvent(event);

Имитация keydown

const keydown = new KeyboardEvent('keydown', {
    key: '1'
});

input.dispatchEvent(keydown);

Мокирование selection API

Проблема курсора

Cleave.js активно работает с:

  • selectionStart;
  • selectionEnd;
  • setSelectionRange.

Без их мокирования тесты становятся нестабильными.


Пример полноценного mock selection

Object.defineProperty(input, 'selectionStart', {
    writable: true,
    value: 0
});

Object.defineProperty(input, 'selectionEnd', {
    writable: true,
    value: 0
});

input.setSelectionRange = jest.fn();

Моки для credit card formatter

Изоляция логики форматирования

При тестировании бизнес-логики форматирование карты может быть несущественным.

Тогда formatter стабируется:

jest.spyOn(Cleave.DefaultProperties, 'creditCard')
    .mockImplementation(() => {});

Mock timer API

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

Некоторые сценарии используют debounce или асинхронные операции.

Jest позволяет стабировать таймеры:

jest.useFakeTimers();

Управление временем

setTimeout(callback, 1000);

jest.advanceTimersByTime(1000);

expect(callback).toHaveBeenCalled();

Мокирование paste-событий

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

const pasteEvent = new Event('paste');

pasteEvent.clipboardData = {
    getData: () => '4111111111111111'
};

input.dispatchEvent(pasteEvent);

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

Некоторые плагины и окружения используют navigator.

Пример:

Object.defineProperty(global, 'navigator', {
    value: {
        userAgent: 'jest'
    },
    configurable: true
});

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

Подмена глобальных объектов

global.window = Object.create(window);

Object.defineProperty(window, 'innerWidth', {
    value: 1920
});

Тестирование React-компонентов с Cleave.js

Mock Cleave component

В React-проектах часто стабируют сам компонент Cleave.

Пример:

jest.mock('cleave.js/react', () => {
    return function MockCleave(props) {
        return (
            <input
                data-testid="cleave"
                onCha nge={props.onChange}
            />
        );
    };
});

Проверка интеграции с React Testing Library

Имитация ввода

fireEvent.change(input, {
    target: {
        value: '4111111111111111'
    }
});

Проверка результата

expect(input.value)
    .toBe('4111 1111 1111 1111');

Мокирование Vue-компонентов

Стабирование Cleave wrapper

global.stubs = {
    Cleave: {
        template: '<input />'
    }
};

Мокирование Angular-интеграции

Создание fake directive

class FakeCleaveDirective {
    writeValue() {}
    registerOnChange() {}
}

SpyOn и Cleave.js

Контроль вызовов методов

const spy = jest.spyOn(cleave, 'setRawValue');

cleave.setRawValue('1234');

expect(spy).toHaveBeenCalledWith('1234');

Частые проблемы при мокировании

Потеря контекста this

Ошибка:

Cannot read property of undefined

Причина:

const method = cleave.getRawValue;

method();

Контекст теряется.


Решение через bind

const method = cleave.getRawValue.bind(cleave);

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

Мок для jsdom

jsdom не всегда содержит requestAnimationFrame.

Исправление:

global.requestAnimationFrame = callback => {
    return setTimeout(callback, 0);
};

Mock implementation для сложных сценариев

Динамическое поведение

const formatterMock = jest.fn(value => {
    return value.toUpperCase();
});

Последовательные результаты

formatterMock
    .mockReturnValueOnce('1111')
    .mockReturnValueOnce('2222');

Мокирование импортов

Полная подмена библиотеки

jest.mock('cleave.js', () => {
    return jest.fn().mockImplementation(() => {
        return {
            destroy: jest.fn(),
            getRawValue: jest.fn(() => '1234')
        };
    });
});

Partial mock

Частичная подмена

jest.mock('cleave.js', () => {
    const original = jest.requireActual('cleave.js');

    return {
        ...original,
        formatCreditCard: jest.fn()
    };
});

Стабирование ошибок

Проверка обработки исключений

jest.spyOn(cleave, 'setRawValue')
    .mockImplementation(() => {
        throw new Error('Formatting failed');
    });

Проверка:

expect(() => {
    cleave.setRawValue('123');
}).toThrow('Formatting failed');

Mock chain methods

Цепочки вызовов

const mockApi = {
    setRawValue: jest.fn().mockReturnThis(),
    destroy: jest.fn().mockReturnThis()
};

Очистка моков

clearAllMocks

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

resetAllMocks

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

Разница между clear и reset

clearAllMocks

Очищает:

  • историю вызовов;
  • аргументы;
  • counters.

Но сохраняет implementation.


resetAllMocks

Полностью сбрасывает:

  • implementation;
  • return values;
  • вызовы.

Изоляция тестов

Проблемы shared state

Если один экземпляр Cleave.js используется между тестами:

const cleave = new Cleave(...);

состояние может утекать между test cases.


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

beforeEach(() => {
    input = document.createElement('input');

    cleave = new Cleave(input, {
        numeral: true
    });
});

Deep mock DOM API

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

jest.spyOn(document, 'createElement')
    .mockImplementation(tag => {
        return {
            tagName: tag.toUpperCase(),
            value: ''
        };
    });

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

Проверка removeEventListener

expect(input.removeEventListener)
    .toHaveBeenCalled();

Проверка утечек памяти

Контроль cleanup

const removeSpy = jest.spyOn(
    input,
    'removeEventListener'
);

cleave.destroy();

expect(removeSpy).toHaveBeenCalled();

Fake clipboard API

Имитация clipboard

global.navigator.clipboard = {
    writeText: jest.fn(),
    readText: jest.fn()
};

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

Снимки состояния input

expect(input.outerHTML)
    .toMatchSnapshot();

Проверка rawValue

Важное отличие value и rawValue

expect(cleave.getRawValue())
    .toBe('4111111111111111');

expect(input.value)
    .toBe('4111 1111 1111 1111');

Интеграционные тесты без моков

Когда моки вредят

Чрезмерное количество mock-объектов может скрывать реальные ошибки:

  • неправильную работу selection;
  • проблемы курсора;
  • ошибки paste;
  • несовместимость jsdom;
  • проблемы событий.

Баланс между mock и integration testing

Обычно применяется схема:

Тип теста Использование моков
Unit Максимальное
Integration Частичное
E2E Минимальное
Browser testing Отсутствует

Структура test utilities

Централизация mock-функций

Удобно выносить mocks в отдельный модуль:

export function createInputMock() {
    return {
        value: '',
        selectionStart: 0,
        selectionEnd: 0,
        addEventListener: jest.fn(),
        removeEventListener: jest.fn(),
        setSelectionRange: jest.fn()
    };
}

Переиспользуемые helper-функции

Dispatch helper

export function triggerInput(element, value) {
    element.value = value;

    element.dispatchEvent(
        new Event('input')
    );
}

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

Mock custom formatter

const customFormatter = jest.fn(value => {
    return value.replace(/\D/g, '');
});

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

expect(customFormatter)
    .toHaveBeenCalledTimes(1);

Проверка порядка вызовов

Invocation order

expect(mock1.mock.invocationCallOrder[0])
    .toBeLessThan(
        mock2.mock.invocationCallOrder[0]
    );

Async testing

Проверка асинхронного форматирования

await Promise.resolve();

expect(callback).toHaveBeenCalled();

Mock fetch API

Если форматирование связано с серверной логикой:

global.fetch = jest.fn(() =>
    Promise.resolve({
        json: () => Promise.resolve({
            valid: true
        })
    })
);

Рекомендации по архитектуре тестов

Наиболее устойчивая стратегия

Хорошая структура тестирования Cleave.js обычно включает:

  1. Unit-тесты formatter-функций.
  2. Изолированные тесты callbacks.
  3. Integration-тесты с jsdom.
  4. Browser-тесты в реальном окружении.
  5. Минимальное количество overly complex mocks.

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

Полное мокирование DOM

Проблемный подход:

global.document = {};

Большинство внутренних механизмов Cleave.js перестанет работать.


Чрезмерный spyOn

Избыточное количество spyOn:

jest.spyOn(cleave, 'method1');
jest.spyOn(cleave, 'method2');
jest.spyOn(cleave, 'method3');

делает тесты хрупкими и зависимыми от внутренней реализации.


Практический пример полноценного теста

Полный unit-test

import Cleave from 'cleave.js';

describe('Credit card formatter', () => {

    let input;
    let cleave;

    beforeEach(() => {

        document.body.innerHTML = `
            <input id="card" />
        `;

        input = document.getElementById('card');

        cleave = new Cleave(input, {
            creditCard: true
        });
    });

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

    test('formats card correctly', () => {

        input.value = '4111111111111111';

        input.dispatchEvent(
            new Event('input')
        );

        expect(input.value)
            .toBe('4111 1111 1111 1111');

        expect(cleave.getRawValue())
            .toBe('4111111111111111');
    });
});