TDD Is Easy, When You Forget About The Code

TDD Is Easy, When You Forget About The Code

TDD can be hard. I get it. But there is a simple way to become a TDD master.

As a big advocate of Test-Driven Development (TDD), I have spent a good amount of time wondering why 95% of my conversations about TDD with other engineers go something like this:

“Yes, TDD is clearly the right way to build software. But I don’t actually do it.”

At this point, the usual reasons and explanations are laid out:

“It’s hard to know what tests to write until I have written the code.”

“How can I know how the tests should work if I don’t know how the code works?”

It is true that if you are testing something that uses completely unfamiliar technology, you probably don’t know yet how to go about testing it. Most of the time, though, the technology is perfectly familiar. It’s not the tools that are unfamiliar and scary - it’s the product that you are trying to build.

Enter my ✨One Simple Trick™✨…

Forget about the code completely (for now). All of it. Don’t think about the implementation, and don’t think about the test code either.

Instead, just write a plan for how the product should work. Here’s how I do it:

Step 1

Start by writing simple comments in human language. Write as many as you can think of, with one per requirement, acceptance criteria, scenario, edge case, or error. Here’s an example:

// animals.test.ts
// Plan for new API endpoint: GET /animals

// returns a list of animals with the ID and name

// paginated 10 at a time by default

// can change the pagination size

// optionally filters by species type (in the query params)

// returns a 403 error if not authenticated

Of course, the code has to happen at some point. But by starting with comments like this, it removes all of the psychological resistance to the idea of writing tests, allowing you to focus on what you’re trying to build.

Step 2

Write the actual tests one by one by converting each comment into an assertion.

// animals.test.ts

import { request } from "supertest";
import { app } from "./app";

it('returns a list of animals with the ID and name', async () => {
  const result = await request(app).get('/animals');
  
  result.forEach(animal => {
    expect(animal).toEqual({
      id: expect.any(String),
      name: expect.any(String),
    });
  });
})

// paginated 10 at a time by default

// can change the pagination size

// optionally filters by species type (in the query params)

// returns a 403 error if not authenticated

It is worth noting noting that this technique is best paired with the philosophy Write Tests. Not Too Many. Mostly Integration. While the ‘comments-first’ approach can be applied to unit tests, it is much easier to start with higher-level, more product-focused integration tests.

Step 3

Write the bare minimum application code to make the first test pass.

For example, to make this first test pass, you could return this hardcoded array from the API endpoint:

// animals.ts

res.send([{ id: "foo", name: "bar"}]);

Step 4

Clearly, this isn’t enough. The urge to immediately write more code is strong.

This critical moment is the final hurdle to overcome, and it is the hardest. Resist the urge! Stop, take deep breath, and look back at your commented plan. Is there anything on that list that will force you to change the code to fix the glaring issue that you’ve seen?

If yes, great! Keep going, it will be fixed soon.

If not, you have two options:

  1. Improve your current test to have more specific checks
  2. Add another comment to your plan for the missing requirement

Step 5

Repeat the process. Write the next test, check that it fails, then write the minimum code to make it pass.

That’s it.

Congratulations, you just TDD’ed! It will be worth it, I promise.

Thank you for reading. This is the first of my new series, Dev Diary, where I will be writing about something I’ve done, something I’ve learned, or something that I’ve found interesting that day.