谈谈个人对 TDD (测试驱动开发) 的理解

Posted coologic

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了谈谈个人对 TDD (测试驱动开发) 的理解相关的知识,希望对你有一定的参考价值。

介绍

测试驱动开发:英文全称Test-Driven Development,简称TDD,是一种不同于传统软件开发流程的新型的开发方法。它要求在编写某个功能的代码之前先编写测试代码,然后只编写使测试通过的功能代码,通过测试来推动整个开发的进行。这有助于编写简洁可用和高质量的代码,并加速开发过程。 (from 百度百科),请自行 google、baidu

这篇文章主要是谈个人的感想,有理解错误的请指教,欢迎评论交流

(p.s. 我是一个 Dev,这个感想更多是从开发角度来看的)

主要包含如下内容:

  • 我心中的 TDD
  • 如何做 Tasking
  • 举个例子 – Tasking 纵向拆分
  • 如何写 Test
  • 一个 class 可以有多个测试文件么?
  • 一个 Tasking 可以有多个测试么?
  • 业务规模 – 代码规模 – TDD 规模越来越大,如何维护?
  • TDD 的测试覆盖率能表示什么?
  • TDD 是测试方法么,是 Unit Test 么?
  • 使用 TDD 对开发时间的影响
  • 敏捷与 TDD, 不是敏捷项目可以 TDD 么?
  • 其他的 TDD 经验 / 疑问
  • 理想与现实

技术图片

我心中的 TDD

TDD 的英文和中文意思都很好:测试驱动开发,Test-Driven Development。从小的教育就是一句话中的动词是很重要的,对于 TDD 我也是认为最重要的是“驱动”。而驱动的是什么呢?是“开发”,通过什么驱动呢?通过“测试”,于是我心中的优先级是:

  1. 驱动
  2. 开发
  3. 测试

其实除了 TDD 以外还有很多 DD,比如 BDD – 行为驱动开发(Behavior Driven Development),再比如 ATDD – 验收测试驱动开发(Acceptance Test Driven Development),这些方法论都是从不同方面给开发提供驱动力,。

驱动开发实际上是为了给开发以一定辅助手段,让开发的正确率、效率、业务表述贴合度提高,无论是测试、行为、验收测试都只是为开发提供的一种工具,一种方法论,一种外界的驱动力

当然还有一种驱动力是自驱力,我相信自驱力,但我也不信自驱力,尤其是在复杂的业务关系、业务逻辑、软件架构、代码设计、技术栈以及组织架构混合下,单纯以自驱力就能够对开发驾驭自如,我们需要优先把这种混合的大泥球拆解开。

再会到 TDD,我想它主要的目的是让开发人员尽可能在 coding 时,准确来说是在开发业务代码时,可以心无旁骛,沉浸于技术实现。那么想要以 TDD 的方式进入“开发阶段”:

  • 一定要现有一系列的驱动力“测试的红、绿”
  • 而想要有这一些列的测试就需要先对业务有足够全面的理解,并写出当前 story 的 tasking。

到此为止,按照上面所说的两个大步,我在使用 TDD 的时候,无论是明确的感知到每一步的进行还是潜意识帮我做了很多,实际上在做 TDD 时,我都会间接地完成下面四步,

  • 理解业务
  • 细分业务
  • 编写符合业务流程的测试
  • 完成测试(开发业务代码)

回想自己写个人项目的经历,埋头苦干,边写、边思考业务,脑子里在并行性工作,产品愿景分析,开发目标是什么,业务分析,功能到底要怎么样,UI 怎么做更好,架构是否够优秀,是否现在要重构,增加这个还是下一个业务时重写一下之前可能有 bug 的功能,这端代码写完了是否正确,好像这一段写出来的好熟悉是不是重复了,我记着曾经看过某人写的博客这里可以引入和框架一行搞定,是不是这个功能和某产品类似了看起来是不是用处不大……稀里糊涂写完了代码,然后启动程序试一试——

怎么就不对了,我感觉写的对着呢!
稍等我找找原因,没看出来什么地方有问题呀 20min ……
这么多代码,在等我断点调试一下。
你看这一步数据是对的,下一步就错了,所以进入这个 method 看看
你看这一步数据是对的,下一步就错了,所以进入这个 method 看看(心理在喷,这不是我写的呀,或者是我很早以前写的了?这代码到底啥意思)
你看这一步数据是对的,下一步就错了,所以进入这个 method 看看
哦对,身份证号不能为空,一个人怎么能没有身份证号呢,怎么这里返回的对象里是 null,我再看看它从哪来的
……
我在这忘记判断身份证号了,这里忘了,抱歉抱歉,真不好意思,现在就改:身份证号为 null 就是脏数据扔掉

