How to write efficient unit tests in Go

Importance of single test

  • Convenient debugging
  • Facilitate code refactoring
  • Improve code quality
  • Make the whole process agile

Go single test Basics

Basic rules

  • Usually our unit test code is placed in_ test. In the file ending with go, the file is generally placed in the same package as the object code.
  • The Test method starts with Test and has the only * testing Parameters of T.

go test use

  • go test tests the current package
  • go test some/pkg test a specific package
  • go test some/pkg /... Recursively test all packages under a specific package
  • go test -v some/pkg -run ^TestSum $tests specific methods under specific packages
  • go test -cover view single test coverage
  • go test -count=1 ignore the cache and run the single test. Note that if you test recursively (. /...), the cache will be used by default

Table test

  • Batch build your own test case s using anonymous structures
  • Sub test is adopted to make the test output more friendly
func TestIsIPV4WithTable(t *testing.T) {
	testCases := []struct {
		IP    string
		valid bool
	}{
		{"", false},
		{"192.168.0", false},
		{"192.168.x.1", false},
		{"192.168.0.1.1", false},
		{"127.0.0.1", true},
		{"192.168.0.1", true},
		{"255.255.255.255", true},
		{"120.52.148.118", true},
	}

	for _, tc := range testCases {
		t.Run(tc.IP, func(t *testing.T) {
			if IsIPV4(tc.IP) != tc.valid {
				t.Errorf("IsIPV4(%s) should be %v", tc.IP, tc.valid)
			}
		})
	}
}

HTTP test

  • Use httptest Newrecorder to test HTTP Handler without actual HTTP listening
  • You can use errorReader to improve test coverage
type errorReader struct{}

func (errorReader) Read(p []byte) (n int, err error) {
	return 0, errors.New("mock body error")
}

func TestLoginHandler(t *testing.T) {

	testCases := []struct {
		Name string
		Code int
		Body interface{}
	}{
		{"ok", 200, `{"code":"a@example.com", "password":"password"}`},
		{"read body error", 500, new(errorReader)},
		{"invalid format", 400, `{"code":1, "password":"password"}`},
		{"invalid code", 400, `{"code":"a@example.com1", "password":"password"}`},
		{"invalid password", 400, `{"code":"a@example.com", "password":"password1"}`},
	}

	for _, tc := range testCases {
		t.Run(tc.Name, func(t *testing.T) {

			var body io.Reader
			if stringBody, ok := tc.Body.(string); ok {
				body = strings.NewReader(stringBody)
			} else {
				body = tc.Body.(io.Reader)
			}

			req := httptest.NewRequest("POST", "http://example.com/foo", body)
			w := httptest.NewRecorder()

			LoginHandler(w, req)

			resp := w.Result()
			if resp.StatusCode != tc.Code {
				t.Errorf("response code is invalid, expect=%d but got=%d",
					tc.Code, resp.StatusCode)
			}
		})
	}
}

It can be seen that the official built-in library is easy enough to use, including not only subtest but also httptest. For small and medium-sized projects, using the official testing library is enough

Other test frameworks

Although the official testing library is excellent enough, it still needs to be improved in some large projects, such as:

  • Assertions are not friendly enough and require a lot of if
  • Continuous integration is not enough, and you have to run the test manually every time
  • BDD not supported
  • The document automation of test case is not enough

Therefore, I have introduced three test frameworks here, which have some improvements for the above points.

Testify

  • https://github.com/stretchr/testify
  • Seamlessly integrate with go test and run it directly with this command
  • Support assertions, easier to write
  • Support mocking
func TestIsIPV4WithTestify(t *testing.T) {
	assertion := assert.New(t)

	assertion.False(IsIPV4(""))
	assertion.False(IsIPV4("192.168.0"))
	assertion.False(IsIPV4("192.168.x.1"))
	assertion.False(IsIPV4("192.168.0.1.1"))
	assertion.True(IsIPV4("127.0.0.1"))
	assertion.True(IsIPV4("192.168.0.1"))
	assertion.True(IsIPV4("255.255.255.255"))
	assertion.True(IsIPV4("120.52.148.118"))
}

GoConvey

  • https://github.com/smartystreets/goconvey
  • Support BDD
  • Ability to run tests using go test
  • The test results can be viewed through the browser
  • Automatically load updates
