Lessons Learned Writing Unit Tests

Jul 4, 2018 11:45 · 1459 words · 7 minute read JavaScript React VSCode

Early on in my career, I decided to try test driven development (TDD). The first couple days felt great: visions of greener coding pastures filled my imagination, my opinion about my code quality skyrocketed, and all untested code began reeking a putrid stench.

Yes, I was that guy: the overconfident junior developer.

Over the next couple months, I wrote a massive suite of tests. As the novelty faded, I made a change to my code, saw about forty tests fail, and started fixing all of the failing tests. Then I realized that my productivity had dropped significantly. Since I was clearly great at coding, it must have been those tests!

I went to Google to find out how to write better unit tests. Google told me a number of things:

  • Long tests are bad!
  • Tests with multiple assertions are bad!
  • Tests that combine multiple modules are bad!

That must have been it! I set out to fix all these awful tests in my massive test suite.

And then over the next couple months, I wrote a massive suite of tests. As the novelty faded, I made a change to my code, saw about forty tests fail, and started fixing all of the failing tests. Then I realized that my productivity had dropped significantly. Since I was clearly great at coding, and I was clearly great at writing unit tests, I was likely wrong about my opinion of both skills.

Back to the drawing board.

This introspection led me to compare my tests and my code to examples from books I owned and to popular open source projects. It didn’t take long for me to realize a couple obvious differences:

  • My tests were larger.
  • My functions were larger.
  • My classes were larger.
  • My files were larger.
  • My directories contained more files.

Writing unit tests unveiled an unsettling truth: I wrote brittle unit tests because I wrote confusing code.

You can’t write bad code and good unit tests. If your unit tests are bad, it might be a symptom of bad code. Go back to the design and figure out how it needs to change to allow writing good unit tests.

It’s probably inefficient to explain via story the next lessons I learned, so I’ll list them out instead.

Lesson 1: Prefer immutability over mutability

A mutable object’s properties can change; an immutable object’s properties can’t change. This has a number of benefits:

  • An immutable object can’t cause side effects. If you pass an immutable object reference to another function and then you refer to that same object again, its value must remain constant. This allows a developer to use the object without investigating all of the hidden areas of code that refer to it.
  • Multiple threads can safely reference an immutable object. You won’t run into any locking issues, metastability, or difficult-to-trace issues from using an immutable object across multiple threads.
  • An immutable object cannot become inconsistent with itself. This holds true for immutable objects referencing other immutable objects, which can guarantee consistency for large portions of your application state. Errors and exceptions resulting from code designed in this manner tends to be easier to debug.
  • You can cache immutable objects indefinitely. This can improve the performance of your application. (Cloning lots of immutable objects can also hurt the performance, so be careful!)

Lesson 2: Prefer pure functions over impure functions

An impure function has side effects; a pure function has no side effects. This has a number of benefits:

  • Given the same set of inputs, a pure function will always produce the same output.
  • It’s incredibly easy to test pure functions. They rarely require mocks; they have predictable output; they have few external dependencies.
  • Debugging pure functions takes less time due to their predictability. If you see an error in the debugger, calling the same function with the same set of parameters will always produce that error. Copy the parameters; paste them into a unit test; fix the unit test; ?; profit.

Lesson 3: Break down large functions into small functions

It’s difficult to reason about large functions. Break them into smaller functions; test the smaller functions. Use the smaller function output to test your previous larger function.

Make sure to name your smaller functions very well. It’s incredible difficult to decipher code composed of small functions with nonspecific names. In many cases this makes the code worse than it was before.

Lesson 4: If you can’t make a test smaller, your code probably has too many dependencies

Imagine testing this function:

function getUser(id, userRepository, userCache, motherName, fatherName, dog) {
  let user;

  if (!userCache.contains(id)) {
    user = userRepository.get(id);
    userCache.set(id, user);
  } else {
    user = userRepository.get(id);
  }

  if (dog === 'golder retriever') {
    userRepository.save({
      ...user,
      happiness: 9001,
    });
  }

  user.mother = userRepository.getByName(motherName);
  user.father = userRepository.getByName(fatherName);
}

This function has about 6,129 design flaws, such as: mutating the database in a function that’s just getting data, making multiple database calls without a join, mixing multiple responsibilities into a single function call, hardcoding labels, etc… Don’t take it seriously. It’s a contrived example intended to make a point.

