一篇文章教你在业务开发中高效玩转TDD(测试驱动开发)

1,821 阅读13分钟

一,TDD(Test-Driven Development)介绍:

1,TDD:敏捷开发中的一项核心实践和技术,也是一种设计方法论。从根本上来讲,TDD的定义还是比较抽象的

TDD的原理是在 开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

2,步骤:

  • 先写测试代码,并执行,但需要断言得到失败结果——红:

  • 写实现代码让测试通过——绿

  • 重构代码,并保证测试通过——重构(其实按照单项目来说,可简单认为是代码的变更)

  • 反复实行这个步骤 测试失败 -> 测试成功 -> 重构。

image.png

3,优点:

  • 在任意一个开发节点都可以拿出一个可以使用,含少量bug并具一定功能和能够发布的产品。

  • 保障代码的正确性,能够迅速发现、定位bug。针对关键代码的测试集,以及不断完善的测试用例,为迅速发现、定位bug提供了条件。

缺点:

  • 增加代码量。测试代码是系统代码的两倍或更多,但是同时节省了调试程序及挑错时间。

  • 在实际上的业务开发上来说,如果要完全实现TDD的整体操作,将会大大增加开发人员的工作量

4,原则(讲的比较宽泛):

  • 测试隔离:不同代码的测试应该相互隔离。对一块代码的测试只考虑此代码的测试,不要考虑其实现细节。

  • 及时重构:无论是功能代码还是测试代码,对结构不合理,重复的代码等情况,在测试通过后,及时进行重构。

  • 可测试性:功能代码设计、开发时应该具有较强的可测试性。其实遵循比较好的设计原则的代码都具备较好的测试性。比如比较高的内聚性,尽量依赖于接口等。

  • 先写断言:测试代码编写时,应该首先编写对功能代码的判断用的断言语句,然后编写相应的辅助语句。

  • 测试驱动:这个比较核心。完成某个功能,某个类,首先编写测试代码,考虑其如何使用、如何测试。然后在对其进行设计、编码。

  • 测试列表:需要测试的功能点很多。应该在任何阶段想添加功能需求问题时,把相关功能点加到测试列表中,然后继续手头工作。然后不断的完成对应的测试用例、功能代码、重构。一是避免疏漏,也避免干扰当前进行的工作。

  • 小步前进:把所有的规模大、复杂性高的工作,分解成小的任务来完成,每个功能的完成就走测试代码-功能代码-测试-重构的循环。通过分解降低整个系统开发的复杂性、

  • 一顶帽子:开发人员完成对应的工作时应该保持注意力集中在当前工作上,而不要过多的考虑其他方面的细节,保证头上只有一顶帽子。避免考虑无关细节过多,无谓地增加复杂度。

5,从主流言论而言,推行TDD的障碍大约有如下几点:

  • 开发人员的质量意识:大部分开发人员,编写出的代码考虑的点总是不全的,或是懒,或是缺乏编写测试用例的意识;
  • 分析需求并进行任务分解的能力; 需求分析能力常常是开发人员的短板。开发人员养成了一个习惯,看什么事情都会从技术实现的角度去思考。要实现一个网页,就会想到如何编写JavaScript来响应用户的动作,如何编写CSS,却不会去思考用户体验和操作的流程。
  • 将测试作为开发起点的开发习惯: 开发人员,一般都是先写代码,再去测试;
  • 开发人员的重构能力,包括如何识别“坏味道”和如何运用重构手法: 在代码版本变更的时候,能通过合适的“重构”,去保证改动小的情况下,能满足新的需求,对于“坏味道”的定义:baijiahao.baidu.com/s?id=171346…
  • 单元测试的基础设施,尤其是测试数据准备: 以及执行自动化单元测试的工具,类似于gitlab的runner自动执行和对应的框架

6,对于37手游平台本身来说,对于单元测试的基础设施,以及bad smell的识别是已经具备的(通过sonarqube和gitlab runner)所以TDD在手游平台的最大实现难度,个人认为主要在于开发人员自身的意识。

二,TDD实际使用过程和分析:

