一,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)首先,我们先写测试用例
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,具体例子:举一个我们业务上用到的人脸识别功能为例
- 方案设计,划分功能模块:根据产品文档,我们可以看出拆分出两个功能大模块——本体人脸认证服务和人脸识别配置后台
- 根据方案模版,拆分开发的任务列表: 从上面的功能模块,我们能更细化的拆分小功能,如人脸认证服务需要提供三个接口,分别是
此时可列出具体开发的任务列表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文件也有自带这个命令,编写完测试用例后,我们应该查看对应的测试用例是否能覆盖主流程大部分的代码,如果不能,则可认为编写的单元测试用例是不充分的。
同时,sonarqube上也有对应的测试代码覆盖率可以观看
(4)对于go test中的测试用例,除了普通的业务逻辑单元测试用例之外,其实还存在基准测试用例(BenchMark开头,用于测试函数性能),性能比较函数测试用例等等,由于基准测试这块在实际的业务开发中,如果强求会过于吹毛求疵,因此在这不做过多描述
具体可看xiaoming.net.cn/2021/03/16/…
五,总结
TDD固然是一个好东西,但也要切记勿过度设计,不复杂化,在合理的范围内进行TDD,才是真正能保证我们自身开发的质量