Mock Framework和Mock Class的取舍(Mock vs Fake)

范德成
2017年9月20日
 
昨天和同事Eric深入讨论了Mock Framework和Mock Class使用上的对比,看看到底哪种实现更好用。先说结论:各有利弊。需要根据实际情况选择使用合适的手段。Mock Framework适合逻辑不复杂、调用层次不多的生产代码。但是Mock Class更适合逻辑复杂、调用层次多的生产代码,因为它能降低生产代码和单元测试代码之间的耦合。
 
首先介绍一下什么是Mock Framework。如果读过一些单元测试的教程的话,也许会听说过F.I.R.S.T.,这是单元测试要满足的五个特性:
 
  • Fast(快速)
  • Isolated/Independent(隔离、独立)
  • Repeatable(可重复)
  • Self-Validating(自验证)
  • Thorough and Timely(完善并且及时更新)
 
我们不必教条地一定要做到这五点,但这五点是做单元测试时值得考量的。首先,做到Isolated以后,自然就会做到Fast(单元测试总比集成测试要快;只要你的算法时间复杂度不高,就肯定会是快的)和Repeatable。Isolated(隔离)是这次要讲的重点,后面会详细讲。Independent是指各个单元测试用例之间尽可能独立,不要把一长串测试流程放在一个用例里面(除非必须要测试这种场景之间的依赖关系),以使得将来更改测试更容易,定位问题更快速,这是测试设计的艺术。其次,用了单元测试框架,可以让测试自动启动(不用单元测试框架的单元测试也能被手动启动,但为了长远考虑还是建议使用框架),自然就能做到Self-Validating。
 
Thorough and Timely则是程序员的自我修养——单元测试是程序员写的,程序员在写某个生产类的第一版单元测试的时候,就要达到一定的测试强度——不只是高的覆盖率,而且是要像专业做测试的同学一样,有多种场景的测试,能通过测试来发现bug,甚至在某些情况下使用高超的测试手段发现一些平时不容易发现的bug。
 
现在来详细讲一下Isolated(隔离)。Isolated的基本做法是,要测试一个类,就把所有它的依赖项都隔离掉。
 
比如,写了一个播放列表生成程序,它会遍历一个目录树并把里面所有包含MP3(*.mp3,不区分大小写)文件的子目录找出来。为每个这样的子目录,把里面的MP3文件的名字读进内存,按顺序排序以后,生成出一个“<目录名>.m3u”文件放在该子目录下。如果遇到同名的m3u文件存在,则将它放进回收站然后再写入新的m3u文件。这里要隔离掉的是文件系统的API和文件I/O的API。为了隔离这些API,必须先定义所有这些操作(FileSystem.listdir、FileSystem.open、FileSystem.recycle、File.write、File.close)的接口(IFileSystem和IFile),并让生产类(PlayListGenerator)使用这些接口。比如在生产类的构造方法参数里放进IFileSystem和IFile对象。
 
Mock Framework的做法是,直接使用编程语言的反射机制,在内存中构造两个对象分别实现IFileSystem和IFile,然后预先把接口方法的输入输出配置好。当生产类调用到这些方法时,只要输入是之前配置过的值,那么就能得到配置的输出值。如果输入是其他没有被配置过的值,那么输出就是空值。比如,listdir("."),我们期望它得到的是["abc", "def"],那么我们就配置listdir的输入为".",输出为["abc", "def"]。
 
想象一个足够复杂的生产方法method_1,里面包含复杂的逻辑,对某个被隔离的外部方法A,它要调用多次,而且调用之间还会有依赖,比如调用方法A,然后调用方法B,然后调用方法C,再调用方法A,这里第一次调用A和第二次调用A的返回值还不一样。Mock Framework支持再次配置同一个方法,让它第二次返回另一个值。
 
Mock Framework的好处是它的隔离非常精确,直接隔离到需要隔离的点,因此几乎不会有任何冗余代码。它也不需要在Mock中实现被隔离的类的机制,因为它是纯的数据隔离。当然,好的Mock Framework,比如ABAP Test Double Framework,是支持自定义响应方法的(custom answer),所以就能在万一需要时,也能实现一些自定义的机制。
 
但这里有个问题,Mock Framework的配置本质上是和生产代码耦合的。如果你读过Robert C. Martin(Uncle Bob)的文章Giving Up on TDD(http://blog.cleancoder.com/uncle-bob/2016/03/19/GivingUpOnTDD.html),他讲到测试和生产代码会有耦合,但不应该是特别大的耦合。
 
然而我却发现,Mock Framework和生产代码的耦合是比较高的,因为它需要在方法按顺序被调用时,输入输出值是被配置的,需要考虑分支结构(If/Else/EndIf)、循环(Do/Loop)、调用类上其他方法等因素。
 
这就导致一个问题。生产代码的实现修改一次,测试也要相应地做修改,或至少需要人工过一遍。当然,你可以押宝说我的这个生产代码很牛,千年不用修改一次,所以单元测试就不需要修改。
 
Mock Framework的小结:用Mock Framework做的mock会与生产代码耦合(缺点),但与被隔离的类的机制不会有耦合(优点)。
 
Mock Class则采取比较传统的思路,即,程序员是写实现的。Mock Class也是一种实现,是隔离了底层的实现。以IFileSystem.open为例,在FileSystemMock.open方法中,它也能处理一些参数,比如输入为"Transparence - Paul Mauriat.m3u", "w"的情况下,返回一个FileMock对象,该FileMock对象有一个可自动扩展的缓冲区(类似于C#中的StringBuilder、Java中的StringBuffer,或Python中的数组列表),而在FileMock.write方法中,它把字符串追加到缓冲区。这样,如果将来要新增一个方法,FileMock.read,来读取文件中的所有内容,那么就可以将该缓冲区中的所有内容都读出来并返回。
 
在单元测试的准备阶段,可以在FileSystemMock中把初始的目录树建立成一个数据成员,将来可以被FileSystemMock.listdir读出来。这样做的好处也是很明显的:当被测试的生产类(又称“当前被测试的单元”,Current Unit of Test,简称CUT)的实现改变时(可能是需求改变,也可能是需求不变而实现被优化),底层生产类(称作“被依赖的组件”,Depended-On Components,简称DOCs)的接口和行为如果不变,那么就没有必要改变任何Mock Class,而只需要更改测试所准备的数据,并根据新的场景更新测试用例就可以了。
 
这也很好理解。当PlayListGenerator的需求改变时(比如要求从数据库里读取专辑信息),那么就需要改变它,也要改变相应的测试,但文件系统API不会因为它的改变而改变,因此这些mock依然有效。
 
Mock Class的小结:用Mock Class做的mock会与被依赖的组件(DOC)的接口和行为产生耦合(缺点),但不会与这些组件不暴露在接口上的实现细节有耦合(优点),并且它与被测试的类不会有耦合(优点)。
 
我从我目前的经验来看,当被测试的单元比较简单(比如每个方法都不超过3、4个方法调用,并且调用层次最多两层)时,用Mock Framework比较合适。但当被测试的单元比较复杂时,就适合用Mock Class。
 
关于Mock Class的扩展话题:如果文件系统本身都是可测试的,那么用Mock Class方法来单元测试该文件系统时,需要mock它自己所依赖的类,比如实现磁盘操作的LogicalDisk类。我们只需要把它隔离出来就可以了。此时,我们甚至可以做这样的测试用例:用真实的FileSystem类,但用假的LogicalDiskMock类,然后测试我们的PlayListGenerator——这就做到了一定意义上的集成测试了!当然这个在工程实践上有多大意义还另当别论。也许工程上比较好的做法还是在虚拟机或物理机上用一个空的磁盘做测试,这样才更有集成的意义。不过就编程的角度而言,不管怎样,这也是一个很有趣的尝试,而且还能通过LogicalDiskMock来观察到文件系统的操作呢!
 
附:又想到一个例子。比如有一个方法perform_check,它有增量检查功能。它在数据库里有历史记录表。当表为空,第一次调用该方法时,它会做检查,并将把必要的数据记录到数据库里。第二次调用该方法时,它会利用数据库里的记录,做增量检查。如果用Mock Framework,要做这个单元测试,就得把它第一次检查后写到数据库的数据生成出来。显然,生成的逻辑在perform_check方法里,使用perform_check本身来生成数据是最自然也是耦合最小的。如果不在Mock Framework中使用自定义的响应方法(如果使用了,本质上就和Mock Class差不多了),那么怎么获取perform_check生成的数据库数据呢?只能通过输出参数或返回值了,但这显然对perform_check的接口是有影响的,是不太好的设计。
 
如果用Mock Class来模拟数据库操作,则完全没有这些问题。第二次调用perform_check时,自然而然地就能使用第一次生成的数据,因为数据保存在mock对象的数据成员里了。于是我觉得,对这种在测试中第二次调用对第一次调用有数据依赖的情况下,还是Mock Class更合适。
 
附2:上网搜了一下。业界也有相应(但用的时候经常被混淆)的对应名词。Mock Framework实现出来的对象,业界称为Mock(相比Stub,它还能验证方法被正确调用的次数),而Mock Class这样的类,业界称为Fake(有自己的内部机制)。

返回“编程天地”