头图

我在成都教人用Flutter写TDD(中)——TDD开发流程

哈喽,我是老刘

书接上文,去成都帮助一家公司搭建基于Flutter的TDD开发流程。
我在成都教人用Flutter写TDD(上)——为啥要搞TDD?

背景是客户接到来自欧洲的Flutter开发项目,要求开发流程使用TDD。
为啥欧美开发者对TDD或者敏捷开发的认可度这么高?
前面那篇文章老刘用自己两次在项目中实战TDD取得的成果做了解答。
本文我们介绍一下在基于Flutter的客户端项目中如何实施TDD流程。

TDD(Test-Driven Development 测试驱动开发)其实不是一个完整的开发流程

一说起来TDD大家首先想到的可能是下面这幅图
image.png
这确实是TDD的核心动作,但它并不是一个完整的开发流程。
一个完整的开发流程至少要解决从拿到需求一直到提交测试的全部过程。
我们以敏捷开发为例,标准的XP流程从最开始和客户聊需求的时候就开始通过与客户沟通,收集需求,并将需求转化为用户故事。
一个迭代完成后还会向用户演示,收集反馈并修正下一个迭代的开发内容等。
本文不打算讲敏捷开发的各种不同框架比如Scrum或者XP。
第一是因为这些流程涉及到的远不止开发团队,包括产品、测试、运维等等都需要共同协作。
在目前国内的软件开发环境下,想推动这么多团队共同进行变革其实很难。
我在前面那篇文章中也说了我的第一次Scrum尝试就因为这个原因失败了,最后只有TDD得以保留。
第二就类似于这次成都的客户,很多以外包业务为主的公司,拿到的项目就是UI设计都已经做好只需要进行开发的项目。
他们也不太需要这种完整的敏捷开发流程。他们只需要在开发这一步能够利用TDD等技术带来的各种好处。
所以,搭建一个以细化后的需求作为输入,一直到提交测试为止的,以TDD为核心的开发流程,就是很多公司或者团队的实际需求。

以TDD为核心的开发流程

假设现在我们接到一个开发电商类App的项目,项目需求包括UI、UE设计都提供了。
那么接下来第一步是把任务分配给开发人员准备开始干活了。

模块分配

对于客户端来说通常是根据页面和功能模块进行任务划分的。
这个大多数团队也是这样操作的,没必要细说。
不过这里要提示两点:
一,其实页面也是功能模块的一种直观体现,本质上也是功能模块,比如购物车模块、商品模块、登录模块等。
除此以外还有很多没有对应UI的模块。
所以任务分配的时候不要只被可见的页面局限。
二,对于从0开始的项目,早期大多数人力应该投入底层模块,比如网络、数据库、基础架构组件等。

这两点做开发的不会有什么问题,但是不懂技术的管理者和开发以外的团队有时候容易误解。
对于看不见的模块,开发以外的人经常会意识不到这部分工作量。

接下来我们就以购物车模块为例看看基于TDD的开发流程如何展开。

将需求拆解为任务

TDD的输入是一个一个的测试,那么对于开发人员,接下来要做的就是把需求转化为测试例。
通常来说我们比较常用的是两级拆解的策略。
具体来说就是不会直接把一个购物车模块拆解为很多测试例。
而是先把购物车模块拆分为UI和业务逻辑两部分,然后UI和业务逻辑进一步拆解为多个不同的功能或者任务。
当然这里也可以先拆解为多个任务,然后每个任务区分UI和业务逻辑。
这两种方案没有固定的标准,要根据不同的具体情况来定。
不过以我们的经验来看,通常UI逻辑和业务逻辑并非一一对应,所以我们更多的是先区分UI和业务逻辑。

购物车的UI部分可以拆解为以下几个任务:

  • 用户可以查看展示购物车列表
  • 用户在每个购物车商品上可以进行删除操作
  • 用户可以批量选中商品进行删除
  • 用户在每个购物车商品上可以修改数量

对于业务逻辑可能拆解为以下几个任务:

  • 用户可以将商品加入购物车。
  • 用户可以从购物车中删除商品。
  • 用户可以查看购物车中的所有商品。

