头图

哈喽,我是老刘

书接上文,去成都帮助一家公司搭建基于Flutter的TDD开发流程。
背景是客户接到来自欧洲的Flutter开发项目,要求开发流程使用TDD。
老刘自己直接或者间接接触过的要求采用TDD或者敏捷开发的客户都是欧美客户。
为啥欧美开发者对TDD或者敏捷开发的认可度这么高?
老刘在这个系列的第一篇文章里结合自己两次敏捷开发的实践做了分析。感兴趣的同学可以看这里:
我在成都教人用Flutter写TDD(上)——为啥要搞TDD?

上一篇文章老刘以一个客户端项目为例介绍了以TDD为核心的开发流程如何搭建。感兴趣的同学可以看这里:
我在成都教人用Flutter写TDD(中)——TDD开发流程

那么本文我们回到Flutter代码层面,来看看具体开发中落实这套流程需要注意哪些东西?

架构设计

我觉得如果希望整个TDD开发流程进行的顺畅,一个关键的基础是合理的架构设计。
其实不仅仅是TDD,任何一个软件系统的开发都需要合理的结构作为基础。
而反过来,站在测试的角度,能否方便顺畅的进行测试代码的开发,也直观的反应了架构的好坏。

客户端基础架构

在客户端开发中我们通常采用的架构是纵向业务逻辑分层,横向配合各种辅助模块(例如日志、用户行为统计等模块)的方式。
image.png

以上图中的架构设计为例,落实到Flutter中就是利用状态管理方案将UI层和逻辑层进行解耦,然后将数据库、远端服务器等操作进一步从中拆分成独立的模块。
再具体一些,我们以使用bloc作为状态管理为例。
一个购物车页面就可以拆分成CartPage、CartBloc以及CartBloc依赖的各种底层服务,比如调用服务器API的ApiService、调用用户信息的UserService等模块。
image.png

我们可以把购物车的页面布局写在CartPage中,把添加商品、删除商品、获取购物车商品列表等业务逻辑写在CartBloc中,在UserService中封装与关于账户的各种操作。
当CartBloc进行的操作比如删除商品完成,会发出购物车状态变化的通知,页面接收到这个通知会更新页面中展示的商品列表。

依赖反转

在实现上面这些代码的时候一定要注意面向对象的一个基本原则:依赖反转。
因为我们在使用TDD开发CartPage这个类的时候并不希望真的去让CartBloc调用一个服务端接口获取商品列表。
我们的测试例希望能够给CartPage注入一个模拟的CartBloc,然后可以根据我们测试例的需要,直接让模拟的CartBloc输出指定的数据。
比如我们想测试空商品列表的购物车页面,就让模拟的CartBloc返回空的商品列表。

如果想做到这一点,在CartPage中直接写死一个私有的成员变量似乎就不是太好的选择了。

CartBloc _bloc = CartBloc();

看看有多少人写过这样的代码?

那应该如何实现可以注入的效果呢?
方案一:
一个看起来不错的思路是将CartBloc作为页面的构造参数传入。
当然在实际开发中为了使用方便,可以为这个构造参数指定默认值。

class CartPage extends StatelessWidget {
  final CartBloc cartBloc;

  CartPage({this.cartBloc = CartBloc()});
}

这就是Dart语言可变参数的一个典型应用场景。
而在测试场景中,就可以为被测试的CartPage传入我们模拟的CartBloc。

方案二:
可以在CartPage中定义一个getBloc或者createBloc方法。
CartPage中创建bloc的动作由该方法完成,而不是直接new一个对象。
这样的好处是可以通过在CartPage的子类中重写这个方法实现bloc的注入。
比如在测试代码中定义一个TestCartPage继承于CartPage,并且重写createBloc方法,返回模拟的bloc。
通常我们在封装一个App的基础设施的时候会定义一些基类。比如BasePage、BaseBloc等等,用于封装多数页面或者bloc中通用的行为。
因此可以在BasePage中定义createBloc方法,让所有的页面都默认使用这种方式创建对应的bloc。

abstract class BasePage<B extends BaseBloc> extends StatelessWidget {
  …
  B createBloc(BuildContext context);
  …
}

