关于我刚开始学着给程序写测试时的一些胡思乱想

关于我刚开始学着给程序写测试时的一些胡思乱想

编程那点事 随便写写 Go 3184 字 / 7 分钟

我把 Cardinal 开源了

就在一周前,我把写了一个寒假的 CTF AWD 平台 Cardinal 开源了。╰(º▽º)╯ 因为项目是放在协会的 GitHub 组织下的,所以要感谢 @Aklis 和 @Li4n0 两位学长的帮忙。

卑微胖茄求各位赏个 star:https://github.com/vidar-team/Cardinal

我的想法是今后将 Cardinal 当做一个长期维护的开源项目。虽然自己的 GitHub 上有这么多奇奇怪怪的 Repo,但正经的好像一个没有。 因此想借着 Cardinal 这个项目去学习尝试除了日常开发之外的东西,比如多人协作开发、规范的 issue / pull request、单元测试、持续集成、文档撰写等等。

感觉这些都是开发中的“软技能”,一直没有找到详细介绍这方面的资料。因而我只能对着一些知名的开源项目自己摸索,这其中难免会有东施效颦的感觉。因此很希望大家能多给予一些意见并指出我的不足之处。

发版前,先测试吧!

刚开源 Cardinal 几天后,我修复了几个 bug 后,便去配 GitHub Actions 了。最初的想法是每次 push 后都用 GitHub Actions 编译一下,每次发布 Release 后 GitHub Actions 会自动编译出各个平台架构下的后端二进制文件。

这里是用了 ngs/go-release.action 这个模块。我在其之上做了些魔改,将项目源码目录改为./src。关于 GitHub Actions,我在《最后的一只章鱼猫 —— GitHub Actions 实现编译打包 Golang 到 Docker 镜像》里已经聊过了,这里就不再赘述了。

今天想聊的是测试相关的东西。

因为 Cardinal 是一个 CTF AWD 线下赛的平台,因此必须要保证功能正常可用,特别是分数计算方面,容不得半点差错。去年 D^3CTF 结束后,在重构平台前, @Li4n0 曾说把单元测试啥的都给安排上。 但是我之前也提到过了,用 Go + Gin 框架编写的 Cardinal,其主要功能代码大多都是放在一个 Handler 方法中:

// GetLogs returns the latest 30 logs.
func (s *Service) GetLogs(c *gin.Context) (int, interface{}) {
	var logs []Log
	s.Mysql.Model(&Log{}).Order("`id` DESC").Limit(30).Find(&logs)
	return s.makeSuccessJSON(logs)
}

这是一个属于 Service 这个结构体指针的方法,方法的入参是 Gin 的上下文,并且还会存在对数据库的操作。 因此我一直觉得这些在测试环境下很难模拟,很难编写对应的测试代码。但是当我去翻了 CTFd 的测试代码时,我发现:他们的测试环境怎么还能有数据库!

这时我才知道 Travis CI 其实是提供原生的数据库服务的。

其实 GitHub Actions 也是能实现的,只不过它是以一个模块的形式进行使用: https://github.com/marketplace/actions/setup-mysql 不过安装这个模块也是需要时间的,所以最后我还是更换成了 Travis CI。 Travis CI 调用数据其实挺容易的:

language: go
go:
  - "1.12.x"
  - "1.13.x"
  - "1.14.x"

service:
  - mysql

env:
  - TEST_DB_NAME=Cardinal GO111MODULE="on"

before_install:
  - mysql -e 'CREATE DATABASE Cardinal;'
  - mysql -e 'ALTER DATABASE Cardinal CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;'

只需要在service里加个mysql即可。Travis CI 的 MySQL 带roottravis两个不同权限的账号,空密码。 在before_install里可以执行一些 SQL 语句来初始化数据库。比如建表,从 SQL 文件导入测试数据之类的。

有坑注意 Travis CI 的 MySQL 数据库的默认编码会有些问题,当插入数据中带有中文时会报错。因此我这里在建表后修改了下数据表的字符集,才解决这个问题。

Gin Test

我按照 Gin 官方文档的建议,首选使用net/http/httptest包。

func TestPingRoute(t *testing.T) {
	router := setupRouter()

	w := httptest.NewRecorder()
	req, _ := http.NewRequest("GET", "/ping", nil)
	router.ServeHTTP(w, req)

	assert.Equal(t, 200, w.Code)
	assert.Equal(t, "pong", w.Body.String())
}

结合 Gin 文档给的 Demo,我们需要先拿到gin.Engine,然后使用原生http包发送请求,响应使用一个httptest.NewRecorder()来接收。最后用assert对返回结果进行断言。

根据 Go 的规范,测试文件以_test.go结尾。并且重要的是,测试文件的代码不属于主程序,因此我们在测试代码里定义的全局变量主程序是访问不到的,这一点很棒。同时在输入要测试的函数名时,Goland 会给出代码提示,我们可以选择是对函数做单元测试(Test_XXX),还是性能测试(Benchmark_XXX)。

测试顺序控制

有时我们需要在测试前做一些初始化的操作。比如先读取配置,然后再初始化数据库,最后执行测试程序。 Go 的测试框架有提供TestMain()方法供我们在测试前或测试后执行代码。但我这里是直接用了init()函数来在程序的最前面执行初始化的代码。

