Хабрахабр

Размыкаем замыкания и внедряем Dependency Injection в JavaScript

image

Бонусом идет 100% юнит-тест coverage. В этой статье мы рассмотрим, как писать чистый, легко тестируемый код в функциональном стиле, используя паттерн программирования Dependency Injection.

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

  • Dependency Injection
    Это паттерн программирования, который предполагает, что внешние зависимости для функций и фабрик объектов приходят извне в виде аргументов этих функций. Внедрение зависимостей — это альтернатива использованию зависимостей из глобального контекста.
  • Чистая функция
    Это функция, результат работы которой зависит только от ее аргументов. Также функция не должна иметь побочных эффектов.
    Сразу хочу сделать оговорку, что рассматриваемые нами функции побочных эффектов не имеют, но их все-таки могут иметь функции, которые нам пришли через Dependency Injection. Так что чистота функций у нас с большой оговоркой.
  • Юнит-тест
    Тест на функцию, который проверяет, что все вилки внутри этой функции работают именно так, как задумал автор кода. При этом вместо вызова любых других функций используется вызов моков.

Фабрика счетчиков, которые отсчитываю tick-и. Рассмотрим пример. Счетчик можно остановить с помощью метода cancel.

const createCounter = () => { 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

Используем паттерн Dependency Injection на setInterval, clearInterval и также перенесем их в объект pool. Решим проблему 3).

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
})

На последнем шаге мы применим Dependency Injection на каждую из наших функций и разорвем оставшиеся связи между ними через объект pool. Теперь почти все хорошо, но еще осталась проблема 4). Заодно разделим один большой файл на множество файлов, чтобы потом легче было писать юнит-тесты.

// 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 в юнит-тестах.

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() })
})

Лишь разомкнув все функции до конца, мы обретаем свободу.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть