头图

我在成都教人用Flutter写TDD(上)——为啥要搞TDD?

哈喽,我是老刘

写这篇文章的时候刚回到北京,之前的一周去成都帮助一家公司完成基于Flutter的TDD流程的搭建。
这个工作一半是敏捷教练,一半是Flutter相关的技术顾问。
起因是这个客户接了一份欧洲那边的开发项目,但是欧洲客户对项目流程的要求比较高,要求开发团队采用TDD流程。
这是老刘第二次碰到要求开发采用TDD的情况,而且都是欧美客户。
为啥欧美软件开发团队对TDD这样的敏捷开发趋之若鹜,而大多数国内团队却鲜少能真正搞起来敏捷开发?
是我们比欧美开发者更务实还是敏捷开发确有其独到之处?
老刘希望用这篇文章尝试解答一下。
image.png
我记得刚去成都的时候大家一起吃饭,讨论起啥样的项目适合TDD,TDD会不会浪费额外的时间这些话题。
当时吃饭只是简单的聊了聊,但是让我意识到一个问题,TDD或者说敏捷开发在国内之所以一直没有真正成为主流更多的可能是意识问题。
看不到明确的收益,却能看到明确的额外工作量,怪不得众多管理者不愿意尝试。
这篇文章会回顾一下我的敏捷开发相关的经历,并借此尝试回答TDD究竟能给我们的开发带来什么收益。

初试敏捷:网络安全领域的尝试

老刘我带着团队使用Flutter进行App开发已经6年多了,而搞TDD的时间更长一些。
大约10多年前我还没有做客户端开发的时候,那时候我是做网络安全方面开发的,使用的是C语言。
那时候公司希望在敏捷开发方面做一些尝试,因为我在研究生期间做过一些敏捷方面的工作,所以就承担起来这个任务的研发部分。
当时公司希望尝试的是Scrum的完整流程,但是因为涉及到的团队和部门有点多,而很多团队对这个流程是持保留意见的,所以最终就没有搞成。
image.png
但是研发内部的单元测试和TDD流程因为只涉及开发团队,最终得以保留,这成为了我们探索敏捷开发的重要起点。

TDD的实践与收获

虽然最终Scrum没有成功搞起来,谈不上升职加薪 ┭┮﹏┭┮
不过在TDD的探索方面确实有很多的收获:

1、实战经验的积累

这是我第一次在百万级代码量以及几十人的项目中实施TDD流程。
相当于把以前只能算是纸上谈兵的认知变成了真正的实战经验。

2、踩坑与成长

在整个的实践过程中,我们几乎踩遍了所有可能的“坑”。
要知道防火墙项目的规模和复杂度相对于一般客户端或者服务端项目要高很多,特别是还有很多底层数据的操作和对系统内核的修改定制。
例如,我们的代码需要在MIPS架构的CPU上运行,而MIPS与常见的x86架构在字节序上存在差异。这种差异导致我们在PC端进行单元测试时,经常遇到与设备上运行时不同的错误。
image.png
这件事直接导致了后来我们对整个代码进行重构,把我们的业务逻辑和cpu架构解耦。
这是我第一次如此直观且深刻的看到架构到底是什么,也让我在后续的开发过程中开始有意识的注意架构设计的合理性。

3、TDD与架构合理性

在解决TDD实践中遇到的各种问题时,我们逐渐意识到,超过一半的问题实际上源于不合理的架构设计。
以之前提到的CPU字节序问题为例,一个合理的架构设计应该将底层数据操作与上层业务逻辑解耦。
理论上,我们都明白低耦合、高内聚的重要性,但在实际操作中,很难把握到何种程度才算真正实现。
而单元测试在这里就扮演了一个标尺的角色。
它通过测试的难易程度和可行性,帮助我们检验架构设计的合理性。
如果某个部分难以测试或者无法测试,这往往意味着架构存在问题。
通过这种方式,TDD不仅促进了代码质量的提升,也推动了架构设计的不断优化。
对TDD的推行加深了我们对架构合理性的理解,也让我们在后续的项目中能够更加注重架构的合理设计。

