В этой статье мы рассмотрим, как написать понятный и простой для тестирования код в функциональном стиле, используя шаблон программирования 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()
  })
})

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