ArticlesEngineering

Before you before

Your test framework supports before/after hooks, don't use them.

Most test frameworks have a concept of hooks. These hooks deal with aspects of setting up the situation where a test can run, and then cleaning that situation up. These hooks are commonly called before and after with plenty of different names, modifiers, and behaviors between implementations. They are often declared and execute like this:

before(() => {
  createDatabase()
})

testCase('this is a test', () => {
  // assert something dependent on the database
})

testCase('this is another test', () => {
  // assert something dependent on the database
})

after(() => {
  destroyDatabase()
})

While common to test frameworks and interesting to implement, hooks ought to be avoided in tests. Instead of hooks, prefer to use plain language constructs such as functions to serve the same purpose:

function withDatabase(func) {
  createDatabase()
  func()
  destroyDatabase()
}

testCase('this is a test', () => {
  withDatabase(() => {
    // assert something dependent on the database
  })
})

testCase('this is another test', () => {
  withDatabase(() => {
    // assert something dependent on the database
  })
})

There are many significant benefits to using functions over hooks:

  • We get improved code locality because everything we need to understand the test case is in the test case. Not only do test frameworks support before/after, they support multiple before/after hooks. This can get pretty crazy as you can find code with hooks shared and registered across many files.

  • We use less magic. Everyone has a baseline understanding of functions, methods, and calling order. Hooks like before/after can and are implemented in crazy ways. Minimizing the mental burden of writing against a specific test framework is advantageous.

  • We can do proper composition of code. Hooks like before/after aren't good for organizing and reusing code because they can only be registered with the test framework. This inversion of control to the framework is very limiting. We can't enforce beforeB requires beforeA or afterC must be called after beforeC; this is all implicit. Functions on the other hand compose so we can do these things plainly:

    const setupB = () => {
      setupA()
      // do set up dependent on A
    }
    
    const withC = (fn) => {
      setupC()
      fn()
      tearDownC()
    }
    
  • No extra work is done for a test case that doesn't need it. Very often some tests will require more convoluted setups than others and tests sharing that before will pay a price. This extra work is wasteful, but also usually noise that muddles what is intended to be under test.

  • We don't have to restate the facts of what happens in the plain function. If you're working in a typed language, you'll often feel the friction of asserting that the before hook did the setup as expected. Because it was done in a far-off place, the type system cannot help make the connections. This leads to adding more noise to satisfy the typechecker or annotations to opt-out of types, making the tests get less out of typing.

    Beyond types, asserting side-effects have occurred also can suffer from this first-verify-before-then-test issue. This happens very naturally when there's an implicit assumption about how the before should run. Since before is shared and may be refactored, we may need some verification to test the before is doing the right thing. Being explicit with functions, we don't need this defensive checking.

  • We don't have a dumping ground for unrelated code. Hooks put negative social pressure on the growth of the code. Using before may feel nice: test cases are shorter because all the work is in before! While true, this also means all the work is in before. As new code is added, people most often repeat established patterns. This leads to more and more code dumped into before, it becomes a junk drawer which exacerbates all the above issues.

  • Even more problems exist with different implementations of hooks. We can't cover all the issues that only some test frameworks exhibit, but rest assured this list is not exhaustive. Functions, on the other hand, have much more portable semantics and are more generally applicable.