Test for Behavior, not Implementation

I'm doing a few days on testing and TDD in advance of my webinar on the topic next week

When you're writing tests for your code, you want to test for inputs (Point A) and outputs (Point B), or starting conditions (Point A) and outcomes (Point B).

You do NOT want to test how your code gets from Point A to Point B. Simply test that when given A, you reach B.

Another way to put this is that your tests shouldn't care what happens between the curly braces. Take this simple test case:

(code formatting looks better on the web)


// takes an arbitrary list of arguments and adds
// them together
function add(...args) {
  let total = 0
  for (let i=0; i<args.length; i++) {
    total += args[i]
  }
  return total
}

// add(1, 2, 3) should return 6
test("add() should add arguments together", () => {
  expect(add(1, 2, 3)).toEqual(6)
})

The test has no opinions about how I get from 1, 2, 3 to 6. Notice I'm not testing how many times I go through the for loop.

I can radically simplify the implementation of the add function, and as long as it still returns 6, my test doesn't know or care about anything that I did:


// Array prototype methods FTW!
// https://developer.mozilla.org/tr/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype
function add(...args) {
  return args.reduce((a,b) => a + b)
}

// test case remains unchanged
test("add() should add arguments together", () => {
  expect(add(1, 2, 3)).toEqual(6)
})

This leaves you free to iterate and refactor your code without having to update your tests. As long as the inputs and outputs stay consistent, you're good.

Testing for behavior also forces you to make explicit design choices about the behavior of your code, rather than just writing tests based on what you've already written.

For instance, as written, this function will behave in ways that are probably undesirable if you pass it non-numeric input. A good set of tests would explicitly define how the function behaves in these instances:


// keep our initial test case
test("add() should add arguments together", () => {
  expect(add(1, 2, 3)).toEqual(6)
})

test("add() should cast strings to numbers", () => {
  expect(add('1', '2', '3')).toEqual(6)
})

test("add() should throw an error if argument can't be cast to string", () => {
  expect(() => {
    add(1, 'foo', 3)
  }).toThrow("foo is not a valid argument to add()")
})

You can argue whether add() should cast the value "2" to a number or throw an error, but the important part here is that we have removed the ambiguity. We made a decision and by committing it to a test case, we've explicitly defined the behavior of the function.

Why then do all the tools exist for mocking and spying and whatnot if we're not supposed to test the internals of our functions? I'll cover that in Wednesday's email.

Tomorrow, I'll talk about why you should be writing your tests before you write the code that satisfies them. There are academic reasons, but I'm much more interested in the practical reasons that helps you write your code faster. More tomorrow.


Did you like this?

I send a daily email with tips and ideas like this one. Join the party!

Icon