重新编译,再启动,这个问题解决了,来咱们继续验收
刚才看过的就算了,咱们看看没看的功能
不对不对,这里刚才不是对着呢?还好又看了一眼
哦对了,我想起来当时写代码为什么身份证没有判断 null 了,因为身份证号可以为 null,有可能是护照等其他证件号
……

其实无论是 TDD 或者其他的任何驱动方式,首要目的都是为了给开发提供一个工具 / 方法,使其能够在开发时只需要沉浸于技术,而不是业务流程+业务边界+代码实现+设计……人并非精密的机器,百密终有一疏。

除此以外,TDD 在执行过程中还是期待能够进行合理的 Tasking 拆分,以供持续集成,更进一步的是持续部署(CI / CD)。

如何做 Tasking

为什么要先写如何做 Tasking,因为做 Tasking 是应该在真正做某个测试、某个实现、某个重构之前的。换句话说,Tasking 是最优先的,应该相将 Tasking 完成,再根据 Tasking 的输出去做写进行红-绿-重构的步骤,一定不是想到一个 task -> 写一个测试 ->实现 -> 重构。

看到很多博客教程的大纲是这样的(我不认为这是合理的):

  • 介绍 TDD
  • 介绍教程的 Story
  • 第一个测试
  • 第一个实现
  • 重构
  • 第二个测试
  • 第二个实现
  • 重构

上面这种大纲我并不认可,这样去写可能在这个简单的 demo 上看不出来什么,但是这样将会导致两个问题:

  • 仍然没有将业务理解和开发分离,仅仅只是先写了个测试
  • 如果开发复杂一些(相对比较大的 Story),写到后面就会对之前的测试、实现、最初对业务的理解有所遗忘,很可能导致整个 Story 的每一部分实现是基于随时间变化的不同的业务理解。

我认为最合适的 TDD Tasking 应该是如下的过程:

  • 了解 Story,(如果是敏捷可以在开卡过程,甚至在开卡并了解了当前代码后,还可以继续再详细的根据当前业务实现现状询问 BA)
  • 根据 Story,识别整体价值,从价值角度初步拆分成交付步骤(这里可能还不是 Task,只是 Story 的几大步,这里是为了应对持续部署、持续交付,可以随时交付价值,而不是只有完整的 Story 完成才可交付价值,同时也是为了下面 Task 拆分时有足够的依据)
  • 根据每个交付步骤完成主业务流程的 Task,写到一个文档里(一定要整理下来,只有整理并且有产出才好让自己进行业务与实现的分离,当然如果这个过程已经熟练掌握那可以不进行持久化,不进行持久化的前提是确保自己的记忆力可以保证对业务理解在整个 Story 过程不会朦胧、突变)
  • 找到各种边界,将边界写成 Task,边界可以特例化的写,不需要遍历相同边界的所有可能,是为了驱动开发,而不是自动化校验所有可能。

总结一下,我认为从需求到实现的过程应该是:自顶向下,逐层剖析,纵向拆分

自顶向下指:从需求出发,以树的形式不断地分析出各个分支,最后到达实现代码。

逐层剖析:分析的过程应该是有层次的,而不是抓住一点一口气钻到底(需求-一个 task-一个test-红-绿-再研究出一个 task……),应该脚踏实地一步一个脚印的分析,通过逐层分析让我们解耦需求、设计、实现。

纵向拆分:就像上面的过程所说的从价值角度拆分,纵向就是

举个例子 – Tasking 纵向拆分

下面每个标题是按照流程写的,当然这个流程可能更像是个人开发者在和非技术人员沟通的过程,通过技术实现他人的 idea,如果项目明确的 BA or 业务负责人,可能技术拿到的第一份需求已经包含了一系列的 AC。

开发,没有正确的,只有最适合的,下面的也只是个范例,我只是期待能够通过这个详细的过程描述,来让更多的开发能够在实践 TDD 时合理的拆分 Task,并提高持续部署、持续交付的意识,当然这个思考方式不止局限于 TDD,也不局限于敏捷。

Story 背景

A:您好,我这有个软件需要开发,主要是管理停车场的……我们商场的停车场比较麻烦,首先我们地下一层有 100 个停车位,地下二层也有 90 个停车位,除此以外我们楼顶还有一个停车区域有 50 个停车位,我们希望能够更好地管理这些位置,现在我们都是人工管理,希望能够数字化,不需要很复杂的管理,三个停车区域没有价格差异,只是希望有位置就能让车停入,没有剩余位置就给出提示就可以了。