1,举个例子,当我们想要编写一个钱包功能,该钱包能存钱,以及能看到自己有多少钱

(1)首先,我们先写测试用例

func TestWalletStoreAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.Store(10)
   got := wallet.Balance()
   want := 10
   if got != want {
      t.Errorf("got %d want %d", got, want)
   }
}

(2)上面的那一段只是在写一段伪代码,实际上,编译器压根就不会编译过,这里就到了TDD的红阶段:

用最短的代码,来编写编译器能通过,但断言失败的代码

type Wallet struct {
}

func (w Wallet) Store(amount int) {

}

func (w Wallet) Balance() int {
   return 0
}

func TestWalletStoreAndGet(t *testing.T) {

   wallet := Wallet{}

   wallet.Store(10)

   got := wallet.Balance()
   want := 10

   assert.Assert(t, got==want)
}

(3)此时由于断言失败,这时候应该用足够的代码,去编写让测试用例通过,这就是TDD的绿阶段:

type Wallet struct {
   Amount int
}

func (w *Wallet) Store(amount int) {
   w.Amount += amount
}

func (w *Wallet) Balance() int {
   return w.Amount
}

func TestWalletStoreAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.Store(10)
   got := wallet.Balance()
   want := 10
   assert.Assert(t, got == want)
}

(4)这时候,钱包还有其他需求,如提现,这里也可以像上面那样,走TDD的流程,编写测试用例 ,不断的红——绿, 这里不做过多描述....最终钱包功能实现的代码,可能是这样子的

type Wallet struct {
   Amount int
}

func (w *Wallet) Store(amount int) {
   w.Amount += amount
}

func (w *Wallet) Balance() int {
   return w.Amount
}
func (w *Wallet) WithDrawAndStore(store int, withdraw int) {
   w.Store(store)
   w.Amount -= withdraw
}

func TestWalletStoreAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.Store(10)
   got := wallet.Balance()
   want := 10
   assert.Assert(t, got == want)
}

func TestWallWithDrawAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.WithDrawAndStore(20, 10)
   got := wallet.Balance()
   want := 10
   assert.Assert(t, got == want)
}

这时候我们会发现,由于开发人员的“失误”,提现功能和存储功能会耦合到一个函数里了,这时候,就要到了TDD中的重构阶段,

单论这个功能而言,要做的其实就是拆分提现为单独的函数,此时也是先编写最少的代码使编译通过,但测试断言不通过

type Wallet struct {
   Amount int
}
func (w *Wallet) Store(amount int) {
   w.Amount += amount
}
func (w *Wallet) Balance() int {
   return w.Amount
}
func (w *Wallet) WithDraw(withdraw int) {
   return 0
}

func TestWalletStoreAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.Store(10)
   got := wallet.Balance()
   want := 10
   assert.Assert(t, got == want)
}

func TestWallWithDrawAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.Store(20)
   wallet.WithDraw(10)
   got := wallet.Balance()
   want := 10
   assert.Assert(t, got == want)
}


func TestWallWithDrawAndGet(t *testing.T) {
   wallet := Wallet{}
   wallet.WithDrawAndStore(20, 10)
   got := wallet.Balance()
   want := 10
   assert.Assert(t, got == want)
}

再通过实现withdraw这一函数,使测试通过

....
func (w *Wallet) WithDraw(withdraw int) {
   w.Amount -= withdraw
}
.....

至此,钱包的基本功能已经完成,这时候如果有产品提出更多的需求,也是按这个流程进行开发

实际上的TDD的使用,就是这样反复的红——绿——重构过程

2,go的示例项目地址:studygolang.gitbook.io/learn-go-wi…

3,从业务开发的实际使用的方法论:从实际上的使用来说,TDD实际上花费时间最多的在于红-绿循环,即测试失败到测试成功的过程;但大部分人在没有TDD的意识时,总会在“红”这一阶段寸步难行,因此在这一步骤前,可以采用这样的方式来实现

1,大致构思软件被使用的方式,把握对外接口的方向——————这一步一般在方案设计里会有体现