func TestIsIPV4WithGoconvey(t *testing.T) {
	Convey("ip.IsIPV4()", t, func() {
		Convey("should be invalid", func() {
			Convey("empty string", func() {
				So(IsIPV4(""), ShouldEqual, false)
			})

			Convey("with less length", func() {
				So(IsIPV4("192.0.1"), ShouldEqual, false)
			})

			Convey("with more length", func() {
				So(IsIPV4("192.168.1.0.1"), ShouldEqual, false)
			})

			Convey("with invalid character", func() {
				So(IsIPV4("192.168.x.1"), ShouldEqual, false)
			})
		})

		Convey("should be valid", func() {
			Convey("loopback address", func() {
				So(IsIPV4("127.0.0.1"), ShouldEqual, true)
			})

			Convey("extranet address", func() {
				So(IsIPV4("120.52.148.118"), ShouldEqual, true)
			})
		})
	})
}

GinkGo

  • https://github.com/onsi/ginkgo
  • It is also a BDD testing framework
  • Can use go test
  • Gomega has its own assertion library
  • Automatic loading of updates is also supported
var _ = Describe("Ip", func() {
	Describe("IsIPV4()", func() {
		// fore content level prepare
		BeforeEach(func() {
			// prepare data before every case
		})

		AfterEach(func() {
			// clear data after every case
		})

		Context("should be invalid", func() {
			It("empty string", func() {
				Expect(IsIPV4("")).To(Equal(false))
			})

			It("with less length", func() {
				Expect(IsIPV4("192.0.1")).To(Equal(false))
			})

			It("with more length", func() {
				Expect(IsIPV4("192.168.1.0.1")).To(Equal(false))
			})

			It("with invalid character", func() {
				Expect(IsIPV4("192.168.x.1")).To(Equal(false))
			})
		})

		Context("should be valid", func() {
			It("loopback address", func() {
				Expect(IsIPV4("127.0.0.1")).To(Equal(true))
			})

			It("extranet address", func() {
				Expect(IsIPV4("120.52.148.118")).To(Equal(true))
			})
		})
	})
})

func TestGinkgotesting(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Ginkgotesting Suite")
}

Other Mock Libraries

So far, we have mastered the usage of Go official library testing and other common testing frameworks, which can facilitate us to write and run regular unit tests.

However, our systems are often complex and rely on many services and basic components. For example, a Web service often relies on MySQL, Redis, etc. Here we mainly explain the use of simulation (mock shielding the actual calls of these services) to test our code logic.

GoMock

  • https://github.com/golang/mock
  • mock library officially launched by golang
  • mock the interface
  • Support mock and stub
  • Using mockgen to generate code
func TestPostIndexWithGoMock(t *testing.T) {
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	Convey("PostController.Index", t, func() {
		Convey("should be 200", func() {
			posts := []*post.PostModel{
				{1, "title", "body"},
				{2, "title2", "body2"},
			}

			m := NewMockPostService(ctrl)
			m.
				EXPECT().
				List().
				Return(posts, nil)

			handler := post.PostController{
				PostService: m,
			}

			req := httptest.NewRequest("GET", "http://example.com/foo", nil)
			w := httptest.NewRecorder()

			handler.Index(w, req)

			So(w.Result().StatusCode, ShouldEqual, 200)
		})

		Convey("should be 500", func() {
			m := NewMockPostService(ctrl)
			m.
				EXPECT().
				List().
				Return(nil, errors.New("list post with error"))

			handler := post.PostController{
				PostService: m,
			}

			req := httptest.NewRequest("GET", "http://example.com/foo", nil)
			w := httptest.NewRecorder()
			handler.Index(w, req)
			So(w.Result().StatusCode, ShouldEqual, 500)
		})
	})
}

HTTPMock

  • https://github.com/jarcoal/httpmock
  • mocking for HTTP Request
  • Any HTTP Response can be customized
  • As of HTTP Request, the user-defined Response is returned directly
  • Give regular matching