B:您好,近年来车辆越来越多,我感觉停车场是个很好的商机,我希望能够开发一个停车场管理系统,能够实现存车取车服务,现阶段希望能管理一个停车场就行了,以后可能会不断扩充,甚至可能需要合理的调度。

为什么要说背景?我不认为没有背景的 Story 是可以分析出价值的,甚至于会影响侧重点。这两个背景可以看出 A 用户是已经有了成熟的管理区域,希望数字化转型,B 用户是只有 idea,没有人,甚至可能也没有场地,是个探索性的商业产品开发。

当然为了简化(或者说我想要偷懒),这里只说 B 用户

Story — 粗略版

我希望我的客户能够在停车场存取车,客户存车以后可以得到一个存车卡,可以用卡取车。

Story — 清晰版

这一步可能是 BA 或者是 PM 也可能是自己根据交流得出来并持久化的 AC 内容。

AC 如下

  1. 用户如果来存车,他可以得到一个存车小票。
  2. 用户如果用这张小票来取车,那么他可以取到自己存进去的车。

Story — 扩充

现在让我们从 AC 还有了解到的现实情况来分析,并和通过不断和需求提供方问答,我们可能会得到一些边界信息,来扩充 AC

  1. 用户如果过来存车,但是没有开着车,这样用户也会得到存车小票。
  2. 用户如果用一个无效的小票(用过的,或者自己创造的假的),或者随便拿其他东西来,都无法取到东西。
  3. 用户不会出现把一辆车存两次的情况,现实中会避免这种情况出现,不需要考虑

Task 过程

下面的 Task 可能会同时包含 Task 的实现,因为发现了很多人 Task 写的很好,但实现远远超出了 Task 内容

先来个 Task:
given 一辆车
when 存车
then 得到小票

given 小票
when 取车
then 得到小票对应的车

……

实现的过程(JAVA):

void x1() { // poor naming
  ParkingLot parkingLot = new ParkingLot();
  Vehicle myCar = new vehicle("京AAAA");
  Ticket myTicket = parkingLot.park(myCar);
  assertNotNull(myTicket);
}
void x2() { // poor naming
  ParkingLot parkingLot = new ParkingLot();
  String vehiclePlateNumber = "京AAAA";
  Vehicle myVehicle = new vehicle(vehiclePlateNumber);
  Ticket myTicket = parkingLot.park(myVehicle);

  Vehicle pickedUpVehicle = parkingLot.pickUp(myTicket);
  assertEquals(vehiclePlateNumber, pickedUpVehicle.getVehiclePlateNumber());
}

这里,很多人写的 Task 并不是 “车牌号为 京A 12345” 的车,可能只是写了提供一个车,但是实现的过程用到了车牌号或者其他的号。

这里有两个问题:

首先:用车牌照号是一种对某个 vehicle object 的唯一识别方式,但是这个行为等于是对需求进行了脑补,上面写到的各种 AC 包括详细问答后扩充的 AC 都没有提到“车牌号”这个概念,若这个时候引入了车牌号,尤其是这个 Story 可以看出来比较简单,很可能只是第一个 Story,这样的行为很大概率会导致后续所有 Story 都被注入了全新的概念,最终结果这就是对整个业务注入了全新的概念,如果真的出现这个情况应该考虑先谈更改 AC,否则直接 new 一个无参、无私有成员的对象就足够了,每个对象都是唯一的。

如果使用 new 一个对象的方式,最后断言在 Java 注意使用 assertSame,其他语言可找类似方案,要明确识别是对象完全一样(就是一个东西),还是对象的内容一样。

其次,是 Task 划分的建议:

在全新功能划分时,根据工作量以及业务价值进行划分,当前业务足够简单,那么存取车完全是一个必须组合到一起的功能,那么可以考虑把 Task 这么划分:

given 一辆车
when 存车
Then 得到小票
when 用小票取车
then 取出来这辆车  -- 一辆车存取可以完成

given A B 两辆车
when 存 A 车
then 得到小票
when 用小票取车
then 取出来 A 车
when 存 B 车
then 得到小票
when 用小票取车
then 取出来 B 车   --- 一辆车存取可以重复完成

given A B 两辆车
when 存 A 车
then 得到 A 车小票
when 存 B 车
then 得到 B 小票
when 用 A 小票取车
then 取出来 A 车
when 用 B 小票取车
then 取出来 B 车   --- 多车可以顺序存顺序取

given A B 两辆车
when 存 A 车
then 得到 A 车小票
when 存 B 车
then 得到 B 小票
when 用 B 小票取车
then 取出来 B 车
when 用 A 小票取车
then 取出来 A 车   --- 多车可以顺序存 逆序取(这里不是乱序,若要乱序最少三辆车)

……