这里要注意一点,如果你测试的是CartPage,同时你又实现了TestCartPage,那么一定要保证TestCartPage中只重写了createBloc方法。
也就是说TestCartPage中不能对你要测试的代码以及所有相关的代码有任何的修改。
这一点有些同学在开始写测试的时候会不注意,为了测试方便修改其中的很多东西。

方案三:
我们可以直接创建bloc,同时为了在测试时可以注入,可以为bloc定义对应的setter方法。
这种方式用起来很方便,在任何需要的时候都可以利用setter方法替换页面中的bloc。
但是本质上这种设计破坏了开放封闭原则。
为了测试方便,破坏代码的合理性,我不觉得是一种好的方法。
所以对于这个方案我觉得应该是仅限于一个场景,就是你的页面本身因为业务逻辑需要,就是要替换bloc。
换句话说,你的CartPage中本来因为业务逻辑的需要,就应该提供一个bloc的setter方法。
当然也可以定义一个只能用于测试环境的set方法:

void setTestBloc(B testBloc) {
    if (isRunningTests) {
      _bloc = testBloc;
    } else {
      throw Exception('setTestBloc 方法只能在test中使用');
    }
  }

单例

提到依赖注入,不得不说一下单例模式。
单例模式作用是保证全局访问的是同一个对象。
但是单例模式对单元测试并不是特别友好。
具体来说如果单例类是你要开发的对象,那一般来说问题不大,正常使用TDD开发即可。
但是如果你要开发的代码依赖了单例,我们需要把那个全局唯一的对象替换成模拟对象以方便测试,这时候不同的单例实现方式可能会给我们带来一些困扰。

Dart语言中实现单例有好几种方法,这里注意不要用factory构造的方式,因为其缓存时内部实现的,很难实现注入。
以UserManager为例,我们先用标准方式实现一个单例:

class UserManager {
  // 私有静态成员变量存储单例实例
  static final UserManager _instance = UserManager._();

  // 私有构造函数,防止外部直接创建实例
  UserManager._();

  static UserManager get instance {
    return _instance;
  }
}

这里有两个点会影响对UserManager的mock操作:
第一个是私有构造函数,这会为创建UserManager的mock子类带来麻烦。
第二个是私有的_instance,这个内部静态变量无法访问,进而造成这个单例无法注入。

那怎么解决这个问题呢?这里提供两种用于单例模式的测试思路:
1、标准单例依赖注入

class UserManager {
  // 私有静态成员变量存储单例实例
  static final UserManager _instance = UserManager();

  // 普通构造函数
  UserManager();

  static UserManager get instance {
    return _instance;
  }

  // 手动设置实例,依赖注入
  static void setTestInstance(UserManager testInstance) {
    if (!testMode) {
      throw Exception('只有测试环境可以调用setTestInstance()方法');
    } 
    
    _instance = testInstance;
  }

}

首先是将构造函数改为标准构造函数。
这样其实带来一个隐患,会不会有人直接new一个UserManager的实例来用,而不是使用单例?
这块可以考虑加@visibleForTesting注解,但是这个注解也只有提醒功能,无法影响编译。
其次是增加了setTestInstance,并且限制只能在测试环境使用。

2、单例代理模式
UserManager可以使用标准的单例模式不做任何修改。
这样就避免了破坏其封装,任何地方都无法获取到其它实例。
但是我们不直接使用UserManager.getInstance()来获取其实例。
我们额外定义一个UserManagerHelper 普通类,内部封装访问 UserManager 的单例。
实际上,UserManagerHelper 在这里充当了对 UserManager 单例的封装和访问代理,它提供了一个间接的访问方式,使得 UserManager 的单例实例通过 UserManagerHelper 来访问。

class UserManagerHelper {
  // 获取UserManager的单例
  UserManager getUserManager() {
    return UserManager.getInstance();  // 返回UserManager的单例实例
  }
}

这样我们可以在测试时,需要模拟UserManager的场景中去mock UserManagerHelper 的 getUserManager() 方法。
而且UserManagerHelper 中也可以增加一些额外的业务逻辑,例如日志记录、权限验证等

但是单例代理也有他的弊端:
一方面使用起来更复杂,没有单例直观。
另一方面对使用者来说有可能意识不到UserManager是一个单例。

