Techniques for Creating Mocking Unit Tests in Golang
When we create a function or code, sometimes we have difficulty carrying out unit tests at several points that we cannot cover with unit tests. So here are several technical ways to carry out unit tests using the mocking technique. But actually we can also use third-party which is already available in several libraries so we just need to use it straight away.
Well, the drawback is that when we use Third-party we don’t fully understand the process of the Third-party unit test to carry out covering unit tests. So, we also need to know how to do mocking so we can see the flow of the code process that we are running.
Higher-Order Functions
Suppose we have a function to connect to a SQL database as below.
1func OpenDB(user, password, addr, db string) (*sql.DB, error) {
2 conn := fmt.Sprintf("%s:%s@%s/%s", user, password, addr, db)
3 sql, err :=sql.Open("mysql", conn)
4 if err != nil {
5 log.Error("error open connection mysql")
6 }
7 return sql, nil
8}So that we can test the function from sql.Open, we need to make changes to our code, namely by mock the function to a function type. To make it easier, you can see the implementation below.
1type (
2 sqlOpener func(string, string) (*sql.DB, error)
3)
4
5func OpenDB(user, password, addr, db string, open sqlOpener) (*sql.DB, error) {
6 conn := fmt.Sprintf("%s:%s@%s/%s", user, password, addr, db)
7 sql, err := open("mysql", conn)
8 if err != nil {
9 log.Error("error open connection mysql")
10 }
11 return sql, nil
12}In the sqlOpener type, we will mock the function for unit test needs later so that we can create test cases for errors to occur and succeed.
When calling the OpenDB function we need to send the sql.Open function so that it can provide according to the main function. To better understand how it is implemented, we can look at the code below.
1 OpenDB("myUser", "myPass", "localhost", "foo", sql.Open)So how do we create unit tests? Please take a look and see the implementation below.
1func TestOpenDB(t *testing.T) {
2 type args struct {
3 user string
4 password string
5 addr string
6 db string
7 open func(string, string) (*sql.DB, error)
8 }
9 tests := []struct {
10 name string
11 args args
12 wantErr bool
13 }{
14 {
15 name: "case 1 : success open connection database mysql",
16 args: args{
17 user: "myUser",
18 password: "myPass",
19 addr: "localhost",
20 db: "foo",
21 open: func(s1, s2 string) (*sql.DB, error) {
22 return &sql.DB{}, nil
23 },
24 },
25 wantErr: false,
26 },
27 {
28 name: "case 2: failed open connection because have error",
29 args: args{
30 user: "myUser",
31 password: "myPass",
32 addr: "localhost",
33 db: "foo",
34 open: func(s1, s2 string) (*sql.DB, error) {
35 return nil, errors.New("got error")
36 },
37 },
38 wantErr: true,
39 },
40 }
41 for _, tt := range tests {
42 t.Run(tt.name, func(t *testing.T) {
43 _, err := OpenDB(tt.args.user, tt.args.password, tt.args.addr, tt.args.db, tt.args.open)
44 if (err != nil) != tt.wantErr {
45 t.Errorf("OpenDB() error = %v, wantErr %v", err, tt.wantErr)
46 return
47 }
48 })
49 }
50}We need to pay attention to this method when we create a mock for the original function, because it could be that when we upgrade the dependency, some parameters change or there are additions, so we also need to change all the function variables created by the ‘mock’ so that we can adjust the function. .
Monkey Patching
This technique is almost the same as the mock Higher-Order Functions technique, in fact it is very similar to this technique, namely that we will make the main function that will be called sql.Open into a global variable.
Instead of passing the function to OpenDB(), we just use the variable for the actual call. Below is the implementation in the code.
1var (
2 SQLOpen = sql.Open
3)
4
5func OpenDB(user, password, addr, db string) (*sql.DB, error) {
6 conn := fmt.Sprintf("%s:%s@%s/%s", user, password, addr, db)
7 sql, err := SQLOpen("mysql", conn)
8 if err != nil {
9 log.Print("error open connection mysql")
10 return sql, err
11 }
12
13 return sql, nil
14}The only difference is the data type used, namely the initialization of variables for this technique. Then, how do you mock in the unit test? Below we will explain.
1for _, tt := range tests {
2 t.Run(tt.name, func(t *testing.T) {
3 SQLOpen = tt.args.open
4 _, err := OpenDB(tt.args.user, tt.args.password, tt.args.addr, tt.args.db)
5 if (err != nil) != tt.wantErr {
6 t.Errorf("OpenDB() error = %v, wantErr %v", err, tt.wantErr)
7 return
8 }
9 })
10}And in the unit test section, the difference is that we do assign to the SQLOpen variable which is mocked from each test case so that it can describe the error or success case.
Sometimes this technique is also not the best way to improve unit test coverage because you need to make sure the variable is public so it can be called by the main function.
Remember! This technique is the same as the previous technique, so we need to be careful when using it when we want to upgrade a third party, so we need to make sure that the mock function must be adjusted again if there are changes to the original dependency.
Interface Substitution
We use this technique for interface or concrete function types. In the Go language, we can do this technique by having an interface function so there is no need to implicitly implement the function.
Sometimes we need to do this interface in order to reduce the range of unit tests we will test. For example, let’s take an example of creating a function to retrieve data from a file like the one below.
1package main
2
3import (
4 "fmt"
5 "os"
6)
7
8func main() {
9 f, err := os.Open("foo.txt")
10 if err != nil {
11 fmt.Printf("error opening file %v \n", err)
12 }
13 data, err := ReadContents(f, 50)
14 if err != nil {
15 fmt.Printf("error from ReadContents %v \n", err)
16 }
17 fmt.Printf("data from file: %s", string(data))
18}
19
20func ReadContents(f *os.File, numBytes int) ([]byte, error) {
21 defer f.Close()
22 data := make([]byte, numBytes)
23 _, err := f.Read(data)
24 if err != nil {
25 return nil, err
26 }
27 return data, nil
28}We need to emulate the function in os.File, namely we use the ReadContents function. Specifically we use the f.Read(data) function to read data from the file and end with us closing the file with defer f .Clode()
That way we will create a mock from os.File which is the standard IO library package from Golang. can be seen below
1type Reader interface {
2 Read(p []byte) (n int, err error)
3}
4
5type Closer interface {
6 Close() error
7}
8
9// ReadCloser is the interface that groups the basic Read and Close methods.
10type ReadCloser interface {
11 Reader
12 Closer
13}Because os.File is an implementation of the io library, we can change the ReadContents function to something like the one below.
1func ReadContents(rc io.ReadCloser, numBytes int) ([]byte, error) {
2 defer rc.Close()
3 data := make([]byte, numBytes)
4 _, err := rc.Read(data)
5 if err != nil {
6 return nil, err
7 }
8 return data, nil
9}In most cases, we will probably need to create our own interface, but here we can reuse the interface defined in the io package. Now we try to create unit tests easily using mock.
1package main
2
3import (
4 "errors"
5 "io"
6 "reflect"
7 "testing"
8)
9
10type (
11 mockReadCloser struct {
12 expectedData []byte
13 expectedErr error
14 }
15)
16
17func (mrc *mockReadCloser) Read(p []byte) (n int, err error) {
18 copy(p, mrc.expectedData)
19 return 0, mrc.expectedErr
20}
21
22func (mrc *mockReadCloser) Close() error { return nil }
23
24func TestReadContents(t *testing.T) {
25 errorz := errors.New("got error")
26 type args struct {
27 rc io.ReadCloser
28 numBytes int
29 }
30 tests := []struct {
31 name string
32 args args
33 expectedData []byte
34 expectedErr error
35 }{
36 {
37 name: "case success getting data read",
38 args: args{
39 rc: &mockReadCloser{
40 expectedData: []byte(`hello`),
41 expectedErr: nil,
42 },
43 numBytes: 5,
44 },
45 expectedData: []byte(`hello`),
46 expectedErr: nil,
47 },
48 {
49 name: "case failed getting data read",
50 args: args{
51 rc: &mockReadCloser{
52 expectedData: []byte(`hello`),
53 expectedErr: errorz,
54 },
55 numBytes: 5,
56 },
57 expectedData: nil,
58 expectedErr: errorz,
59 },
60 }
61 for _, tt := range tests {
62 t.Run(tt.name, func(t *testing.T) {
63 got, err := ReadContents(tt.args.rc, tt.args.numBytes)
64 if !reflect.DeepEqual(got, tt.expectedData) {
65 t.Errorf("expected (%b), got (%b)", tt.expectedData, got)
66 }
67 if !errors.Is(err, tt.expectedErr) {
68 t.Errorf("expected error (%v), got error (%v)", tt.expectedErr, err)
69 }
70 })
71 }
72}Please note that struct mockReadCloser is a mock of the interface, this way, each test can create a struct and return values as desired.
Embedding Interfaces
Embedding Interface is a mocking technique using embedded interface functions that we create as if the implementation matches our expectations. Here we use the AWS SDK Library which we can use to do unit testing.
The following is an example code, for example, we use AWS Dynamodb to retrieve batch item data.
1package main
2
3import (
4 "log"
5
6 "github.com/aws/aws-sdk-go/aws/session"
7 "github.com/aws/aws-sdk-go/service/dynamodb"
8 "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
9)
10
11func main() {
12 sess := session.New()
13 svc := dynamodb.New(sess)
14
15 GetBatchItem(svc, &dynamodb.BatchGetItemInput{
16 RequestItems: map[string]*dynamodb.KeysAndAttributes{
17 "a": &dynamodb.KeysAndAttributes{
18 AttributesToGet: []*string{},
19 },
20 },
21 })
22}
23
24func GetBatchItem(svc dynamodbiface.DynamoDBAPI, input *dynamodb.BatchGetItemInput) (*dynamodb.BatchGetItemOutput, error) {
25 batch, err := svc.BatchGetItem(input)
26 if err != nil {
27 log.Printf("error")
28 return nil, err
29 }
30
31 return batch, nil
32}The complete unit test looks like this where we create a mockDynamoDBClient struct containing the dynamodbiface.DynamoDBAPI interface which has several methods. What we mock is only the method we need, namely the BatchGetItem method, so we don’t need to implement everything.
1package main
2
3import (
4 "errors"
5 "testing"
6
7 "github.com/aws/aws-sdk-go/service/dynamodb"
8 "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"
9)
10
11type mockDynamoDBClient struct {
12 dynamodbiface.DynamoDBAPI
13}
14
15func (m *mockDynamoDBClient) BatchGetItem(d *dynamodb.BatchGetItemInput) (*dynamodb.BatchGetItemOutput, error) {
16 if len(d.RequestItems) == 0 {
17 return nil, errors.New("got error")
18 }
19 return &dynamodb.BatchGetItemOutput{
20 Responses: map[string][]map[string]*dynamodb.AttributeValue{},
21 }, nil
22}
23
24func TestGetBatchItem(t *testing.T) {
25 type args struct {
26 svc dynamodbiface.DynamoDBAPI
27 input *dynamodb.BatchGetItemInput
28 }
29 tests := []struct {
30 name string
31 args args
32 wantErr bool
33 }{
34 {
35 name: "success get batch items",
36 args: args{
37 svc: &mockDynamoDBClient{},
38 input: &dynamodb.BatchGetItemInput{
39 RequestItems: map[string]*dynamodb.KeysAndAttributes{
40 "a": {
41 AttributesToGet: []*string{},
42 },
43 },
44 },
45 },
46 },
47 {
48 name: "failed get batch items",
49 args: args{
50 svc: &mockDynamoDBClient{},
51 input: &dynamodb.BatchGetItemInput{},
52 },
53 wantErr: true,
54 },
55 }
56 for _, tt := range tests {
57 t.Run(tt.name, func(t *testing.T) {
58 _, err := GetBatchItem(tt.args.svc, tt.args.input)
59 if (err != nil) != tt.wantErr {
60 t.Errorf("GetBatchItem() error = %v, wantErr %v", err, tt.wantErr)
61 return
62 }
63 })
64 }
65}Mocking out Downstream HTTP Calls
Creating mocks for external HTTP calls is a bit tricky if we want to implement them. but with this technique we can completely cover all the cases that we will make.
Suppose we have a function which will access an external Rest API, more details as follows.
1type Response struct {
2 ID int `json:"id"`
3 Name string `json:"name"`
4 Description string `json:"description"`
5}
6
7func MakeHTTPCall(url string) (*Response, error) {
8 resp, err := http.Get(url)
9 if err != nil {
10 return nil, err
11 }
12 body, err := ioutil.ReadAll(resp.Body)
13 if err != nil {
14 return nil, err
15 }
16 r := &Response{}
17 if err := json.Unmarshal(body, r); err != nil {
18 return nil, err
19 }
20 return r, nil
21}Nah lalu bagaimana caranya agar bisa kita buat unit test-nya?
Ini biasnaya kita menggunakan httptest library standar-nya dari Golang yang nantinya seolah-olah bisa membuat API external dengan response yang disesuaikan.
Lebih lengkapnya yuk kita coba langkah-langkahnya sebagai berikut.
- Arahkan kursor pada fungsi
MakeHTTPCalllalu klik kanan dan pilihGo: Generate Unit Tests For Function, maka akan dilakukan generate code default unit test seperti ini.
1func TestMakeHTTPCall(t *testing.T) {
2 type args struct {
3 url string
4 }
5 tests := []struct {
6 name string
7 args args
8 want *Response
9 wantErr bool
10 }{
11 // TODO: Add test cases.
12 }
13 for _, tt := range tests {
14 t.Run(tt.name, func(t *testing.T) {
15 got, err := MakeHTTPCall(tt.args.url)
16 if (err != nil) != tt.wantErr {
17 t.Errorf("MakeHTTPCall() error = %v, wantErr %v", err, tt.wantErr)
18 return
19 }
20 if !reflect.DeepEqual(got, tt.want) {
21 t.Errorf("MakeHTTPCall() = %v, want %v", got, tt.want)
22 }
23 })
24 }
25}- Next, add to the struct
argsthis variableserver *httptest.Serverwhich functions to create mock external http call data. - Then below before calling
MakeHTTPCallsomething needs to be updated like this1defer tt.args.server.Close() 2var url string 3if tt.args.url == "" { 4 url = tt.args.server.URL 5} 6got, err := MakeHTTPCall(url)
Information:
defer tt.args.server.Close()is intended so that for eachNewServertest we need to close the server so it doesn’t conflict.var url stringis used to check whether the url is true or false
- Finally, we add the test cases that we need according to the code we created.
1{ 2 name: "success call http", 3 args: args{ 4 server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 5 w.WriteHeader(http.StatusOK) 6 w.Write([]byte(`{"id": 1, "name": "santekno", "description": "santekno jaya"}`)) 7 })), 8 }, 9 want: &Response{ 10 ID: 1, 11 Name: "santekno", 12 Description: "santekno jaya", 13 }, 14 wantErr: false, 15}, 16{ 17 name: "failed call http when http 400", 18 args: args{ 19 server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 w.WriteHeader(http.StatusBadRequest) 21 })), 22 }, 23 want: nil, 24 wantErr: true, 25}, 26{ 27 name: "failed url http call", 28 args: args{ 29 url: "localhost", 30 server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 w.WriteHeader(http.StatusBadRequest) 32 })), 33 }, 34 want: nil, 35 wantErr: true, 36},
Everything is filled in, we just have to try to run whether each test case covers all of our code or not.
Want to know more about the unit test code? Here we will provide more detailed information
1func TestMakeHTTPCall(t *testing.T) {
2 type args struct {
3 url string
4 server *httptest.Server
5 }
6 tests := []struct {
7 name string
8 args args
9 want *Response
10 wantErr bool
11 }{
12 {
13 name: "success call http",
14 args: args{
15 server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 w.WriteHeader(http.StatusOK)
17 w.Write([]byte(`{"id": 1, "name": "santekno", "description": "santekno jaya"}`))
18 })),
19 },
20 want: &Response{
21 ID: 1,
22 Name: "santekno",
23 Description: "santekno jaya",
24 },
25 wantErr: false,
26 },
27 {
28 name: "failed call http when http 400",
29 args: args{
30 server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31 w.WriteHeader(http.StatusBadRequest)
32 })),
33 },
34 want: nil,
35 wantErr: true,
36 },
37 {
38 name: "failed url http call",
39 args: args{
40 url: "localhost",
41 server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42 w.WriteHeader(http.StatusBadRequest)
43 })),
44 },
45 want: nil,
46 wantErr: true,
47 },
48 }
49 for _, tt := range tests {
50 t.Run(tt.name, func(t *testing.T) {
51 defer tt.args.server.Close()
52 var url string
53 if tt.args.url == "" {
54 url = tt.args.server.URL
55 }
56 got, err := MakeHTTPCall(url)
57 if (err != nil) != tt.wantErr {
58 t.Errorf("MakeHTTPCall() error = %v, wantErr %v", err, tt.wantErr)
59 return
60 }
61 if !reflect.DeepEqual(got, tt.want) {
62 t.Errorf("MakeHTTPCall() = %v, want %v", got, tt.want)
63 }
64 })
65 }
66}Conclusion
If we don’t study it manually, we won’t know how the unit test works, so it is hoped that before we use Third-Party which supports the fulfillment of unit tests, it would be a good idea for us to also know how the mechanism works.
The purpose of unit tests is actually to test whether our code meets business needs, the product we are developing and so that there are minimal bugs when we run it in production (live).