Размыкаем замыкания и внедряем Dependency Injection в JavaScript
В этой статье мы рассмотрим, как писать чистый, легко тестируемый код в функциональном стиле, используя паттерн программирования Dependency Injection. Бонусом идет 100% юнит-тест coverage.
Автор статьи будет иметь в виду именно такое трактование нижеупомянутых терминов, понимая, что это не есть истина в последней инстанции, и что возможны другие толкования.
- Dependency Injection
Это паттерн программирования, который предполагает, что внешние зависимости для функций и фабрик объектов приходят извне в виде аргументов этих функций. Внедрение зависимостей — это альтернатива использованию зависимостей из глобального контекста. - Чистая функция
Это функция, результат работы которой зависит только от ее аргументов. Также функция не должна иметь побочных эффектов.
Сразу хочу сделать оговорку, что рассматриваемые нами функции побочных эффектов не имеют, но их все-таки могут иметь функции, которые нам пришли через Dependency Injection. Так что чистота функций у нас с большой оговоркой. - Юнит-тест
Тест на функцию, который проверяет, что все вилки внутри этой функции работают именно так, как задумал автор кода. При этом вместо вызова любых других функций используется вызов моков.
Рассмотрим пример. Фабрика счетчиков, которые отсчитываю tick
-и. Счетчик можно остановить с помощью метода cancel
.
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
.
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). Используем паттерн Dependency Injection на setInterval
, clearInterval
и также перенесем их в объект pool
.
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). На последнем шаге мы применим Dependency Injection на каждую из наших функций и разорвем оставшиеся связи между ними через объект 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% coverage в юнит-тестах.
Также хочу заметить, что для написания юнит-тестов нам не понадобиться производить никаких манипуляций с 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()
})
})
Лишь разомкнув все функции до конца, мы обретаем свободу.