4、TDD是思维模式的转变

自己用TDD方式写代码和组织大家一起写又是一种不同的经验。
TDD本质上不是一个编码流程,而是一个设计流程。
在工作中去观察那些真正的资深程序员和新手的差别就会发现,资深程序员是在做脑力劳动,新手是在做体力劳动。
image.png
资深程序员通常是把一个功能点的逻辑链条想清楚才开始动手写,而新手却往往急于动手,发现问题后才不得不对代码做大幅的修改。
所以他们不是在加班写代码就是在加班改bug,然后就把工时卷上去了。
TDD为普通程序员提供了一条追赶资深程序员的捷径。
通过先编写单元测试,再通过测试驱动来编写正式代码,TDD迫使开发者在编码之前深入思考功能的逻辑链条。
image.png
这种拆解测试例的过程实际上是在进行设计工作,它要求开发者在动手之前就想清楚功能点的代码设计。
即使最初的设计或思考存在问题,TDD的重构步骤也会促使开发者及时发现并解决问题,减少定位问题的成本。
而且,所有的重构和修改都是在测试代码的保护下进行的,这样可以确保修改不会对现有代码造成意外的影响。
因此,TDD不仅仅是工作顺序的改变,它是一种完全不同的思维模型。
这种思维模式的转变对于提升整个团队的开发效率和代码质量都具有深远的影响。
(关于这一点我会在后面的经历中更详细的说明。)

TDD在客户端开发中的尝试

后来我开始转做Android客户端方向的开发工作。
我尝试着在客户端开发中使用TDD流程。
刚开始我以为这会是一件非常简单的事情,因为Android开发是基于Java体系的,而Java生态中有大量单元测试框架可供挑选。
万万没想到的是在Android工程中执行单元测试每次都需要等待漫长的编译过程。
基本上可以理解为执行一个单元测试就需要把整个工程编译一遍。
虽然可以通过Robolectric等库脱离对Android环境和SDK的依赖,但实际效果仍然很不理想。
要知道TDD的核心在于小步快跑,如果每个测试例都需要编译几分钟然后运行,就完全背离了TDD的出发点了。
所以这次在客户端开发中对TDD的尝试以失败告终。

Flutter带来全新的可能

大约六年前,我带领着Android开发团队,我们当时面临着一个挑战:如何解决Android和iOS两个客户端在用户体验上的差异问题,同时还需要摆脱原生项目中历史代码的泥潭。
在寻找跨平台开发框架的过程中,Flutter以其技术优势脱颖而出,但其年轻和缺乏有说服力的案例让我犹豫不决。
最终是Flutter对单元测试的良好支持让我最终下定了决心。
在此之前,我已经放弃了在手机端开发中实施TDD的想法,因为原生单元测试的体验实在令人沮丧。
但当我尝试了Flutter的单元测试后,我意识到TDD在移动开发中还能再抢救一下。
image.png
Flutter的单元测试体验可以用“舒适”来形容。
测试的运行速度达到了秒级,并且在测试场景下对UI组件的支持也非常出色。
这使得TDD从看似不可能的任务变成了一个顺畅且自然的过程。
Flutter的设计考虑到了TDD的场景,并且不仅仅是在小规模项目中,即使在大规模工程中也能实施TDD,这为移动应用开发带来了全新的可能性。

但是TDD在Flutter上的实施过程也不是一帆风顺的,主要挑战来源于两个方面:
首先,团队成员对Flutter的掌握程度不足。
由于团队中的小伙伴们都是Flutter的初学者,他们在对Flutter本身还不够熟悉的情况下尝试执行TDD,遇到了不少技术上的挑战。
其次,长期从事客户端开发的资深程序员在切换到新的开发流程时,似乎比新手更加困难。
这些资深程序员由于多年养成的开发习惯,很难立即适应TDD的模式。
他们习惯于传统的开发流程,对于TDD这种先编写测试用例再编写代码的方式感到不适应。
这种习惯的转变需要时间和实践,才能逐渐适应并掌握TDD的精髓。