2,大致构思功能的实现方式,划分所需的组件(Component)以及组件间的关系(所谓的架构)。当然,如果没思路,也可以不划分——————按平台的方案设计来说也会有体现

3,根据需求的功能描述拆分功能点,功能点要考虑正确路径(Happy Path)和边界条件(Sad Path)————像测试一样,在开发需求前有自己的测试用例,并且拆分任务列表

4,依照组件以及组件间的关系,将功能拆分到对应组件——————如dao层的对mysql一些crud函数设计用例,对redis的crud设计用例

5,针对拆分的结果编写测试,进入红 / 绿 / 重构循环——————实际应用TDD方式进行开发

三,在手游平台业务开发中的使用

1,在实际的业务开发中,我们其实如果完全按照TDD的思想去实现,对开发人员来说的负担很大,而且很可能到最后会成为开发人员的枷锁,TDD红绿循环的本质,个人认为其实是一个很浅显易懂的道理:其实就是在写功能的同时,自然而然的就把测试用例给完善了,你懂的如何设计测试用例,如何写bug,写出来的功能自然也是完善的。

2,因此个人提倡,在业务开发中的TDD,可简化为以下步骤:

  • 拿到需求后,根据需求文档,先进行方案设计,划分功能模块———— 具体可参考技术方案模版,在这一步骤就可以把模块,架构,对外接口进行设计
  • 根据方案模版,拆分开发的任务列表———— 这里可根据功能模块进行拆分,列出具体的任务。

  • 根据任务列表,按TDD的方式编写代码,但循环次数根据情况做自己的考虑——— 实际的开发过程,应该是先编写某个模块的功能代码,再编写数据操作类型的功能代码,最后通过红绿循环将整体串起来;不过从业务上来讲,你的功能拆分的越细,测试用例也会随着越详细,系统的可靠性自然而言也会随之提升。

3,具体例子:举一个我们业务上用到的人脸识别功能为例

  • 方案设计,划分功能模块:根据产品文档,我们可以看出拆分出两个功能大模块——本体人脸认证服务和人脸识别配置后台

iShot2023-05-12 10.32.26.png

  • 根据方案模版,拆分开发的任务列表: 从上面的功能模块,我们能更细化的拆分小功能,如人脸认证服务需要提供三个接口,分别是

iShot2023-05-12 10.29.44.png 此时可列出具体开发的任务列表TODO LIST:

-   发起人脸识别功能开发:涉及到调用阿里云的人脸识别接口和mysql中人脸识别记录的插入  
      

-   验证人脸识别是否成功开发:涉及到mysql中人脸识别记录的修改  
      

-   检查是否需要人脸识别验证接口开发:涉及到mysql中人脸识别记录的插入和人脸识别配置的读取


  • 根据任务列表,按TDD的方式编写代码: 这里由于篇幅原因,只根据 发起人脸识别功能为例

  • 先用最短的代码,让编译器通过,但断言失败的代码:因为req和InitFaceVerifyDetail,只进行了声明,对应的结果肯定是为空的,所以测试用例会不通过

        func TestInitFaceVerify(t *testing.T) {
           req := service.InitFaceVerifyReq{}
           res, err := s.InitFaceVerifyDetail(ctx, req)
           assert.Assert(t, err == nil)
           assert.Assert(t,res.CertifyID!="")
        }

  • 再用足够的代码,让测试用例能够通过:这里我们把编写可以拉宽一点,最终呈现的样式为这样:
