We open the closures and inject Dependency Injection into JavaScript

  • Tutorial

image


In this article, we will look at how to write clean, easily testable code in a functional style using the Dependency Injection programming pattern. The bonus is 100% unit test coverage.


Terminology to be used in the article.


The author of the article will have in mind precisely this interpretation of the following terms, realizing that this is not the ultimate truth and that other interpretations are possible.


  • Dependency Injection
    This is a programming pattern that assumes that external dependencies for functions and object factories come from outside as arguments to these functions. Dependency injection is an alternative to using dependencies from the global context.
  • Pure function
    This is a function whose result depends only on its arguments. Also, the function should not have side effects.
    At once I want to make a reservation that the functions of side effects considered by us do not have, but they still can have functions that came to us through Dependency Injection. So the purity of the functions we have with a big reservation.
  • Unit test
    A function test that checks that all the plugs inside this function work exactly as the author of the code intended. In this case, instead of calling any other functions, the call of mocks is used.

We understand in practice


Consider an example. Factory counters that count down - tickand. The counter can be stopped using the method cancel.


const createCounter = ({ ticks, onTick }) => {
  const state = {
    currentTick: 1,
    timer: null,
    canceled: false
  }
  const cancel = () => {
    if (state.canceled) {
      thrownewError('"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
}
exportdefault createCounter

We see human readable, understandable code. But there is one catch - you can’t write normal unit tests to it. Let's see what's stopping you?


1) you can not reach the functions inside the circuit cancel, onIntervaland test them separately.


2) the function onIntervalcannot be tested separately from the function cancel, since The first has a direct link to the second.


3) external dependencies are used setInterval, clearInterval.


4) the function createCountercannot be tested separately from other functions, again due to direct links.


Let's solve the problem 1) 2) - Remove function cancel, onIntervalthe closure and tear direct links between them through the object pool.


exportconst cancel = pool => {
  if (pool.state.canceled) {
    thrownewError('"Counter" already canceled')
  }
  clearInterval(pool.state.timer)
}
exportconst 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
}
exportdefault createCounter

Solve the problem 3). We use the Dependency Injection pattern on setInterval, clearIntervaland also transfer them to the object pool.


exportconst cancel = pool => {
  const { clearInterval } = pool
  if (pool.state.canceled) {
    thrownewError('"Counter" already canceled')
  }
  clearInterval(pool.state.timer)
}
exportconst 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
}
exportdefault createCounter.bind(null, {
  setInterval,
  clearInterval
})

Now, almost everything is fine, but there is still a problem 4). In the last step, we will apply Dependency Injection to each of our functions and break the remaining links between them through the object pool. At the same time we will divide one large file into multiple files, so that later it would be easier to write unit tests.


// index.jsimport { createCounter } from'./create-counter'import { cancel } from'./cancel'import { onInterval } from'./on-interval'exportdefault createCounter.bind(null, {
  cancel,
  onInterval,
  setInterval,
  clearInterval
})

// create-counter.jsexportconst 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.jsexportconst onInterval = pool => {
  pool.config.onTick(pool.state.currentTick++)
  if (pool.state.currentTick > pool.config.ticks) {
    pool.cancel()
  }
}

// cancel.jsexportconst cancel = pool => {
  const { clearInterval } = pool
  if (pool.state.canceled) {
    thrownewError('"Counter" already canceled')
  }
  clearInterval(pool.state.timer)
}

Conclusion


What do we have in the end? A bunch of files, each of which contains one clean function. The simplicity and clarity of the code has slightly deteriorated, but this is more than offset by the 100% coverage picture in unit tests.


coverage


I also want to note that in order to write unit tests we don’t need to perform any manipulations with the requireNode.js file system.


Unit tests
// cancel.test.jsimport { 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.jsimport { createCounter } from'../src/create-counter'
describe('method "createCounter"', () => {
  test('should create a counter', () => {
    const boundCancel = jest.fn()
    const boundOnInterval = jest.fn()
    const timer = 42const 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.jsimport { 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()
  })
})

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


Also popular now: