单元测试 vs. 功能测试

最近在某一次讨论测试的话题中,有一位同事认为“重构结果看护,用集成测试(Integration Test)就可以了,UT看护的逻辑太小,导致修改过于频繁,维护成本太高,没必要。”

几种测试名词:

  • 单元测试(Unit Test或UT)
    针对小的代码逻辑编写的测试,不需要运行在真实环境上的,可以随时部署,随时运行,结果应当保持一致
  • 功能测试(Function Test)
    针对特性功能编写的测试,应当与实际运行环境保持一致,测试软件产品端到端功能
  • 集成测试(Integration Test)
    针对系统或子系统中某个组件的特性功能编写的测试,对其依赖组件进行打桩,并在真实环境进行部署测试,通常用于看护代码提交质量
  • 冒烟测试(Smoke Test)
    我们通常说的ST指的是冒烟测试,而非系统测试(System Test),冒烟测试与集成测试指代同一种测试

另外,IT或ST都是一种功能测试,除此之外,还有看护完整特性功能的测试,性能测试等,都属于功能测试

这位同学给出IT可以胜任看护重构结果的原因有:

  • IT通过对周边依赖进行打桩,可以达到80%的覆盖率,看护力度足够
  • 只要设计足够精巧,完成一个大型工程的IT只需要十分钟,运行速度足够快

因为在重构中,IT完全可以看护代码质量1⃣️,所以UT的作用就削弱了2⃣️。又因为UT看护粒度过细,导致UT代码维护困难,维护UT得不偿失3⃣️。而实际上,那个榜样部门就是这样做的——只用IT看护重构,库上代码放弃开发维护UT。

先不遑论该部门做法的对与错,我们首先针对上面提到的三个观点进行剖析。

IT完全可以看护代码质量

这一点我是认同的。UT的关注点是组成接口代码的小逻辑。虽然理论上只要保证每个小逻辑的输入输出正确,就可以保证一个接口的正确性,进而到模块,组件,系统级别。但是,因为UT对模块的划分比较细,模块之间采用打桩的方式解决依赖问题,而mock质量参差不齐,势必会造成对接口间,模块间的测试误差。这也是不可测性的一种体现。相反,因为IT的测试关注点,在特性功能(至少是组件级的功能),所以IT更擅长于发现组件集成时的bug(所以叫集成测试嘛)。

UT的作用被削弱了

刚才说到IT相比UT更擅长看护代码质量,那是不是意味着UT没用了呢?
答案是否定的。我们先看看什么是IT不擅长的:

  1. 由于IT关注的代码粒度较大,看护的是模块或组件间的特性接口,不利于构造细粒度的case,这造成分支覆盖率低。或者说如果要实现超高分支覆盖率,IT付出的成本会比较高。其结果就是测试稳定性高了(随代码改动而失败的频率降低,只要组件间接口保持一致,IT就不需要变更),但对代码修改的敏感度降低。
  2. IT的开发成本高。因为涉及到多模块甚至多组件协同,case复杂度高。如果涉及模块间接口打桩,由于接口复杂度高,桩代码实现也更为复杂。在特性代码开发过程中,无法进行有效的IT开发和测试。
  3. IT运行时间久。前面说到一个比较大的工程运行一次IT,运行时间可以控制到十分钟。可能很多人都认为十分钟是能够忍受的长度。对于IT来说,这确实是一个很优秀的数字了(我想为了实现这一点本身也是要付出很大的代价的)。但是如果将其运用到red-green-refactor的开发节奏中,还是有些不合时宜。试想,你要运行一个预计十分钟会完成的测试,你会全身灌注地盯着屏幕,等待十分钟直到测试完成吗?

我想通常是不会的。这时候,自制力差一点的同学就开始刷手机了,自制力好一点的同学会着手处理其他问题。这其实就是另一种被打断。而被打断是软件开发的大忌。你不仅失去进入“心流”的机会,并且你回到主线开发的时间间隔越长,花费在重建现场的额外开销就会越大,进而影响你的效率。

先看看我们利用IT进行一次前文提到的大型“重构”的流程会是怎么样的?