面对这些挑战,我们采取了多种措施来促进TDD的实施。例如,组织定期的培训和研讨会,帮助团队成员加深对Flutter的理解,并通过实际案例来演示TDD的实践方法。同时,我们也鼓励资深程序员和新手之间的交流和合作,通过分享经验和教训,共同克服实施TDD过程中的困难。

基于Flutter的TDD带来的改变

经过半年多的学习、尝试以及代码架构的调整,我们从项目数据上看到了一些明显的变化。
首先解释一下之所以用了超过半年的时间,主要是因为我们的项目采用Flutter + 原生的混合开发模式。
刚开始的时候主要以原生代码为主,Flutter用于开发少数不重要的页面进行测试。
验证了Flutter的用户体验和稳定性后,Flutter页面的比例才开始逐步上升,然后Flutter才变为日常开发的主导。
随着Flutter变成日常开发的主要选择,可以看到几个明显的变化。

1、开发效率提升60%

这其中一半是Flutter本身优秀跨平台能力带来的。
只要不涉及原生功能,Flutter基本可以完全覆盖所有需要编写的代码。
从性能和用户体验来说Flutter页面也完全能胜任代替原生。
另外一半效率的提升则来源于TDD。
这主要体现在下面的几点。

2、提交测试后bug减少70%

这个数据其实从不同的维度统计会有不同的结果,我个人倾向于减少的bug比例会更高一些。
举个例子,产品需求写的可能是从家里坐车到全聚德烤鸭店。
开发人员要实现的是走3米到家门口——开门——走5米到电梯——按电梯哪个按钮——出电梯左转——走5米后右转出单元门……
这还只是正常情况,没有考虑如果电梯坏了怎么处理,路上有人放了东西怎么绕开。
开发工作的本质其实就是把所有正常的、异常的可能情况都进行处理。
这个过程中主要会出两种问题:

  • 有些场景没有考虑到,比如没想到路上被堆了箱子需要绕路。
  • 处理流程不达预期,比如考虑了绕路,但是绕路的流程不对,走不到单元门。

我们正常的瀑布流程其实就是开发人员先思考一下整个流程和都有哪些可能情况,这一步是设计阶段,更精细一些的可能还会区分概要设计和详细设计。
然后就是用代码实现这个流程,并且补充每一个细节,这一步就是编码阶段了。
开发完成后研发人员会进行简单的测试,比如验证按照自己的代码能不能走到全聚德。
如果能走到就会认为功能正常把软件交给测试人员进行更详细的测试。
测试人员会测试所有能想到的可能场景,发现某些场景走不到就给开发人员提bug。
image.png

按照这个流程写代码,出现bug是再正常不过的事情了。
主要有几个原因:

  1. 现实情况纷繁复杂,总会有一开始没有预料到的情况发生。甚至有些情况测试同学也没有预料到,只有APP上线了用户使用中发现了才会反馈回来。
  2. 即使有很严格的概要设计、详细设计流程,其精细程度也远远做不到真实代码的精细度。而设计过程越粗放,编码过程中遗漏、出错的概率就越高。
  3. 研发自己测试代码功能相对来说覆盖范围比较小,有些功能自己感觉实现的没问题,但是又没有测试到,只能在测试阶段由测试人员发现。

上面几种情况中第一种其实是无法避免的,而后面两种TDD都可以帮助开发人员最大幅度的降低发生的概率。
前面说了TDD是一个设计流程,它本质上代替的是概要设计和详细设计。
image.png
我们通过把一个功能需求拆分成不同的任务,把一个任务拆分成多个很具体的测试例来进行代码功能的拆分设计。
这种设计精细到什么程度呢?
每一行功能逻辑的代码都有对应的测试例,因为每一行功能代码都是测试例驱动下编写的。
而且TDD从流程上要求先写测试代码,这就强制开发者必须先进行设计层面的思考,然后才能开始编码。
这进一步避免了瀑布流程中省略或者敷衍设计流程,直接进行编码的情况。