这里可以看到第一步的跨度可能有点大,但这样的 Task 更符合价值,最少在这时候我能保证存车、取车,虽然只是一辆车,但这时候已经可以做一定的 showcase。

当然我这里并没有严格的遵守 given-when-then 的流程,可能在一个测试里有很多的行为(这可能会被喷吧),上面的这些流程是可以只在最后断言的,能保证的就是断言唯一,让整体读起来的感觉还是一个完整的意义。我认为这样是可以的,因为可以在阅读 task 的同时理解到各种业务场景。

有人可能说:这样就没有单独测试 存车 接口的测试了,并不是的,具体可以看后面的章节“一个 Tasking 可以有多个 测试么?”

还见到的其他的 Task
  • 小票给脑补出了 number,或者 实现上用到了 number,这时候更神奇的是要考虑 number 怎么生成?甚至要引入 UUID?如果非敏捷项目确实有明确的设计可以这么做,减少未来的大改动,但这个只是简单地 demo,明确了没有复杂内容
  • 实现上,把车存到了小票里,ParkingLot 仅仅只是从小票里拿出来了车,这个实现最后功能完全正确,但是对于后续项目转手给他人后会很难让人理解
  • 为了测试而实现,给 ParkingLot 提供了一个当前存车数量的 public method,提前公开了停车场这个属性,实际上并没有这个诉求,但是公开的 API 就会被四处使用,这就需要对这个接口进行维护。比如未来如果需要预留空位呢?那么这个时候返回的停车场存车数量应该包含预留么?难道要分成 3 个 API 分别针对历史的调用者(无预留空位时期的)、对于新的非预留车辆的、对于新的预留车辆的。(当然可以通过三个枚举、一个 API 维护这个内容,但无论怎样都会因为从未有过的诉求导致维护成本)
  • 如果存车时没有给车,那么能拿到存车票,这里要如何实现呢?返回 null?返回一个空的包装对象?当前的 Story 并没有看出来什么,怎么写都可以

最后,很抱歉,这个 demo 可能并不适合讲解在价值上的纵向拆分,因为这个 Task 无论怎么变动,开发时间都很短,可能无论哪种方案,最后完成的总时间都是几分钟,无法感受到差异,只能期望读者能够体会到我所描述的本意了(唉,也可以说我懒吧)。如果有合适的 demo 欢迎留言。

如何写的 Test

无论是单元测试的 3A 模板:

  1. 组织数据(Arrange)
  2. 执行需要被测的函数(Action)
  3. 验证结果(Assertion)

还是上面写的 Tasking 的 Given-when-then,我们最后都是要在“一个可控的环境下期待某种行为会产生理想的结果”

这里面很重要的两个问题就是:

  • 一个测试如何构建“一个可控的环境”?
  • 什么是“产生理想的结果”

下面我以多个小节分别分析两个内容,当然本文重点在个人的理解,讲述的流程、方法而不是具体的代码

注意,这章讲的是如何写 Test,不是如何写 TDD 的测试。这是我从 TDD 实践过程以及一定的测试经历总结的,并非专业测试人员的角度,可能并不正确。

一个可控的环境 – 测试工具的 BeforeAll / BeforeEach

无论什么测试工具,都会有对整个 Class 测试前的前置处理方法,以及对 Class 每个 method 运行前会执行的方法,这两个工具并不建议使用。

也有一些语言或者框架并没有 class 的概念,但是会有整个文件的概念,在文件里任何一个测试用例之前执行的方法,也算是 BeforeAll,下文都以 class 描述。

可以使用 BeforeAll 对整个 class 需要 mock 的对象进行创建(注意只是对象创建,不是对象里的数据创建),可以指定整个测试类运行时是用什么对象进行执行。如果这样进行,也请务必保证,整个测试类里的每个测试用例都是要且都是会使用这个或者这些 mock 的,或者说这里面的所有测试用例一定都需要 mock 的,千万不要把 mock 的测试用例和正常运行的测试用例放在一个 class 里面。只要保证了上面说的,那么我们可以认为 BeforeAll 仅仅只是构建了一个 mock 的环境,并不会在 BeforeAll 中定义具体 mock 成了什么,调用 mock 的方法会返回什么等内容。

对于 BeforeEach 完全不建议使用,如果 BeforeAll 已经对 mock 大环境做出了定义,那么不需要针对每个 method 单独进行定义了,如果需要改变大环境,那么请再建立一个 文件 / class

除此以外,如果是面向对象的语言,有可能会出现测试 class 继承自某个自定义的 class,那么请让父类保持 BeforeAll 的要求,只对整个测试类的大环境做处理,比如每次启动前启动一个虚拟的数据库环境,每次整个 class / method 运行后清理数据库,不要进行具体的数据操作。