所以我在前面说单例模式对单元测试不太友好,就是因为虽然有方案能够解决单例的注入问题,但是每种方案都有其缺点。

测试范围

有了合理的架构只是第一步,使用Flutter开发的项目通常都是包含UI交互的应用。
在这种类型的项目中,并非所有的代码都是适合使用TDD的,也就是说有些代码是不适合测试的。
那么哪些代码适合测试,哪些代码不适合呢?
对于普通App的功能页面来说,我觉得有两部分代码可以不测试。
1UI布局不测
比如图片距离屏幕边缘有20dp的padding,这种UI布局的细节可以不测试。
某种程度上说,UI布局不属于业务逻辑的范畴,它更像是代码中一些配置参数的调整。
以老刘的经验,我们的代码发布前,一般UI设计的同学会对产品进行一个检查。
这个过程中有可能会对这些参数进行微调以保证页面的展示效果,比如把padding的20dp改为15dp。
在实战中测试UI布局的细节需要写大量的测试代码,同时写这些测试也无法获得TDD的大部分好处,比如更好的代码设计、测试代码对逻辑的保护。
所以简单来说就是测试UI布局的细节性价比很低。

这里大家要注意,我指的是App的业务页面,不需要去测试这个页面的UI布局。
但是,如果你当前开发的就是一个UI展示组件,那么当传入不同的参数时组件展示的UI布局是否符合预期,这个就是应该测试的了。

2、外部代码不测
比如我们通过ApiService封装了dio库的Http请求的逻辑。那么不要去测试http请求的逻辑。
因为这部分逻辑不是你自己开发的,它对你来说理论上应该是个黑盒,所以不要去测试这部分逻辑。
如果你对外部代码的正确性不放心,可以在封装的时候增加一些保护性的检查。

所以结合我们前面的架构设计,Flutter项目常规的测试范围就如下图所示:
image.png

当然,上面的划分只是一个基本的原则,在实战中有很多情况是处于灰色地带的。
比如一个只能点击一次的按钮,点击后变成灰色不可点击是属于不测试的UI布局还是需要测试的UI逻辑?
再比如基于服务端接口返回的json数据生成了数据结构,这个数据类是不是要测试呢?

其实这些问题都没有一个固定的标准答案,对于不同的项目,不同的场景都是不一样的。
老刘觉得重点不是找到一个标准答案,而是在团队内对于这些问题达成一致。
可以把类似的问题整理出来,大家一起讨论看看,找到一个团队共同的答案后在开发守则中固定下来,作为后续开发的指导和标准。

Mock和继承

假设正在通过TDD方式去开发CartBloc这个类。
CartBloc会调用ApiService发送http请求获取购物车数据。
但是在测试例的执行过程中,我们肯定不会真正的去调用ApiService发送http请求的。
一方面是因为这样执行效率太低,会拖慢测试例的运行速度。
更重要的是结果不可控,网络状态会影响测试结果,并且也无法指定返回结果测试不同的场景。

这个时候可以定义一个专门用于测试的TestApiService,并将这个TestApiService注入到CartBloc中。
每次调用TestApiService发送网络请求时,它会返回我们指定的数据。
如何将TestApiService注入,我们在前面的依赖反转那部分讲过了。
那么如何实现这个TestApiService呢?通常会使用以下两种方法:

继承

TestApiService 继承于 ApiService 并重写其中发送http请求的方法,同步或者异步返回我们测试例中想要的数据。
我们还可以增加一个设置返回数据的方法,这样多个测试例就可以复用这一个TestApiService 了。

继承这种方案的好处是简洁直观。
因为TestApiService 是我们自己写的类,想要什么样的行为和数据都可以通过代码实现。
另外也不需要引入其他工具的支持,所以使用方便。

但是他的缺点就是功能有限。
继承并不能完全控制其父类的行为,比如一个类在初始化的时候会执行大量耗时或者依赖网络、外部环境的操作,这些动作我们在测试过程中是不希望去执行的。
另外有些测试场景使用继承的方式实现非常复杂。
比如我们想测试一个对象的某个方法被调用指定次数或者没有被调用。

为了解决前面说的这些问题,就出现了Mock工具。

Mock

