Among the few positive aspects of the lock-down, having more time to read is definitely one of them. Two weeks ago I started reading again the Test Driven Development (TDD) bible written by Kent Beck, who is considered by most the father of TDD. Regardless of what your thoughts are about TDD, the book is a gold mine on testing. I highly recommend it.
In the same spirit of the book, this article is going to be a practical walk through on how to develop code driven completely by tests; an example from start to end on how to apply TDD. I am going to start with a brief recap on TDD, then I'll walk you through an example in which we are going to code a throttle the TDD way. Last, I'll share some resources that can be used to practice TDD.
The target audience for this post is people who are considering using TDD in the development process. If you have already delved into TDD or you are already using it, this post probably won't add any new information to your knowledge. However it might still be useful as a reference you can share with others curious about the topic.
Preface
TDD is one of the software engineering practice which has stood the test of time. At the beginning of 2000s Kent Beck came out with the book "Test Driven Development: By Example". The book is twenty years old, though TDD as a concept it's probably older than that. It was Kent Beck himself to say that he did not "invent" TDD, but rather "rediscover" it from old articles and papers. The humble programmer, Dijkstra (1972) and the Report of The Nato Software Engineering Conference (1968) both described the process of testing the specification before writing the code. While, Kent Beck might not have been the one who invented, he definitely was the one who made it popular.
Is a 20+ engineering practice still relevant today?
Everything we do is built upon layer of abstractions and decisions made decades ago. People who made those decisions lived in a different context, had different constraints and problems to solve. What they did, is what we do today: they came up with the best solution they could think of at the time.
Their decisions live with us. But most often, their reasons do not.
Technology changed, the problems we need need to solve changed, the world has changed.
As software engineer one of the most valuable skills I picked up is to question everything, understand why things are the way they are. Searching for the context in which these decisions were made is the key to understand if the same decisions are applicable in the current world.
So, is TDD still relevant today? I think it is, because:
- we still need to write unit tests to prove that our code respect the specification
- we still want to reduce the number of bugs which make it all the way to production
- we still want to iterate fast and integrate changes often
- we still want to build highly cohesive and loosely coupled components
I believe the premises of TDD are still valid in the context we live in.
TDD is controversial
Not everybody thinks TDD is useful. I couldn't agree more - not everybody must use it. During the years, a few research studies were made to determine the effectiveness of TDD in the software development process, but they were largely inconclusive. I think that's because quantitative measurements about source code quality and speed of iterations are too noisy and dependent on social factors - all things which are difficult to take into account in a research study.
I want to conclude this quite lengthy preface by saying that I am not religious about TDD - and I hope neither will you. It's like any other tool we have in our toolbox - it allows to see the problem from a different point of view.
TDD
TDD is a predictable way to develop code which relies on the following three steps:
- RED - Write a unit test, run it an watch it fail. The unit test should be short and focus on a single behavior of the system under test. By writing the failing test you ensure that your test is calling the correct code and that the code is not working by accident. It's a meaningful failure, and you expect it to fail
- GREEN - Write the minimum amount of code needed to make the test pass
- REFACTOR - Eliminate the duplication (both in the test and in the code, including duplication between test and code). More in general this is the step in which you would perform refactoring
There isn't much else you need to know to start using TDD. Using it effectively is just a matter of practicing it over and over again. Project after project you become better at it.
Why TDD?
- you are always one test away from functional code
- tests are more expressive; the result is usually tests that cover the behavior of the module instead of the underlying implementation
- increased test coverage and reduced coupling between test and production code
- it's very useful when you know what you have to build, but have no idea where to start; a situation quite common when you need to add or change a new feature in a piece of the code base you are not familiar with
Throttling example
In this section we will build a throttle. The end goal of throttling is to limit how many times a function can be called in a given interval of time. It's generally used to avoid overloading the receiver with too many calls (for example a remote server) or because a sample of the events is sufficient to carry on with the functionality.
To sum it up to throttle a function means to ensure that the function is called at most X times in a specified time period (for instance, at most three times every second). The throttle we are going to build, is a slightly simpler version which only allows at most one call in a specified period. This is the spec:
throttle returns a function which is called at most once in a specified time period.
It takes as input the function to throttle and the period.
If the period is less or equal than zero, then no throttle is applied.
Let's try to build it. Since we are using TDD, this means writing our test first.
First test
describe("Given the throttle time is 0", () => {
it("Runs the function when we call it", () => {
let count = 0;
const
fun = () => count++,
funT = throttle(fun, 0);
funT();
expect(count).toBe(1);
});
});
In the test, we defined a simple function called fun which simply increments a variable called count every time we invoke the function. We call our throttle function giving it as parameter the function we just defined and a throttle period of zero. According to the specification, if the throttle period is zero, the function must be invoked when we call it. We called funT (as in fun Throttled) the result of applying throttle to fun.
Run the test and watch it fail. Now, we have to make it pass by writing the minimum amount of code necessary. So. let's create the throttle function:
function throttle(fun, throttleTime) {
return () => {
fun();
}
};
module.exports = { throttle };
Run the test again, and it's green! To make the test green, we just had to create the throttle function and make it invoke fun. At this point there is nothing to refactor, so we'll move the next test.
Second test
According to the spec, if the throttle period is zero, the function must be invoked "every" time we call it because no throttle is applied. Let's test that:
describe("Given the throttle time is 0", () => {
it("Runs the function 'every' time we call it", () => {
let count = 0;
const
fun = () => count++,
funT = throttle(fun, 0),
calls = 10;
for (let i = 0; i < calls; i++) {
funT();
}
expect(count).toBe(calls);
});
});
Instead of calling funT once like in the previous test, now we are calling it ten times and we expect the count variable to be ten at the end.
Run the tests and...it's green. We did not even have to add any code for it, good. Before we go ahead with the next test, we are going to refactor: the second test includes the first one so we can remove it, which leaves us with the following suite:
describe("throttle suite", () => {
describe("Given the throttle period is 0", () => {
it("Runs the function 'every' time we call it", () => {
let count = 0;
const
fun = () => count++,
funT = throttle(fun, 0);
calls = 10;
for (let i = 0; i < calls; i++) {
funT();
}
expect(count).toBe(calls);
});
});
});
Third test
Let's add another test when the throttle period is negative:
describe("Given the throttle period is negative", () => {
it("Runs the function 'every' time we call it", () => {
let count = 0;
let count = 0, calls = 10;
const
fun = () => count++,
funT = throttle(fun, -10);
for (let i = 0; i < calls; i++) {
funT();
}
expect(count).toBe(calls);
});
});
Again, it passes and we did not have to add any code. We can refactor since the test for the negative period and the zero period are very similar:
describe("throttle suite", () => {
const runFun = (throttlePeriod) => {
it("Runs the function 'every' time we call it", () => {
let count = 0, calls = 10;
const
fun = () => count++,
funT = throttle(fun, throttlePeriod);
for (let i = 0; i < calls; i++) {
funT();
}
expect(count).toBe(calls);
});
};
describe("Given the throttle period is 0", () => runFun(0));
describe("Given the throttle period is negative", () => runFun(-10));
});
Fourth test
describe("Given the throttle period is positive", () => {
describe("When the throttle period has not passed", () => {
it("Then `fun` is not called", () => {
let count = 0;
const
fun = () => count++,
funT = throttle(fun, 1* time.Minute);
funT();
expect(count).toBe(1);
funT();
expect(count).toBe(1);
});
});
});
Run the test and watch it fail:
Failures:
1) throttle suite
Given the throttle period is positive
When the throttle period has not passed
Then `fun` is not called
Message:
Expected 2 to be 1.
What's happening here? We expect the first call to funT to go through because the throttle does not apply to the first call. Thus in the first expectation we check if the variable count is equal to one. The second time we call funtT must be throttled because at least one minute needs to pass between the first and the second call; that's why we expect count still to be one in the second expectation. Except it isn't. The count variable is two because we haven't implement any throttling logic yet.
What's the smallest step to make the test pass? What I have come up with is:
- check if it's the first time we are calling the function
- differentiate between a positive throttle period and a less than zero period
function throttle(fun, throttleTime) {
let firstInvocation = true;
return () => {
if (throttleTime <= 0) {
fun();
return;
}
if (firstInvocation) {
firstInvocation = false;
fun();
}
}
};
The introduction of firstInvocation
and the if statement
was enough to make the test pass.
Fifth test
Next one is interesting.
describe("When the throttle period has passed", () => {
it("Then `fun` is called", () => {
let count = 0;
const
fun = () => count++,
funT = throttle(fun, 1* time.Minute);
funT();
expect(count).toBe(1);
// 1 minute later ...
funT();
expect(count).toBe(2);
});
});
In this test we want to verify that after one minute has passed, the function won't be throttled. But how do we model time? We need to have something that allows to keep track of time, like a timer or something similar. More importantly, we need to manipulate the state of the timer in the test. Let's assume we already have what we need and change the test accordingly:
describe("When the throttle period has passed", () => {
it("Then `fun` is called", () => {
let count = 0, timer = new MockTimer();
const
fun = () => count++,
funT = throttle(fun, 1 * time.Minute, timer);
funT();
expect(count).toBe(1);
// fast forward 1 minute in the future
timer.tick(1 * time.Minute);
funT();
expect(count).toBe(2);
});
});
The diff between this version of the test and the previous is the introduction of the MockTimer. It's initialized with the rest of the variables at the beginning of the test. Right after the first expectation the timer tick method is called to move the timer one minute in the future. Since the throttle timeout is one minute, we expect the next call to funT() to go through.
Let's run the test. Not surprisingly, it fails because the MockTimer does not exist. We need to create it.
Before doing that, let's figure how we would use the timer in the throttle function. You can come up with different ways of using it. In my case I decided I needed to have a way to start the timer and check if it is expired or not. With that in mind let's change the throttle function to make use of a Timer that does not exist yet. Using a function before implementing it seems stupid, but in fact it's quite useful because you get to see the usability of the api before writing the code for it.
function throttle(fun, throttleTime, timer) {
let firstInvocation = true;
return () => {
if (throttleTime <= 0) {
fun();
return;
}
if (firstInvocation) {
firstInvocation = false;
fun();
timer.start(throttleTime);
return;
}
if (timer.isExpired()) {
fun();
timer.start(throttleTime);
}
}
};
Established the api, let's implement a mock timer for our test:
class MockTimer {
constructor() {
this.ticks = 0;
this.timeout = 0;
}
tick(numberOfTicks) {
this.ticks += numberOfTicks ? numberOfTicks : 1;
}
isExpired() {
return this.ticks >= this.timeout;
}
start(timeout) {
this.timeout = timeout;
}
}
Run the test again, and boom, tests are green!
Let's change our test and make it richer:
describe("When the throttle period has passed", () => {
it("Then `fun` is called", () => {
let count = 0, timer = new MockTimer();
const
fun = () => count++,
funT = throttle(fun, 1 * time.Minute, timer);
funT();
expect(count).toBe(1);
timer.tick(1 * time.Minute);
funT();
expect(count).toBe(2);
timer.tick(59 * time.Second);
funT();
expect(count).toBe(2);
timer.tick(1* time.Second);
funT();
expect(count).toBe(3);
for (let i = 0; i < 59; i++) {
timer.tick(1 * time.Second);
funT();
expect(count).toBe(3);
}
timer.tick(1* time.Second);
funT();
expect(count).toBe(4);
});
});
At this point, we just need to plug in an actual timer which we could build with a similar process, for example:
class Timer {
constructor() {
this.expired = true;
this.running = false;
}
isExpired() {
return this.expired;
}
start(timeout) {
if (this.running) {
return new Error("timer is already running");
}
this.expired = false;
this.running = true;
setTimeout(() => {
this.expired = true;
this.running = false;
}, timeout);
}
}
Tidying up the API
There is one last thing. We can create a default timer instead of requiring the caller to pass it as a parameter:
function throttle(fun, throttleTime) {
return throttleWithTimer(fun, throttleTime, new Timer());
}
function throttleWithTimer(fun, throttleTime, timer) {
// ... same as before
Finally we can use our throttle function:
throttle(onClickSendEmail, 1 * time.Second);
Practice TDD
If you like the idea of writing your test first, then give TDD a try. In this article I showed the throttle function, maybe you can try the debounce by yourself. When I was thinking about the article I almost settled on using Conway's Game of Life as example, but it did not take me long to realize that the article would be too long. If you are up for it, it's a fun exercise to build with TDD.
You could also try some of the programming Katas available online, like:
Conclusion
Whatever you pick to flex your TDD muscle, my suggestion is to give it sometime. At least for me, TDD did not click right away. The first times I tried it, I got stuck - I could not figure how to write the test before the code. But I kept practicing on my own and ultimately it became natural to think about the test before thinking about the code.
Follow me on Twitter to get new posts in your feed.
Credit for the cover image to GraphicMama-team
Top comments (4)
Nice Article Nicola! I'd like to add my own little experience with TDD. It also didn't click right away for me.
I usually mostly code web-apps which involve database transactions and few "algorithms" - think for example a blog where you just need to post and fetch articles.
I naturally thought that the best way was to do what Laravel calls "feature tests" which assert the result of an API call.
Then, as I progressed, I learned that in some cases I also need to run tests outside of API calls, so I started writing what would technically be called "Integration tests".
Today, I've reached a stage where TDD has helped me greatly in decoupling my code into small, readable functions and classes.
The final thing I haven't come to a conclusion yet is using mocks to replace external dependencies when testing my code. I think it has to do with the nature of my work which highly involve storing and fetching data to/from the database without much "algorithms" to be tested per se.
I don't think the hassle of implementing something like the repository pattern just to get that extra layer between the database and the ORM so that I can mock/ditch the ORM.
I'm so far still saving myself a good amount of time by spinning up the whole process on a test database and still using the ORM on my "unit" tests. If I had to follow it by the book, I should be mocking every single external dependency.
Do you agree with me, or do you think I haven't caught on writing unit tests with mocks quite yet?
Thank you!
Your question is interesting and a bit open ended. I'll give you my 2 cents on mocking the datastore (via repository interface or similar).
Most of the code I have seen does use some interface between the business logic and the datastore. And I think the reason is: you want to have a way to test that the code that uses the repository works as expected even at the boundaries; for example: am I catching that SQLException? Do I fallback to a different replica? What happens if the answer from the datastore does not contain the field I was expecting? Can I handle multiple version of the item?
All these things are hard to set up with an integration test.
This is why I think using an interface is useful, but depending on who you ask, you might also get as answers:
I do not completely agree with those because:
To sum it up - it depends on the use case. I tend to prefer a lightweight repository pattern because I can easily test edge conditions.
I hope I answered your question.
Yep, I see where you're getting. Thanks a lot for the input. I'll keep running the database in the foreseeable future even if some people will hate me :D
If you're looking for the next book in your TDD journey, take a look at Growing Object-Oriented Software Guided by Tests.