为什么上面各种不建议呢?

通过上面的描述可以看到,一系列的禁止后,我们可以保证一个 class 的 每个 method 的数据不会受到 BeforeAll、BeforeEach,我们可以认为我们看到的 class name 已经能够给我们足够的类信息,我们所写的 method 就是在这个信息下的,不会有意想不到的内容。

那么如果整个测试每个 method 都需要在开始之前创建一个固定的数据呢?比如都要创建一个停车场,有 10 个停车位,其中位置 5 个存了车,这时候请建立一个 privat method,然后在每个测试用例第一行先调用这个私有方法。

这么做有什么好处呢?

请回想一下,现在有一个功能改动,你要在现有的功能下进行修改要如何操作?首先有了 story、无论是否做 tasking 吧,找到对应的实现代码,找到实现代码需要的测试类,创建一个 method(也有可能是复制上面的 method),然后写测试,然后运行一下,实现一下,测试过了,ok 了。(不要说拿到一个要修改的功能就直接改了,从不写测试)

再回想一下,如果让阅读一个遗留代码,看了 story 不太懂,发现竟然有测试(好悲伤,我这里用到了竟然),然后是怎么读测试的?是不是根据方法命名,快速找到自己需要的功能,然后阅读

上面的描述适合于多少人呢?

上面两个过程都是:我们很少、极少会去有意识的、主动的、优先的看 BeforeAll 和 BeforeEach 方法。既然如此为什么要写这两个方法?如果在一个测试用例里明确的调用某个 method,我们可以很清楚的去查阅,如果用这一类便捷的方法那就是理想与现实的对立……

为什么用”有意识的、主动的、优先的”三个词呢?意识决定了如果出现错误我们是否会考虑到是因为这两类方法的锅、主动决定了是否找到并考虑到此问题避免测试撰写错误、优先是不要等着测试挂了或者写完了测试才想到这个问题。

其实 BeforeAll 和 BeforeEach 还有其他的可能问题,比如

  • 数据库我存进去两个数据,我的测试用例需要获取他们两个并经过一系列过滤后还能留下他们两个,然后我的测试过了,线上挂了,是什么原因呢?
  • 比如我的另一个测试需要 mock 一个方法传入“1”返回 null,但是 BeforeEach 里同样作了 mock 传入任何值都返回 null,然后我的执行行为写错了传入的是“2”,我成功得到了 null
  • ……

最最最重要的:每一个测试用例应具有完整的语义。

每一个测试用例单独抽离出来也应该是可用的。对于测试的 class 其实是一组测试用例的聚合,它可以归并一些用例的共同性,但往往我们归并的只是“是否属于这个类的测试”,那么这种归并就不应该提取出会影响到我们执行行为的数据内容。有共同需求的测试用例第一行写一行调用就好,对自己、对他人、对未来的自己、对未来的他人都是友好的。

一个可控的环境 – 涉及到数据库如何构建数据

请尽量严格遵守前面所说的根据价值纵向拆分: 数据库的数据不会凭空而来,所以我们的 tasking 一定是有数据输入的。

如果是集成测试。比如说个“查看用户订单详情”Story 吧,如果可能了话,我们尽可能的去“规矩的创建订单”,而不是通过 Repository 直接插入数据

集成测试最复杂的就是启动各个环境,我们费劲的启动了内存数据库,然后我们调过了所有创建订单的过程,直接插入了个订单,然后进行查看订单的行为及结果断言。这有用么?有用!这断言证明了“在我这种插入数据的方式下插入的数据可以被查看订单接口查阅”。

为什么说纵向拆分呢,这里如果不纵向拆分,那么很可能 get 的 task 在 save 之前,那就完全无法合理的测试了,如果是纵向切分呢?save 一定是在之前的,或者 save 和 get 一起在第一个 task 里,这时候我们就有 save API,那么请调用 save 构造数据,这样可以保证无论 save 的行为如何改变,我们这个测试都是有价值的,我们测得不是能够查看数据库里固定样子(脑补、理想)的订单数据能够查看,我们测得是用户创建的订单能否查看

这个是看到了很多很多人写的 API 测试,每次运行都要很久很久启动环境,然后 数据库插入、API 读取。。。。那为什么不直接 mock Repository 直接返回我们存进去的内容呢?和 mock 有什么区别,难道主流的框架自己没有测试而且基础的数据库读写会错?再进一步何必集成测试?无论是 JAVA 的 Spring 还是其他语言的其他框架,我想只要成为了主流,那么规矩的使用就不需要测试框架是否能调用了,框架不会把规矩的使用者路由错误的,如果真的怕有问题,那么可以专门写一个框架 API 调用行为的正确性

