Skip to content
Santekno.com | Level Up Your Engineering Skills
ID
📖 0%
29 Nov 2022 · 13 mnt baca ·Artikel 23 / 119
Go

Teknik Membuat Mocking Unit Test Pada Golang

IH
Ihsan Arif
Penulis di Santekno · Backend Engineer

Saat kita membuat fungsi atau sedang melakukan code terkadang kita suka kesusahan untuk melakukan unit test di beberapa titik yang tidak bisa kita cover dengan unit test. Maka berikut ini ada beberapa cara teknik melakukan unit test dengan teknik mocking. Tetapi sebenarnya kita juga bisa menggunakan Third-party yang sudah tersedia dibeberapa library sehingga tinggal langsung kita pakai saja.

Nah, kekurangannya yaitu ketika kita menggunakan Third-party kita tidak mengerti secara menyeluruh proses dari unit test Third-party tersebut untuk melakukan covering unit test. Maka, kita perlu juga tahu cara bagaimana melakukan mocking agar bisa melihat alur dari proses code yg kita jalankan.

Higher-Order Functions

Misalkan kita memiliki fungsi untuk melakukan koneksi ke dalam database SQL seperti dibawah ini.

go
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}
agar kita bisa melakukan test pada fungsi dari sql.Open maka kita perlu melakukan perubahan dari sisi code kita yaitu dengan cara mock fungsi tersebut menjadi tipe fungsi. Agar lebih mudah bisa dilihat implementasinya dibawah ini.
go
 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}

Pada tipe sqlOpener, kita akan mock fungsi tersebut untuk kebutuhan unit test nantinya sehingga bisa kita membuat test cases untuk terjadi error dan sukses.

Ketika memanggil fungsi OpenDB ini kita perlu mengirimkan fungsi sql.Open agar bisa provide sesuai dengan fungsi utamanya. Agar lebih bisa terbayang bagaimana impelementasinya bisa kita lihat code dibawah ini.

go
1    OpenDB("myUser", "myPass", "localhost", "foo", sql.Open)

Lalu bagaimana cara kita membuat unit test-nya? Berikut ini coba simak dan lihat impelemntasinya dibawah ini.

go
 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}
Metode ini perlu kita perhatikan ketika kita membuat mock untuk fungsi aslinya, karena bisa jadi ketika kita akan melakukan upgrade dependency tersebut lalu ada beberapa parameter yang berubah atau ada penambahan maka kita perlu juga merubah semua variabel fungsi yang dibuat mock tersebut agar bisa menyesuaikan fungsinya.

Monkey Patching

Teknik ini hampir sama dengan teknik mock Higher-Order Functions bahkan mirip sekali dengan teknik tersebut yaitu kita akan menjadikan fungsi utama yg akan dipanggil sql.Open ini menjadi variabe global.

Alih-alih meneruskan fungsi ke OpenDB(), kami hanya menggunakan variabel untuk panggilan yang sebenarnya. Berikut ini implementasi dalam code-nya.

go
 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}

Perbedaanya itu hanya dari tipe data yang dilakukan yaitu inisialisasi variabel untuk teknik ini. Lalu, bagaimana cara mock di dalam unit test-nya? Dibawah ini kita akan jelaskan.

go
 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}
Dan di bagian unit test-nya pun perbedaannya yaitu kita melakukan assign ke dalam variabel SQLOpen tersebut yang di mock dari tiap test case sehingga bisa menggambarkan case error atau sukses.

Terkadang teknik ini juga bukan cara terbaik untuk memperbaiki unit test coverage karena perlu di pastikan variabel tersebut publik sehingga bisa di panggil oleh fungsi utamanya.

Ingat! Teknik ini sama dengan teknik sebelumnya maka perlu hati-hati dalam menggunakannya ketika third-party akan kita upgrade maka perlu dipastikan lg mock fungsinya harus di sesuaikan lagi jika ada perubahan pada dependency aslinya.

Interface Substitution

Teknik ini kita gunakan untuk tipe fungsi yang interface atau concrete. Di dalamn bahasa Go, kita bisa melakukan teknik ini dengan memiliki fungsi interface sehingga tidak perlu secara implisit menjadikan implementasi fungsi tersebut.