接下来说开发者自测覆盖范围有限的问题。
基于TDD编写的代码每一行都是先有测试代码的,可以说每一行功能代码都是测试覆盖的。
可以在大概率上保证功能代码的运行结果和我们预期是一致的。
那种我写了很多代码,一运行结果和我想的完全不一样的情况在TDD中很难出现。

所以最终效果就是使用TDD流程后,我们团队提交测试的项目,总的bug数量大幅降低,测试周期和产品发布周期的稳定也得以保证。

3、交付健壮、可修改的代码

不知道各位看到这里的同学有多少是正在996的。
加班这种事在国内的软件开发领域很常见,这里面当然有即使不忙也要把工时耗够这种无效内卷的加班文化的原因。
但是也确实有很大一部分比例是因为项目进度不达预期导致开发人员不得不加班加点赶进度。
为什么项目总是面临延期的风险呢?
排除了那些管理混乱总是临时插入新需求的团队,瀑布流程本身对项目进度掌控力不足才是罪魁祸首。
做过几年开发的同学可能都碰到过这种情况:
项目刚开始的时候时间并不紧张,开发进度推进到中期发现原本以为很简单的一个对现有代码模块的修改远比想象中复杂。
要么是那坨代码和众多地方耦合,牵一发动全身,根本没法简单修改。
要么是那坨代码根本看不懂,写它的大神早已离职或者被当作大动脉裁掉了。
image.png
你薅秃仅剩的头发,熬夜加班把这部分代码改完,然后发现不是功能不好用就是原先工作正常的模块出问题了。
然后原本正常上下班的项目迭代周期就变成了996。
这种情况反复的发生,于是开发者在预估项目时间时就会增加很多的冗余时间来应对。
项目管理者知道你会预留冗余的时间,要么压缩项目进度,要么时不时的插入临时功能。
最终无效内卷的死循环达成了。

那么TDD能解决这个问题吗?
答案是可以,而且TDD能将这种情况发生的概率降到最低。
其实我们看前面说的场景,本质上就是两个问题,对现有代码“看不懂、改不动”。
先说“看不懂”的问题
TDD中业务代码都是由测试代码驱动生产的。
所以测试例和测试代码本身就是对业务代码最好的说明。
而且这种说明是站在业务代码的使用者角度,会向你展示业务代码是如何被使用的以及各种不同情况下预期的结果是什么。
另一方面,要想实现所有业务代码都基于测试代码产生,测试例的顺序必须是层层递进的。
所以基于测试例的前后顺序也很容易把握业务逻辑的脉络。

再来说“改不动”
当我们面对一团乱麻的代码,即使这份代码就是自己写的,想在上面做些修改也绝非易事。
究其根本还是当初写代码的时候只顾着实现功能,没有腾出手来对代码的合理性做一些优化。
这其实和一个程序员的能力水平关系不大。
我们的大脑本质上是单核cpu,无法同时干两件同类的事情。
比如你没办法一边和人说话一边写邮件。
同样的道理,你也没办法一边思考业务逻辑如何实现一边思考代码结构如何优化。
敏捷开发的先行者们对这种情况有很清晰的认知,所以敏捷开发中队这种情况也有不少很好的应对手段。
而TDD就是其中很有效的一种。
TDD的小步快跑最后一步就是重构。
image.png
它不要求你实现业务逻辑时就把代码结构一并调整完善,而是把重构这个动作放在了实现业务逻辑之后。
这样就可以保证每个步骤只专心的完成一件事。
写测试代码的时候就专心思考业务代码应该提供什么样的功能。
写业务代码的时候就专心思考具体的技术细节如何实现。
重构的时候就专心思考代码结构怎么样更合理更健壮。

把重构作为一个独立步骤的第二个好处是会强制你进行重构的思考。
其实大部分程序员都是有意愿去优化代码结构的。
但是如果碰到项目进度紧急或者代码结构看起来很简单清晰等情况,人的潜意识就会让我们跳过重构这个动作。
这是人脑降低功耗的一种本能行为。
TDD的好处是把这个动作流程化、标准化。走到这一步,不管代码看起来是不是很简单,你都会去想一想有没有可以重构的地方。
即使最后真的没啥可以改动的,你也尽最大可能做到了代码优化。
而且从实际经验来看,很多时候即使看起来很简单的代码,当你专门去思考重构的时候还是会有一些值得修改的地方。