func (s Service) InitFaceVerifyDetail(ctx context.Context, param service.InitFaceVerifyReq) (res service.InitFaceVerifyResp, err error) {
   log := logger.FromContext(ctx).WithTag("InitFaceVerifyDetail")
   // 根据token解析出具体信息
   ....
   // 请求阿里云验证服务接口
   aliResp, err := s.AliFaceVerifyClient.InitFaceVerifySimply(&cloudauth.InitFaceVerifyRequest{
      ProductCode:  tea.String(model.AliFaceVerifyProductCode),
      SceneId:      tea.Int64(s.FaceVerifyClientConf.AliFaceVerifySceneID),
      OuterOrderNo: tea.String(uuid.New().String()),
      CertType:     tea.String(model.AliFaceVerifyCertType),
      CertName:     tea.String(userInfo.Name),
      CertNo:       tea.String(userInfo.IdCardNumber),
      MetaInfo:     tea.String(param.MetaInfo),
   })
   if err != nil {
      log.WithFields(map[string]interface{}{
         "err":      err.Error(),
      }).Error("InitFaceVerifyDetail err")
      return res, errmsg.GetCallAliClientFail(errmsg.TypeMsgCallAliClientFail)
   }


   // 将对应的人脸识别记录插入
   err = s.ValidateMysql.InsertFaceVerifyLog(ctx, model.SyFaceVerifyLogTBModel{
      .....
   })

   res.CertifyID = *aliResp.ResultObject.CertifyId
   return res, nil

}
func TestInitFaceVerify(t *testing.T) {
   req := service.InitFaceVerifyReq{
       .....
   }
   res, err := s.InitFaceVerifyDetail(ctx, req)
   assert.Assert(t, err == nil)
   assert.Assert(t, res.CertifyID != "")
}
  • 这时候我们会发现,总体调用阿里云那边的接口是通了的,但实际因为插入数据库并没有具体的编写,导致插库本身是失败的;因此针对库本身我们也可以有测试用例
func TestInsertFaceVerifyLog(t *testing.T){
   err:=s.InsertFaceVerifyLog(ctx,model.SyFaceVerifyLogTBModel{})
   assert.Assert(t,err!=nil)
}

重复TDD的流程即可

  • 另外,除了发起人脸识别验证成功外,我们还应该有发起人脸识别验证失败的情况,因此测试用例也可添加失败时候的情况
func TestInitFaceVerifyErr(t *testing.T) {
   req := service.InitFaceVerifyReq{
      Pid:       1,
      Gid:       1000000,
      Token:     "",
   }
   res, err := s.InitFaceVerifyDetail(ctx, req)
   assert.Assert(t, err != nil)
   assert.Assert(t, res.CertifyID != "")
}

然后重复TDD的流程即可

四,单元测试用例的编写:

(1)编写规范:这里只做介绍,实际的测试用例编写还是要根据业务开发而定的

AIR原则具体包括:

  • A: Automatic (自动化)
  • I: Independent (独立性)
  • R: Repeatable (可重复)

BCDE原则:

  • B: Border,边界值测试,包括循环边界、特殊取值、特殊时间点,数据顺序。

  • C: Correct,正确的输入,并得到预期的结果。

  • D: Design,与设计文档相结合,来编写单元测试。

  • E: Error,单元测试的目的是证明程序有错,而不是证明程序无错。为了发现代码中潜在的错误,我们需奥在编写测试用例时有一些强制的错误输入(如非法数据、异常流程、非业务允许输入等)来得到预期的错误结果

(2)达到的质量标准:

针对框架代码我们可以要求覆盖率低一些,比如50%即可,能覆盖主逻辑。
核心逻辑代码的覆盖率越高越好,最好能达到100%。

(3)go test自带的-cover命令能输出对应的代码覆盖率,现在gitlab-ci.yml文件也有自带这个命令,编写完测试用例后,我们应该查看对应的测试用例是否能覆盖主流程大部分的代码,如果不能,则可认为编写的单元测试用例是不充分的。

image.png

同时,sonarqube上也有对应的测试代码覆盖率可以观看

iShot2023-05-12 10.47.49.png

(4)对于go test中的测试用例,除了普通的业务逻辑单元测试用例之外,其实还存在基准测试用例(BenchMark开头,用于测试函数性能),性能比较函数测试用例等等,由于基准测试这块在实际的业务开发中,如果强求会过于吹毛求疵,因此在这不做过多描述

具体可看xiaoming.net.cn/2021/03/16/…

五,总结

TDD固然是一个好东西,但也要切记勿过度设计,不复杂化,在合理的范围内进行TDD,才是真正能保证我们自身开发的质量