我可以的接受 调用 HTTP save API 存数据,数据库查询:

  1. 有些业务需要只需要存储不需要查阅
  2. 存储可以做技术上的测试,确定存储行为是正确的
  3. 还有一种可能是:业务后来才需要查阅功能,但旧的测试仍然符合业务,计划删除但暂时未删,且新的测试完整测了存储与读取。

对于第三点,写的很严谨,新的测试用例是完整的,且与旧的是包含关系,且是暂时未删除计划会删除。

但是删掉一个测试用例真的这么难么?或者说留着旧的只是为了找个人一起背锅?难道版本管理系统的历史并不靠谱?

我这里强调的是意识,一定要有对重复测试进行处理的意识,而不是无所谓的态度。

如果要把这种类型的测试转成第二条说的技术上的测试,那么请把 HTTP API 调用这一步改成直接调用对应的服务 method,并 mock Repository 判断要存储的内容是否正确即可,这样这个测试的开销会很小也完成了测试需要,以后要修改也会相对容易处理

当然真的要这么复杂的修改么?如果是 TDD 的测试,TDD 是为了驱动开发,当然可以不改,又没有冲突,如果没有时间,只要开发好就行。

一个可控的环境 – 涉及到第三方服务如何构建数据

mock,但是 mock 的返回内容请写到自己的测试用例中,不要写到 BeforeAll 和 BeforeEach 中,每个用例期待的第三方返回内容可能不同。

然后?然后!我们 mock 了第三方调用,那么怎么保证这在链路中的数据真的是想要的格式呢?契约测试,可以使用相应的库、工具进行测试构建,对于微服务之间的契约测试我测试是考虑,具体开发实践过程就不细谈了。

产生理想的结果 – 正确断言

我们到底期待的是什么?那么请断言什么,千万避免下面这种断言方式:

  • 扩大断言范围:若停车场以车牌号作为唯一依据,判断取出来的车就是我存进去的车,那么请直接用车牌号判断,千万不要判断:assertEquals(pickedUpVehicle, myVehicle)。如果做完整的判断那么对于功能改动就需需改所有这样判断的测试,实际上大家的目的都不同,总不能说给一个 class 增加一个字段然后就要改几十个测试吧?
  • 断言混乱:并不是说一个测试只能有一个断言,可以一系列断言,但这些断言应该具有相同的目的,所以一般来说这些断言应该是连续的针对一个对象内容的断言。
  • 断言漏洞:如果返回值是个 list,里面应该有 A 和 B,且 AB 无顺序要求,那么如何断言?直接判断包含关系并不正确,还需要再补充一个数量的断言,只有数量确定,且分别为 A 和 B 才能证明是正确的,否则返回了 2 个 A ,1 个 B 也会通过。
  • ……

不要过度的判断非测试内容的信息,因为测试最重要的是通过,无论是测试失败,还是测试运行失败都不是通过。如果中间内容很重要需要判断,那么可以做其他的测试,不要柔和到一起。

一个 class 可以有多个测试文件么?

这不需要长篇大论了:可以。

一个 class 最少可以分成:需要 mock 进行的测试,可以直接调用进行的测试。测试不一定只有 test 这个目录,可以根据测试的类型进行分类

一个 class 维护了很重要的一个数据内容,他的增删改查有独有的逻辑且很重要,需要完善各种边界的测试,每一个 method 可能都有几十个测试,那么可以分别放在不同的文件,一遍快速查找理解。这时候放在一个文件里可能难以通过 method name 快速理解,需要更多地 name 辅助理解。(一般来说边界的出现一定有对应的逻辑,如果真的有这么多的复杂边界,那么这个类的逻辑是有多复杂?是否能够拆分成其他类?比如 XxxxSaveAssistant、XxxxDeleteAssistant)

一个 Tasking 可以有多个测试么?

根据 Task 划分的粒度,为什么不能?如果按照我上面的停车场例子,given-when-then-when-then-when-then 这么多如果为了放心,完全可以写个单独的存车是否正确的技术验证。

再比如停车场再复杂一些:

我们有个停车场管理者,他管理了多个停车场,存取车只需要找管理者,他会帮忙放好,车主不需要知道车放到了哪里,给管理者就行。

这时候我们对业务的测试可能和前面的一个停车场的完全一样,那怎么验证到底是否存到了停车场还是存到了管理者自己身上?具体存到了哪个停车场?这时候可以进行技术实现的测试,mock 多个停车场对象,判断存车时是否调用了期待的那个停车场就行了。

