TDD in Golang
Definition
Test-driven development (TDD) is a software development process relying on software requirements being converted to test cases before software is fully developed, and tracking all software development by repeatedly testing the software against all test cases.
Why?
Why write tests?
- Ensure the code is doing what it's supposed to
- Catch errors/failures early
- Documentation
- Tests can act as documentation for your code, since it ensures that you cover a variety of cases. As a reader, you can understand the different use cases for the components/functions.
- Future proof your code
- As the code base grows, you can't test each workflow which covers every line of code
- Writing tests ensures that even if you make a change in one component does not affect other components
Why follow TDD?
- Helps us to develop the logic in our code
- Very high test-coverage
- Better structure to our code
- Write testable code instead of restructuring the code afterwards
How? / Let's learn by scenarios
Scenario 1 - Implement a Sum() function
Demoes the process of TDD
Functionality
Get the sum of an array of numbers
Steps in TDD
- Write unit test
- Write the minimal amount of code for the test to run
- (Iteratively) Write enough code to make the test pass
TDD for Sum() functioanlity
-
Write unit test
sum_test.go
package sum import "testing" func TestSum(t *testing.T) { numbers := []int{1, 2, 3, 4, 5} expected := 15 actual := Sum(numbers) if actual != expected { t.Errorf("input: %v; expected: %d; actual: %d\n", numbers, expected, actual) } }
Run the test
$ go test * # command-line-arguments [command-line-arguments.test] ./sum_test.go:9:12: undefined: Sum FAIL command-line-arguments [build failed] FAIL
-
Write the minimal amount of code for the test to run
sum.go
package sum func Sum(input []int) int { return 0 }
Run the test
$ go test * --- FAIL: TestSum (0.00s) sum_test.go:12: input: [1 2 3 4 5]; expected: 15; actual: 0 FAIL FAIL command-line-arguments 0.287s FAIL
-
Write enough code to make it pass
sum.go
package sum func Sum(input []int) int { sum := 0 for _, v := range input { sum += v } return sum }
Run the test
$ go test -v -run ^TestSum$ ./... === RUN TestSum --- PASS: TestSum (0.00s) PASS ok github.com/albingeorge/tdd/1_sum 0.113s
Scenario 2 - Implement a countdown
Shows how TDD can help developers cut down the time it takes to code, but reducing the effort to refactor the code after completing implementation.
Functionality
Write a program which counts down from 3, printing each number on a new line (with a 1-second pause) and when it reaches zero it will print "Go!" and exit.
3
2
1
Go!
Let's implement without following TDD
countdown.go
package countdown
import (
"fmt"
"time"
)
func CountdownNonTdd() {
num := 3
for i := num; i > 0; i-- {
fmt.Println(i)
time.Sleep(1 * time.Second)
}
fmt.Println("Go!")
}
countdown_test.go
func TestCountdownNonTdd(t *testing.T) {
CountdownNonTdd()
}
Execute the test
$ go test -v -run ^TestCountdownNonTdd$ ./...
=== RUN TestCountdownNonTdd
3
2
1
Go!--- PASS: TestCountdownNonTdd (3.00s)
PASS
ok github.com/albingeorge/tdd/2_countdown 3.105s
Problems with the above implementation
- We can't test the below functionalities
- Is the right data getting printed? (in this case, "3, 2, 1, Go!")
- Is the sleep happening in between the entries?
- The test takes very long time to execute
- Test prints to stdout polluting the test results
TDD approach 1
Use Dependency Injection design pattern to capture the things to test.
-
Write test
countdown_test.go
func TestCountdown(t *testing.T) { // Create a []byte buffer buffer := &bytes.Buffer{} // Pass the buffer as a dependency to the implementation Countdown(buffer) // Fetch the content of the bufer to compute the output got := buffer.String() want := `3 2 1 Go!` if got != want { t.Errorf("got %q want %q", got, want) } }
Execute test
$ go test -run ^TestCountdown$ *.go # command-line-arguments [command-line-arguments.test] ./countdown_test.go:14:2: undefined: Countdown FAIL command-line-arguments [build failed]
-
Write the minimal amount of code for the test to run
countdown.go
func Countdown(writer io.Writer) { }
Execute the test
$ go test -run ^TestCountdown$ *.go --- FAIL: TestCountdown (0.00s) countdown_test.go:21: got "" want "3\n2\n1\nGo!" FAIL
-
Write enough code to make it pass
countdown.go
// Accept the io.Writer interface func Countdown(writer io.Writer) { num := 3 for i := num; i > 0; i-- { // Replaces the Println with Fprintln, which accepts an io.Writer interface fmt.Fprintln(writer, i) time.Sleep(1 * time.Second) } fmt.Fprint(writer, "Go!") }
Execute the test
go test -v -run ^TestCountdown$ *.go === RUN TestCountdown --- PASS: TestCountdown (3.00s) PASS ok command-line-arguments 3.123s
Usage
main.go
func main() {
countdown.Countdown(os.Stdout)
}
Output
$ go run main.go
3
2
1
Go!
Advantages of using Dependency Injection in this approach
- Enables us to test the internals of a function even if the function does not return an output
- Make the implementation more general purpose. We can now use the Countdown() function for multiple implementations of io.Writer
- Get the output as a string - as explained in the test case
- Use the function to print to standard output - as mentioned under Usage
- Use the function to print to http response writer, etc
Problem with Approach 1
- Can't test sleep functionality
- Tests are slow - bottleneck at
time.Sleep()
call
TDD approach 2
Use mock to test the sleep functionality
-
Write test
countdown_test.go
// Create a mock implementation, which calls the Sleep() function type SleeperMock struct { count int } // In this test, we intend to test how many times Sleep() were called // from within the function. We can extend this for more functionality if needed. func (m *SleeperMock) Sleep() { m.count++ } func TestCountdownImproved(t *testing.T) { buffer := &bytes.Buffer{} sleep := SleeperMock{} // For lack of a better name CountdownImproved(buffer, &sleep) got := buffer.String() want := `3 2 1 Go!` if got != want { t.Errorf("got %q want %q", got, want) } sleepWanted := 3 if sleep.count != sleepWanted { t.Errorf("sleep count: got %q want %q", sleep.count, sleepWanted) } }
Run the test
$ go test -run ^TestCountdownImproved$ *.go # command-line-arguments [command-line-arguments.test] ./countdown_test.go:40:2: undefined: CountdownImproved FAIL command-line-arguments [build failed] FAIL
-
Write the minimal amount of code for the test to run
countdown.go
type SleeperInterface interface { Sleep() } func CountdownImproved(writer io.Writer, s SleeperInterface) { }
countdown_test.go
$ go test -v -run ^TestCountdownImproved$ *.go === RUN TestCountdownImproved countdown_test.go:48: got "" want "3\n2\n1\nGo!" countdown_test.go:53: sleep count: got '\x00' want '\x03' --- FAIL: TestCountdownImproved (0.00s)
-
Write enough code to make the test pass
countdown.go
// Does not matter if the time.Sleep() method is called here // The responsibility of what Sleep() does is passed on to the // consumer of this function. type SleeperInterface interface { Sleep() } func CountdownImproved(writer io.Writer, s SleeperInterface) { num := 3 for i := num; i > 0; i-- { fmt.Fprintln(writer, i) s.Sleep() } fmt.Fprint(writer, "Go!") }
Run the test
$ go test -v -run ^TestCountdownImproved$ *.go === RUN TestCountdownImproved --- PASS: TestCountdownImproved (0.00s) PASS
Key takeaways for this approach
- Time it takes to run the test is 0.00s
- We're now able to test that Sleep() is called 3 times in the function
Further improvements
- How do we ensure that the Sleep() is called in the right sequence?
Usage of countdown()
main.go
type sleeperImplementation struct{}
func (s sleeperImplementation) Sleep() {
time.Sleep(1 * time.Second)
}
func main() {
sleeper := sleeperImplementation{}
countdown.CountdownImproved(os.Stdout, sleeper)
}
Execute main
$ go run main.go
3
2
1
Go!
Conclusion
Rundown of all the tests
$ go test -v ./...
? github.com/albingeorge/tdd [no test files]
=== RUN TestSum
--- PASS: TestSum (0.00s)
PASS
ok github.com/albingeorge/tdd/1_sum 0.135s
=== RUN TestCountdownNonTdd
3
2
1
Go!--- PASS: TestCountdownNonTdd (3.00s)
=== RUN TestCountdown
--- PASS: TestCountdown (3.00s)
=== RUN TestCountdownImproved
--- PASS: TestCountdownImproved (0.00s)
PASS
ok github.com/albingeorge/tdd/2_countdown 6.194s