What is Test-Driven Development?
Test-Driven Development (TDD) is a software development methodology that focuses on writing tests before writing the actual code. In TDD, you start by creating a test case that defines the expected behavior of a particular piece of code. Then, you write the code to make the test pass. TDD follows a cycle known as the "Red-Green-Refactor" cycle:
Red: You begin by writing a failing test case (the "Red" phase) that specifies the desired functionality.
Green: You then write the minimal code required to make the test pass (the "Green" phase). This ensures that the code meets the specified requirements.
Refactor: After the test passes, you can refactor the code to improve its quality while keeping the test passing. This maintains the code's correctness as you make changes.
Before diving into our inaugural TDD Go application, it's crucial to emphasize another vital facet of TDD: setting up source control, such as Git. Git plays a pivotal role by allowing you to commit the latest functional iteration of your TDD process. This safeguards your progress, enabling you to revert to a previous version and embark on a fresh refactoring journey with confidence.
Lets start TDD with our hello world program again
Prerequisite:
Create new project folder e.g:
hello-world
Create 2 files inside it
hello_test.go
andhello.go
, its Ok if the contents are emptyGenerate go module with
go mod init hello
.
And you should be ready to follow along.
RED
Open your hello_test.go
file in your editor/IDE and write a test case first:
package main
import "testing"
func TestHello(t *testing.T) {
t.Run("Say hello to John Doe", func(t *testing.T) {
got := Hello("John Doe")
expected := "Hello, John Doe"
if got != expected {
t.Errorf("expected %q but got %q", expected, got)
}
})
}
Run the go test -v
and see it failing
go test -v ─╯
# hello [hello.test]
./hello_test.go:7:10: undefined: Hello
FAIL hello [build failed]
GREEN
Open your hello.go
file in editor and create a Hello
function because our test case is complaining Hello
is undefined.
// hello.go
package main
func Hello() string {
}
Run the test again and see another failure.
go test -v ─╯
# hello [hello.test]
./hello.go:4:1: missing return
./hello_test.go:7:16: too many arguments in call to Hello
have (string)
want ()
FAIL hello [build failed]
Running your tests at each step is essential to ensure that your test cases do not unexpectedly pass when they shouldn't.
Lets write enough code to make this test GREEN
// hello.go
package main
func Hello(name string) string {
return "Hello, " + name
}
Run your test case again and it should pass this time.
go test -v ─╯
=== RUN TestHello
=== RUN TestHello/Say_hello_to_John_Doe
--- PASS: TestHello (0.00s)
--- PASS: TestHello/Say_hello_to_John_Doe (0.00s)
PASS
ok hello 1.417s
Source Control
💡At this point because we have workable version of the code, lets commit our changes to git
git commit -am "Function to say hello to John Doe"
REFACTOR
Now that our test case is passing, let's refactor our code.
// hello.go
// ...
const englishHelloPrefix = "Hello, "
func Hello(name string) string {
return englishHelloPrefix + name
}
Run the test again go test
and everything should still pass. It might seem silly to make that change right now, trust me we need it.
In this step, we've extracted the "Hello, "
message into a constant. It's worth considering the use of constants to convey the meaning of values and, in some cases, to optimize performance.
💡As a final step, because now we have all steps satisfied RED, GREEN, REFACTOR, it is time to commit your changes to git and also push it to the git repository.
Continue to add features with TDD in mind
Requirement: if name
is ""
empty string, return Hello, world
instead.
RED
func TestHello(t *testing.T) {
t.Run("Say hello to John Doe", func(t *testing.T) {
got := Hello("John Doe")
expected := "Hello, John Doe"
if got != expected {
t.Errorf("expected %q but got %q", expected, got)
}
})
t.Run("say 'Hello, world' when an empty string is given", func(t *testing.T) {
got := Hello("")
expected := "Hello, world"
if got != expected {
t.Errorf("expected %q but got %q", expected, got)
}
})
}
Run the test go test
it should fail with this message hello_test.go:20: expected "Hello, world" but got "Hello, "
.
GREEN
const englishHelloPrefix = "Hello, "
func Hello(name string) string {
if name == "" {
name = "world"
}
return englishHelloPrefix + name
}
Run the test go test
again and it should pass.
💡commit your code to git
REFACTOR
There is nothing to refactor in the actual program, but we can extract duplicate code from our test case to keep it DRY (Don't Repeat Yourself)
func TestHello(t *testing.T) {
t.Run("Say hello to John Doe", func(t *testing.T) {
got := Hello("John Doe")
expected := "Hello, John Doe"
assert(t, expected, got)
})
t.Run("say 'Hello, world' when an empty string is given", func(t *testing.T) {
got := Hello("")
expected := "Hello, world"
assert(t, expected, got)
})
}
func assert(t testing.TB, expected, got string) {
t.Helper()
if expected != got {
t.Errorf("Expected %q but got %q", expected, got)
}
}
Run your test again and it should still pass.
We have created assert
function that expects:
testing.TB
object as first argument, this will allow us to pass either*testing.T
testing or*testing.B
benchmarking.we also said this is a helper function by calling
t.Helper()
. We did this because if the test case fail, it will show the exact line number where it failed from the actual test function rather then showing line number of theassert()
function. You can change one of your test case with wrong assertion and see it for yourself by keeping and removingt.Helper()
fromassert()
function.
>💡commit your changes to git, and push your code to git repo
Enjoying so far? 😍lets keep moving forward.
Requirement: our Hello
function should support additional parameter to specify the language of the greeting. Example Hola,
in Spanish, Bonjour,
in French, and you can choose your own language, example नमस्ते,
(Nameste) in Nepali.
RED
func TestHello(t *testing.T) {
// old code
t.Run("greeting in Spanish", func(t *testing.T) {
got := Hello("John", "es")
expected := "Hola, John"
assert(t, expected, got)
})
}
Run the test and it should fail. BTW because go is Statically typed language, if your Editor/IDE is correctly configured, you should see compiler yelling at you at this point.
./hello_test.go:21:24: too many arguments in call to Hello
have (string, string)
want (string)
FAIL hello [build failed]
GREEN
Add lang string
parameter to your Hello()
function
const englishHelloPrefix = "Hello, "
func Hello(name, lang string) string {
if name == "" {
name = "world"
}
return englishHelloPrefix + name
}
Run the test
go test ─╯
# hello [hello.test]
./hello_test.go:7:16: not enough arguments in call to Hello
have (string)
want (string, string)
./hello_test.go:14:16: not enough arguments in call to Hello
have (string)
want (string, string)
FAIL hello [build failed]
As you can see, by writing TDD you know exactly when your code breaks, lets fix our old tests
t.Run("Say hello to John Doe", func(t *testing.T) {
got := Hello("John Doe", "") // added "" as second argument
expected := "Hello, John Doe"
assert(t, expected, got)
})
t.Run("say 'Hello, world' when an empty string is given", func(t *testing.T) {
got := Hello("", "") // added "" as second argument
expected := "Hello, world"
assert(t, expected, got)
})
t.Run("greeting in Spanish", func(t *testing.T) {
got := Hello("John", "es")
expected := "Hola, John"
assert(t, expected, got)
})
Run the test again
go test ─╯
--- FAIL: TestHello (0.00s)
--- FAIL: TestHello/greeting_in_Spanish (0.00s)
hello_test.go:24: Expected "Hola, John" but got "Hello, John"
FAIL
exit status 1
FAIL hello 0.804s
Now you should see, we are expecting Hola, John
but our function is returning Hello, John
. Lets fix it.
const englishHelloPrefix = "Hello, "
func Hello(name, lang string) string {
if name == "" {
name = "world"
}
if lang == "es" {
return "Hola, " + name
}
return englishHelloPrefix + name
}
Run the test and you should see everything passing, 🎉
💡commit your changes to git
REFACTOR
Let's extract Spanish greeting to it's own constant and also extract lang
value to its own constant.
const (
englishHelloPrefix = "Hello, "
spanishHelloPrefix = "Hola, "
)
const spanish = "es"
func Hello(name, lang string) string {
if name == "" {
name = "world"
}
if lang == spanish {
return spanishHelloPrefix + name
}
return englishHelloPrefix + name
}
Run the test again and everything should pass.
💡 Commit your changes to git and push to git repo
Exercise: Lets add French language support
Solution: RED
/// old code
t.Run("greeting in French", func(t *testing.T) {
got := Hello("John", "fr")
expected := "Bonjour, John"
assert(t, expected, got)
})
Run the test you should see hello_test.go:31: Expected "Bonjour, John" but got "Hello, John"
GREEN
const (
englishHelloPrefix = "Hello, "
spanishHelloPrefix = "Hola, "
frenchHelloPrefix = "Bonjour, "
)
const (
spanish = "es"
french = "fr"
)
func Hello(name, lang string) string {
if name == "" {
name = "world"
}
if lang == spanish {
return spanishHelloPrefix + name
}
if lang == french {
return frenchHelloPrefix + name
}
return englishHelloPrefix + name
}
Now run the test, it should pass again.
REFACTOR
Now lets use switch
statement to replace our if
statements
const (
englishHelloPrefix = "Hello, "
spanishHelloPrefix = "Hola, "
frenchHelloPrefix = "Bonjour, "
)
const (
spanish = "es"
french = "fr"
)
func Hello(name, lang string) string {
if name == "" {
name = "world"
}
prefix := englishHelloPrefix
switch lang {
case spanish:
prefix = spanishHelloPrefix
case french:
prefix = frenchHelloPrefix
}
return prefix + name
}
And the test should still pass.
What we practiced here?
Write a test first and see it fail (RED)
Make the code and make compiler happy
Run the test case and see it pass (GREEN)
Commit your code to git at this point, so that further Refactor becomes seemless
Refactor.
Commit your code to git and push your changes to git repository.
Hope you enjoyed this TDD approach.
Top comments (0)