这里说的重构,我认为称为re-architecture更合适,和我们平时说的refactor还是不太一样。他们的共同点是,保证代码功能不变的前提下,优化代码架构和实现。区别是,re-architecture是一次根本性的架构变更,可能涉及到很多模块需要重写,而refactor更多的是在平时做一些代码调整,即所谓的小步重构。

  1. 定好特性架构,开始特性开发
  2. 特性代码接近完成时,开始开发IT
  3. 开发IT的同时,一边调试IT,一边使用IT测试特性代码
  4. 重复步骤3直到IT代码和特性代码均没有问题,即达到可交付状态。
  • 前文提到IT的测试时间相比UT还是比较久的,如果将其运用到red-green-refactor节奏中,则容易造成注意力分散,进而引起时间管理困境
  • 如果没有UT,步骤2通常会一直处于“裸奔”状态,“裸奔”的时间取决于开发者对特性代码开发状态的评估,一般要到特性代码比较接近完工水平时,才可以编写IT。由于IT开发调试难度高,基本也不太可能和特性代码一同开发。
  • 由于IT开发和运行的成本高,那么势必造成开发时,针对特性代码的测试运过少,那么由于开发的不稳定性,产生的返工可能性大,成本高。(换句话说,如果开发对于特性的熟悉程度非常高,实际上也可以不一定即时UT,还是选择与平衡问题。不过这种情况据我观察还是比较少的)

所以抛弃UT,确实节省了一些代码开发时间,但也丢失了一件非常重要的武器,导致我们的开发节奏又回到了瀑布式开发,而无法实现小步快跑的即时重构开发方式。

实际上我认为,引入UT就是为了引入reg-green-refactor的开发节奏,从而通过实时重构,及时消除代码坏味道,进而实现代码自下而上的架构设计。与前期自上而下的特性设计配合,以实现最合理的代码架构。避免在特性设计阶段过于关注实现细节,也避免在开发阶段,过于纠结权衡欠设计与过设计导致的效率低下。

UT的运行成本非常低,这是因为UT不关注代码功能,只关注代码输入输出逻辑。全程对依赖接口进行打桩实现,运行速度很快(没有任何的延时和多线程操作,也可能连IO操作都没有)。UT针对小逻辑组织case,单case复杂度低,case之间的依赖关系被严格控制。所以运行的时候,一方面速度快,另一方面可以根据需要,随意组合运行的case以达到需要的测试范围和粒度。例如:可以选择运行一个case或是一个suite,也可以通过正则表达式运行多个case,或者完整运行所有的case。

综上可见,UT和IT是两类用途完全不同的测试方法。一个好比军刀,可以上战场杀敌;一个好比菜刀,可以烹小鲜慰军。一个看护结果;一个促进过程。

UT维护困难,维护UT得不偿失

那UT带来的工作量呢?

刚才说,如果没有UT,其实会引入很多隐性工作量。如果要衡量工作量,更公平的比较应当是将维护UT的工作量与这些隐性工作量进行比较。通常我听到的对UT的抱怨大多是因为UT看护内部模块边界。当组件内部实现修改时,例如函数改名字,变更函数原型等一些特别频繁的重构操作,由于IT看护组件边界接口,IT更稳定不需要变动。而UT由于看护了内部模块边界,所以UT要随之更新。这些确实看起来是额外的工作量,但当前流行的开发工具,例如一些IDE或者VS code,都已经能够提供很好的重构工具,在修改函数名字,修改函数原型,或者抽取函数或内联函数等操作上,都已经可以大大减少开发者的操作难度。另外,由于UT对代码的敏感性,让你实时都感受到你的代码变动都是经过测试的,每一次变动都被看护,这种感觉不是很令人有安全感吗😊?

而传统的瀑布式开发带来的隐性问题,很多是因为人类思考方式导致的。例如思路打断,对过设计和欠设计的恐惧,不停返工引起的沮丧心情。这些带来的影响很难去估量,他们可能很严重,也可能无足轻重,完全取决于开发者自身的素质。以我个人的经验看,我是更愿意花一些切实的UT成本,来消除这些不确定性。

总结

再回过头来分析一下那个成功部门的做法。通过IT看护重构结果,并丢弃UT这种测试方法。首先,重构结果大概率不会有问题,因为有足够测试力度的IT作为看护工具。其次,因为缺乏及时运行的测试case,重构开发时效率得不到保证。大概率只能通过模块重写实现。另外,又因为重构效率低,成本高,重构只能积攒起来一次性完成。于是refactor变成re-architecture。

不过话说回来,测试策略并没有对错之分,永远是项目组根据自身情况,权衡得出的。可能项目组一时无法获得UT的价值,或者项目组对UT对技术积累不充分,从而无法很好对实践UT。但我们还是应该厘清单元测试和功能测试的差别,分清应用场合,这样才能更好地朝正确的方向演进。