为什么提升 Go 项目的测试覆盖率有点难

注意,这里讨论的内容可能有争议。如果不同意,欢迎讨论。

awesome-go 要求项目测试覆盖率达到 80% 以上才符合入选标准。有一些公司也会要求项目有相对合理的测试覆盖率(如 70% 以上才符合代码准入条件等等)。

但有时,我们的逻辑代码却挺难做到这么高的覆盖率,主要还是因为目前 Go 的错误处理逻辑:

func Register(req RegisterReq) error{
	if len(req.Username) == 0 {
		return errors.New("length of username cannot be 0")
	}

	if len(req.PasswordNew) == 0 || len(req.PasswordRepeat) == 0 {
		return errors.New("password and password reinput must be longer than 0")
	}

	if req.PasswordNew != req.PasswordRepeat {
		return errors.New("password and reinput must be the same")
	}

	if emailFormatValid(req.Email) {
		return errors.New("invalid email")
	}

	createUser(req.Username, req.PasswordNew, req.Email)
	return nil
}

上面是一个做接口的请求校验的例子(当然请求校验应该直接使用 validator 库,这里只是举个例子),整个 register 代码中和业务逻辑相关的代码只有最后 createUser(req.Username, req.PasswordNew, req.Email) 这一行,在这个函数里,业务逻辑:非业务逻辑基本都快 1:5 了。

如果我们想要让 Register 函数的测试覆盖率达到 100%,那么我们需要把每种可能的错误都构造出来,这要求我们得写大量的 test case,来处理和业务逻辑没什么关系的用户输入,稍微有点舍本逐末。上面这样的例子,可能的直接结果就是,我们的 test case 数,业务相关:业务无关也是 1:5。如果项目的入口比较多,会有很多很多重复的 test case 散落在各种地方,并不是很好维护。

错误处理代码占比太高的情况下,碰上偷鸡程序员甚至可以在不写任何逻辑代码测试的情况下,只构造错误输入就让该文件的局部覆盖率上升到 80%。

当然,我们用 validator 的话就没这么麻烦了,因为 validate 本来也只有一行:

err := v.validate(req)
if err != nil {
    return err
}

这样我们只要对 validator 本身进行大量严格的测试,甚至可能是各种 fuzz test。就不用在业务项目里去写太多业务无关的 case 了。这里暴露出来的问题,主要还是 Go 本身错误处理的问题,

我们平常很可能也会有类似下面这样的代码:

func MultiStepsProcess() error {
    // step 1
    err := doStep1()
    if err != nil {
        return err
    }
    
    // step 2
    err = doStep2()
    if err != nil {
        return err
    }
    
    // step 3
    err = doStep3()
    if err != nil {
        return err
    }
    
    // step 4
    err = doStep4()
    if err != nil {
        return err
    }
    
    // step 5
    err = doStep5()
    if err != nil {
        return err
    }
    
    ....
}

整个流程只要逐步完成就可以,如果哪一步有问题,只要把这个错误通知给入口,在入口打条日志就可以了。但是还是因为 if err 的问题,我们想要覆盖各种异常情况下的流程,其实也并不容易。这个函数里,逻辑代码和错误处理代码比例是 1:1。错误处理占的比例比上面低了一些,但同样会造成你的 test case 大量膨胀,且并没有什么实际的收益(当然,收益这种事情还是需要具体问题具体分析,有些比较严谨的流程确实需要我们把一个流程的各种可能的中断点都测到),从业务逻辑上来讲,碰到错误上抛并打日志这只是很简单的单一行为,我们并不需要为了验证这一个行为构造无穷无尽的各种情况下的错误。。。。即使从可读性上来讲的话,一步 err 一处理确实很易读。

test_case

除了构造 case 麻烦,if err 还会增加我们函数的圈复杂度。跑过 golangci-lint 的 cyclo lint 就应该知道,每次多一个 if 分支,圈复杂度 +1,switch 中一个 case +1。圈复杂度本身所揭示的问题是,函数复杂度会随着逻辑分支的上升而上升。带有一个 if 分支的函数,本身有 2 种可能的逻辑路径,那么你至少要写两个 case 才能覆盖到。如果你的函数有 10 个 if 分支,那你至少需要 11 个 case 才能把这个函数完整覆盖到。尤其如果是 10 个 if err != nil return err,那你构造 case 一定会比较崩溃。在 Go 里,看起来你是在写行覆盖测试,实际上你是在写分支覆盖测试[doge]。这也就是为什么其它语言要求你的函数圈复杂度最多 10,而 Go 会放宽到 15[doge]。

理论上我们可以期望 Go 语言本身对错误处理做一些简化,主要就是这种遇错就上抛的场景。社区里针对错误处理的抱怨也很多,error handling,从反馈来看,有这种需求的人还是挺多的。毕竟在业务系统很多情况下我们并不关心发生了什么错误,只要能通过日志事后追溯原因就可以了。

之前的 try error proposal 因为实在太丑,没有通过。也许 Go2 能稍微有点不一样。

Xargin

Xargin

If you don't keep moving, you'll quickly fall behind
Beijing