关于单元测试,其实是我们讨论的非常多的一点,作为一个测试人员,笔者唯一没怎么接触的测试,其实就是单元测试。这段时间刚好在开发一些平台,在代码中也涉及到了这块,因此记录一下自己的一些想法。
笔者用一个场景来说明一下思路。
开发一个查询接口,接受页面传入的参数,再查询配置服务获取数据库的配置信息。最后拼成 SQL 之后查询结果返回。
笔者这里用 Go 写一个伪代码来演示,忽略那些特有的语法,相信单纯看逻辑应该是没问题的。
func GetSomething(c *Request) {
userName := c.Query("user_name")
page := c.Query("page")
size := c.Query("size")
if userName == ""{
return error
}
if page == 0{
return error
}
if size == 0{
return error
}
rsp, err := QryDbInfoFromConfigCenter()
if err != nil{
return err
}
database := rsp.Get("Database")
table := rsp.Get("Table")
if database == ""{
return error
}
if table == ""{
return error
}
sql := fmt.Sprintf("select * from %s.%s where username='%s' and offset %d limit %d", database, table, userName, page, size)
resp, err := DoQryInfo(tableAddr, sql)
if err != nil{
return err
}
if resp.Code != 0{
return error
}
for(i:=0;i<len(resp.Data);i++){
if resp.Data[i].status == 0{
resp.Data[i].nickStatus = "成功"
} else if resp.Data[i].status == 1{
resp.Data[i].nickStatus = "失败"
}
}
return resp.Data
}
上面的代码是一个非常典型的写法,这种线性的写法几乎存在于接触的 80% 的代码中。毫无疑问它是能够正常工作的,并且书写也非常方便,整个流程符合正常的线性思维。
但是,如果要对这样的代码去做单元测试,几乎没办法进行单元测试。因为它的每个步骤都耦合在一起,如果要测试,就必须准备一个查询 db 配置的服务,准备一个有数据的 db。这样做单元测试的成本确实是非常高,测试用例于环境强关联,局限性非常大,并且跟做集成测试几乎没有区别。
笔者眼中的单元测试应该有这么几点:
这里其实涉及到了代码的变动,争议应该是非常大的,笔者这里阐述自己的理解。
这个查询功能大致是这样:接收请求数据->检查数据是否合法->查询 DB 信息->检查返回信息的合法->对数据做一定的转换 (生成 SQL)->请求 DB 查询->解析返回结果->返回结果做一定的处理->返回。
大致可以分成这 10 步,其中除去开头的接受数据和返回结果,有 8 步。其中外部依赖的是 2 步,查询db信息
和请求db查询
。其他的步骤都是一些数据的转换和处理。那么代码应该把这些抽离出来作为单一功能的方法,这样单纯的数据处理的方法,就能够不依赖任何环境从而进行单元测试验证。
从这个思路来推导
总结一下,就是通过这样的拆分,确保了我们请求外部服务的时候,参数一定是按照约定传的,如果有改动破坏了这个约定,单元测试就能发现。同样,返回数据的解析处理也是按照约定处理的,如果有改动破坏了这个约定,单元测试也是能够发现的。
如果有了这样的保证,那么外部服务是否真的去请求,实际上区别并不是特别大。
同样用 Go 的伪代码来写这个改造后的代码。
type Rqst struct{
UserName string
Page int32
Size int32
}
type DatabaseInfo struct {
Database string
Table string
}
func NewRqst(c *Request) *Rqst{
var r = new(Rqst)
r.UserName := c.Query("user_name")
r.Page := c.Query("page")
r.Size := c.Query("size")
}
func(r Rqst)checkParam()error{
if r.UserName == ""{
return error
}
if r.Page == 0{
return error
}
if r.Size == 0{
return error
}
}
func (r Rqst)buildQrySQL(d *DatabaseInfo)string{
sql := fmt.Sprintf("select * from %s.%s where username='%s' and offset %d limit %d", d.Database, d.Table, r.UserName, r.Page, r.Size)
}
func NewQryRsp(rsp *QryResp) *DatabaseInfo{
var d = new(DatabaseInfo)
d.Database := rsp.Get("Database")
d.Table := rsp.Get("Table")
return d
}
func(d *DatabaseInfo)checkResp(){
if d.Database == ""{
return error
}
if d.Table == ""{
return error
}
return d
}
func checkQryDbResult(resp *DoQryInfoResp)error{
if resp.Code != 0{
return error
}
return nil
}
func setNickStatus(resp *DoQryInfoResp){
for(i:=0;i<len(resp.Data);i++){
if resp.Data[i].status == 0{
resp.Data[i].nickStatus = "成功"
} else if resp.Data[i].status == 1{
resp.Data[i].nickStatus = "失败"
}
}
}
func GetSomething(c *gin.Context) {
r := NewRqst(c)
err := r.checkParam()
if err != nil{
return err
}
_rsp, err := QryDbInfoFromConfigCenter()
if err != nil{
return err
}
rsp := NewQryRsp(_rsp)
err = rsp.checkResp()
if err != nil{
return err
}
sql := r.buildQrySQL(rsp)
resp, err := DoQryInfo(tableAddr, sql)
err = checkQryDbResult(resp)
if err != nil {
return err
}
setNickStatus(resp)
return resp.Data
}
从改造的结果来看,代码变多了很多,主要就是更多的结构体的定义和方法声明的代码,实际的业务代码来看是差不多的。
但是改造后的优点确非常的明显,主流程GetSomething
中的代码更清晰简单,阅读的人可以很快的明白这个接口到底只做什么的,而不需要完全读懂这个代码。
同样,改造后,每个方法都是可以单独的写对应的测试用例,而这样写出来的单元测试用例由于只是一些数据变动的处理逻辑,没有涉及到外部的请求,因此是可以在任意环境执行,并且结果可靠有效,不会出现环境问题导致的用例失败。
这样的做法,实际上涉及到了代码结构的变动,个人认为这样写会更加优雅易读,但是由于每个人的想法、思维方式等都不同,包括工作经历,也会影响这些,因此关于代码优雅性这块不做更多的讨论,读者可以保留自己的想法。
对于单元测试来说,笔者做过一部分代码改造来实践这部分内容,发现效果还不错,确确实实帮忙发现了一些问题,应该说,是具有一定的合理性的。
最后还有一点关于代码覆盖率的,业界普遍的要求单元测试的覆盖率是 80%。按照笔者最近的实践来看,如果你的代码没有写很多废话或者废逻辑,这么干要达到 80%,对于业务不复杂(也就是数据处理部分少)的工程来说还是有难度的。这或许是另一个值得探讨和学习的点。