В этой статье мы рассмотрим, как написать понятный и простой для тестирования код в функциональном стиле, используя шаблон программирования Dependency Injection. В качестве бонуса мы также достигаем 100% покрытия модульными тестами.
Терминология
Автор статьи будет иметь в виду именно такую интерпретацию следующих терминов, понимая, что это не истина в последней инстанции и возможны другие интерпретации.
- Внедрение зависимостей
Это шаблон программирования, который предполагает, что внешние зависимости для функций и фабрик объектов приходят извне в виде аргументов для этих функций. Внедрение зависимостей - альтернатива использованию зависимостей из глобального контекста.
- Чистая функция
Это функция, результат которой зависит только от ее аргументов. Также у функции не должно быть побочных эффектов.
Сразу хочу сделать замечание, что рассматриваемые нами функции побочных эффектов не имеют, но все же могут иметь функции, которые пришли к нам через Dependency Injection. Так что чистота функций в статье условна.
- Модульный тест
Тест для функции, который проверяет, что все ветви внутри этой функции работают в точности так, как задумал автор кода. В этом случае вместо вызова каких-либо других функций используется вызов моков.
Упражняться
Посмотрим на живом примере - фабрика счетчиков. Счетчик увеличивается с каждым тиком и может быть остановлен вызовом функции cancel
. Когда ставится галочка, вызывается onTick
обратный вызов.
const createCounter = ({ ticks, onTick }) => { const state = { currentTick: 1, timer: null, canceled: false } const cancel = () => { if (state.canceled) { throw new Error(‘“Counter” already canceled’) } clearInterval(state.timer) } const onInterval = () => { onTick(state.currentTick++) if (state.currentTick > ticks) { cancel() } } state.timer = setInterval(onInterval, 200) const instance = { cancel } return instance } export default createCounter
Мы видим читаемый, понятный код. Но есть одна загвоздка - для него нельзя написать нормальные модульные тесты. Посмотрим, что вам мешает?
1) внутренние функции cancel
и onInterval
недоступны из модульного теста для независимого тестирования
2) функция onInterval
не может быть протестирована независимо от `cancel`, поскольку первая функция имеет прямую ссылку на вторую функцию
3) внешние зависимости используются в функциях setInterval
и clearInterval
4) функция createCounter
не может быть протестирована независимо, так как наличие прямых ссылок
Давайте решим 1-ю и 2-ю задачи, извлекая cancel
и onInterval
функции из замыкания, и разберем прямые ссылки, введя дополнительный объект - pool
.
// index.js export const cancel = pool => { if (pool.state.canceled) { throw new Error(‘“Counter” already canceled’) } clearInterval(pool.state.timer) } export const onInterval = pool => { pool.config.onTick(pool.state.currentTick++) if (pool.state.currentTick > pool.config.ticks) { pool.cancel() } } const createCounter = config => { const pool = { config, state: { currentTick: 1, timer: null, canceled: false } } pool.cancel = cancel.bind(null, pool) pool.onInterval = onInterval.bind(null, pool) pool.state.timer = setInterval(pool.onInterval, 200) const instance = { cancel: pool.cancel } return instance } export default createCounter
Решаем 3-ю задачу. Используя шаблон внедрения зависимостей, внешние зависимости setInterval
и clearInterval
также могут быть перемещены в объект pool
.
// index.js export const cancel = pool => { const { clearInterval } = pool if (pool.state.canceled) { throw new Error(‘“Counter” already canceled’) } clearInterval(pool.state.timer) } export const onInterval = pool => { pool.config.onTick(pool.state.currentTick++) if (pool.state.currentTick > pool.config.ticks) { pool.cancel() } } const createCounter = (dependencies, config) => { const pool = { ...dependencies, config, state: { currentTick: 1, timer: null, canceled: false } } pool.cancel = cancel.bind(null, pool) pool.onInterval = onInterval.bind(null, pool) const { setInterval } = pool pool.state.timer = setInterval(pool.onInterval, 200) const instance = { cancel: pool.cancel } return instance } export default createCounter.bind(null, { setInterval, clearInterval })
Сейчас почти все нормально, но осталась 4-я проблема. На последнем шаге мы применим внедрение зависимостей к каждой из наших функций и разорвем оставшиеся ссылки между ними через объект `pool`. При этом мы разделим один большой файл на множество файлов, чтобы потом было проще писать модульные тесты.
// index.js import { createCounter } from ‘./create-counter’ import { cancel } from ‘./cancel’ import { onInterval } from ‘./on-interval’ export default createCounter.bind(null, { cancel, onInterval, setInterval, clearInterval }) // create-counter.js export const createCounter = (dependencies, config) => { const pool = { ...dependencies, config, state: { currentTick: 1, timer: null, canceled: false } } pool.cancel = dependencies.cancel.bind(null, pool) pool.onInterval = dependencies.onInterval.bind(null, pool) const { setInterval } = pool pool.state.timer = setInterval(pool.onInterval, 200) const instance = { cancel: pool.cancel } return instance } // on-interval.js export const onInterval = pool => { pool.config.onTick(pool.state.currentTick++) if (pool.state.currentTick > pool.config.ticks) { pool.cancel() } } // cancel.js export const cancel = pool => { const { clearInterval } = pool if (pool.state.canceled) { throw new Error(‘“Counter” already canceled’) } clearInterval(pool.state.timer) }
Заключение
Каков вывод? Куча файлов, каждый из которых содержит одну чистую функцию. Простота и понятность кода немного ухудшились, но это с лихвой компенсируется картиной 100% покрытия в модульных тестах.
Также хочу отметить, что нам не нужно проделывать какие-либо манипуляции с `require` и имитировать файловую систему Node.js для написания модульных тестов.
Модульные тесты
// cancel.test.js import { cancel } from ‘../src/cancel’ describe(‘method “cancel”’, () => { test(‘should stop the counter’, () => { const state = { canceled: false, timer: 42 } const clearInterval = jest.fn() const pool = { state, clearInterval } cancel(pool) expect(clearInterval).toHaveBeenCalledWith(pool.state.timer) }) test(‘should throw error: “Counter” already canceled’, () => { const state = { canceled: true, timer: 42 } const clearInterval = jest.fn() const pool = { state, clearInterval } expect(() => cancel(pool)).toThrow(‘“Counter” already canceled’) expect(clearInterval).not.toHaveBeenCalled() }) }) // create-counter.test.js import { createCounter } from ‘../src/create-counter’ describe(‘method “createCounter”’, () => { test(‘should create a counter’, () => { const boundCancel = jest.fn() const boundOnInterval = jest.fn() const timer = 42 const cancel = { bind: jest.fn().mockReturnValue(boundCancel) } const onInterval = { bind: jest.fn().mockReturnValue(boundOnInterval) } const setInterval = jest.fn().mockReturnValue(timer) const dependencies = { cancel, onInterval, setInterval } const config = { ticks: 42 } const counter = createCounter(dependencies, config) expect(cancel.bind).toHaveBeenCalled() expect(onInterval.bind).toHaveBeenCalled() expect(setInterval).toHaveBeenCalledWith( boundOnInterval, 200 ) expect(counter).toHaveProperty(‘cancel’) }) }) // on-interval.test.js import { onInterval } from ‘../src/on-interval’ describe(‘method “onInterval”’, () => { test(‘should call “onTick”’, () => { const onTick = jest.fn() const cancel = jest.fn() const state = { currentTick: 1 } const config = { ticks: 5, onTick } const pool = { onTick, cancel, state, config } onInterval(pool) expect(onTick).toHaveBeenCalledWith(1) expect(pool.state.currentTick).toEqual(2) expect(cancel).not.toHaveBeenCalled() }) test(‘should call “onTick” and “cancel”’, () => { const onTick = jest.fn() const cancel = jest.fn() const state = { currentTick: 5 } const config = { ticks: 5, onTick } const pool = { onTick, cancel, state, config } onInterval(pool) expect(onTick).toHaveBeenCalledWith(5) expect(pool.state.currentTick).toEqual(6) expect(cancel).toHaveBeenCalledWith() }) })
Только открыв все функции до конца, мы обретаем свободу.