搜索

我的FlutterTDD心路历程

发布网友 发布时间:2024-10-24 00:26

我来回答

1个回答

热心网友 时间:2024-11-14 19:45

导语:Test-drivendevelopment(TDD)在当前国内很多软件开发人员理解中比较模糊,大部分人也没有明确和有意识的去实施TDD,因此很多人都有着不同的理解,包括我本人在实践TDD之前都比较排斥。不过有句话说得好:“实践是检验真理的唯一标准,任何没有经过实践就轻易下的结论都是耍流氓”(后半句话是我说的,没错)本文记录了我在Flutter中实践TDD的一些所思所考,全文根据真实经历,没有改编,仅供参考

阅读前提:对Flutter、Dart、Fluttertest以及TDD稍有了解

0.怀疑和抗拒

感受不到TDD带来的价值,TDD打破了常规的开发思路

觉得TDD繁琐,明明可以一口气实现的代码,为什么非要拆细

先写用例,但是无从下手,怎么设计用例

觉得写的用例有点傻,感觉没什么用

我写的代码逻辑很简单,肯定不会有问题,没必要写单测

写着写着发现之前的用例好像不太对,想改用例?

用例怎么拆?怎么控制粒度?

什么时候才重构?

1.从无到有

案例:实现一个通用的支持上滑加载下拉刷新的Flutter列表

用例梳理:

加载过程显示loading动画

加载结果为空列表显示empty页面

加载结果失败显示error页面

...

一开始只梳理出三个用例,为了聚焦,没有考虑所有场景,理论上TDD是可以慢慢补充用例完善功能的,先聚焦这三个相对简单的用例

尝试一下TDD流程:先写单测用例->用例失败->编写最小可运行单测版本的实现

1.1第一个用例:加载过程显示loading动画

先写单测

思考:当前没有任何实现代码,意味着单测怎么写完全跳脱出具体实现,那肯定是怎么简单怎么来(不需要mock),这里甚至不考虑合理性,先把用例需求用单测代码描述出来

Given:首先我肯定需要准备一个Widget,因为三个用例是不同加载状态对应不同显示Widget,那我暂且设计成这个Widget需要一个Status入参,先不考虑合理性和扩展性,至少目前是可测的(后面会涉及重构)

When:加载Widget,并传入参数为loading表示加载中

Then:验证当前页面是否有loadingwidget出现

编码实现:

voidmain(){testWidgets("列表加载状态显示loading",(tester)async{FeedListfeedList=constFeedList(loadingStatus:LoadingStatus.loading);awaittester.pumpWidget(MaterialApp(home:feedList));varloadingFinder=find.bySemanticsLabel("feed_loading");expect(loadingFinder,findsOneWidget,reason:"没有找到loading控件");});}

用例运行失败

这个用例目前肯定是跑不过的

第一,根本没有FeedList这个widget

第二,也不可能有feed_loading这个semantics的widget

编写最小可运行单测版本的实现

