如何在 Go 中写出高效的单元测试

用户头像
_why先生
关注
发布于: 2020 年 07 月 12 日
如何在 Go 中写出高效的单元测试

本周在团队做了一次关于 Go 单元测试的分享,分享题目为《Testing in Go-how to write efficient unit test》。

内容大纲



  • 单元测试的重要性

  • Go 单元测试基础知识

  • 表格测试和 HTTP 测试

  • 其它测试框架

  • 其它 Mock 库

  • 与 Docker 集成

单测的重要性



  • 能够尽早的发现 bug

  • 方便 debugging

  • 方便代码重构

  • 提升代码质量

  • 使整个过程敏捷



讲这部分的目的是为了让团队达成共识---单测很重要,我们必须要做好单元测试

Go 单测基础知识

基本规则



  • 通常我们的单元测试代码都放在以 _test.go 结尾的文件中,该文件一般和目标代码放在同一个 package 中。

  • 测试的方法以为 Test 开头,并且拥有唯一一个 *Testing.T 的参数

go test 使用



  • go test 测试当前包

  • go test some/pkg  测试一个特定的包

  • go test some/pkg/... 递归测试一个特定包下面的所有包

  • go test -v some/pkg -run ^TestSum$ 测试特定包下面的特定方法

  • go test -cover 查看单测覆盖率

  • go test -count=1 忽略缓存运行单测,注意如果以递归方式(./...)测试的时候,默认会使用 cache

表格测试



  • 使用匿名结构体批量构建自己的测试 case

  • 采用子测试的方式让测试输出更友好



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 测试



  • 使用 httptest.NewRecorder 来测试 HTTP Handler 而不需要真正进行 HTTP 监听

  • 可以使用 errorReader 来提高测试覆盖率



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



Go 单测基础知识这部分的内容主要是向大家讲解 Go 官方单测库 testing 以及命令行 go test 的使用。



可以看到官方自带的库已经足够好用, 不仅带有 subtest 还有 httptest 的相关内容,对于中小型的项目而言使用官方的 testing 库足够。

其它测试框架



虽然官方 testing 库足够优秀,但在一些较大项目上,它还是有很多需要完善的地方,如:



  • 断言不够友好,需要通过大量 if

  • 持续集成不够,每次都要手动跑测试

  • BDD 无支持

  • 测试 case 的文档自动化不够



所以我这里介绍了三种测试框架,针对以上几点都有一定的改进。

Testify





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





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 (4K+ 关注)

  • 也是一个 BDD 测试框架

  • 能够使用 go test

  • 有自己的断言库 Gomega

  • 也支持自动加载更新



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")
}

其它 Mock 库



到目前为止我们已经掌握了 Go 官方库 testing 和其它常见的测试框架的用法,能够方便我们编写和运行常规的单元测试。



但我们的系统往往比较复杂,依赖很多服务和基础组建,比如一个 Web 服务往往依赖 MySQL、Redis 等,这里主要讲解采用模拟(mock-屏蔽掉这些服务的实际调用)的方式来测试我们的代码逻辑。

GoMock





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 (1K 关注)

  • 针对 HTTP Request 来进行 mocking

  • 能够自定义任意的 HTTP Response

  • 截止 HTTP Request 直接返回自定义的 Response

  • 给予正则匹配



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





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")
})
})
}

与 Docker 集成



虽然我们可以采用 Mock 的方式屏蔽掉某些服务,但是还是存在某些服务比较复杂,很难 mock(如 MongoDB) ,而且有时我们确实想和依赖的服务做某些集成测试。



此时我们可以用 Docker 来快速构建我们的测试依赖环境,并且用完即释放、非常高效,下面就是一个包含 MongoDB 的 Dockerfile:



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 ./...

总结



本次分享主要从单元测试的重要性入手,依次讲解了官方库 testing、社区测试框架(Testify、GoConvey、GinkGo)、Mock 相关技术、Docker 集成的内容,总结如下:



  • 单元测试应该是一个团队共识

  • 单元测试并不难

  • 使用 Mock 能够使我们单元测试高效

  • 应该面向接口编程,方便做 mock (gomock)

  • 官方库足够优秀,包含表格测试、http 测试相关内容

  • 社区有很多优秀测试框架,能够让我们更好的实践 BDD或者TDD

  • Docker 能够适用于更复杂的测试场景

参考



发布于: 2020 年 07 月 12 日 阅读数: 159
用户头像

_why先生

关注

学习是从了解到使用再到输出的过程 2018.04.27 加入

做 1000 件事不如把 1 件事做到极致,喜欢从量变到质变的过程。

评论

发布
暂无评论
如何在 Go 中写出高效的单元测试