func TestPostClientFetch(t *testing.T) {
	httpmock.Activate()
	defer httpmock.DeactivateAndReset()

	postFetchURL := "https://api.mybiz.com/posts"

	client := &PostClient{
		Client: &http.Client{
			Transport: httpmock.DefaultTransport,
		},
	}

	Convey("PostClient.Fetch", t, func() {
		Convey("without error", func() {
			httpmock.RegisterResponder("GET", postFetchURL,
				httpmock.NewStringResponder(200, `[{"id": 1, "title": "title", "body": "body"}]`))

			items, err := client.Fetch(postFetchURL, 1)
			So(len(items), ShouldEqual, 1)
			So(err, ShouldEqual, nil)
		})

		Convey("with error", func() {
			Convey("response data invalid", func() {
				httpmock.RegisterResponder("GET", postFetchURL,
					httpmock.NewStringResponder(200, `[{"id": "213"}]`))

				items, err := client.Fetch(postFetchURL, 1)
				So(items, ShouldBeEmpty)
				So(err, ShouldNotBeNil)
			})

			Convey("without error", func() {
				httpmock.RegisterResponder("GET", postFetchURL,
					httpmock.NewStringResponder(500, `some error`))

				items, err := client.Fetch(postFetchURL, 1)
				So(items, ShouldBeEmpty)
				So(err.Error(), ShouldContainSubstring, "some error")
			})
		})
	})
}

SQLMock

  • https://github.com/DATA-DOG/go-sqlmock
  • mock all interfaces of database/sql
  • Matching based on regular expressions
  • Support query, update, transaction, etc. mock
func TestPostDaoList(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close()

	Convey("PostDao.Fetch", t, func() {
		dao := post.NewPostDao(db)

		Convey("should be successful", func() {
			rows := sqlmock.NewRows([]string{"id", "title", "body"}).
				AddRow(1, "post 1", "hello").
				AddRow(2, "post 2", "world")
			mock.ExpectQuery("^SELECT (.+) FROM posts$").
				WithArgs().WillReturnRows(rows)

			items, err := dao.List()
			So(items, ShouldHaveLength, 2)
			So(err, ShouldBeNil)

		})

		Convey("should be failed", func() {
			mock.ExpectQuery("^SELECT (.+) FROM posts$").
				WillReturnError(fmt.Errorf("list post error"))

			items, err := dao.List()
			So(items, ShouldBeNil)
			So(err.Error(), ShouldContainSubstring, "list post error")
		})
	})
}

Integration with Docker

Although we can mask some services by mock, some services are complex and difficult to mock (such as MongoDB), and sometimes we really want to do some integration tests with the dependent services.

At this time, we can use Docker to quickly build our test dependency environment and release it after use, which is very efficient. The following is a Dockerfile containing MongoDB:

FROM ubuntu:16.04
RUN apt-get update && apt-get install -y libssl1.0.0 libssl-dev gcc

RUN mkdir -p /data/db /opt/go/ /opt/gopath
COPY mongodb/bin/* /usr/local/bin/

ADD go /opt/go
RUN cp /opt/go/bin/* /usr/local/bin/
ENV GOROOT=/opt/go GOPATH=/opt/gopath

WORKDIR /ws
CMD mongod --fork --logpath /var/log/mongodb.log && GOPROXY=off go test -mod=vendor ./...

summary

  • Unit testing should be a team consensus
  • Unit testing is not difficult
  • Using Mock can make our unit testing efficient
  • Interface oriented programming should be used to facilitate mock (gomock)
  • The official library is excellent enough, including table test and http test
  • There are many excellent testing frameworks in the community, which can enable us to better practice BDD or TDD
  • Docker can be applied to more complex test scenarios

reference resources

https://github.com/songjiayang/gotesting

https://golang.org/pkg/testing

https://golang.org/pkg/cmd/go/internal/test

https://golang.org/pkg/testing/#hdr-Subtests_and_Sub_benchmarks

https://golang.org/pkg/net/http/httptest

https://github.com/stretchr/testify

https://github.com/onsi/ginkgo

https://en.wikipedia.org/wiki/Behavior-driven_development

https://github.com/onsi/gomega

https://github.com/smartystreets/goconvey

https://github.com/golang/mock

https://github.com/jarcoal/httpmock

https://github.com/DATA-DOG/go-sqlmock

Keywords: Go unit testing

Added by geo3d on Sat, 15 Jan 2022 05:31:59 +0200