enumLoadingStatus{loading,}classFeedListextendsStatelessWidget{finalLoadingStatusloadingStatus;constFeedList({Key?key,requiredthis.loadingStatus,}):super(key:key);@overrideWidgetbuild(BuildContextcontext){//注意:这里压根就没有判断状态,而是直接就显示一个loading态//因为目前只有一个用例,这样的代码就已经能让用例通过了returnSemantics(label:"feed_loading",child:Container(),);}}

这样,之前的用例就能跑过了

思考:可以看到当前的实现很挫,是不符合我们功能的预期的,而是仅仅能够让用例通过的实现版本。按照我们常规的开发流程或者习惯,我们在实现的时候可能会忍不住想去优化代码,去想各种边界条件,然后写出一个比较完善的实现版本。例如这里我们可能习惯性定义好各种状态的枚举,然后在build的时候判断各种状态,实现各个状态的处理逻辑。这个看来很顺手的事情,我们现在暂且不做,按照TDD的开发流程,到这一步我们是坚决不能过早地去优化代码,去编写用例以外的实现的。先记住一个原则:我们所写的每一行代码,都尽可能先编写好测试用例来覆盖,即先写测试用例,再写实现

这里我们先忍着不着急去优化或者重构,我们继续往下

1.2第二个用例:加载结果为空列表显示empty页面

先写单测

有了之前的代码,第二个用例自然而然就是换个状态入参即可,这也说明我们之前的设计到目前为止还是比较可测的,代码如下

testWidgets("加载结束之后空列表状态显示空列表widget",(tester)async{FeedListfeedList=constFeedList(loadingStatus:LoadingStatus.empty);awaittester.pumpWidget(MaterialApp(home:feedList));varloadingFinder=find.bySemanticsLabel(FeedList.semanticsFeedEmpty);expect(loadingFinder,findsOneWidget,reason:"没有找到空列表控件");});

用例运行失败

增加这个用例之后,现在跑一下单测:第一个用例成功,第二个用例失败

显而易见,之前我们只实现了loading状态,甚至都没有判断入参,因此第二个用例肯定是失败的

编写最小可运行单测版本的实现

为了让两个用例都能够通过,现在我们就不得不加载判断逻辑了

enumLoadingStatus{loading,empty,}classFeedListextendsStatelessWidget{staticconstsemanticsFeedLoading="feed_loading";staticconstsemanticsFeedEmpty="feed_empty";finalLoadingStatusloadingStatus;constFeedList({Key?key,requiredthis.loadingStatus,}):super(key:key);@overrideWidgetbuild(BuildContextcontext){//增加判断逻辑switch(loadingStatus){caseLoadingStatus.loading:returnSemantics(label:semanticsFeedLoading,child:Container(),);caseLoadingStatus.empty:returnSemantics(label:semanticsFeedEmpty,child:Container(),);default:returnconstSizedBox();}}}

这样,两个用例就都能通过了

1.3第三个用例:加载结果失败显示error页面

有了前两个用例和实现铺垫,第三个用例就没有什么可讲了,增加一个判断逻辑即可,最终的单测代码和实现如下

voidmain(){group("feed不同加载状态显示不同widget",(){testWidgets("列表加载状态显示loading",(tester)async{FeedListfeedList=constFeedList(loadingStatus:LoadingStatus.loading);awaittester.pumpWidget(MaterialApp(home:feedList));varloadingFinder=find.bySemanticsLabel(FeedList.semanticsFeedLoading);expect(loadingFinder,findsOneWidget,reason:"没有找到loading控件");});testWidgets("加载结束之后空列表状态显示空列表widget",(tester)async{FeedListfeedList=constFeedList(loadingStatus:LoadingStatus.empty);awaittester.pumpWidget(MaterialApp(home:feedList));varloadingFinder=find.bySemanticsLabel(FeedList.semanticsFeedEmpty);expect(loadingFinder,findsOneWidget,reason:"没有找到空列表控件");});testWidgets("加载结束之后失败状态显示失败widget",(tester)async{FeedListfeedList=constFeedList(loadingStatus:LoadingStatus.failed);awaittester.pumpWidget(MaterialApp(home:feedList));varloadingFinder=find.bySemanticsLabel(FeedList.semanticsFeedLoadFailed);expect(loadingFinder,findsOneWidget,reason:"没有找到加载失败控件");});});}import'package:flutter/widgets.dart';enumLoadingStatus{loading,empty,failed,loaded,}classFeedListextendsStatelessWidget{staticconstsemanticsFeedLoading="feed_loading";staticconstsemanticsFeedEmpty="feed_empty";staticconstsemanticsFeedLoadFailed="feed_load_failed";finalLoadingStatusloadingStatus;constFeedList({Key?key,requiredthis.loadingStatus,}):super(key:key);@overrideWidgetbuild(BuildContextcontext){switch(loadingStatus){caseLoadingStatus.loading:returnSemantics(label:semanticsFeedLoading,child:Container(),);caseLoadingStatus.empty:returnSemantics(label:semanticsFeedEmpty,child:Container(),);caseLoadingStatus.failed:returnSemantics(label:semanticsFeedLoadFailed,child:Container(),);caseLoadingStatus.loaded:returnconstSizedBox();default:returnconstSizedBox();}}}

这里补充一点Flutter单测小知识:用group可以把一组相关的用例组合起来,这样有助于归类问题。

2.初体验后的思考

思考:可不可以一开始就把三个用例都写好,然后统一编写实现一次性让三个用例都通过?

这里目前用例比较简单,且三种状态具有很强的相关性,只是状态不同,因此完全是可以的先编写好这三个用例的。拆分的粒度怎么控制?我觉得秉承一个原则:拆分出来任务是足够聚焦的,不容易发散的。

例如,这里举的三个用例,状态是有限的,因此足够聚焦;而假设我们一次性把上滑加载、下拉刷新等单测都一并写了,首先这样凭空写用例是很难写的(大家可以自己尝试一下),其次当我们想要实现让所有单测通过,我们要考虑的边界就变得很复杂,很容易造成A单测通过,B单测都失败的情况。

继续完善功能,增加用例:加载成功且数据不为空,列表展示对应数据的item

编写单测

思考:我们期望传入A,B,C三个数据,在加载成功之后,页面中能够显示A,B,C三个item。此时,之前设计的入参Status已经不够用了,我们还需要传入一个列表,这里我们暂且设计成一个数据类FeedModel,里面包含一个状态和一个列表。同时因为我们需要验证页面是否展示对应的item,还需要一个列表item构建的回调函数

单测代码如下

testWidgets("加载成功且数据不为空,列表展示对应数据的item",(tester)async{List<String>expectList=["hello","hi","good","bad"];List<String>actualList=[];FeedListfeedList=FeedList<String>(feedModel:FeedModel(loadingStatus:LoadingStatus.loaded,listData:expectList,),builder:(context,index,data){actualList.add(data);returnContainer();},);awaittester.pumpWidget(MaterialApp(home:feedList));expect(actualList.length,expectList.length,reason:"实际数据长度和预期数据长度不一致");actualList.asMap().forEach((index,actualData){expect(actualData,expectList[index],reason:"实际数据和预期数据不符");});});

单测运行失败

编写让单测通过的最小实现版本

为了让单测通过,这个应该不难实现,只需要一个ListView,使用传入的数据作为入参,然后把builder回调出来即可。

classFeedModel<T>{finalLoadingStatusloadingStatus;finalList<T>listData;constFeedModel({requiredthis.loadingStatus,this.listData=const[],});}ListView.builder(itemBuilder:(context,index){returnbuilder!.call(context,index,feedModel.listData[index]);}},itemCount:feedModel.listData.length,)3.首次尝到甜头

增加用例:如果还有下一页,滑动到最后一个item的时候,显示加载更多widget

用例

testWidgets("滑动到最后一个item的时候,如果还有下一页,显示加载更多widget",(tester)async{List<String>expectList=["hello","hi","good","bad","last"];FeedListfeedList=FeedList<String>(feedModel:FeedModel(loadingStatus:LoadingStatus.loaded,listData:expectList,hasNext:true,),builder:(context,index,data){//setheight100tomakesurelistcanscrollreturnSizedBox(height:100,key:ValueKey(data));},);awaittester.pumpWidget(MaterialApp(home:feedList));//scrolltotheendvarlistFinder=find.byType(Scrollable);varlastItemFinder=find.byKey(constValueKey("last"));awaittester.scrollUntilVisible(lastItemFinder,80,scrollable:listFinder);//shouldshowloadmorewidgetvarloadMoreFinder=find.bySemanticsLabel(FeedList.semanticsFeedLoadMore);expect(loadMoreFinder,findsOneWidget,reason:"没有找到加载更多widget");});

单测失败

编写让单测通过的最小实现版本

思考:入参需要增加一个字段,代表是否还有下一页;同时当列表滑动到最后一个item的时候,返回一个loadingWidget

参数

enumLoadingStatus{loading,}classFeedListextendsStatelessWidget{finalLoadingStatusloadingStatus;constFeedList({Key?key,requiredthis.loadingStatus,}):super(key:key);@overrideWidgetbuild(BuildContextcontext){//注意:这里压根就没有判断状态,而是直接就显示一个loading态//因为目前只有一个用例,这样的代码就已经能让用例通过了returnSemantics(label:"feed_loading",child:Container(),);}}0

loadingwidget是一个假数据,因此我们需要在原始数据基础上+1;如果没有下一页,也就不需要假数据和loadingwidget,因此count的计算规则如下

enumLoadingStatus{loading,}classFeedListextendsStatelessWidget{finalLoadingStatusloadingStatus;constFeedList({Key?key,requiredthis.loadingStatus,}):super(key:key);@overrideWidgetbuild(BuildContextcontext){//注意:这里压根就没有判断状态,而是直接就显示一个loading态//因为目前只有一个用例,这样的代码就已经能让用例通过了returnSemantics(label:"feed_loading",child:Container(),);}}1

而ListView的Builder实现代码如下

enumLoadingStatus{loading,}classFeedListextendsStatelessWidget{finalLoadingStatusloadingStatus;constFeedList({Key?key,requiredthis.loadingStatus,}):super(key:key);@overrideWidgetbuild(BuildContextcontext){//注意:这里压根就没有判断状态,而是直接就显示一个loading态//因为目前只有一个用例,这样的代码就已经能让用例通过了returnSemantics(label:"feed_loading",child:Container(),);}}2

这样,刚刚写的用例就通过了。

但是我们发现,之前的用例「加载成功且数据不为空,列表展示对应数据的item」失败了

可以看到,之前的这个用例,我们期望builditem数量为4,但是实际却只有3个,这个是为什么呢?

在这之前单测一直都是通过的,说明我们刚刚的实现,破坏了之前的用例,由于之前的用例,我们没有传入hasNext,而hasNext默认参数是false,当hasNext为false的时候,count=feedModel.listData.length,在用例中即为4,而ListViewbuilder实现中,我们判断了当index==count-1的时候,返回loadingwidget而不是回调传入的builder参数,因此,builder只回调了三次,这也就导致之前的用例失败了。

那么我们只需要增加一个判断就可以了

这个情况在我们日常开发中是很容易出现的,当我们开发新功能时,很容易忽略掉一些边界或者把之前的逻辑改坏,这时候单测就能够发挥其价值,而且,如果我们严格遵循TDD的开发流程,就可以把这种badcase扼杀在开发过程中,可以让我们交付出更有质量保障的代码

思考:刚刚出现的问题,codereview能够轻易的发现吗?

4.开始增加复杂性

持续增加功能:

上滑加载结束之后,不应该展示loadingmorewidget

上滑加载结束之后,新列表插入旧列表尾部

从这里开始,有了一定的复杂性,之前的用例,基本上都是静态的(Stateless),状态通过参数传入,即状态一开始就确定了,不存在发生变化的可能。而现在,我们需要知道什么时候加载结束,引入了可变的状态(Stateful)并且需要在加载结束之后做一些验证。

思考:由于「加载更多」是由列表内部触发的,如果我们想知道加载什么时候结束,我们就必须拿到加载的句柄,在Dart中,一般我们用Future来表示,于是我们能想到:我们可以从外部传入一个返回Future

声明:本网页内容为用户发布,旨在传播知识,不代表本网认同其观点,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。
E-MAIL:11247931@qq.com
Top