Terkadang kita perlu melakukan ini interface agar mengurangi jangkauan unit test yang akan kita uji. Misalkan kita contohkan membuat fungsi untuk mengambil data dari file seperti dibawah ini.

go
 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}

Kita perlu meniru fungsi yang ada di dalam os.File yaitu kita gunakan pada fungsi ReadContents.Secara khusus kita menggunakan fungsi f.Read(data) untuk membaca data dari file dan diakhiri dengan kita menutup file dengan defer f.Clode()

Dengan begitu kita akan membuat mock dari os.File tersebut yang mana ini merupakan package standar library IO dari Golang. bisa dilihat dibawah ini

go
 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}
Karena os.File ini merupakan implementasinya dari io library, maka kita bisa ganti fungsi ReadContents tersebut menjadi seperti dibawah ini.

go
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}

Dalam kebanyakan kasus, Kita mungkin perlu membuat interface sendiri, tetapi disini kita dapat menggunakan kembali interface yang ditentukan dalam package io. Sekarang kita coba membuat unit test dengan mudah menggunakan mock.

go
 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}
Perlu diperhatikan bahwa struct mockReadCloser merupakan mock dari interface-nya, dengan cara ini, setiap pengujian dapat membuat struct dan mengembalikan nilai sesuai dengan keinginan.

Embedding Interfaces

Embedding Interface ini adalah teknik mocking menggunakan embedded fungsi interface yang kita buat seolah-olah implementasinya sesuai dengan ekspektasi kita. Disini kita menggunakan SDK Library AWS yang mana ini bisa kita gunakan untuk melakukan unit testing.

Berikut ini contoh kode misalkan kita menggunakan aws dynamodb untuk mengambil data batch item.

go
 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}

Unit test lengkapnya seperti ini dimana kita membuat mockDynamoDBClient struct berisi interface dynamodbiface.DynamoDBAPI yang memiliki beberapa method. Yang kita mock hanya method yg kita butuhkan saja yaitu method BatchGetItem jadi tidak perlu semuanya kita implementasikan.

go
 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

Membuat mock untuk eksternal call HTTP memang agak tricky jika kita ingin mengimplemenasikannya. tetapi dengan teknik ini kita bisa secara lengkap bisa meng-cover semua case yang akan kita buat.

Misalkan kita memiliki fungsi yang mana akan mengakses Rest API eksternal, lebih lengkapnya sebagai berikut.

go
 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 MakeHTTPCall lalu klik kanan dan pilih Go: Generate Unit Tests For Function, maka akan dilakukan generate code default unit test seperti ini.
go
 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}
  • Selanjutnya tambahkan di dalam struct args variable ini server *httptest.Server yang berfungsi untuk membuat mock data http external call.
  • Lalu dibawah sebelum pemanggilan MakeHTTPCall perlu ada yg diupdate seperti ini
    go
    1defer tt.args.server.Close()
    2var url string
    3if tt.args.url == "" {
    4		url = tt.args.server.URL
    5}
    6got, err := MakeHTTPCall(url)

Info:

  1. defer tt.args.server.Close() dimaksudkan agar tiap NewServer testing kita perlu close server-nya agar tidak bentrok.
  2. var url string ini digunakan untuk kondisi pengecekan url yang benar dan salah
  • Terakhir kita tambahkan test case yang perlu kita butuhkan sesuai dengan code yang kita buat.
    go
     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},

Semua sudah terisi tinggal kita coba jalankan apakah tiap test case tersebut sudah meng-cover semua code kita atau belum.

Ingin tahu lebih lengkap code unit test-nya? Ini nih kita informasikan lebih detail lagi yaa

go
 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}

Kesimpulan

Jika kita tidak mempelajari secara manual, kita tidak akan tahu alur dari uni test itu bekerja maka diharapkan sebelum kita menggunakan Third-Party yang mendukung pemenuhan unit test, ada baiknya kita juga perlu tahu bagaimana mekanisme didalamnya.

Tujuan dari unit test sebenarnya kan menguji code kita apakah sesuai dengan kebutuhan bisnis,produk yang sedang kita kembangkan dan agar minim bug saat kita jalankan di production (live).

Artikel Terkait

💬 Komentar