单元测试里Mock须谨慎
最近为新加的feature写单元测试的时候,遇到了非常诡异的事情,我花了两天的时间才搞明白,问题的根源出在最最原始Mock出来的东西上。这一过程也印证了,什么“见鬼了”、“太奇怪了”、“这里怎么可能出错”、“我的机器上跑很正常啊”......等等抱怨只有一个真相,那就是程序里面有bug,还是老老实实去找Root Cause吧!
下面我来简单介绍一下这件事的原委吧,希望对你们以后写单元测试有一定的启示。
为什么你这样写可以,我这样写就出错呢?
首先简单说说我这个单元测试里用到的mock的东西的背景。由于涉及到公司内部的产品,我就不用原本的东西来介绍,而是替换成大家应该都能看明白的数据库吧。假设我们做的应用需要用到数据库中的几张表,对表的操作涉及到根据指定的key来读写或者根据特定的命令行来查询表。我们用一个TableService
来管理这些表,整个系统可以有多个TableService
,我们用ServiceEndpoint
来区分不同的TableService
。
var tableService = TableServiceClient.GetTableService(serviceEndPoint)
tableService.GetAsync(tableName, key)
tableService.PutAsync(tableName, row)
大概就是这么一个简单的东西,但是由于我们这里GetTableService()
这个接口是静态的,不容易mock,所以我们在这个用TableService
的地方包了一层,从而可以用mocked TableService
。
我要做什么呢?
我有一个方法会往TableA
或者TableB
里面写东西,写之前会先按照某个唯一的key读出某个row(如果这个key不存在会抛异常),然后写入某个值之后,再把整个row写回去。(注:我们这里读写的频率不高,也不存在并行,所以超级简单)
我在单元测试的setup的时候会往mocked table里面写上这一行,这样才能保证执行到上面那个方法的时候能按照我们预想的情况走下去,最后再检查mocked table里面相应的值是否正确。
我遇到的问题是什么呢?
而当我把一切都写好,在本地run这些tests都一切正常,就是最后提交到服务器的时候再跑就不正常了。错误居然显示,我在最最开始的setup单元测试环境的时候往table里面写东西时,mocked table不存在。再看看code,这简直不可能啊,这个table怎么会不存在,明明new TableService
的时候已经创建了。关键是,别的test也这样写,不是跑得很正常吗?
回忆最初的设计
看到上面的问题是不是很无厘头,其实我到现在也都还没有搞懂呢。我们还是来先看看最初有问题的mock设计吧。
上面已经介绍过了,其实关键在于我们根据EndPoint
去获取TableService
的方法是一个静态的。我们必须想办法不要直接在code里面去使用这个静态方法获取TableService
,否则在单元测试里面很不好处理。
所以我们写了一个TableOperation
类来封装对table的一些操作,该类有两个成员tableName
和tableService
。它的构造函数需要提供tableName
的参数,然后如果是测试环境,我们就利用tableName
和endpoint
来创建一个 mockTableOperationConfig
,这个mocked config里面也包含了tableName
,
endPoint
,还有一个static
的mock的tableservice
。
// Mocked table service
internal class MockTableService<TKey, TValue> : ITableService
{
private MockContext context;
public MockTableService(string tableName, string endPoint)
{
context = new MockContext();
context.CreateTable<TKey, TValue>(tableName);
}
public async Task<Row> GetAsync(string tablePath, TKey key)
{
var table = context.GetTable<TKey, TValue>(tablePath);
if (table == null)
{
throw new TableOperationException($"Mocked table [{tablePath}] is not initialized!");
}
return await table.TryGetAsync(key);
}
public async Task PutAsync(string tablePath, TKey key, TValue value)
{
var table = context.GetTable<TKey, TValue>(tablePath);
if (table == null)
{
throw new TableOperationException($"Mocked table [{tablePath}] is not initialized!");
}
await table.PutAsync(key, value);
}
}
我们先来看看之前mock的TableService
(其中TKey
和TValue
是因为这里要操作具体的table,而不用的table结构可能是不一样的,所以这里采用了泛型),我们实现了ITableService
接口里面的GetAsync
和PutAsync
,注意到这两个方法都需要提供参数tablePath
。而一个TableService
对应的所有的table都保存在哪里呢?一个context
里面。你们看出问题了吗?真实的TableService
里面可以有很多个table,我们在实际的应用中用到的table都是已经存在的,所以直接去Get/Put
就好了。但是在上面这个mocked TableService
里面,我们只是在构造函数里创建了一个table。当test的需要用到多个table肯定会出问题的。另外,由于创建table需要context
,而这个context
又包在了MockTableService
里面,我们在setup测试的环境时根本没有办法往这个context
里面创建test所需的table。
其实这里写成这样最主要的原因还是当初没有搞清楚TableService
的功能和结构,并且当时就只用到了一个table。
上面这个问题其实还不算太大,毕竟也就导致了你在构造mockTableService
时和使用它提供的方法时传入的table不一样的时候会出错。一般情况下还是一致的,所以这里只能算是一个隐患。下面来看看最最根本的问题,如何使用这个mockTableService
。
// Mocked table operation config
public class MockTableOperationConfig<TKey, TValue> : TableOperationConfig
{
private static ITableService mockTableService = null;
public override string TablePath { get; set; }
public override string ServiceEndPoint { get; set; }
public override ITableService TableService
{
get
{
if (mockTableService == null)
{
mockTableService = new MockTableService<TKey, TValue>(TablePath, ServiceEndPoint);
}
return mockTableService;
}
}
}
首先,在泛型类中使用静态成员真的是得非常小心,因为不同类型参数的封闭构造类型之间是不共享静态成员变量的。也就是说如果在List<T>
里面有一个static
的count
,那么List<int>
和List<string>
是分开计数的。实际上MSDN的Design Warning里有这么一条CA1000: Do not declare static members on generic types。
现在回到我们最开始的问题,来看看测试的setup里面是怎么使用这些mock的东西的。
void Setup()
{
var configA = new MockTableOperationConfig<KA, VA>()
{
TablePath = TablePathA;
ServiceEndPoint = ServiceEndPointConst;
}
configA.TableService.PutAsync(AKey, Avalue).Wait();
var configB = new MockTableOperationConfig<KB, VB>()
{
TablePath = TablePathB;
ServiceEndPoint = ServiceEndPointConst;
}
configB.TableService.PutAsync(BKey, Bvalue).Wait();
}
我们在第一次往一个test table里面写东西的时候就失败了,失败的原因是mocked
table不存在。我们再一步步看看setup的过程,首先new MockTableOperationConfig
只是两个赋值,configA
的TableService
是空的。然后在call configA.TableService.PutAsync()
的时候首先会去拿configA
的TableService
,这个时候会去检查那个静态的mockTableService
是否为空,如果是空的就去new
一个MockTableService
,而MockTableService
的构造函数中则会先创建一个mockContext
,然后在这个context
下面创建所需的table。所以正常情况下,当拿到configA.TableService
的时候,我们所需要的那个mocked
table已经存在了。
但实际上,这里还是有问题的,如果多个相同类型的table,它们的tableName
并不同,最后只创建第一个table,其他table用的都是第一个table对应的TableService
。另外,在判断mockTableService
是否为空,然后去创建新的MockTableService
这部分,在多线程的情况下也会出问题。
总之,其实到现在为止我还是不太清楚为什么在cloud端会出现上面的异常,只是确定这里是有问题的。当我把MockTableOperationConfig
里面静态的mockTableService
换成了一个静态的ConcurrentDictionary<string, ITableService>
,其中key是tableName+serviceEndPoint
。这样整个test就可以跑通了。
重申Mock须谨慎
其实上面整个Mock的结构是非常不合理的,我们还有很多refactor的工作需要做。我们在mock的时候应该尽量跟真实的设计一样。例如,实际上的一个TableService
可以包含多个table,上面mock的显然不满足这个要求。
当我做了上述修改之后,又发现了好几个test都失败了。结果细细一看,有些是因为在test里面写的table和实际用的table名字不一样,有些则是因为在code里面有地方是直接从真实的TableService
环境里面去读取东西的。天知道,之前它们都是怎么跑通的。
总而言之,在你写单元测试时需要mock的时候,千万不要只想到你当下要写的test需要做什么,而是从你要mock的那个东西着手,它有什么功能,它是怎么工作的。只有把真正的它研究清楚了,你mock的它才能起到test的作用。如果连地基都建偏了,你以后的房子要么越盖越偏,要么就轰然倒塌。所以mock须谨慎。