Writing code feels so much easier than writing tests for it, and does that one-line method really need to be tested, anyway? It’s so trivial! Any tests you add would just double or triple development time, and the next time you change the code, you’ll have to change the test, too. It seems like such a waste, especially when you only have a little bit of time left on your estimate.

But soon, your code is only 20% covered by tests, and any change you make to your code feels like trying to replace the middle layer of a house of cards without knocking the whole thing over. Somewhere, something went wrong and even though the decisions you made seemed right at the time, you still ended up with a totally unmaintainable codebase.

How did you get here? You wanted your tests to provide a safety net and allow you to confidently refactor. They should have helped make your code better! Instead, you came back to low test coverage on code you don’t understand anymore, and the tests you have somehow make it harder to change the code.

This isn’t a skill failure. It happens to the best developers. It’s a process failure. With a few changes in how you write new features, you can protect your code without your tests slowing you down. Your tests can make your code more understandable and more flexible. You’ll be able to change your code with confidence, knowing every path through it is tested.

You shouldn’t have to make a decision

If you’re sitting at your keyboard trying to decide whether a bit of code needs to be tested, you’re already going down the wrong path. You should always default to “Test it!” Even if it seems too trivial to need tests, write the test.

If the code is trivial to write, it should be easy to test. And complicated code will never seem as trivial as it does right after you wrote it. How do you know it’ll still seem as trivial six months from now?

But you don’t want to overtest

A gigantic test suite can be its own problem. Tests that take 20 minutes to run are as good as no tests at all, since you won’t run them all the time. (You say you will, but I know you won’t). Even worse, if you have too many brittle tests, it makes refactoring even more of a pain than it was before, so you won’t do it. You’ll end up with methods longer than most novels.

Does this contradict my earlier point? Not necessarily. Your tests should always focus on your code’s interface, not its implementation. For example:

1
2
3
4
5
6
7
8
9
class Cart
  def initialize(item_params)
    @line_items = Array(item_params).map {|item| LineItem.new(item[:name], item[:price])}
  end

  def total
    @line_items.sum(&:price)
  end
end

It really feels like the code here needs to test both the Cart class and the LineItem class. But is the LineItem class used by anything else? If it’s just an implementation detail of Cart, and isn’t exposed to the outside world, how many tests does it really need? Can’t it just be tested through your Cart class?

Classes that are extracted by refactoring often don’t need their own test suite. They’re just an implementation detail. It’s only when they’re used on their own that they need those extra tests.

With a great test suite against a public interface, you have the flexibility to change your implementation without rewriting all your tests. You can do this with a lot less effort than writing even an average test suite against your object’s implementation.

Amortize your test costs with Test-Driven Development

In the first section, you learned that you should test everything. In the second section, you learned that you should only test public interfaces. It’s Test-Driven Development that brings these two opposing goals together.

With Test-Driven Development, your tests drive the design and implementation of your code by following this process:

  1. Write a failing test that assumes that the code you need is already there.
  2. Write the simplest implementation of the code that passes the test.
  3. Refactor to remove duplication (or make the code more expressive).
  4. Run the tests again (to make sure they still pass).
  5. Return to step 1.

By following these steps, you’ll test everything (since no code should be written without a failing test), while only testing public interfaces (since you don’t write new tests right after refactoring).

It’s never exactly that easy. But there are still ways to test-drive even the most complicated code.

TDD has some side benefits:

  • You’ll find yourself with a more flexible, tested object model (this is arguably the main benefit).
  • Your system is by definition testable, making future tests less expensive to write.
  • You amortize your testing costs across the development process, making your estimates more accurate.
  • It keeps you in flow, because you never have to decide what to do next.

So how do I get started?

Starting is the hard part! Once you get in the rhythm of writing test-driven code, it’s hard to stop.

The next time you work on a new feature, try following the TDD steps above. You’ll find yourself with close to 100% code coverage with the least amount of work possible, you’ll have a solid foundation to build on, and you’ll have total confidence that your test suite will protect you when you have to change the code next year. Or later today, when your requirements change again.

When you’re done, send me an email and let me know how it went.

By following a straightforward testing process, you can spend less time making simple decisions and chasing down bugs, and more time writing code that solves your customers’ and business’ needs.

Don't miss out on my next essay

Sign up below to get my weekly Ruby column. I'll send you original articles and advice every Friday to help make you a smarter, better Ruby developer. Drop your name in the box!

Did you like this post? You should read these:

Comments