从加入购物车这个功能就可以看出来,对于购物车页面来说可能在UI上没有加购的功能。
但是从业务逻辑来看是有这个功能的,这个功能是提供给其他页面比如商品详情页使用的。

有了具体的任务,接下来就可以把每个任务拆解为一个或者多个测试例了。

任务拆解为测试例

我们在UI和业务逻辑中各拿出一个任务进行拆解。

UI:在单个购物车商品上进行点击删除按钮

  • 在商品上横滑展示操作菜单
  • 点击删除按钮调用删除操作
  • 删除成功更新商品列表
  • 删除失败,更新商品列表,toast提示失败原因

业务逻辑:从购物车中删除商品

  • 正常删除:传入商品列表,返回删除成功

    从UI上可能有在商品上横滑和批量选中两种删除方式,但是在业务逻辑的实现上可能只需要提供批量删除一种操作就可以了。
  • 异常删除1:部分商品id异常,返回失败商品id列表

    部分id异常的场景可能是有的商品在其它终端已经删除了,但是当前页面没有更新。
  • 异常删除2:全部商品id异常,返回失败商品id列表

    部分id异常和全部id异常的后台操作可能不一样,这取决于产品业务逻辑如何设计。如果产品设计这两种操作一致,则可能没有必要区分这个测试例。

同样都是删除购物车商品的功能,这里就可以比较清晰的看出来UI和业务逻辑的差异了。
通常来说拆分测试例后要和产品及测试的同学做一个简单的核对。
这主要是为了保证对不同部门间对产品细节的理解是一致的,以及避免遗漏场景。

还要注意一点,测试例的拆解没有一个统一的标准。
不同项目,不同技术选型,不同团队拆解的思路可能会有差异。
一个经验准则是单个测试例的完成时间在15分钟以内。
但这只是一个粗略的估计标准,不能严格限制,如果某个测试例碰到难点,那就按照实际情况去完成就好,不必强求15分钟。

红灯、绿灯、重构

接下来就是大家喜闻乐见的TDD标准动作了。
image.png

1、编写第一个失败的测试用例
在选择测试例的时候一定不要选择依赖其它未实现功能的测试例。
比如如果你第一个测试例选择了我们前面列出的

  • 在商品上横滑展示操作菜单

这个就不太合适了,因为这个删除功能是依赖于展示商品列表的。
这时我第一个测试例可能是“用户打开购物车页面,页面标题是购物车”。

这个测试例的示例代码如下:

testWidgets('打开购物车页面后,标题应为购物车', (WidgetTester tester) async {  
    // 构建CartPage页面  
    await tester.pumpWidget(MaterialApp(  
      home: CartPage(),  
    ));  
  
    // 查找标题文本(AppBar中的标题)  
    final titleFinder = find.text('购物车');  
  
    // 验证标题是否显示在页面上  
    expect(titleFinder, findsOneWidget);  
  });

理论上这时候我们还没有写任何购物车相关的业务代码,所以这时候写的任何测试代码都是无法测试通过的,甚至是语法报错的。
这就是标准的红灯状态的一种。
所以其实红灯状态有两种典型的情况:
第一种是测试代码执行报错,通常在IDE中用红色test运行信息提示。
第二种是测试代码有语法错误无法执行,比如测试代码使用的类、方法不存在。通常IDE的编辑器也会用红色的波浪线提示语法错误。

那么在这个例子中,CartPage并不存在,IDE会提示语法错误,我们的红灯这一步就完成了。

2、实现功能代码使测试通过
接下来,按照测试用例的要求逐步实现功能代码。
这里有两个关键点:

  • 小步前进:每次只实现足够让一个测试通过的功能,确保每次提交的代码都是可测试的和可工作的。
  • 避免过早优化:只实现最基本的功能,避免过度设计和优化,先让测试通过,再考虑其他细节。

这两个要点其实本质上是一件事,就是实现功能的时候就专心用最简单最快速的方法满足测试例的要求。
这里不要分心去考虑代码结构是否合理,代码逻辑是否需要抽象等等问题,TDD接下来的重构步骤会让你专门去做这些事。
记住,人脑同一时间只能专注做好一件事

