原文出处:琴水玉
概述
单测是提升软件质量的有力手段。然而,由于编程语言上的支持不力,以及一些不好的编程习惯,导致编写单测很困难。
最容易理解最容易编写的单测,莫过于独立函数的单测。所谓独立函数,就是只依赖于传入的参数,不修改任何外部状态的函数。指定输入,就能确定地输出相应的结果。运行任意次,都是一样的。在函数式编程中,有一个特别的术语:“引用透明性”,也就是说,可以使用函数的返回值彻底地替代函数调用本身。独立函数常见于工具类及工具方法。
不过,现实常常没有这么美好。应用要读取外部配置,要依赖外部服务获取数据进行处理等,导致应用似乎无法单纯地“通过固定输入得到固定输出”。实际上,有两种方法可以尽可能隔离外部依赖,使得依赖于外部环境的对象方法回归“独立函数”的原味。
(1) 引用外部变量的函数, 将外部变量转化为函数参数; 修改外部变量的函数,将外部变量转化为返回值或返回对象的属性。
(2) 借助函数接口以及lambda表达式,隔离外部服务。
隔离依赖配置
先看一段代码。这段代码通过Spring读取已有服务器列表配置,并随机选取一个作为上传服务器。
public class FileService {
// ...
@Value("${file.server}")
private String fileServer;
/**
* 随机选取上传服务器
* @return 上传服务器URL
*/
private String pickUrl(){
String urlStr = fileServer;
String[] urlArr = urlStr.split(",");
int idx = rand.nextInt(2);
return urlArr[idx].trim();
}
}
咋一看,这段代码也没什么不对。可是,当编写单测的时候,就尴尬了。 这段代码引用了实例类FileService的实例变量 fileServer ,而这个是从配置文件读取的。要编写单测,得模拟整个应用启动,将相应的配置读取进去。可是,这段代码无非就是从列表随机选取服务器而已,并不需要涉及这么复杂的过程。这就是导致编写单测困难的原因之一:轻率地引用外部实例变量或状态,使得本来纯粹的函数或方法变得不那么“纯粹”了。
要更容易地编写单测,就要尽可能消除函数中引用的外部变量,将其转化为函数参数。进一步地,这个方法实际上跟 FileService 没什么瓜葛,反倒更像是随机工具方法。应该写在 RandomUtil 里,而不是 FileService。 以下代码显示了改造后的结果:
public class RandomUtil {
private RandomUtil() {}
private static Random rand = new Random(47);
public static String getRandomServer(String servers) {
if (StringUtils.isBlank(servers)) {
throw new ExportException("No server configurated.");
}
String[] urlArr = servers.split(",");
int idx = rand.nextInt(2);
return urlArr[idx].trim();
}
}
private String pickUrl(){
return RandomUtil.getRandomServer(fileServer);
}
public class RandomUtilTest {
@Test
public void testGetRandomServer() {
try {
RandomUtil.getRandomServer("");
fail("Not Throw Exception");
} catch (ExportException ee) {
Assert.assertEquals("No server configurated.", ee.getMessage());
}
String servers = "uploadServer1,uploadServer2";
Set<String> serverSet = new HashSet<>(Arrays.asList("uploadServer1", "uploadServer2"));
for (int i=0; i<100;i++) {
String server = RandomUtil.getRandomServer(servers);
Assert.assertTrue(serverSet.contains(server));
}
}
}
这样的代码并不鲜见。 引用实例类中的实例变量或状态,是面向对象编程中的常见做法。然而,尽管面向对象是一种优秀的宏观工程理念,在代码处理上,却不够细致。而我们只要尽可能将引用实例变量的方法变成含实例变量参数的方法,就能让单测更容易编写。
隔离依赖服务
一个分页例子
先看代码。这是一段很常见的分页代码。根据一个查询条件,获取对象列表和总数,返回给前端。
@RequestMapping(value = "/searchForSelect")
@ResponseBody
public Map<String, Object> searchForSelect(@RequestParam(value = "k", required = false) String title,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "10") Integer pageSize) {
CreativeQuery query = new CreativeQuery();
query.setTitle(title);
query.setPageNum(page);
query.setPageSize(pageSize);
List<CreativeDO> creativeDTOs = creativeService.search(query);
Integer total = creativeService.count(query);
Map<String, Object> map = new HashMap<String, Object>();
map.put("rows", (null == creativeDTOs) ? new ArrayList<CreativeDO>() : creativeDTOs);
map.put("total", (null == total) ? 0 : total);
return map;
}
要编写这个函数的单测,你需要 mock creativeService。对,mock 的目的实际上只是为了拿到模拟的 creativeDTOs 和 total 值,然后塞入 map。 最后验证 map 里是否有 rows 和 total 两个 key 以及值是否正确。
我讨厌 mock !引入一堆繁重的东西,mock 的代码并不比实际的产品代码少,而且很无聊 ! 对于懒惰的人来说,写更多跟产品和测试“没关系”的代码就是惩罚!有没有办法呢? 实际上,可以采用函数接口来隔离这些外部依赖服务。 见如下改写后的代码: getListFunc 表达了如何根据 CreativeQuery 得到 CreativeDO 的列表, getTotalFunc 表达了如何根据 CreativeQuery 得到 CreativeDO 的总数。 原来的 searchForSelect 方法只要传入两个 lambda 表达式即可。
public Map<String, Object> searchForSelect(@RequestParam(value = "k", required = false) String title,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "rows", defaultValue = "10") Integer pageSize) {
CreativeQuery query = buildCreativeQuery(title, page, pageSize);
return searchForSelect2(query,
(q) -> creativeService.search(q),
(q) -> creativeService.count(q));
}
public Map<String, Object> searchForSelect2(CreativeQuery query,
Function<CreativeQuery, List<CreativeDO>> getListFunc,
Function<CreativeQuery, Integer> getTotalFunc) {
List<CreativeDO> creativeDTOs = getListFunc.apply(query);
Integer total = getTotalFunc.apply(query);
Map<String, Object> map = new HashMap<String, Object>();
map.put("rows", (null == creativeDTOs) ? new ArrayList<CreativeDO>() : creativeDTOs);
map.put("total", (null == total) ? 0 : total);
return map;
}
/*
* NOTE: can be placed in class QueryBuilder
*/
public CreativeQuery buildCreativeQuery(String title, Integer page, Integer pageSize) {
CreativeQuery query = new CreativeQuery();
query.setTitle(title);
query.setPageNum(page);
query.setPageSize(pageSize);
return query;
}
现在,如何编写单测呢? buildCreativeQuery 这个自不必说。 实际上,只需要对 searchForSelect2 做单测,因为这个承载了主要内容; 而 searchForSelect 只是流程的东西,通过联调就可以测试。单测代码如下:
public class CreativeControllerTest {
CreativeController controller = new CreativeController();
@Test
public void testSearchForSelect2() {
CreativeQuery creativeQuery = controller.buildCreativeQuery("haha", 1, 20);
Map<String, Object> result = controller.searchForSelect2(creativeQuery,
(q) -> null , (q)-> 0);
Assert.assertEquals(0, ((List)result.get("rows")).size());
Assert.assertEquals(0, ((Integer)result.get("total")).intValue());
}
}
注意到,这里使用了 lambda 表达式来模拟返回外部服务的返回结果,因为我们本身就用 Function 接口隔离和模拟了外部服务依赖。 细心的读者一定发现了: lambda 表达式,简直是单测的 Mock 神器啊!
It’s Time to Say Goodbye to Mock Test Framework !
改写业务代码
看一段常见的业务代码,通过外部服务获取订单的物流详情后,做一段处理,然后返回相应的结果。
private List<Integer> getOrderSentIds(long sId, String orderNo) {
OrderParam param = ParamBuilder.buildOrderParam(sId, orderNo);
PlainResult<List<OrderXXXDetail>> xxxDetailResult =
orderXXXService.getOrderXXXDetailByOrderNo(param);
if (!xxxDetailResult.isSuccess()) {
return Lists.newArrayList();
}
List<OrderXXXDetail> xxxDetails = xxxDetailResult.getData();
List<Integer> sentIds = Lists.newArrayList();
xxxDetails.forEach(xxxDetail -> sentIds.add(xxxDetail.getId()));
return sentIds;
}
从第三行 if 到 return 的是一个不依赖于外部服务的独立函数。为了便于写单测,实际上应该将这一部分抽离出来成为单独的函数。不过这样对于程序猿来说,有点生硬。那么,使用函数接口如何改造呢?可以将 orderXXXService.getOrderXXXDetailByOrderNo(param) 作为函数参数的传入。 代码如下:
private List<Integer> getOrderSentIds2(long sId, String orderNo) {
OrderParam param = ParamBuilder.buildOrderParam(sId, orderNo);
return getOrderSentIds(param, (p) -> orderXXXService.getOrderXXXDetailByOrderNo(p));
}
public List<Integer> getOrderSentIds(OrderParam order,
Function<OrderParam, PlainResult<List<OrderXXXDetail>>> getOrderXXXFunc) {
PlainResult<List<OrderXXXDetail>> xxxDetailResult = getOrderXXXFunc.apply(order);
if (!xxxDetailResult.isSuccess()) {
return Lists.newArrayList();
}
List<OrderXXXDetail> xxxDetails = xxxDetailResult.getData();
List<Integer> sentIds = Lists.newArrayList();
xxxDetails.forEach(xxxDetail -> sentIds.add(xxxDetail.getId()));
return sentIds;
}
现在,getOrderSentIds2 只是个顺序流,通过联调可以验证; getOrderSentIds 承载着主要内容,需要编写单测。 而这个方法现在是不依赖于外部服务的,可以通过 lambda 表达式模拟任何外部服务传入的数据了。单测如下:
@Test
public void testGetOrderSentIds() {
OrderParam orderParam = ParamBuilder.buildOrderParam(55L, "Dingdan20170530");
PlainResult<List<OrderXXXDetail>> failed = new PlainResult<>();
failed.setSuccess(false);
Assert.assertArrayEquals(new Integer[0],
deliverer.getOrderSentIds(orderParam, p -> failed).toArray(new Integer[0]));
OrderXXXDetail detail1 = new OrderXXXDetail();
detail1.setId(1);
OrderXXXDetail detail2 = new OrderXXXDetail();
detail2.setId(2);
List<OrderXXXDetail> details = Arrays.asList(detail1, detail2);
PlainResult<List<OrderXXXDetail>> result = new PlainResult<>();
result.setData(details);
Assert.assertArrayEquals(new Integer[] {1,2},
deliverer.getOrderSentIds(orderParam, p -> result).toArray(new Integer[0]));
}
更通用的方法
事实上,借助于函数接口及泛型,可以编写出更通用的方法。 如下代码所示。 现在,可以从任意服务获取任意符合接口的对象数据,并取出其中的ID字段了。泛型是一个强大的工具,一旦你发现一种操作可以适用于多种类型,就可以使用泛型通用化操作。
public interface ID {
Integer getId();
}
public <P, T extends ID> List<Integer> getIds(P order,
Function<P, PlainResult<List<T>>> getDetailFunc) {
PlainResult<List<T>> detailResult = getDetailFunc.apply(order);
if (!detailResult.isSuccess()) {
return Lists.newArrayList();
}
List<T> details = detailResult.getData();
return details.stream().map(T::getId).collect(Collectors.toList());
}
隔离方法调用引起的间接依赖
这个例子显示了代码的另一种常态:queryEsData 里混杂外部实例变量 this.serviceUrl 以及调用依赖外部服务的下层方法 query ,其中充斥的条件逻辑、循环逻使得方法显得更加“复杂”,导致难以进行单测。注意到,这里已经使用了函数接口来表达如何从获取的HTTP返回结果中提取感兴趣的数据集。
/**
* 根据 ES 查询对象及结果提取器提取 ES 数据集
* @param initQuery ES 查询对象
* @param getData ES结果提取器
* @return List ES数据集
*/
public <T> List<T> queryEsData(QueryBuilder initQuery, Function<JSONObject, List<T>> getData) {
List<T> rsList = new ArrayList<T>();
try {
JSONObject result = query(initQuery.toJsonString(), this.serviceUrl + "?search_type=scan&scroll=600000");
logger.info("ES search init result: " + result.toJSONString());
String scrollId = result.getString("scroll_id");
if (scrollId == null) {
return rsList;
}
String scrollUrl = this.serviceUrl + "?scroll=600000";
while (true){
DataQueryBuilder dataQuery = new DataQueryBuilder();
dataQuery.setScroll_id(scrollId);
JSONObject jsonResult = query(JSON.toJSONString(dataQuery), scrollUrl);
scrollId = jsonResult.getString("scroll_id");
List<T> tmpList = getData.apply(jsonResult);
if(tmpList.size() == 0){
break;
}
rsList.addAll(tmpList);
}
} catch (Exception e) {
logger.error("getESDataException", e);
}
return rsList;
}
咋一看,似乎无从下手。别急,一步步来。
提取依赖变量和依赖函数
很容易看到,这个方法两次调用了 query, 可以先将 query 隔离出来,变成:
public <T> List<T> queryEsData(QueryBuilder initQuery, Function<JSONObject, List<T>> getData) {
return queryEsDataInner(initQuery, this::query, getData);
}
不过, 外部实例变量 this.serviceUrl 还在 queryEsDataInner 里面,会破坏 queryEsDataInner 的纯粹性,因此,要把这两个URL提取出来,放到 queryEsData 里传入给 queryEsDataInner. 效果应该是这样:
public <T> List<T> queryEsData(QueryBuilder initQuery, Function<JSONObject, List<T>> getData) {
String initUrl = this.serviceUrl + "?search_type=scan&scroll=600000";
String scrollUrl = this.serviceUrl + "?scroll=600000";
return queryEsDataInner(initQuery, initUrl, scrollUrl, this::query, getData);
}
public <T> List<T> queryEsDataInner(QueryBuilder initQuery, String initUrl, String scrollUrl,
BiFunction<String, String, JSONObject> query,
Function<JSONObject, List<T>> getData) {
try {
JSONObject result = query.apply(initQuery.toJsonString(), initUrl);
logger.info("ES search init result: " + result.toJSONString());
String scrollId = result.getString("scroll_id");
if (scrollId == null) {
return new ArrayList<>();
}
List rsList = new ArrayList();
while (true){
DataQueryBuilder dataQuery = new DataQueryBuilder();
dataQuery.setScroll_id(scrollId);
JSONObject jsonResult = query.apply(JSON.toJSnull