Let's talk about automation tests in Golang

David Chou @ Crescendo Lab

CC-BY-SA-3.0-TW

Engineering Mgr @ Crescendo Lab

Co-organizer @ Golang Taipei

 

Golang Taipei Telegram

Golang Taipei Facebook

Unit-test in Golang is simple

because of the build-in testing framework

go test

  • go test -v -run={TestName}

  • go test -race

  • go test -count=1

  • go test -c -o {TestExe}

  • go test -coverprofile=coverage.out
    go tool cover -html=coverage.out

My make test

$ go test -race -cover -coverprofile cover.out 

$ go tool cover -func=cover.out | tail -n 1

$ go test -race -coverprofile cover.out
ok      github.com/.../internal/pkg/apiutil       0.334s  coverage: 53.3% of statements
ok      github.com/.../internal/pkg/emailutil     17.611s coverage: 90.7% of statements
ok      github.com/.../internal/pkg/integrationutil       2.026s  coverage: 0.0% of statements
ok      github.com/.../internal/pkg/lambdautil    0.829s  coverage: 62.5% of statements
ok      github.com/.../internal/pkg/s3util        2.056s  coverage: 81.8% of statements
ok      github.com/.../internal/pkg/smtp/processor/attachment     0.627s  coverage: 76.4% of statements
ok      github.com/.../internal/pkg/smtp/processor/event  0.082s  coverage: 71.4% of statements
ok      github.com/.../internal/pkg/smtp/processor/integration    0.060s  coverage: 15.4% of statements
ok      github.com/.../internal/pkg/smtp/processor/lambda 0.415s  coverage: 82.4% of statements
ok      github.com/.../internal/pkg/smtp/processor/payload        0.053s  coverage: 68.8% of statements
ok      github.com/.../internal/pkg/videoutil     0.413s  coverage: 75.9% of statements

$ go tool cover -func=cover.out | tail -n 1
57.8%

Test helpers

save time ​by writing ​less test codes

func TestSomething(t *testing.T) {
    value, err := DoSomething()
    if err != nil {
        t.Fatalf("DoSomething() failed: %s", err)
    }
    if value != 100 {
        t.Fatalf("expected 100, got: %d", value)
    }
}
import "github.com/stretchr/testify/assert"

func TestSomething(t *testing.T) {
    value, err := DoSomething()
    assert.NoError(t, err)
    assert.EqualValues(t, 100, value)
}

testify

testify

  • Nil(), NoNil()

  • Error(), NoError()

  • EqualValues(), Equal(), JSONEq()

  • Len(), True(), False(), Empty()

func TestEventuallyExample(t *testing.T) {
	status := "pending"

	// 模擬一個異步操作,1 秒後將 status 更新為 "done"
	go func() {
		time.Sleep(1 * time.Second)
		status = "done"
	}()

	// 使用 assert.Eventually 確保 status 在 2 秒內變為 "done"
	assert.Eventually(t, func() bool {
		return status == "done"
	}, 2*time.Second, 100*time.Millisecond, "status should eventually be 'done'")
}

testify

Test async function with eventually()

func (suite *MyDBTestSuite) TestCreateUser() {
   suite.NoError(createUser())
}

func (suite *MyDBTestSuite) TestDeleteUser() {
   suite.NoError(deleteUser())
}

testify

Use suite to organize test functions with
fine-grained setup/teardown

type MyDBTestSuite struct {
    suite.Suite
}

func (suite *MyDBTestSuite) SetupSuite() {
    // run db migrations, setup all tables
}

func (suite *MyDBTestSuite) TearDownSuite() {
    // do nothing
}

func (suite *MyDBTestSuite) SetupTest() {
    // insert mock data
}

func (suite *MyDBTestSuite) TearDownTest() {
    // remove all data
}

Test helper functions

func testTempFile(t *testing.T) (string, func()) {
	f, err := ioutil.TempFile("", "test")
	require.NoError(t, err)
	f.Close()
	return f.Name(), func() { os.Remove(f.Name()) }
}

func TestThing(t *testing.T) {
	f, remove := testTempFile(t)
    defer remove()
    
    // do something with the temp file
}

Test helper functions

