写点什么

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

作者:Java你猿哥
  • 2023-05-13
    湖南
  • 本文字数:6225 字

    阅读完需:约 20 分钟

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

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

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

2,步骤:

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

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

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

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


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)首先,我们先写测试用例

go复制代码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 的红阶段:

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

go复制代码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 的绿阶段:

go复制代码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的流程,编写测试用例 ,不断的红——绿, 这里不做过多描述....最终钱包功能实现的代码,可能是这样子的
go复制代码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 中的重构阶段,

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

go复制代码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 这一函数,使测试通过

go复制代码....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,具体例子:举一个我们业务上用到的人脸识别功能为例

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


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


此时可列出具体开发的任务列表 TODO LIST:

markdown复制代码-   发起人脸识别功能开发:涉及到调用阿里云的人脸识别接口和mysql中人脸识别记录的插入        
- 验证人脸识别是否成功开发:涉及到mysql中人脸识别记录的修改
- 检查是否需要人脸识别验证接口开发:涉及到mysql中人脸识别记录的插入和人脸识别配置的读取
复制代码


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

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

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


  • 再用足够的代码,让测试用例能够通过:这里我们把编写可以拉宽一点,最终呈现的样式为这样:

scss复制代码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 != "")}
复制代码


  • 这时候我们会发现,总体调用阿里云那边的接口是通了的,但实际因为插入数据库并没有具体的编写,导致插库本身是失败的;因此针对库本身我们也可以有测试用例,框架自身也是支持的

go复制代码func TestInsertFaceVerifyLog(t *testing.T){   err:=s.InsertFaceVerifyLog(ctx,model.SyFaceVerifyLogTBModel{})   assert.Assert(t,err!=nil)}
复制代码

重复 TDD 的流程即可

  • 另外,除了发起人脸识别验证成功外,我们还应该有发起人脸识别验证失败的情况,因此测试用例也可添加失败时候的情况

css复制代码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 文件也有自带这个命令,编写完测试用例后,我们应该查看对应的测试用例是否能覆盖主流程大部分的代码,如果不能,则可认为编写的单元测试用例是不充分的。


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


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

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

五,总结

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


用户头像

Java你猿哥

关注

一只在编程路上渐行渐远的程序猿 2023-03-09 加入

关注我,了解更多Java、架构、Spring等知识

评论

发布
暂无评论
一篇文章教你在业务开发中高效玩转TDD(测试驱动开发)_Java_Java你猿哥_InfoQ写作社区