但在测试程序中,我们可能也会有顺序要求。比如测试程序要先创建 Challenges,再创建 GameBox,再生成 Flag。 然而测试是无状态的,每一个测试函数都是一个单独的测试用例。在同一个文件中,测试函数会从上向下按顺序执行,因此我是直接将很多个测试函数放在了同一个文件里。这样它们就能从上向下有顺序执行了。但我觉得这样写可能还是不够优雅。 Stack Overflow 上的一则回答给出一种解决办法:

// this is the whole stateful sequence of tests - to the testing framework it's just one case
func TestWrapper(t *testing.T) {
   // let's say you pass context as some containing struct
   ctx := new(context)
   test1(t, ctx)
   test2(t, ctx)
   ...
}
// this holds context between methods
type context struct {
    eventId string
}
func test1(t *testing.T, c *context) {
   // do your thing, and you can manipulate the context
   c.eventId = "something"
} 
func test2(t *testing.T, c *context) {
   // do your thing, and you can manipulate the context
   doSomethingWith(c.eventId)
}

他这里是将一个个测试步骤都做成函数,然后在一个测试函数中按顺序调用。之后有时间我觉得可以改成这样。

我是怎么想的?

这是 Cardinal 里的删除队伍接口的测试:

func TestService_DeleteTeam(t *testing.T) {
	// error id
	w := httptest.NewRecorder()
	req, _ := http.NewRequest("DELETE", "/manager/team?id=asdfg", nil)
	req.Header.Set("Authorization", managerToken)
	service.Router.ServeHTTP(w, req)
	assert.Equal(t, 400, w.Code)

	// id not exist
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("DELETE", "/manager/team?id=233", nil)
	req.Header.Set("Authorization", managerToken)
	service.Router.ServeHTTP(w, req)
	assert.Equal(t, 404, w.Code)

	// success
	w = httptest.NewRecorder()
	req, _ = http.NewRequest("DELETE", "/manager/team?id=3", nil)
	req.Header.Set("Authorization", managerToken)
	service.Router.ServeHTTP(w, req)
	assert.Equal(t, 200, w.Code)
}

我这里是对返回的状态码进行了断言。可能有人会觉得我每测一次接口就要写一遍重复的代码,为何不封装一下成一个请求的函数呢? 这里其实我在看到了些文章后,自己对于测试的一些想法。测试之禅

首先,测试代码它也是程序,如果我们编写的测试代码流程过于复杂,其中的嵌套了许多分支,封装了很多层,那必然更容易出错。那我们该怎么证明这个测试代码它本身是可靠的呢?是不是还得编写测试的测试代码来测试测试?嘻嘻(禁止套娃!) 所以我认为测试代码可以重复一些,啰嗦一些,尽可能少一些骚操作,少用第三方封装的包而尽可能用语言原生的包,只要做到流程明确清晰即可。

其实转而一想,测试这个东西,它的目的就是为了去证明一个程序不可靠。但是它却无法证明一个程序绝对可靠,你不可能尝试遍历所有的输入并检验所有的输出。**也就是证明全真不可能,但是证明不为真只需要举出一个反例。**所以说如果能有一项测试不通过,这应该是好事,这至少说明了我们的程序或者测试代码二者当中必有一个存在问题。能找到这个问题,我们编写测试的目的也就达到了。 整个测试跑下来全部 PASS 通过时,也不要庆幸,这里面说不定就存在你的程序代码和测试代码都是有 bug 的情况。(虽然这种情况概率应该很小

上面说到我不建议把测试写得复杂,并且提到在测试通过后,可能会存在程序代码和测试代码二者都有 bug 的情况。 那我们其实可以极端一点,干脆就把测试写得无比复杂,流程嵌套无比多,代码飞来飞去跳来跳去,给程序布下天罗地网。整个测试跑下来就像是小心翼翼地穿过银行金库的激光防盗装置一样:

如果程序能通过如此复杂的测试,那大概率可以说明程序没有 bug。 一旦有 bug 的话,那这个 bug 估计会很难很难被找到,它会藏得特别深。

上面说了这么多,其实可以看出测试是个很矛盾的东西。我们可以断定“任何一个程序存在 bug”,因为这个命题无法被证伪。但是我们又不能说能找出这个程序中所有的 bug。只能用尽可能多,尽可能广的测试样例,来保证大范围内的功能或操作是可正常运行的。

以上就是我在写测试时胡思乱想地一些东西,感觉这都不关乎具体技术实现了,都是些虚无缥缈的“哲学”了。

Codecov

下面就是我很期待的东西了! 既然写了单元测试,那么就可以通过单元测试覆盖率来衡量测试的好坏。通俗一点就是这个测试是否测了你的所有代码,是否所有分支都进行了测试。 Go 可以在go test后加上参数来生成覆盖率报告:

go test -v -coverprofile=coverage.txt -covermode=atomic ./...

而 Codecov 其实相当于一个覆盖率报告可视化的东西吧。它可以生成对应的图表以及好看的 badge。 接入的方式也是很简单,只需要在 CI 的最后下载执行一下 Codecov 的一段 Shell 脚本即可。

after_success:
  - bash <(curl -s https://codecov.io/bash)

这样就会将生成的覆盖率报告上传到 Codecov 上。目前 Cardinal 的测试覆盖率在 75% 左右,不算高。这是我强行肝了两天的成果。 真别说,这其中还真测出了两个小 bug。 同时在提交了 pull request 后,Codecov 还会发布一个 comment 将覆盖测试率报告给贴上。哇!好高级啊!

再说几句

至此,Cardinal 的测试就做得差不多了。下面的工作就是 i18n 相关了,得赶紧把英文文档给安排上呀!(如果有人能帮我写该多好,发出了鸽子的声音