func testTempFile(t *testing.T) (string, func()) {
	f, err := ioutil.TempFile("", "test")
	require.NoError(t, err)
	f.Close()
	return f.Name(), func() { os.Remove(f.Name()) }
}

func TestThing(t *testing.T) {
	f, remove := testTempFile(t)
    defer remove()
    
    // do something with the temp file
}
  • Don't return error, but pass in testing.T and fail

  • No error return; the usage could be quite clean

Test helper functions

func testTempFile(t *testing.T) (string, func()) {
	f, err := ioutil.TempFile("", "test")
	require.NoError(t, err)
	f.Close()
	return f.Name(), func() { os.Remove(f.Name()) }
}

func TestThing(t *testing.T) {
	f, remove := testTempFile(t)
    defer remove()
    
    // do something with the temp file
}
  • Return a func() to clean up resource or recover state

  • The func() can access testing.T to also fail

Table-driven test

golang officially recommends

func IsStrExist(slice []string, find string) bool {
	for _, v := range slice {
		if v == find {
			return true
		}
	}
	return false
}
func TestIsStrExistSimple(t *testing.T) {
	ok := IsStrExist([]string{"a", "b"}, "a")
	require.True(t, ok)

	ok = IsStrExist([]string{"a", "b"}, "c")
	require.False(t, ok)

	ok = IsStrExist([]string{}, "a")
	require.False(t, ok)

	ok = IsStrExist(nil, "a")
	require.False(t, ok)
}

Table-driven test

func TestIsStrExist(t *testing.T) {
	tests := map[string]struct {
		slice []string
		find  string
		want  bool
	}{
		"find":    {[]string{"a", "b"}, "a", true},
		"notfind": {[]string{"a", "b"}, "c", false},
		"empty":   {[]string{}, "a", false},
		"nil":     {nil, "a", false},
	}
	for name, tt := range tests {
		t.Run(name, func(t *testing.T) {
        	got := IsStrExist(tt.slice, tt.find)
			require.EqualValues(t, tt.want, got)
		})
	}
}

Table-driven test

func TestIsStrExistP(t *testing.T) {
	tests := map[string]struct {
		slice []string
		find  string
		want  bool
	}{
		"find":    {[]string{"a", "b"}, "a", true},
		"notfind": {[]string{"a", "b"}, "c", false},
		"empty":   {[]string{}, "a", false},
		"nil":     {nil, "a", false},
	}
	for name, tt := range tests {
		tt := tt // fix loop iterator variable issue; no need if >= 1.22
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			require.EqualValues(t, tt.want, IsStrExist(tt.slice, tt.find))
		})
	}
}

Table-driven test in parallel

Table-driven test

  • Easier to add new test cases

  • Easier to exhaustive corner cases

  • Make reproducing issues simple

  • Try to do this pattern if possible

Writing testable code

testable code != TDD

that code is testable when we don’t have to change the code itself when we’re adding a unit test to it

SOLID principle

dependency inversion

Accept interface, return concrete type

concrete type is usually pointer or struct

Do not define interfaces on the implementor side

Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.

package s3

type S3 struct {...}

func New(...) *S3 {...}

func (s *S3) CopyObject(*s3.CopyObjectInput) (*s3.CopyObjectOutput, error)
func (s *S3) CopyObjectWithContext(aws.Context, *s3.CopyObjectInput, ...request.Option) (*s3.CopyObjectOutput, error)
func (s *S3) CopyObjectRequest(*s3.CopyObjectInput) (*request.Request, *s3.CopyObjectOutput)
	
func (s *S3) GetObject(*s3.GetObjectInput) (*s3.GetObjectOutput, error)
func (s *S3) GetObjectWithContext(aws.Context, *s3.GetObjectInput, ...request.Option) (*s3.GetObjectOutput, error)
func (s *S3) GetObjectRequest(*s3.GetObjectInput) (*request.Request, *s3.GetObjectOutput)

