How I Practice TDD
Not a day passes on LinkedIn without seeing a post praising the benefits of TDD, but I don’t see many examples of how to do it, so my plan in this article is to show you how I use TDD with a basic example.
This post is not about convincing you to start doing TDD. I don’t do it every time and still manage to sleep at night. However, I found TDD very useful when writing unit tests. Sometimes, I solve a problem I did not know how to solve because TDD allows me to solve it incrementally without using all my brain's computing power.
The problem
The problem I’m going to solve is a function that, given a sentence, returns the number of words in that sentence. For simplicity, commas or other special characters still count as words.
[Note: To familiarize yourself with strings, check my previous article, Demystifying Golang Strings]
I don’t know if there are other ways of doing TDD, but my approach is the Red-Green-Refactor pattern, where you make a test fail, then make it pass, and then refactor if necessary:
The process
Step 1
Ok, so I plan to have a CountWords function in a counter package.
I start with the simplest case, where my function receives an empty string. For that, my function should return 0 :
package counter
import "testing"
func TestEmptySentence(t *testing.T) {
count := CountWords("")
expected := 0
if count != expected {
t.Errorf("expected %d, received %d", expected, count)
}
}🔴 Running my tests with go test ./… I see that my function doesn’t exist:
$ go test ./...
# article-tdd/counter [article-tdd/counter.test]
counter/counter_test.go:6:11: undefined: CountWords
FAIL article-tdd/counter [build failed]
FAIL🟢 Let’s make it green with the least effort. I create the function and just return zero:
package counter
func CountWords(s string) int {
return 0
}I want to emphasize the least effort. You’ll notice I made the smallest effort to make the function pass. I didn’t write if len(s) == 0 for example, because I would get ahead of myself.
My focus is not the function's code but the test itself: I want to write it in a way that clearly describes what my function does, and I want to do it fast. Don’t worry; the code will follow.
I also don’t do any refactoring now because there is nothing to refactor. Refactoring is an optional step.
Step 2
Now, I think about another scenario, which is still simple. If a sentence has one word, it should return 1:
package counter
import "testing"
func TestEmptySentence(t *testing.T) {
count := CountWords("")
expected := 0
if count != expected {
t.Errorf("expected %d, received %d", expected, count)
}
}
func TestWithOneWord(t *testing.T) {
count := CountWords("dog")
expected := 1
if count != expected {
t.Errorf("expected %d, received %d", expected, count)
}
}🔴 Red again:
$ go test ./...
--- FAIL: TestWithOneWord (0.00s)
counter_test.go:19: expected 1, received 0
FAIL
FAIL article-tdd/counter 0.002s
FAILLet’s make it green. Again, in the simplest way possible:
package counter
func CountWords(s string) int {
if s == "dog" {
return 1
}
return 0
}🟢 Green, the test passes:
$ go test ./...
ok article-tdd/counter 0.002sNotice how my test scenarios start with simple cases that incrementally become more complicated: an empty string, then one word, etc.
This allows me to build the solution organically without thinking that hard.
Starting with a complex scenario makes me think too hard to find the right solution, and I want to avoid that. This is overengineering at a smaller scale, making the problem more complicated than it has to be.
Step 3
At this point, I see that my scenarios are pretty similar. So, I turn my tests into table-driven tests. This is not a step in the Red-Green-Refactor flow, but I always strive to make my tests as readable as possible.
package counter
import "testing"
var testcases = []struct {
name string
in string
expected int
}{
{
name: "with empty sentence",
in: "",
expected: 0,
},
{
name: "with one word",
in: "dog",
expected: 1,
},
}
func TestCountWords(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
count := CountWords(tc.in)
if count != tc.expected {
t.Errorf("expected %d, received %d", tc.expected, count)
}
})
}
}I like table-driven tests because they help me add new scenarios quickly. This is very useful if I want to make my tests fail, and the Red-Green-Refactor strategy has less friction because I don’t need that much typing.
🔵 Since my tests are green, I start with my first refactoring. Refactoring means that you look at your function and try to see if you can improve it somehow:
package counter
func CountWords(s string) int {
if s == "dog" {
return 1
}
return 0
}I realize dog is hard-coded, so I remove it and check for the length of s instead:
package counter
func CountWords(s string) int {
if len(s) > 0 {
return 1
}
return 0
}🟢 Run the tests. Still green:
$ go test ./...
ok article-tdd/counter 0.002sAlways refactor when tests are in the green stage to be sure they were working fine before.
Get Andrei Boar’s stories in your inbox
Join Medium for free to get updates from this writer.
That’s the beauty of TDD: You end up with a structure that will tell you if your refactor broke anything.
Step 4
Ok, let’s make it red again by adding a new case:
{
name: "with two word",
in: "a dog",
expected: 2,
}with tests looking like this:
package counter
import "testing"
var testcases = []struct {
name string
in string
expected int
}{
{
name: "with empty sentence",
in: "",
expected: 0,
},
{
name: "with one word",
in: "dog",
expected: 1,
},
{
name: "with two word",
in: "a dog",
expected: 2,
},
}
func TestCountWords(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
count := CountWords(tc.in)
if count != tc.expected {
t.Errorf("expected %d, received %d", tc.expected, count)
}
})
}
}🔴 Red again:
$ go test ./...
--- FAIL: TestCountWords (0.00s)
--- FAIL: TestCountWords/with_two_word (0.00s)
counter_test.go:33: expected 2, received 1
FAIL
FAIL article-tdd/counter 0.001s
FAILTime to do some thinking!
If the sentence is empty, the result is zero. But if I have multiple words, I need to count them individually. How can I distinguish them? They are split by space. So, I make a counter variable that increments every time a character is not space:
package counter
func CountWords(s string) int {
counter := 0
for _, v := range s {
if v == ' ' {
continue
}
counter++
}
return 0
}🔴 Nope, still doesn't work:
$ go test ./...
--- FAIL: TestCountWords (0.00s)
--- FAIL: TestCountWords/with_one_word (0.00s)
counter_test.go:33: expected 1, received 0
--- FAIL: TestCountWords/with_two_word (0.00s)
counter_test.go:33: expected 2, received 0
FAIL
FAIL article-tdd/counter 0.002s
FAILAh, I forgot to return counter instead of 0. Let's fix that:
package counter
func CountWords(s string) int {
counter := 0
for _, v := range s {
if v == ' ' {
continue
}
counter++
}
return counter
}🔴 Now I get a different message:
$ go test ./...
--- FAIL: TestCountWords (0.00s)
--- FAIL: TestCountWords/with_one_word (0.00s)
counter_test.go:33: expected 1, received 3
--- FAIL: TestCountWords/with_two_word (0.00s)
counter_test.go:33: expected 2, received 4
FAIL
FAIL article-tdd/counter 0.002s
FAILSo for dog I receive 3. It looks like my condition is not correct. I don’t need to count each character but every word. I need to keep track of the last character and increment the counter only if lastChar was a space and the current character is not a space itself:
package counter
func CountWords(s string) int {
counter := 0
lastChar := ' '
for _, v := range s {
if lastChar == ' ' && v != ' ' {
counter++
}
lastChar = v
}
return counter
}🟢 Yay!
➜ article-tdd go test ./...
ok article-tdd/counter 0.002s🔵 In the Refactor stage, I do some renaming and change the order in my if condition to make it more clear:
package counter
func CountWords(sentence string) int {
counter := 0
lastChar := ' '
for _, currentChar := range sentence {
if currentChar != ' ' && lastChar == ' ' {
counter++
}
lastChar = currentChar
}
return counter
}🟢 Still green! My refactoring didn’t break the tests:
$ go test ./...
ok article-tdd/counter 0.002sNow, I add some extra scenarios. At this point, I’m only trying to break the tests:
package counter
import "testing"
var testcases = []struct {
name string
in string
expected int
}{
{
name: "with empty sentence",
in: "",
expected: 0,
},
{
name: "with one word",
in: "dog",
expected: 1,
},
{
name: "with two spaces",
in: "a dog",
expected: 2,
},
{
name: "with multiple spaces",
in: "a dog ",
expected: 2,
},
}
func TestCountWords(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
count := CountWords(tc.in)
if count != tc.expected {
t.Errorf("expected %d, received %d", tc.expected, count)
}
})
}
}🟢 It seems that it’s still working!
Going back to my function, I try to see if there is something I can improve, but I just leave it as it is. You must know when to stop; perfect is the enemy of the good. Now I’m at a point where:
1. I’m fairly certain it works.
2. I have a full list of scenarios.
3. If someone discovers a bug in the future, I just update my scenarios to make the test fail, and then I’ll make it green again.
I now have a working function and a structure that will ensure the requirements are set in stone and notify me when they are broken.
Closing remarks
In this post, I showed you a basic unit test, but the strategy behind it works even when you write more complicated tests.
Start with the simplest scenario and increment from there. Red-Green, Red-Green and you’ll quickly reach your solution.
Avoid overthinking; make it green as fast as possible. At first, your tests are more important than your function’s code.
Make your tests as readable as you can. They’re not only testing your code, they also serve as documentation.
You can use table-driven tests to add new test cases easily.
Refactoring is optional, but don’t leave it until the end. Sometimes, it’s easier to refactor after 2–3 test cases, and sometimes, leaving it at the end is OK. Knowing when to do it comes with experience.
That’s, in a nutshell, how I practice TDD. I hope you enjoyed the article. If you did, please give it a 👏 so others can see it.
If you have questions or suggestions, feel free to leave a comment or connect with me on LinkedIn.