Mock(通常使用像 mockito、mocktail 等库)通过创建一个假的对象,模拟原对象的行为。
Mock对象通常用于模拟外部依赖和资源(如API、数据库、文件系统等)的交互,而无需实际执行这些操作。

Mock工具实现这种灵活和强大的代价就是使用起来略微复杂。
需要引入额外的库,比如mockito、mocktail。
需要在代码中进行配置比如增加注解。
需要执行一些外部操作比如build runner。

在实战中其实这两种方案是互补的,并不是说那种方案就一定更好,甚至有些比较复杂的场景下两种方案需要组合使用。
比如我们测试CartPage,依赖了CartBloc。
而CartPage中调用了CartBloc的多个接口,有些是获取外部数据的,有些是进行内部计算的。
这些内部的计算逻辑的接口,是不需要进行模拟的,直接调用更为方便。
这时如果只是简单的使用Mock工具创建一个假的CartBloc,就需要把每一个被调用的接口都模拟一遍。
如果有多个测试例都是这样的场景,就会出现大量的重复或者类似测试代码。
这种场景我们可以定义一个Mock对象MockCartBloc,同时定义一个TestCartBloc继承CartBloc。
把MockCartBloc作为TestCartBloc的代理。
对于那些依赖外部数据的接口,比如网络请求,就是用Mock对象的模拟功能。
对于那些内部逻辑,就直接代理到TestCartBloc的真实代码中。
当然也可以反过来把TestCartBloc作为MockCartBloc的代理。

在Flutter的TDD实战中,这样的场景还真的不算罕见,有不少比较复杂的页面都符合上面举例的情况。

关于Getx

首先要强调的是Getx是一个非常优秀的库,我对其没有任何偏见。
只不过我个人的开发风格是喜欢把各种不同的功能边界清晰的拆分开,比如状态管理就是状态管理模块,路由管理就是路由管理模块。它们之间最好没有依赖或者耦合关系。
所以我自己就一直没有用过Getx。
而这次客户用到了Getx作为其状态管理、路由管理和全局数据维护等很多部分。
于是我也借着这个机会对Getx有了一个算是比较深入的了解。
总的来说站在TDD的角度Getx不算是太友好。
原因的话我觉得有两个方面,一个是我们前面说的架构问题,不同模块的互相依赖会对测试造成一定困扰。
另一个是Getx中大量使用单例和全局状态,包括controller在内的很多东西是习惯于存储在全局状态中的,比如Get.put。
这给我们的注入和模拟都带来了一定的麻烦。

解决方法也比较简单,前面其实都介绍过了。
简单来说就是封装一个中间层。
比如这次给成都这边的客户端建议就是把路由管理、状态管理等基于Getx的功能都进行一个封装。
例如可以封装BaseController作为状态管理的基类,封装RouteHelper进行路由管理。
在这些类中封装Getx的全局方法,在其它地方只调用这些封装类,不要直接调用Getx。
这样我们针对BaseController、RouteHelper进行mock或者继承就都比较方便了。

其实任何一个三方库,特别是提供状态管理这样核心功能的三方库,我们都不应该直接使用。而是应该用我们自己的代码做一层封装再使用。
这样一方面可以帮我们隔离内部和外部逻辑方便测试。
另一方面也提供了一个安全性保证,如果这个三方库出现一些比较麻烦的问题,也能比较容易的切换到其它库。

总结

本文讨论了一些在Flutter中实施TDD在代码层面的问题。
老刘的核心观点是框架大于细节。
优先确认好项目架构、测试范围和边界等框架性的问题,然后在这个框架内去确定术细节。
比如确定了架构就可以有的放矢的去选择状态管理库、路由管理库等等。
总之,Flutter已经为TDD开发提供了足够充分的技术和生态基础。
如果选择了Flutter作为客户端开发框架,就一定不要错过TDD这个宝藏。
而如果还在纠结客户端开发选择哪个框架,我觉得TDD应该成为Flutter背后最重的那枚砝码。
看到这里的同学有学习Flutter或者TDD的兴趣,欢迎联系老刘,我们互相学习。
点击免费领老刘整理的《Flutter开发手册》,覆盖90%应用开发场景。
可以作为Flutter学习的知识地图。
覆盖90%开发场景的《Flutter开发手册》


程序员老刘
1 声望2 粉丝

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