这样我们对于这个新的停车场管理者的测试就会有业务流程测试、技术实现测试等等多个。当然也可能有的为了降低复杂性进行将复杂逻辑沉入到一系列单元测试,在集成测试测常规主流程。

对于 TDD 来说为了驱动开发,一切都很灵活。

业务规模 – 代码规模 – TDD 规模越来越大,如何维护?

见“TDD 是测试方法么,是 Unit Test 么??”

TDD 的测试测试覆盖率能表示什么?

见“TDD 是测试方法么,是 Unit Test 么?”

TDD 是测试方法么,是 Unit Test 么?

这里先共同回答三个问题:

  • 业务规模 – 代码规模 – TDD 规模越来越大,如何维护?
  • TDD 的测试覆盖率能表示什么?
  • TDD 是测试方法么,是 Unit Test 么?

TDD 的测试不会出现“ 规模越来越大”这种问题的,感觉太多了删了就好,他们是为了驱动你的开发么?没有,可能只有你现在写的测试时为了驱动你的开发,为什么要为其他人的驱动力发愁?我们可能真正需要考虑的是“测试的规模越来越大”。

同样的对于 “TDD 的测试测试覆盖率” 我感觉可能接近于 0 吧!

为什么要这么说?因为我不认为 TDD 是测试!TDD 是一种方法论,它以让开发人员先根据业务写出一定的测试再利用这些测试对开发过程进行保护的方法、开发流程。但这些测试的撰写的目的都是为了开发,只是个开发工具,并不是测试。

注意,我前面几章的内容,他们都没有针对 TDD 去写:

  • 如何写 Test
  • 一个 class 可以有多个测试文件么?
  • 一个 Tasking 可以有多个测试么?

这样一看 TDD 的产出物包含三种:Tasking 持久化后的文档、一系列的测试、开发出的内容。这些的直接价值只有“开发出的内容”。

对于前两者,可以转化成测试,但它们在“转化”之前并不是测试。我为什么要提转化这个词,因为我们在交付之前应该对这些测试进行“专业化”,可以找 QA 聊聊,或者自己抛弃开发的想法,从测试角度来考虑,认真的看待这一系列的测试,他们是否要修改,这时候请转变身份,请认真的审视这些测试代码,不要逃避瑕疵,断言不合理?测试流程不正确?业务覆盖不全面?请用心修改,让他们成为真正的测试。

在 TDD 开发过程写的测试是为了开发,属于 TDD 的中间产出物(可以删掉的),审查、转化后才是真正的测试。

这样就解释了另外两个问题,若有需要请参考测试的管理、测试覆盖率的价值。

对于测试覆盖率我想说。。。这个名字应该严谨的说“测试代码的代码覆盖率”,他只能表示代码,若代码都没有覆盖那一定是没有涉及到的,若代码覆盖了并不能保证一定覆盖了这个逻辑。所以测试覆盖率是用来挑错的,不能用它证明任何正确性

使用 TDD 对开发时间的影响

根据我观察到的情况,TDD 对于使用者来说可能要分为几个阶段:迷茫、了解、掌握……再往后我还差得远

迷茫:可能在前期使用时会很迷茫,但是往往更多的是 follew,更多地是学习一套测试框架怎么用,如果项目有遗留代码,那么直接 copy 改动改动可能就完成了“使用”,根据业务复杂性会有一定的时间,但是并不多(除非项目对于 TDD 要求很严格,迫使此类人员不得不认真撰写才能过验收),否则可能更多的人会选择写的差不多就行了。当然,更可怕的是,有些人根本不知道自己写的根本覆盖不全业务,或者干脆没写测试(因为测试覆盖率过了)。这时候可能会对开发时间没有任何影响,也有可能会导致开发时间翻倍,这是很不稳定的时期。

了解:知道如何去做,为什么去做,但是对于测试工具,尤其是 mock 工具的使用可能不太熟练或者不知道如何使用,这需要一个学习的过程,或者因为“不知道”而导致的设计错误(不知道能够 mock,为了测试给实现提供了多个接口),如果业务不太复杂,对于喜欢钻研的人员开发时间可能略微提高,对于无意识的人员开发时间没有影响,但是可能会导致长远的影响或者在 code review 时被要求修改。如果业务很复杂,天呀,可能会一团糟,此时就是 TDD(Test-Destroyed Development),测试写不好、实现不对、理解不断完整,实现改来改去,测试跟着一起变,最后测试也许还被删了。

掌握:可以很好的根据业务要求进行 tasking,并严格的进行测试撰写及开发,对于复杂业务来说总体时间并不会提高很多,因为测试极大的降低了各种容易忽略的细节错误了,并保证长远来说的稳定性;对于很简单的业务可能会有所影响,甚至需要时间翻倍,因为容易很可能就一行代码搞定,那需要些最少一个测试,甚至一堆测试做各种边界校验。