Most of this function’s poor design reduces to just doing too much. It’s impossible to write good tests for this function because in order to test that dog === 'golden retriever, you have to mock the user cache, mock userRepository.get, and mock userRepository.getByName. You have to do this for every test. That’s code smell.

Lesson 5: Extract complex conditionals and test them in isolation

Before:

function purchaseBeer(user) {
  if (user.money > 500 &&
    ((user.country === 'usa' && user.age >= 21)
      || (user.country !== 'usa' && user.age >= 18))) {
    return new Beer();
  } else {
    return new Water();
  }
}

// ...

test('cannot purchase beer if not enough money', () => {
  const drink = purchaseBeer({ money: 100, country: 'usa', age: 21 });
  expect(drink).to.be.a(Water);
});

test('can purchase beer if living in usa and is 21 years or older', () => {
  const drink = purchaseBeer({ money: 500, country: 'usa', age: 21 });
  expect(drink).to.be.a(Beer);
});

test('cannot purchase beer if living in usa and under 21 years', () => {
  const drink = purchaseBeer({ money: 500, country: 'usa', age: 20 });
  expect(drink).to.be.a(Water);
});

test('cannot purchase beer if living outside usa and 18 years or older', () => {
  const drink = purchaseBeer({ money: 500, country: 'poland', age: 18 });
  expect(drink).to.be.a(Vodka);
});

test('cannot purchase beer if living outside usa and under 18 years', () => {
  const drink = purchaseBeer({ money: 500, country: 'poland', age: 12 });
  expect(drink).to.be.a(Water);
});

After:

function canPurchaseBeer(user) {
  return user.money >= 500 && (
    user.country === 'usa'
      ? user.age >= 21
      : user.age >= 18
  );
}

function purchaseBeer(user) {
  return canPurchaseBeer(user)
    ? new Beer()
    : new Water();
}

// ...

test('cannot purchase beer if not enough money', () => {
  return expect(
    canPurchaseBeer({ money: 100, country: 'usa', age: 21 })
  ).to.equal(false);
});

test('can purchase beer if living in usa and is 21 years or older', () => {
  expect(
    canPurchaseBeer({ money: 500, country: 'usa', age: 21 })
  ).to.equal(true);
});

test('cannot purchase beer if living in usa and under 21 years', () => {
  expect(
    canPurchaseBeer({ money: 500, country: 'usa', age: 20 })
  ).to.equal(false);
});

test('cannot purchase beer if living outside usa and 18 years or older', () => {
  expect(
    canPurchaseBeer({ money: 500, country: 'poland', age: 18 })
  ).to.equal(true);
});

test('cannot purchase beer if living outside usa and under 18 years', () => {
  expect(
    canPurchaseBeer({ money: 500, country: 'poland', age: 12 })
  ).to.equal(true);
});

test('purchasing beer returns water if cannot purchase beer', () => {
  mock(canPurchaseBeer, (user) => false);
  expect(
    purchaseBeer({ money: 500, country: 'usa', age: 20 })
  ).to.be.a(Water);
});

test('purchasing beer returns beer if can purchase beer', () => {
  mock(canPurchaseBeer, (user) => true);
  expect(
    purchaseBeer({ money: 500, country: 'usa', age: 21 })
  ).to.be.a(Beer);
});

And just so you don’t think I’m nuts, you should probably write it this way:

const beerPrice = 500;

const drinkingAges = {
  usa: 21,
  everywhereElse: 18,
};

function getDrinkingAge(country) {
  return drinkingAges[country] || drinkingAges.everywhereElse;
}

function canPurchaseBeer(user) {
  return user.money >= beerPrice && user.age >= getDrinkingAge(user.country);
}

function purchaseBeer(user) {
  return canPurchaseBeer(user)
    ? new Beer()
    : new Water();
}

Okay, I’ll stop beating this example to death.

Outro

In retrospect, unit testing caused me to start adopting functional coding practices wherever possible. It generally makes code easier to test, simpler to understand, and easier to change without breaking tests. And this applies ubiquitously: I’ve written plenty of tests in C#, JS, Python, Ruby, PHP, etc… The same concepts apply in all places.

These lessons are far from complete, and I’m sure some might even disagree with them. If that’s you, I’d love to hear your thoughts. If not, I hope this post at least gave you something new to consider.

Happy coding!

Tweet Share

Subscribe to my newsletter to receive updates about new posts.

* indicates required