----
type S3API interface {
	CopyObject(*s3.CopyObjectInput) (*s3.CopyObjectOutput, error)
	CopyObjectWithContext(aws.Context, *s3.CopyObjectInput, ...request.Option) (*s3.CopyObjectOutput, error)
	CopyObjectRequest(*s3.CopyObjectInput) (*request.Request, *s3.CopyObjectOutput)
	
	GetObject(*s3.GetObjectInput) (*s3.GetObjectOutput, error)
	GetObjectWithContext(aws.Context, *s3.GetObjectInput, ...request.Option) (*s3.GetObjectOutput, error)
	GetObjectRequest(*s3.GetObjectInput) (*request.Request, *s3.GetObjectOutput)
    
	// ... over 300 functions
}
package s3client

type S3Service interface {
	GetObject(*s3.GetObjectInput) (*s3.GetObjectOutput, error)
}

func Download(s3Srv S3Service, bucket, key string) error {...}

func Main() {
	s3Srv := s3.New()
	err := Download(s3Srv, "bucket", "key")
	...
}
package s3client_test

type S3ServiceMock struct {}

func (m S3ServiceMock) GetObject(*s3.GetObjectInput) (*s3.GetObjectOutput, error) {...}

func TestDownload(t *testing.T) {
	var m S3ServiceMock
	err := Download(m, "mybucket", "mykey")
	...
}

Mocking

mocking is the super power of your tests

type OrderModel interface {
	GetOrderByID(orderID string) (Order, error)
}

func IsOrderPaid(orderModel OrderModel, orderID string) (ok bool, err error) {
	var order Order
	if order, err = orderModel.GetOrderByID(orderID); err != nil {
		return false, err
	}

	return order.status == "completed", nil
}

IsOrderPaid()

type OrderModelMock struct{}

func (m OrderModelMock) GetOrderByID(orderID string) (Order, error) { ... }

func TestIsOrderPaidSimple(t *testing.T) {
	var orderMock OrderModelMock

	ok, err := IsOrderPaid(orderMock, "orderId")
 	...
}
  • Too boilerplate to write all these mockers

  • Hard to add different behaviors into mockers

  • Need manually update mockers if interface changes

gomock

  • The official mocking framework

  • Build-in mocking generator

  • Type-safe mocking objects

  • Powerful stub API

gomock

//go:generate mockgen -destination automocks/ordermodel.go -package=automocks . OrderModel
type OrderModel interface {
	GetOrderByID(orderID string) (Order, error)
}
$ go generate -x ./...
mockgen -destination automocks/ordermodel.go -package=automocks . OrderModel

run go generate to generate mocking objects

gomock

m := automocks.NewMockOrderModel(ctrl)
m.EXPECT().
	GetOrderByID(gomock.Any()).
	Return(Order{}, nil).
	Times(1)
   
    
m.EXPECT().
	GetOrderByID("correctOrderID").
	DoAndReturn(func(orderID string) (Order, error) {
		...
	}).MinTimes(3)

create mocker with expectation API

gomock + table-driven test?

func TestIsOrderPaid(t *testing.T) {
	tests := []struct {
		name    string
		args    args
		wantOk  bool
		wantErr error
	}{
		{
			name:    "paid",
			args:    args{...},
			wantOk:  true,
			wantErr: nil,
		},
		{
			name:    "notpaid",
			args:    args{...},
			wantOk:  false,
			wantErr: nil,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()

			orderModel := getOrderModel(t, ctrl, tt.args)
            
			gotOk, err := IsOrderPaid(orderModel, tt.args.orderID)
			...
		})
	}
}
type args struct {
	orderID  string
	order    Order
	orderErr error
}
func getOrderModel(c *gomock.Controller, args args) OrderModel {
	orderModel := automocks.NewMockOrderModel(c)
	orderModel.EXPECT().
		GetOrderByID(args.orderID).
		Return(args.order, args.orderErr).
		Times(1)
	return orderModel
}

Fake data

Fake it until you make it

It's annoying to create test data for each test cases

testAnswer := "test123"
testResult := 1024

faker

Generate fake data with no effort
https://github.com/go-faker/faker

type AStruct struct {
	Number int64
	Height int64
	B      BStruct
}

type BStruct struct {
	Image string
}

