How Test Driven Development results in cleaner code

One of my favorite things about test driven development is that it results in much more concise, and easier to understand, code. By having a test suite that ensures we are not introducing bugs, we can refactor our solutions as we develop a better understanding of the problem. In test driven development, we follow the “red, green, refactor” pattern (sometimes called “red, green, blue”). What this means is that we write a test that fails (which test runners represent with red text), make that test pass (which is represented as green text), and then refactor as needed.

Let’s run through a simple example. Let’s say we need to flatten an array that might look something like this:

[1, 2, [3, 4], 5]

In TDD, we want to start by writing a failing test and then doing the smallest thing possible in order to make that test pass. I like to use jest as my testing framework.

const flatten = require('./index')
const input = [1, [2, 3], 4]

describe('flatten', () => {
  it('returns an array', () => {
    expect(Array.isArray(flatten(input))).toBe(true)
  })
})

Running this test will result in a failure, because we haven’t implemented anything yet. Let’s fix this.

module.exports = function flatten(input) {
  return input
}

Now our test will pass! We’ve gone from “red” to “green”. The refactor phase is optional, and I don’t know how we could improve our simple return statement. Let’s move on to “red” again. We do this by adding another test, for the next smallest thing we can do to reach our requirements. In this case, we can make sure that the function does not nest arrays.

it('does not return nested arrays', () => {
  let result = flatten(input).map((element) => {
    return Array.isArray(element)
  });

  expect(result.includes(true)).toBe(false);
})

Running our tests again, we’ll see that 1 test passes and 1 fails. We’re back in “red”. Let’s get back to “green” by implementing the flattening logic. A first-pass solution might look something like this:

let output = [];

for (var i = input.length - 1; i >= 0; i--) {
  if (Array.isArray(input[i])) {
    for (var o = input[i].length - 1; o >= 0; o--) {
      output.push(input[i][o])
    }
  } else {
    output.push(input[i])
  }
}

return output;

If we run our tests again, we’ll see that both pass. But, is a for loop ideal? I don’t think so! How can we improve this code? What if we leverage something like Array.reduce? I’m a huge fan of functional programming, so reduce is something I use often. Here’s what I came up with:

return input.reduce((accumulator, currentValue) => {
  if (Array.isArray(currentValue)) {
    return accumulator.concat(flatten(currentValue));
  }
  return accumulator.concat(currentValue);
}, []);

Running our tests again, we’re still seeing green- excellent! I’m not real happy with this solution though. For one, it’s pretty verbose. It also uses some paradigms that some of our colleagues might not be familiar with. With the safety of our tests, we can make this much more concise. Let’s use the spread operator to turn this into a one-liner!

return [].concat(...input)

Ok, let’s make sure we didn’t break anything by running our tests again… Success! We still have 2 passing tests after making big changes to our code!

While this is a very simple example, you can easily see how beneficial these practices are.

-Matt

Published 29 Oct 2018

Programming Is Easy; Humans make it hard.
Matt Chandler on Twitter