基于上面的原则,我们实现了最简单的购物车页面:

class CartPage extends StatelessWidget {  
  @override  
  Widget build(BuildContext context) {  
    return Scaffold(  
      appBar: AppBar(  
        title: Text('购物车'),  // 页面标题  
      ),  
    );  
  }  
}

接下来重新运行所有测试代码,所有测试例通过,绿灯这一步就完成了。

3、重构代码
重构的目的是改善代码结构,使其更加清晰、可维护,但重构不应影响功能的正确性。
就好像前面那一步我们专心思考如何实现功能,不用去考虑代码的好坏。
这一步我们也专心思考如何让代码简洁、清晰、优雅。只要保证对外的接口不变,就不用去考虑功能问题。
这一步有一点需要注意,就好像我们在例子中演示的初始版本的购物车页面。
代码非常简单,而且因为是刚开始,也没啥结构需要调整的地方。
但是这里的重构动作一定要做,就是说一定要去思考哪里有可以重构的点。
要知道我们的大脑进化的基本原则是节能。
所以如果你不强制大脑去思考哪里有可以重构的地方,潜意识给你的反馈大概率是代码很好,不用重构。
而当你真的要求大脑去思考,经常能从感觉没啥问题的代码中找到需要优化的地方。
重构代码后保证所有的测试例都测试通过,就能保证你的重构没有对之前的功能造成影响。
这时候TDD的重构这一步就完成了,这个测试例代表的功能点也就开发完了。

4、循环上述步骤
在开发过程中,你会不断循环进行前面的三个操作:

  • 编写新的测试用例(驱动开发)。
  • 实现代码使测试通过。
  • 重构代码,确保结构清晰。

每实现一个功能,就编写相应的测试,并且不断验证,确保需求的每个小部分都被充分覆盖。
直到所有的测试例都实现完成,你的基础开发工作就完成了。

集成与端到端测试

当单元测试都已经通过,并且各个功能模块完成时,可以进行集成测试和端到端测试,确保系统的整体协同工作。

  • 集成测试:验证不同模块之间的接口是否正确,确保数据流通和功能集成。
  • 端到端测试:模拟用户从头到尾的交互流程,确保整体应用的流程符合需求。

这里面集成测试一定是开发者完成的,但是端到端测试可能开发者只完成其中最基础的部分。
更完整的端到端测试交给测试团队的小伙伴进行。
老刘这里建议有能力的团队这两步一定要进行自动化。
因为随着一次次迭代,项目规模越来越大,靠人工测试后期不可能做到每个迭代都覆盖到所有功能。
到了这里,研发部分的整个流程就走完了,后面就是客户验收交付或者发布上线等环节了。

总结

本文主要是针对无法推动完整的敏捷开发流程,又希望利用TDD等敏捷开发核心实践的团队。
希望搭建一个以TDD为核心的完整开发流程。
这套流程可以让研发团队在不需要外部其他团队配合的情况下,只在研发内部将原先的瀑布流程替换成以TDD为核心的开发流程,从而提升开发效率、产品可维护性和产品质量。

老刘之前也写过一篇主要针对个人开发者的开发流程。
如何用Flutter从0开始搭建一个App
其中老刘也提到了核心编码流程建议采用TDD。
其实那套个人流程与TDD匹配程度更高一些。
但是那篇文章中为了避免一次填充太多概念,就省略了TDD部分,感兴趣的同学可以将本文中的TDD核心部分拿去直接用。

好了,本文是这个系列文章的第二篇。
第一篇文章主要说明为啥要搞TDD,哪些场景可以从TDD中获益。
本文主要是以适合这次成都客户的场景为例,讲了一下以TDD为核心的开发流程如何搭建。
接下来会再写一篇,还是以成都的客户为例,讲解一下Flutter实战TDD中碰到的一些坑和技术细节。
如果看到这里的同学对Flutter或者TDD感兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》


程序员老刘
1 声望2 粉丝

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