Test Doubles in Unit Testing
Test double is one of the key concepts of TDD(Test Driven Development). Test Double is a generic term for any case where you replace a production object for testing purposes.
There are various kinds of test doubles: Dummies, Stubs, Mocks, Spies, Fakes.
Whew, what on earth is that? Alright, let's check them out one by one:
Dummies
A dummy is an object that is passed to a function or method but is never actually used. Its purpose is to satisfy the function's signature, allowing it to be compiled and executed during testing. Dummies are often used when a function requires multiple arguments, but only some of them are needed for a particular test case.
Here's an example of a dummy in Go:
1func Add(a, b int, _ interface{}) int {
2 return a + b
3}
4
5func TestAdd(t *testing.T) {
6 result := Add(2, 3, nil)
7 if result != 5 {
8 t.Errorf("Expected 5, but got %d", result)
9 }
10}
In this example, the Add function takes three arguments: a, b, and _
. The third argument is a dummy variable that is never actually used. We pass nil
as the value of the dummy variable in the TestAdd function, which allows us to test the behavior of the Add function without worrying about the third argument. The test checks whether the result of adding 2 and 3 is equal to 5, which is the expected result.
Note that dummies are different from other test doubles like stubs or mocks, as they do not have any behavior or implementation. They are simply placeholders that allow the code to compile and run during testing.
Stubs
A stub is an object that provides pre-defined responses to method calls. The purpose of a stub is to isolate the code being tested from its dependencies, allowing the test to focus on the behavior of the code itself. In Go, you can implement a stub by creating a struct that satisfies the interface being used by the code under test, and then defining pre-defined values or behaviors for its methods.
Here's an example of how to implement a stub in Go:
1type DB interface {
2 Get(key string) (string, error)
3 Set(key, value string) error
4}
5
6type StubDB struct {
7 getFunc func(key string) (string, error)
8 setFunc func(key, value string) error
9}
10
11func (s *StubDB) Get(key string) (string, error) {
12 if s.getFunc != nil {
13 return s.getFunc(key)
14 }
15 return "", errors.New("getFunc is not defined")
16}
17
18func (s *StubDB) Set(key, value string) error {
19 if s.setFunc != nil {
20 return s.setFunc(key, value)
21 }
22 return errors.New("setFunc is not defined")
23}
In this example, we define a DB interface with Get and Set methods. We then define a StubDB struct that satisfies this interface, and includes two functions getFunc and setFunc that can be used to define the behavior of the stub. The Get and Set methods check whether getFunc and setFunc have been defined, respectively, and call them with the appropriate arguments if they have. If getFunc or setFunc has not been defined, the methods return an error indicating that the function is not defined.
To use this stub in a test, we can define the behavior of the stub by setting the getFunc and setFunc functions:
1func TestGetUser(t *testing.T) {
2 db := &StubDB{
3 getFunc: func(key string) (string, error) {
4 return "test", nil
5 },
6 }
7 user, err := db.Get("test")
8 if err != nil {
9 t.Errorf("Unexpected error: %v", err)
10 }
11 if user != "test" {
12 t.Errorf("Expected 'test', but got '%s'", user)
13 }
14}
In this example, we create a StubDB object with a getFunc function that returns "test" and no error. We then call the Get method on the stub with "test" as the key, and check whether the result is "test". If the result is not "test", or if an error occurs, the test fails.
Using a stub like this allows us to test the behavior of the code under test without having to rely on the behavior of the actual database. We can define exactly what responses the database will provide in each test case, ensuring that the test remains isolated and reproducible.
Spies
A spy is an object that records information about the calls made to it, such as the arguments passed in and the number of times it was called. This information can be used to verify that the code being tested is behaving correctly.
Here's an example of a spy in Go:
1type MyService struct {}
2
3func (s *MyService) DoSomething(arg string) error {
4 // some implementation
5 return nil
6}
7
8type MySpy struct {
9 Calls []string
10}
11
12func (s *MySpy) DoSomething(arg string) error {
13 s.Calls = append(s.Calls, arg)
14 return nil
15}
16
17func TestMyService(t *testing.T) {
18 spy := &MySpy{}
19 service := &MyService{}
20
21 // Replace the real implementation with the spy
22 service.DoSomething = spy.DoSomething
23
24 // Call the service
25 service.DoSomething("arg1")
26 service.DoSomething("arg2")
27
28 // Verify that the spy recorded the calls correctly
29 if len(spy.Calls) != 2 {
30 t.Errorf("Expected 2 calls, but got %d", len(spy.Calls))
31 }
32 if spy.Calls[0] != "arg1" {
33 t.Errorf("Expected first call to be 'arg1', but got '%s'", spy.Calls[0])
34 }
35 if spy.Calls[1] != "arg2" {
36 t.Errorf("Expected second call to be 'arg2', but got '%s'", spy.Calls[1])
37 }
38}
In this example, we have a MyService type with a DoSomething method that takes a string argument and returns an error. We also have a MySpy type with a DoSomething method that records the calls made to it in a slice.
In the TestMyService function, we create an instance of MySpy and an instance of MyService. We replace the real implementation of DoSomething with the implementation of DoSomething in MySpy. We then call DoSomething twice on MyService with different arguments.
Finally, we verify that the calls were recorded correctly by checking the length of the Calls slice and the values of the elements in the slice.
This is just a simple example, but in more complex scenarios, spies can be very useful for testing interactions between different parts of a system.
Mocks
A mock is an object that simulates the behavior of a real object in a controlled way. It allows you to test your code in isolation from its dependencies by replacing them with mock objects. In the Go programming language, mocking is often done using interfaces and dependency injection.
Here's example of a use case of mocks.
Imagine we have an EmailSender interface and a UserNotifier struct that depends on it:
1package main
2
3import "fmt"
4
5type EmailSender interface {
6 SendEmail(to string, subject string, body string) error
7}
8
9type UserNotifier struct {
10 emailSender EmailSender
11}
12
13func NewUserNotifier(emailSender EmailSender) *UserNotifier {
14 return &UserNotifier{emailSender: emailSender}
15}
16
17func (u *UserNotifier) NotifyUser(email string, message string) error {
18 return u.emailSender.SendEmail(email, "Notification", message)
19}
20
21type RealEmailSender struct{}
22
23func (r *RealEmailSender) SendEmail(to string, subject string, body string) error {
24 fmt.Printf("Sending email to %s with subject %s and body %s\n", to, subject, body)
25 return nil
26}
In this example, UserNotifier depends on the EmailSender interface to send emails. The RealEmailSender implements this interface and sends emails. To test UserNotifier without actually sending emails, we can create a mock EmailSender:
1package main
2
3import (
4 "errors"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8)
9
10type MockEmailSender struct {
11 sendEmailFunc func(to string, subject string, body string) error
12}
13
14func (m *MockEmailSender) SendEmail(to string, subject string, body string) error {
15 return m.sendEmailFunc(to, subject, body)
16}
17
18func TestUserNotifier_NotifyUser(t *testing.T) {
19 // Create a mock EmailSender
20 mockEmailSender := &MockEmailSender{
21 sendEmailFunc: func(to string, subject string, body string) error {
22 assert.Equal(t, "[email protected]", to)
23 assert.Equal(t, "Notification", subject)
24 assert.Equal(t, "Hello, user!", body)
25 return nil
26 },
27 }
28
29 // Inject the mock into UserNotifier
30 userNotifier := NewUserNotifier(mockEmailSender)
31
32 // Execute the test
33 err := userNotifier.NotifyUser("[email protected]", "Hello, user!")
34 assert.NoError(t, err)
35}
In this test, we create a MockEmailSender that implements the EmailSender interface. We define the behavior of the SendEmail method using a function that receives the input arguments and checks if they are as expected. Then, we inject the mock into UserNotifier and call NotifyUser to make sure it behaves correctly.
Fakes
A fake is a simplified implementation of a real object that is used to test code that depends on the real object. Fakes are useful when the real object is too complex or too slow to use during testing.
Here's an example of a fake in Go:
1type MyService interface {
2 DoSomething(arg string) error
3}
4
5type MyFake struct {
6 LastArg string
7}
8
9func (f *MyFake) DoSomething(arg string) error {
10 f.LastArg = arg
11 return nil
12}
13
14func TestMyCode(t *testing.T) {
15 // Create a fake object
16 fake := &MyFake{}
17
18 // Inject the fake into the code being tested
19 myCode := NewMyCode(fake)
20
21 // Call the code being tested
22 myCode.DoSomethingWithMyService()
23
24 // Verify that the fake was called correctly
25 if fake.LastArg != "arg1" {
26 t.Errorf("Expected last argument to be 'arg1', but got '%s'", fake.LastArg)
27 }
28}
In this example, we have a MyService interface with a DoSomething method that takes a string argument and returns an error. We also have a MyFake type that implements the MyService interface by recording the last argument passed in.
In the TestMyCode function, we create an instance of MyFake, and then we inject it into the NewMyCode function which takes a MyService parameter. We then call DoSomethingWithMyService on the myCode object.
Finally, we verify that the fake was called correctly by checking the value of the LastArg field in the MyFake object.
This is a simple example, but in more complex scenarios, fakes can be very useful for testing code that depends on external systems or services that may not be available during testing, such as databases or third-party APIs. Fakes can also be used to test error handling or edge cases that may be difficult to reproduce with real objects.
It's important to note that while fakes can be useful for testing, they should be used carefully, as they may not accurately reflect the behavior of the real object being replaced. It's also important to ensure that the behavior of the fake closely matches that of the real object being replaced to avoid false positives in your tests.
Phew~
That's huge, if you've read through this far, I assume you are a Gopher :).
Happy coding and have fun!