Principles of Test-Driven Development

For several years now, I’ve been a big fan of writing tests when I code. My recent involvement in a Node.js-powered front-end project powered by headless Drupal has provided me with the opportunity to brush up on my testing skills and introduce fellow developers to the practice. This has led to a lot of thinking about what makes tests effective, what makes them valuable, and what keeps them from falling by the wayside. The result is a set of principles which encapsulate my testing philosophy.

Prelude: What is TDD?

Test-driven development is the practice of developing software by first writing tests and then producing the minimum amount of code required to pass those tests. That statement pretty much captures it: you write clear requirements then you write enough code to meet them, and nothing more. TDD encourages other good practices, like writing functional code whenever possible and using dependency injection, because these practices make your code easier to test. They also have the added benefit of making your code more reusable, while tests will make future refactors significantly less daunting.

This post applies also to the practice of writing tests after having written code. Although this is —strictly speaking— not test-driven development, most of the same principles still apply. After all, delivering code that is tested after the fact is still worlds better than shipping code that is not tested at all, and when a test is written has little bearing on most of the principles that make it valuable to your project.

Finally, this post isn’t a tutorial, but I’ll be using some technical terms which can be a little vague, so let’s clarify them right off the bat:

  • Test: A test is a block of code that declares a provable statement (e.g. the function addOne() returns a number value) and runs a callback function which proves that the statement is correct. This callback contains one or many assertions.
  • Assertion: An assertion is a single statement which checks whether a value meets our expectations (e.g. expect(addOne(3))'number')). Sometimes that value is a direct object, while others it might be a meta value (e.g. expect(addOne)

With all of that out of the way, here are the basic principles I keep in mind when writing tests:

Aim for confidence.

Test the things that will help you feel confident about your code. It’s easy to fall into the trap of testing everything that is testable, but that’s a rabbit hole you want to avoid. Instead, focus on testing the behaviors on which other parts of your code will rely.

Use tests as antibodies.

A test suite doesn’t make your project impervious to bad code, but it can improve how you respond to bad code. For example, accompany your hotfixes with tests that prove their effectiveness. It demonstrates your fix today and ensures its continued effectiveness tomorrow. In this way, your test documents the bug and serves as an antibody against it.

Test one thing and assert many.

If is often said that tests should focus on one thing. This is good advice because it encourages you to ask yourself what’s really important and think about that one thing critically. It’s not unlike an athlete that isolates a specific movement or muscle group to improve their overall performance: we isolate key parts of our code and ensure they work as expected, thus improving our overall reliability.

From any one test you will usually need to make several assertions. For example, if we’re testing that a function invokes a callback with a given object, you might need to assert that an object is passed and that its properties meet certain expectations. With every test, include as many assertions as needed to demonstrate that what the test claims to prove is true.

Write tests before code.

Writing tests before code can take some getting used to, but once you’ve had experience with the practice, you’ll realize that a great deal of the time you spend writing tests you’re also figuring out the problem. This cuts down on development time and often results in better, more robust solutions.

This practice also helps you get a good sense of how to solve the problem. In my experience, by the time I’m finished writing a few tests, I not only have a firmer grasp of the challenges ahead, but I often have a good sense of what the solution should look like.

Use your tests!

In addition to writing useful tests, it is important to run them purposefully. Here are some practices that have helped me stay on top of my test-running game.

Learn to run a subset of tests.

Test suites get pretty big pretty fast. They can sometimes take a bit long to execute, which can lead to fatigue and —eventually— letting yourself not run tests at all. To avoid this, a good test runner will allow you to run a single test file or even a single test. You can find plugins for many IDEs and text editors that facilitate this kind of granular test running.

Automate test running.

Running tests should not be a burden. I’m a strong believer that we should make the right thing be the easy thing, and few things are as effective at that as automation. Thankfully, test runners are designed to facilitate this. Identify key points in your workflow at which running tests increases confidence, and take steps to ensure that tests are run automatically and relevant parties are notified if any of them fail. Common points are git pre-push hooks, pull request submissions, and deployment scripts.

Review tests as you do code.

I’ve gotten into the habit of running tests as part of my code review process. I don’t know how common this is, but I will often begin reviewing a pull request by checking out the relevant branch and running tests locally. Then I review the tests themselves to see if they can be improved. This also helps me understand the requirements and edge cases that the PR aims to address, and makes me a more informed reviewer of the actual code.

When incorporated purposefully and with care, tests can have a profoundly positive effect on developer productivity and satisfaction. They improve your team’s confidence that today’s working code will continue to work when new features, dependencies, and environments are introduced tomorrow. I hope the principles I’ve laid out above help you achieve better code by writing better tests.