image


В этой статье мы рассмотрим, как писать чистый, легко тестируемый код в функциональном стиле, используя паттерн программирования 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 в юнит-тестах.


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

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

Комментарии (16)


  1. andreyverbin
    18.02.2019 12:24

    Почему бы не замокать serInterval и clearInterval и не написать тесты на изначальный вариант? Такие тесты прямо бы документировали контракт createCounter и отпала бы необходимость иметь кучу файлов по одной функции и все эти сложности с DI.


    1. MrCheater Автор
      18.02.2019 13:40
      +1

      С более сложным примером, где было бы больше веток и больше состояний, нас ждал бы Комбинаторный взрыв. Пришлось бы писать огромное количество тестов. И их количество было бы неустойчиво к рефакторингу. Добавил одну вилку в любую функцию — нужно удвоить общее количество тестов, что не есть хорошо.


      1. andreyverbin
        18.02.2019 14:13
        +2

        С более сложным примером, где было бы больше веток и больше состояний, нас ждал бы Комбинаторный взрыв.


        Кажется, что комбинаторный взрыв никуда не исчезает от того, что протестировали маленькие кусочки большой программы. Нужно будет еще протестировать как «большая» программа реагирует на все возможные выходы из каждого «кусочка». Если же мы мокаем «маленькие кусочки» в тесте «большой» программы, то нужно как-то гарантировать, что наши моки ведут себя равно также как настоящий «маленький кусочек». Все это крайне сложно и на практике не реализуемо. IMHO мокать нужно то, что невозможно или очень сложно контролировать в тесте.

        И их количество было бы неустойчиво к рефакторингу.


        В одном случае вы тестируете контракт 2х функции (createCounter и cancel), в другом тестируете контракты 3х (createCounter, cancel и onInterval). Рефакторинг с большей вероятностью затронет контракты внутренних функций и приведет к необходимости менять тесты. Если же принять за догму классическое определение рефакторинга, как улучшение кода без изменения контракта (видимо контракта createCounter и cancel, но не контракта onInterval), то тесты только для createCounter/cancel будут максимально устойчивы к такому рефакторингу. То есть я утверждаю обратное — тесты только createCounter/cancel более устойчивы к рефакторигу, чем тесты createCounter/cancel/onInterval.

        Добавил одну вилку в любую функцию — нужно удвоить общее количество тестов, что не есть хорошо.


        Почему удвоить, а не возвести в квадрат или взять факториал? Мне кажется, что варианты A и B ниже имеют одинаковое количество состояний и оттого, что часть функционала вынесли в функцию Step2 ничего не изменилось.

        void A(Input) {
        //STEP 1
        //STEP 2
        }

        void B(Input) {
        //STEP 1
        Step2(Input);
        }

        void Step2(Input) {
        //STEP 2
        }


        1. MrCheater Автор
          18.02.2019 14:25
          +1

          В целом соглашусь с вами.

          комбинаторный взрыв никуда не исчезает

          варианты A и B ниже имеют одинаковое количество состояний

          Но юнит-тесты не гарантируют работоспособность программы в целом, только её кусочков. (см. примечание автора про терминологию). Юнит-тестов все-таки получится меньше.

          А если же мы хотим протестировать весь модуль, то должны на него писать функциональные тесты. А в функциональных тестах можно и ограничиться основными use-case-ами.


          1. andreyverbin
            18.02.2019 21:14

            Юнит-тестов все-таки получится меньше.


            Не соглашусь. Положим мы тестируем белый ящик, тогда множество входов I разбивается на классы эквивалентности, на которых программа ведет себя «одинаково» с точки зрения потребителя. Для гипотетической страницы логина мы все корректные пары логин/пароль записываем к класс 1, валидный логин и неверный пароль в класс 2, валидный логин/пароль при нерабочей базе данных в класс 3 и т.д. до класса N. Так как мы знаем алгоритм (белый ящик), то мы может получить и эти N классов (на самом деле, на практике, мы этого не можем, но представим, что все таки можем). Для покрытия системы нам теперь нужно написать по одному тесту для каждого класса эквивалентности.

            Обратите внимание, текст выше не говорит о юнитах или других видах тестов. Вам просто надо написать N тестов на контракт вашей функции и все тут. Если добавить еще и юниты, которые тестируют внутренности, вроде onInterval, то у вас будет N+M тестов вот и все.

            Дискуссия unit test vs все остальное во многом искусственная и смысла большого не имеет. Цель же получить рабочий софт, который легче поддерживать. Идеальное решение вообще достигает этой цели без тестов. Все это ведет к более практическим вопросам
            — Как выделить классы эквивалентности? Вроде бы ответ это опыт и знание мат. части, например знание того, что БД бывает отваливается, файлы не открываются, юзеры вводят 1Гб текста и т.п. Возможно хитрые системы типов. Хороших решений на горизонте не видно.
            — Какие куски системы надо изолировать и мокать, а какие оставить как есть. Тут вообще ничего не ясно, вкусовщина и эвристика. Наука, к сожалению, молчит.


  1. rgs350
    18.02.2019 14:20
    +1

    Очередное frontend безумие. Тчк.


    1. MrCheater Автор
      18.02.2019 14:38
      +1

      Это вы еще DI на классах и декораторах не видели :) www.npmjs.com/package/@decorators/di


      1. DespotMagic
        19.02.2019 10:36
        +1

        Нормальный кстати подход )). Удобный. Очень напоминает Dependency Injection из Angular.


  1. ThisMan
    18.02.2019 15:23
    +1

    Думаю, такой подход идеально подходит для всяких npm пакетов и либ, на реальном проекте такое раздувание файлов (вместо одного файла у нас куча файлов на каждую функцию ) ни к чему.


  1. halfcupgreentea
    18.02.2019 15:41
    +1

    Простота и понятность кода немного ухудшилась, но это с лихвой компенсируется картиной 100% coverage в юнит-тестах.

    Очень спорно. Вы предлагаете писать код, который проще тестировать, но сложнее понимать. И ради чего? 100% покрытие? Оно достигается проще: достаточно остановиться на декомпозиции, а остальное решается моками.
    По моему надо стараться писать код для людей, а не для тестов.


    1. MrCheater Автор
      18.02.2019 16:02
      +1

      То, что вы говорите, справедливо для разработки приложения, решения прикладных задач. Но для разработки библиотеки — не факт. Авторы многих npm-пакетов жертвуют читаемостью ради тестов.


      1. halfcupgreentea
        18.02.2019 16:39

        А вы какой код больше пишете — библиотечный, или прикладной? Я вот 99% времени занимаюсь разработкой приложений, поэтому ответил так.
        Но мне не кажется, что код библиотеки чем-то концептуально отличается. Да, там ценность тестов и покрытия ими выше, но почему это должно приводить к усложнению кода?
        Я не отрицаю, что есть проблема сложно тестируемого кода, но я предпочту решать ее упрощая и разбивая такой код. Это пойдет на пользу не только тестам, но и читабельности.


        1. MrCheater Автор
          18.02.2019 16:44
          +1

          Я — библиотечный. И пачки тестов на него. Поэтому такая проф. деформация


          1. halfcupgreentea
            18.02.2019 16:51

            Было бы здорово, если вы это отразили в статье, это и правда своего рода деформация.
            У меня кстати тоже есть своя: я вообще не верю в юнит тестирование на живом, развивающемся проекте (не библиотеке!). Смоук, e2e — да, но юниты — в моих глазах просто еще код, который надо поддерживать (переписывать вслед за любым изменением тестируемого кода). А поддерживать проще всего тот код, которого нет.
            Да, юниты дают определенную уверенность в коде, но я не согласен на цену. Лучше проинвестирую в e2e по критическому пути.


  1. A1ien
    18.02.2019 16:59

    По факту мы из объекта выкусили состояние и сделали наши функции зависимыми от этого состояния подающегося им на вход. Добро пожаловть в мир классов на старом добром C:) Там функции вполне чистые, и результат зависит только от аргументов(в том числе this), но всегда ли это хорошо и удобно? Да, я знаю про функуиональный стиль итд, но вот вопрос, не превращается ли это в написание кода только ради написания кода?


  1. justboris
    18.02.2019 22:20
    +1

    Не убедили. С помощью timer-mocks можно удобно замокать нативные методы и тестировать createCounter целиком, без DI.


    Такие тесты проще поддерживать, потому что они поменяются только вместе с требованиями к библиотеке, и не зависят от внутренних модулей, которые могут меняться при внедрении новых фич и рефакторинге.


    Возможно, пример с createCounter просто неудачный, но на нем преимуществ никаких не видно.