func Example_withoutTag() {
	a := AStruct{}
	faker.FakeData(&a)
	fmt.Printf("%+v", a)
	/*
		Result:
		{
			Number: 1
			Height: 26
			B: {
				Image: RmmaaHkAkrWHldVbBNuQSKlRb
			}
		}
	*/
}
faker.Email()           // => mJBJtbv@OSAaT.com
faker.URL()             // => https://www.oEuqqAY.org/QgqfOhd
faker.FirstName()       // => Whitney
faker.E164PhoneNumber() // => +724891571063
faker.UUIDHyphenated()  // => 8f8e4463-9560-4a38-9b0c-ef24481e4e27
faker.IPv4()            // => 99.23.42.63
faker.IPv6()            // => 975c:fb2c:2133:fbdd:beda:282e:1e0a:ec7d
faker.Word())           // => nesciunt
faker.Sentence()        // => Consequatur perferendis voluptatem accusantium.
faker.TimeString()      // => 03:10:25
faker.MonthName()       // => February
faker.YearString()      // => 1994
faker.DayOfWeek()       // => Sunday
faker.DayOfMonth()      // => 20
faker.Timestamp()       // => 1973-06-21 14:50:46
...
// For deterministic result
faker.SetRandomSource(faker.NewSafeSource(rand.NewSource(seed)))

testfixtures

Insert predefined data into database
https://github.com/go-testfixtures/testfixtures

# comments.yml
- id: 1
  post_id: 1
  content: A comment...
  author_name: John Doe
  author_email: john@doe.com
  created_at: 2020-12-31 23:59:59
  updated_at: 2020-12-31 23:59:59
  
 - id: 2
  post_id: 2
  content: Another comment...
  author_name: John Doe
  author_email: john@doe.com
  created_at: 2020-12-31 23:59:59
  updated_at: 2020-12-31 23:59:59
loader, err := testfixtures.New(
	testfixtures.Database(db.DB),
    testfixtures.Dialect("postgres"),
    testfixtures.Location(time.UTC),
    // Load predefined data
    testfixtures.Files(
  	    "fixtures/users.yml",
        "fixtures/comments.yml",
    ),
)

loader.Load()

Integration test

the next step beyond unit test

a real environment?

expensive and hard to maintain

docker-compose up?

good, but we could do better

ory/dockertest

A swiss knife to boot up ephemeral container easily

func LaunchDB(t *testing.T) (*sql.DB, func()) {
    pool, _ := dockertest.NewPool("")

	// pulls an image, creates a container based on it and runs it
	resource, _ := pool.RunWithOptions(&dockertest.RunOptions{
		Repository: "mysql",
		Tag:        "8.0",
		Env:        []string{"MYSQL_ROOT_PASSWORD=secret"},
	})

	// exponential backoff-retry
	var db *sql.DB
	pool.Retry(func() error {
		var err error
		db, err = sql.Open("mysql", DSN)
		if err != nil {
			return err
		}
		return db.Ping()
	})

	return db, func() { pool.Purge(resource) }
}
var db *sql.DB

func TestMain(m *testing.M) {
	var close func()
	db, close = testutil.LaunchDB()

	// Run all integration tests
	code := m.Run()

	// Close db container
	close()

	os.Exit(code)
}

func TestSomething(t *testing.T) {
	// db.Query()
}

ory/dockertest

  • Launch container just in your go test code

  • Remove containers just after test finishes

  • Smoothly integrate with go test framework and your IDE

  • Could use build-tag to run integration test explicitly

// +build integration

import testing
$ go test --tags integration ./...

Continuous smoke test

keep testing in production

Don't let the customer tell you the system is down

Continuous smoke test

      Steps:

  • Identify critical user journey

  • Build smoke tests for those critical cases

  • Continuously run those tests on production

  • Send alerts if these critical smoke tests fail

Continuous smoke test

Benefits

  • Proactively find issues from the user's perspective

  • More possible to find the problems before the user calls us

  • Can be used to calculate SLI

  • go test

  • Test helpers

  • Table-driven test

  • Testable code

  • Mocking

  • Fake data

  • Integration test

  • Continuous smoke test

Happy Go Test

  • We are a regional SaaS startup with two major products

  • Provide B2B solutions for marketing, sales, and customer service needs

We send > 4 billion messages per year

even more than 中華電信's SMS

We are on journey from 1 to 100 with unique challenges and opportunities

Our Tech Blog

We are hiring!!!

Let's talk about automation tests in Golang

By Ting-Li Chou

Let's talk about automation tests in Golang

  • 80