- PVSM.RU - https://www.pvsm.ru -
В этой статье мы рассмотрим, как писать чистый, легко тестируемый код в функциональном стиле, используя паттерн программирования Dependency Injection. Бонусом идет 100% юнит-тест coverage.
Автор статьи будет иметь в виду именно такое трактование нижеупомянутых терминов, понимая, что это не есть истина в последней инстанции, и что возможны другие толкования.
Рассмотрим пример. Фабрика счетчиков, которые отсчитываю 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()
})
})
Лишь разомкнув все функции до конца, мы обретаем свободу.
Автор: Антон Жуков
Источник [2]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/309134
Ссылки в тексте:
[1] Image: https://habr.com/ru/company/devexpress/blog/440552/
[2] Источник: https://habr.com/ru/post/440552/?utm_source=habrahabr&utm_medium=rss&utm_campaign=440552
Нажмите здесь для печати.