- 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
Нажмите здесь для печати.