Introduction to Test-Driven Development (TDD) with Classic TDD Example
Testing isn't regularly taught in schools
As an industry, we struggle to agree on testing terminology
Understanding what to test and how to test it takes practice
We leave testing until the very end
Testing is a part of architecture
The Test-Driven Development (TDD) Process
Types of tests (unit, integration, E2E, acceptance)
The big picture: How TDD works on real-life projects
Starting with the acceptance test
Classic (Inside-Out/Chicago) and Mockist (Outside-In/London) TDD
Demonstration: Classic TDD — Palindrome Example
2. Write the simplest code to make the test pass
5. Write the simplest code to make the test pass
8. Write the simplest code to make the test pass
🌱 This article hasn't fully bloomed. It's very likely to change over the next little while.
Watch on YouTube
If you're like me, you see programming as a type of trade 1.
Over the past year, I've been paying more attention to how traditional tradespeople work. I've been looking for parallels between how they work and how we work as software developers.
When I most recently took my car in to get an oil change, I noticed that the technicians seemed to consistently, reliably, and confidently get the job done, and get it done right. One client after the next. They weren't making messes. They weren't missing their estimates. They weren't discovering that things won't work at the last minute - in fact, I've even seen them come out, 2 minutes into the job to let someone know that there's more work to be done than they initially expected.
It appears, at least to me, as if these are principled workers, and — forgive the pun, but it seems like they're not reinventing the wheel.
Part of what it means to be an Agile software craftsperson is to consistently deliver value to the customer. That's hard. But that's what we're constantly trying to do here.
The goal of this blog is to discover the best techniques for writing testable, flexible, maintainable code, and to teach others how to do it too. Today, the software quality attribute we're most interested in is testability.
And in my experience:
The best way to write testable code 2 is to write the test first
Introduction
This is the first post in a series on Test-Driven Development (TDD): a test-first technique for developing software 🧪.
In this introductory post, you'll build a beginner foundation for TDD. We'll learn about what TDD is, what makes it important, and how developers are using it to consistently deliver value on real-life projects. We'll also discuss what makes testing so challenging to get right, and in the end, we'll wrap up with a demonstration of the Classic TDD process, using TDD to construct a palindrome checker.
You'll learn everything you need to know to get started practicing the TDD Red-Green-Refactor process. This is just the starting point. We need to get this foundation down first before we can learn how to apply advanced TDD techniques in the real-world 3.
Let's begin.
Why tests?
There are lots of reasons why tests are helpful, but the two best reasons, in my opinion, are confidence and feedback.
Confidence ⭐ ⭐ ⭐ ⭐
They say that change is a constant in software development.
To find the confidence to safely add, remove, or refactor code without the fear of introducing bugs and regressions, we need tests.
This is especially important later on in a project when the codebase is much larger than it was at the beginning, and there's way more code that one human being can mentally account for anymore.
I can tell you from experience that this is not a fun situation to be in— thousands of lines of code into a project with no tests and no safety. It's a great way to turn a promising codebase into an unstable mess.
Feedback ⭐ ⭐ ⭐ ⭐ ⭐
In my opinion, the most important reason for tests is feedback.
Tests give us feedback to let us know:
- When regressions have been introduced
- What our progress towards implementing a feature looks like
- That we actually understand the customer requirements
- And if our designs are feasible
Depending on how you think about it, confidence may actually come from the feedback we get from tests.
[Principle]: Listen to your tests — Tests that are hard to write typically signal a deficiency in design. Use the immediate feedback you get from feeling out how hard it is to write a particular test to reconsider the design.
Feedback is so important that Kent Beck lists it as one of the primary values of Extreme Programming: the influential Agile software development methodology.
More reasons to write tests
- Measure progress ⭐ ⭐
- Sculpt out public APIs ⭐
- Understand requirements ⭐ ⭐
- Keep a feature in scope ⭐ ⭐
- Documentation for other developers ⭐ ⭐ ⭐
Why testing is hard
I didn't start writing tests until later in my career. When I first started out, I knew that we should probably have them, but as for writing them? Yeah, right. Like many new developers, I was lost as to how to even get started. After surveying the landscape, here's why I think testing is hard.
Testing isn't regularly taught in schools
This may not be the case for some readers, but the majority of my college/university/bootcamp-going peers weren't exposed to how to properly test code until a mentor sat down and showed them how to do it.
As an industry, we struggle to agree on testing terminology
Ask two developers what they believe an integration test is. As Fowler writes, there are two completely different notions of what this means, and we still haven't converged on a standard.
This is true for much of the other test types as well (unit, acceptance, E2E, contract, etc).
It also appears that your organization style and role (front-end, back-end, full-stack) play a role in how we see certain types of tests as well.
Understanding what to test and how to test it takes practice
Knowing what to test is hard. And this is especially misleading for newer developers since a lot of libraries and frameworks tell you how to test code within their library or framework, but don't actually give you best practices for testing your code within them.
The magic rule here is to test against behavior. And if you understand the requirements, we can pretty much turn those user stories or customer requirements directly into tests.
[Principle]: Prefer tests against behavior, not implementation — Seek to test "behavior" using the language of the domain to write the tests. There will be times when the tests you need to write are actually against more technical concepts (ie: integration tests), but you can always write your tests to test behavior, not implementation.
As for knowing how to test behavior— that's a different story.
Most of the time, the code we write is more complex than plain ol' vanilla JavaScript or TypeScript. Typically, we're writing code that relies on dependencies like web servers, caches, databases, and even front-end library code like React or Vue.js 3.
It takes some up-front planning and foresight to figure out how we're going to test our code in most of these scenarios.
We leave testing until the very end
I also think that testing is hard because developers often write tests after the production code has been written. While this approach sometimes works, I don't think it's the best way to go about things.
Writing all the tests at the end isn't really acting like we value feedback, because we're leaving all the uncertainty of "if we can even test this thing", "if this thing was designed well", and "if this thing even works" to the very end.
Testing is a part of architecture
This has been a massive realization for me. And I hope it will be for you as well. Testing is a part of architecture.
Ralph Johnson, co-author of the famous design patterns book said this of architecture:
"... it is the decisions you wish you could get right early in a project”
Architecture is about the expensive, hard to change stuff like choosing a tech stack (React, Apollo, GraphQL, Mongo), an architectural style (Reactive, Event-Driven, Transaction Script), or in this case — our testing approach.
The trouble with not thinking about how we're going to test early on is that we're leaving a lot of room for uncertainty down the road towards the end of the project.
We're not sure if there are edge cases we're missing, if there are structural problems with the way we've written our code, and if we're even going to be able to test the thing.
[Principle]: Expose uncertainty early - Decide on how you're going to test and deploy your application at the start of the project. Get your test architecture set up in Sprint 0.
As Agile software developers, we should value feedback, exposing bad design and uncertainty as early as possible.
"I'm not a great programmer, I'm just a good programmer with great habits" — Kent Beck, the creator of Extreme Programming
At this point, I hope you're sold on what tests can do for us and why we'd want them.
Now let's talk about the TDD process.
The Test-Driven Development (TDD) Process
TDD (test-driven development), is a technique — or a process for developing software. The goal is to keep code quality high and keep you productive, even as projects grow to be really large and complex.
Red-Green-Refactor
The TDD process works by following the Red-Green-Refactor loop. It goes:
- Red — Write a failing test
- Green — Write just enough code that will pass the failing test
- Refactor — Criticize the design and refactor the code, keeping the tests intact
We should like this process because it keeps tight feedback loops. It gives us the ability to produce cleaner, simpler designs and helps us introduce abstractions only when they are absolutely necessary (see YAGNI — You Aren't Gonna Need It).
Should I always follow the TDD loop?: I know what you're thinking. Khalil, you can't expect me to completely follow this rule. You probably don't even do this. You're right. I don't always follow it. Rules are a great way to get started, but sometimes I break them. When I'm driving, I'll sometimes perform a rolling stop instead of coming to a complete stop. When I'm crossing the street, sometimes I'll take a quick gander to my left and right before jaywalking. I'm of the mindset that if you master this technique, you'll have the skill to decide when to break it, and to do so with confidence.
Types of tests (unit, integration, E2E, acceptance)
There are different types of tests, and you can apply the Red-Green-Refactor process to each of them. As we mentioned earlier, the scopes of these tests are up for interpretation, and they may be slightly different if you're a front-end or back-end developer, but in general, they are:
- Unit ⭐ — Test an individual, isolated component
- Integration ⭐ ⭐ ⭐ ⭐ — Test that multiple units work together OR "tests that confirm our code works against code we don't own" (like external APIs, databases, caches, etc)
- End-to-End ⭐ ⭐ — Tests that act as a user actually using the application; tests the entire stack from top-to-bottom
- Acceptance ⭐ ⭐ ⭐ ⭐ ⭐ — Domain-driven tests that verify a user story (also comparable to a use case, customer test, command/query, feature, or vertical slice) works as expected.
The big picture: How TDD works on real-life projects
Allow me to give you the big-picture so you can see where we're going with TDD and why I find it so incredibly powerful.
In the real-world, a good testing architecture typically involves more than one type of test.
As written about in the influential "Extreme Programming Explained" and "Growing Object-Oriented Software Guided By Tests" books, the test we start with is the acceptance test: that is — the test that most closely represents the feature we want to build. From here, we build out the internals of the feature using other tests including unit tests.
Starting with the acceptance test
Starting with the acceptance test, we convert the user story into a behavioral test written using the exact same language from the domain. This means our tests should read like plain English, and represents the exact user story that we're about to build.
From here, we identify the objects and their public APIs (properties, methods, etc) for the feature we need to realize while maintaining a single layer of abstraction. When we need to, we step a layer deeper into the objects we come up with and write more specific tests.
This technique is called Double Loop TDD and it's how we sculpt our solution to fit our tests.
Double Loop TDD
The idea of Double Loop TDD is to maintain two TDD loops.
The outer loop:
- is the acceptance test loop
- and it catches regressions
The inner loop:
- is the unit test loop
- and it measures progress towards implementing a feature
When we're writing tests for the outside acceptance test loop, we're usually coding from the outside-in. When we're writing tests for the inner unit test loop, we're coding inside-out.
Inside-Out and Outside-In. These are also names for two schools of thought for TDD.
Classic (Inside-Out/Chicago) and Mockist (Outside-In/London) TDD
Classic TDD (also known as Inside Out or Chicago style TDD) is a style of unit testing where we start from the unit tests, and build outwards, fleshing out the internal details of the objects we know we need. This is probably the most straightforward and commonly known form of TDD.
Whereas with Mockist TDD (also known as Outside In or London style TDD), we start at the boundaries of the application (typically the controller) and end up coding inwards, creating mock objects to build out the shape of the system and the APIs of objects we know we're going to need to fulfill the acceptance test.
Both styles of TDD are useful. By using a mixture of both inside-out and outside-in, we can compose an elegant approach to TDD our way through an entire project.
[Principle]: Inside-Out when you know how to build it, and Outside-In when you're working out the pieces.
We've covered about 70% of the essential theory for TDD so far. If your head is spinning, that's OK. One of the goals in this introduction is to show you the big picture of TDD and how it's used to develop software from start to finish.
Next, you'll want to get the technique down.
Let's walk through a demonstration.
Reminder: If you prefer to see the demonstration visually, you can watch me code it here.
Demonstration: Classic TDD — Palindrome Example
We'll build a palindrome checker using the Classic TDD approach.
Requirements
- Create a palindrome checker
- It should be able to detect that a string is a palindrome: that is, it is the same word or phrase in reverse.
- Words like "mom" are palindromes
- Words like "bill" aren't palindromes
- It should still know that something is a palindrome, even if the casing is off. So that means that "Mom" is still a palindrome.
- It should also be able to detect palindromes in phrases like "Was It A Rat I Saw" and "Never Odd or Even" too.
Rules
So we know we're going Red-Green-Refactor. According to Robert. C Martin, the three rules of TDD for this are:
- You are not allowed to write any production code unless it is to make a failing unit test pass
- You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
- You are not allowed to write any more production code than is sufficient to pass the one failing unit test
Getting started
I recommend trying this example as well using the Simple TypeScript Starter repo. To get started, paste this command in your terminal.
git clone https://github.com/stemmlerjs/simple-typescript-starter.git
cd simple-typescript-starter
npm install && npm run test:dev
1. Write the failing test
The first test I'll write is that the palindrome checker can tell that "mom" is a palindrome. I will:
- Write the test name using the requirements
- Pretend that something called
palindromeChecker
exists and that it has anisAPalindrome
method on it. - Expect the method to return
true
formom
. - Save
index.spec.ts
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ❌ });
})
At this point, our test should be failing because nothing named palindromeChecker
exists and it doesn't have an isAPalindromeMethod
.
2. Write the simplest code to make the test pass
Let's move to green. In this next step, we:
- Create a
PalindromeChecker
class - Give it an
isAPalindrome
method - Return
true
(the simplest thing that would work) - and import it in our test
Implementing this, the index.ts
containing our PalindromeChecker
looks like this:
index.ts
export class PalindromeChecker {
isAPalindrome (str: string): boolean {
return true; // This is the simplest thing!
}
}
And our test file now looks like this:
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
const palindromeChecker = new PalindromeChecker(); expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅ });
})
Our test passes 🎉.
Let's move to the next step.
3. Refactor
When refactoring, keep a lookout for duplication (at least three times) and code smells.
However... There's none here yet, so let's move to the next failing test.
4. The next failing test
Let's grab the next test. We're going to test that "bill" isn't a palindrome. Our tests should look something like this now.
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅
});
it('should be able to tell that "bill" isnt a palindrome', () => { const palindromeChecker = new PalindromeChecker(); expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy(); // ❌ });})
Since our test fails, we're in the red phase.
Let's turn it green.
5. Write the simplest code to make the test pass
Currently, this is what our PalindromeChecker
class looks like:
index.ts
export class PalindromeChecker {
isAPalindrome (str: string): boolean {
return true; // This is the simplest thing!
}
}
There are several ways to go from Red to Green. If we're really writing the simplest possible thing that would work, then we'd likely find no simpler thing to do than this:
index.ts
export class PalindromeChecker {
isAPalindrome (str: string): boolean {
if (str === 'mom') {
return true;
} else {
return false;
}
}
}
But we know where this leads — so let's implement the Obvious Implementation of reversing the string and seeing if it matches the originally provided one.
index.ts
export class PalindromeChecker {
isAPalindrome (str: string): boolean {
const reversed = str.split("").reverse().join("");
return reversed === str;
}
}
And if we save that, we should notice that our test passes.
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅
});
it('should be able to tell that "bill" isnt a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy(); // ✅
});
})
Nice — now we're in Green again.
6. Refactor
Refactoring doesn't just mean refactoring production code. It means refactoring our test code too. And you may have noticed that there's some duplication in our test setup.
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
const palindromeChecker = new PalindromeChecker(); expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅
});
it('should be able to tell that "bill" isnt a palindrome', () => {
const palindromeChecker = new PalindromeChecker(); expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy(); // ✅
});
})
However, we're going to use the Rule of Three to remove duplication. So we'll leave it for now.
[Principle]: Use the Rule of Three to remove duplication —Introducing abstractions too early is one cause of poor design. Let's wait until we see duplication three times before we make an effort to refactor it.
7. The next failing test
The last test I'll demonstrate is the test case that specifies that a word is still a palindrome, even if it includes capitals and lowercase letters. The business rule here dictates that a word is a palindrome regardless of if it is capitalized or not.
Let's write the failing test.
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅
});
it('should be able to tell that "bill" isnt a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy(); // ✅
});
it('should still detect a palindrome even if the casing is off', () => { const palindromeChecker = new PalindromeChecker(); expect(palindromeChecker.isAPalindrome("Mom")).toBeTruthy(); // ❌ });})
Aha! Look. We have that duplication we were talking about a moment ago. But we're not in the refactor phase yet. We're about to turn this Red test Green.
We impose a little bit of discipline here and shift our focus to making this test pass.
8. Write the simplest code to make the test pass
At first glance, the simplest thing to do is to merely add .toLowerCase()
to both the original and the reversed strings. So let's do that.
index.ts
export class PalindromeChecker {
isAPalindrome (str: string): boolean {
const reversed = str.split("").reverse().join(""); return reversed.toLowerCase() === str.toLowerCase(); }
}
And if we save it and check our tests, we should see it pass as well.
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
it('should be able to tell that "mom" is a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅
});
it('should be able to tell that "bill" isnt a palindrome', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy(); // ✅
});
it('should still detect a palindrome even if the casing is off', () => {
const palindromeChecker = new PalindromeChecker();
expect(palindromeChecker.isAPalindrome("Mom")).toBeTruthy(); // ✅
});
})
9. Refactor
And now that we're in the refactor phase, we can look to improving that duplication we saw earlier.
Jest, our test runner, has a way to specify things that we must do before each test. And creating the PalindromeChecker
is what we'd like to do here.
We can clean up our code with the following:
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
let palindromeChecker: PalindromeChecker;
beforeEach(() => { palindromeChecker = new PalindromeChecker(); })
it('should be able to tell that "mom" is a palindrome', () => {
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy(); // ✅
});
it('should be able to tell that "bill" isnt a palindrome', () => {
expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy(); // ✅
});
it('should still detect a palindrome even if the casing is off', () => {
expect(palindromeChecker.isAPalindrome("Mom")).toBeTruthy(); // ✅
});
})
That looks good to me!
Continue
We can continue on like this by writing more and more failing tests, making the code more generic to make them pass, and improving the design.
I'll leave the next two tests for you to do.
index.spec.ts
import { PalindromeChecker } from './index'
describe('palindrome checker', () => {
let palindromeChecker: PalindromeChecker;
beforeEach(() => {
palindromeChecker = new PalindromeChecker();
})
it('should be able to tell that "mom" is a palindrome', () => {
expect(palindromeChecker.isAPalindrome('mom')).toBeTruthy();
});
it('should be able to tell that "bill" isnt a palindrome', () => {
expect(palindromeChecker.isAPalindrome('bill')).toBeFalsy();
});
it('should still detect a palindrome even if the casing is off', () => {
expect(palindromeChecker.isAPalindrome("Mom")).toBeTruthy();
});
it('should be able to tell that "Was It A Rat I Saw" is a palindrome', () => {
// You do this one!
});
it('should be able to tell that "Never Odd or Even" is palindrome', () => {
// And this one!
})
})
Conclusion
We've covered a lot of ground here.
We discussed why we want to write tests, what TDD is and why it's important, what it looks like in the real world, and we demonstrated the basic technique: implementing the Red-Green-Refactor loop using Classic TDD.
Homework
- Master the Classic TDD technique. This should feel like breathing before we jump into Outside-In TDD and combine 'em to do Double Loop TDD. Try the Red-Green-Refactor process out on more coding katas.
- Try this out when you're building your domain objects (like value objects and entities).
Next in the series
- How to Test Code Coupled to APIs or Databases
- More coming
You also likely have questions at this point:
- "how do you write acceptance tests on the front-end"?
- "what is the difference between an acceptance test and an integration test"?
In that case, leave a question in the comments down below or let me know what you'd specifically like to see.
To see where we're going next with this series, check out this twitter thread and take a look at the new outline for solidbook.io.
[1]: Some developers see programming as an art, math, science, etc. I think there are really good arguments for all of these ways to look at programming, but here's my way of thinking. If you're being paid to write code to make money, save money, or protect revenue, then you should treat the work you do as a craft, because the negative economic consequences for doing it poorly far contrast those of programming for exploratory, experimental, philosophical, or creative reasons.
[2]: There are two broad-stroke categories of test: white box and black box. White box testing is more concerned with what's on the inside. You could say these are tests for the developer. Black box tests are more concerned with the observable output and less about the internals or what's going on within the code. You could call these tests for the customer. When we refer to testable code, we're referring to the mean between these two extremes. If your application adhered to CQS and a query doesn't change the system, then you could technically black box test any badly written code from the outside. It is significantly more difficult (maybe even sometimes impossible) to successfully white box test badly written code. This is why it's a good idea to treat tests as architecture - to start with it before writing any code.
[3]: Architecturally speaking, there is core code and there is infrastructure code. Core code is the code that you use your hands to write. On the backend, with respect to the clean or hexagonal architecture, this is application and domain-layer code. On the front-end, with respect to client-side architectural layers, this is the interaction layer and UI logic. It's the code that you own. Infrastructure code is the stuff that you don't own. Databases, caches, and web servers on the backend and view layer libraries, GraphQL clients, browser APIs and HTTP clients on the front-end. In the real-world, we use both. And this is where many of our testing challenges arise. How do we decouple our core code from the infrastructure code so that we can test our code, and test it fast? These are questions that are better asked and answered earlier than later on in a project.
Stay in touch!
Join 15000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖
View more in Test-Driven Development