TDD给重构带来的第三个好处是你的重构是在测试代码保护下进行的。
实际工作中开发人员有时候排斥重构一个重要的原因是责任问题。
比如原先代码虽然混乱,但是能正常工作,你重构了结果出现bug,在很多团队里你就要背这个锅。
TDD的特点是大部分代码都是在测试例的覆盖下。
你的重构是否会对现有代码逻辑造成影响跑一遍测试就知道了。
所以我们重构的时候也不用担心把原先好用的功能搞坏了。

同时这种所有代码都在测试保护下的特性,也解决了“改不动”的另一个原因:代码副作用。
这里说的代码副作用是指当我们为了开发新功能对原有代码进行修改时,影响原有功能不能正常运转。
这一点有过团队开发经验的同学应该深有体会。
新功能提交测试了,收到一堆bug都是影响了原先正常的功能。
当然这本质上还是架构不合理、代码混乱的原因。
但是在TDD的场景下,你要修改的代码都在测试代码的保护下,即使架构不合理也不用担心把原先的逻辑改坏了。

4、Flutter与原生代码对比

我们团队使用Flutter一年多之后,已经有几十个页面由原生迁移到Flutter上。
因为在Flutter端采用了TDD流程,对比这些页面的原生代码和Flutter代码,可以明显看出两者的不同。
Flutter代码更为整洁、清晰。
在Flutter代码中几乎找不到大段的重复代码,基本都在重构阶段被消灭了。
而Flutter代码中除了UI布局和底层的三方库封装部分,其他代码都对应的测试覆盖。
直接体现在开发过程中就是当我们同时有Flutter和原生的新需求时,Flutter投入一个人,原生两端各投入一个人,都是1周开发时间。
Flutter提测后bug数量比原生少了70%以上,三天就测试完成可以交付。
而原生端前前后后改了一周bug才勉强达到交付标准。
那段时间原生iOS端的开发同学每天晚上加班改bug到10点多(那个模块iOS端的历史代码太混乱,经常一不小心就把原先的代码改出问题)。
所以不同流程下开发效率的差距可见一斑。
而且这种差距会随着项目迭代,代码量的累积和越来越大。

总结

六年后再来看,我们的App大多数页面都已经切换到Flutter版本。
前段时间领导找我谈心好几次,都是因为我们客户端团队平均工时太少。
可是我们对比另一个产品没有使用Flutter的客户端团队,规模差不多的项目,我们的人手是他们的一半。我们交付的稳定性、项目周期可控性、bug率等指标都远好于他们。
应该说TDD充分证明了在这种长期迭代的项目中的可靠性和项目收益。

那么回到文章最开始的问题,在短期项目中是否值得使用TDD呢?
我的观点是只要你的项目不是那种写完代码不需要改bug就交付的类型,那TDD一定能带来正收益。
这几年我们也做了几个临时性的小App,都是不需要后续长期维护的那种。
我们全部沿用TDD流程,结果也如预料,项目的测试周期、交付质量和我们的主项目基本一致,远高于以前的原生项目。
所以真的不要再觉得写测试代码是花了额外的时间,这些时间本来就是用于进行设计的。
你只不过是换了一种概要设计和详细设计的方式。
而得到的收益就是你的项目从架构到代码细节的质量再到后期修改和维护的能力都得到了不可思议的提升。

好了,本文主要是结合我过往的TDD实战的经历,希望能从理论上说明TDD在不同类型的项目中能给我们带来哪些收益。
下一篇文章,我会结合成都客户的具体情况和我们自己在Flutter上实践TDD的过程来讲讲基于Flutter的TDD具体流程和技术细节。

如果看到这里的同学有学习Flutter或者TDD的兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》


程序员老刘
1 声望2 粉丝

客户端架构师,客户端团队负责人。一个月带领客户端团队从0基础迁移到Flutter 。目前团队已使用Flutter五年。