敏捷与 TDD, 不是敏捷项目可以 TDD 么?

当然可以,如果项目整体都没有测试,并且无法推动出测试,那也可以写,写完删了就好都不需要转化成单元测试。。。。这只是一种开发过程,根据个人习惯就好。

其他的 TDD 经验 / 疑问

TDD 的驱动与红-绿-重构

TDD 无论是否合理的进行 tasking,但只要真的想做(自驱力),那怎么也会有测试代码出现,一定会出现红色,为了能提交代码,那一定会出现绿色。

但是重构呢?重构由谁来驱动?

code review 确实会驱动重构,但这并非是 TDD 理想的重构效果,TDD 的红-绿-重构自身就是一个 circle,又有多少人破坏了 最后关键的一步,直接提交了绿色代码?

红到绿是驱动的产物,就像我之前说的,我相信自驱力,但又不信自驱力,在个人项目做 tasking 写测试时完全依靠自驱,在团队项目有团队整体的风格可以参考,或者说有团队软件工程实践的规则限制,一定会写测试。

有自驱的人是主动,无自驱的人是被要求的,总之一定会有测试的出现,红到绿的过程一定会被驱动出来,无论出来的质量如何。但是重构这一步没有驱动!

这就带来了另一个问题:在敏捷的团队中,有多少人在喷着以前的代码设计的多么奇葩……

我现在想到的途径只有在 code review 时的不断赋能,一对一的 feedback,但仍然没有红到绿这一步的强验收方式。

TDD 在修 Bug 时的应用

这里是最可怕的事情了。

有多少人在 TDD 的实践过程中,修 bug 的时候完全没有 TDD,这里可怕之处不是第一个开发者无意识的挖了坑,而是修 bug 时没有补充测试用例就修改,这算有意识的改动了坑,这就成功造就一个埋了雷的坑。

更可怕的是,这个修 bug 的人没有添加新的测试,还在修 bug 以后跑测试发现有挂的,再改了这些挂了的测试……

既然是个 bug,修改之前请先根据当前的操作流程和期待的正确结果写出一个测试,通过测试复现出这个 bug,然后修好它,最后把这个产出物转化单元测试,这个测试很大概率本身就属于以前某个 story 的 task,只不过并未考虑到。为这才是填坑的过程。

理想与现实

上面说的是否太理想态了?现实中可能也就做个 demo、TDD 培训会注意这个,甚至培训都不会注意讲师也不会这么要求,只是单纯的告诉我们开发前先根据 story 写 tasking 进一步写 test 最后写出通过 test 的实现。

理想态与现实差距很大!是不是我们可以顺其自然?我并不这么认为。

虽然环境恶劣,但总要有量变的过程,只有越来越多的人有这个认知,才能有更多的人在开发过程中有这个意识,才能逐渐有人去践行这些方法论,才会有一个个经过方法论产出的产品,才能够产生反馈效应,给更多的人一种此方法已被校验,给更多只是有认知、有意识的人继续走下去、坚持下去的信心和赋能他人的有力支撑。

最后

上述所有内容仅供参考,非标准定义,仅为个人经验、理解,可以参考但请根据实际开发项目情况合理调整。我不认为 TDD 需要有条条框框,本身就是一个为开发提供外部驱动力的一种方法,如何合理的调整方法论的践行方式及流程?

唯一的校验就是驱动的力量是否足够大,可以看产出速度,可以看主业务流程 bug 出现数量及业务边界操作出错的概率,可以看业务变动时连带的改动成本,可以看与 QA 交流及交接过程是否通畅,可以看与 BA 讨论过程是否能够在流程上边界上更容易达到理解一致,可以看项目转手后的开发理解速度……

欢迎评论交流

 

| 版权声明: 本站文章采用 CC 4.0 BY-SA 协议 进行许可,转载请附上原文出处链接和本声明。
| 本文链接: Cologic Blog - 谈谈个人对 TDD (测试驱动开发) 的理解 - https://www.coologic.cn/2020/01/1738/

以上是关于谈谈个人对 TDD (测试驱动开发) 的理解的主要内容,如果未能解决你的问题,请参考以下文章

浅谈测试驱动开发(TDD)

Go语言:利用 TDD 测试驱动开发帮助理解数组与动态数组(切片)的区别

python+selenium自动化软件测试(第10章):测试驱动TDD

使用带有 Rails 脚手架的测试驱动开发 (TDD)

Go语言:利用 TDD 驱动开发测试 学习结构体方法和接口

测试驱动开发-TDD