作者:lovespcc
概述
单测是提升软件质量的有力手段。然而,由于编程语言上的支持不力,以及一些不好的编程习惯,导致编写单测很困难。
最容易理解最容易编写的单测,莫过于独立函数的单测。所谓独立函数,就是只依赖于传入的参数,不修改任何外部状态的函数。指定输入,就能确定地输出相应的结果。运行任意次,都是一样的。在函数式编程中,有一个特别的术语:“引用透明性”,也就是说,可以使用函数的返回值彻底地替代函数调用本身。独立函数常见于工具类及工具方法。
不过,现实常常没有这么美好。应用要读取外部配置,要依赖外部服务获取数据进行处理等,导致应用似乎无法单纯地“通过固定输入得到固定输出”。实际上,有两种方法可以尽可能隔离外部依赖,使得依赖于外部环境的对象方法回归“独立函数”的原味。
(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.toJSONString(dataQuery), scrollUrl);
scrollId = jsonResult.getString("scroll_id");
List<T> tmpList = getData.apply(jsonResult);
if(tmpList.size() == 0){
break;
}
rsList.addAll(tmpList);
}
return rsList;
} catch (Exception e) {
logger.error("getESDataException", e);
return new ArrayList<>();
}
}
queryEsData 不纯粹没关系,反正它就是个壳。 仅这一步,就让 queryEsDataInner 方法变成了独立方法,不再依赖于任何外部变量和外部service,也不依赖于调用函数。不过 queryEsDataInner 会有点丑:有五个参数。 丑是丑了点,不过单测相对比较好写了。这里的 getData 动态生成不同的结果以便退出,因此用了点技巧,见代码:
@Test
public void testQueryEsDataInner() {
HttpEsClient esClient = new HttpEsClient();
List empty = esClient.queryEsDataInner(new QueryBuilder(), "initUrl", "", (q, u) -> new JSONObject(), (jo) -> new ArrayList());
Assert.assertEquals(0, empty.size());
JSONObject jo = new JSONObject();
List<Integer> list = Arrays.asList(1,2,3);
jo.put("scroll_id", "1");
jo.put("list", list);
List result = esClient.queryEsDataInner(new QueryBuilder(), "initUrl", "scrollUrl", (q, u) -> jo, this::dyGetData);
Assert.assertArrayEquals(new Integer[]{1,2,3,1,2,3}, result.toArray(new Integer[0]));
}
private static int i = 2;
private List<Integer> dyGetData(JSONObject jsonObject) {
if (i == 0) { return new ArrayList<>(); }
i--;
return jsonObject.getJSONArray("list").toJavaList(Integer.class);
}
外部依赖引入源
综上例子,一个方法的外部依赖引入源主要有:
(1) 方法所在类的实例变量,在方法里引用就如同引用了可能被随时修改的全局变量,是非常破坏方法的纯粹性的;
(2) 方法所在类注入的Service, 在方法里使用就成了方法的外部依赖,往往要写Mock外部依赖的结果数据才能进行单测;
(3) 方法调用了依赖外部服务的下层方法,导致方法有间接依赖。
对于(1),含有业务逻辑的方法应当将实例变量作为函数参数; 对于 (2) 和 (3), 使用函数接口和lambda表达式隔离和模拟依赖服务。
不过这里有两个问题:
(1) 如果一个方法依赖了多个 service 或 多个方法,怎么办? 那就要传入多个 Function 参数了。 另一种办法是,遵循单一职责原则,尽量编写短小的只含有至多一个Service或方法依赖的方法。每个方法只做明确的一件事。 很多调用多个Service 或多个方法的方法,就是做了太多事情了,每件事都不彻底,导致每次扩展都要在一个方法里增加很多条件分支。
(2) 大量的函数接口和lambda表达式可能像回调一样,容易将人绕晕。因此,一个函数最多两个函数接口为宜。 而函数接口和lambda表达式的使用,需要整体策略来控制,保持工程的可理解性和可维护性。 毕竟,可测性只是工程质量的一个属性,不能过于追求一个属性而破坏其他属性。
工程的“版图”
一个工程里应当被划分为“两半版图”:版图A是依赖于各种外部服务的调用,版图B是不依赖于任何外部服务的独立业务方法和工具类。版图B中的独立业务方法充满着各种业务逻辑和判断,是容易编写单测的,而版图A是没有必要写单测的,因为里面没有逻辑。这样,我们将工程中的外部依赖“驱逐到”版图A,类似于第九区里的“外星人管理区”。
理想情况下,版图B应该是占90%的领土,版图A应该占10%的领土。不过,实际工程中正好相反,版图A占了90%的领土,版图B却被驱逐到util包下,只占10%,单测还往往被忽视。 怎么改造呢? 实际上也很简单: 一旦从A的业务方法 FA 中发现外部依赖,就抽离出一个独立方法 FB 来隔离外部依赖,放到版图B里,然后对 FB 进行仔细单测,而 FA 只作为一个壳或外观模式,通过联调来确保正确。
对外部依赖的隔离,使得更容易编写单测,更容易获得更高的单测覆盖率和单测质量。
此外,导致单测编写困难的另一个“罪魁祸首”,就是不好的编程习惯,将大量多个逻辑放在同一个方法里。这样,为了测试一个东西,要构造大量的对象;同时,对其中的子部分则不容易测试彻底,导致隐藏的BUG。
对于增强代码可测性的唯一建议就是: 拆解、隔离。
单测策略
并不是所有代码都需要写单测的。也不是所有代码用单测更有效率。 在我看来,如果是纯顺序的逻辑,可以通过接口测试来保证,尤其是对于那些依赖外部服务的单行调用,既无法写单测也不必要写单测。而对于具有条件分支、循环分支等的逻辑,则要尽可能隔离成独立方法或函数,从而更容易滴更有效率地单测。
单测并不需要100%的覆盖率,也不应当花费过度的成本去追求高的覆盖率。 100%的覆盖率也不代表质量杠杠滴。 在单测覆盖率和软件开发成本中,必须有一个平衡。更好的软件质量,应当是较高的单测覆盖率与适当的接口用例覆盖的双重护航而保障,而不是把注都押在单测上。
疑虑
当然,使用任何一种新方式,总会有疑虑的。
高阶函数不易掌握
使用函数接口,或者说高阶函数的写法,对于很多童鞋可能还很不适应。 不过,这种写法以后很可能会成为主流。 因为它便捷、安全,而且很容易产生通用化的方法。通过高阶框架函数以及许多自定义业务函数的反复组合,构建起整个软件。
事实上,高阶函数并不陌生。在 C 语言时代,就已经通过函数指针支持传入函数参数了。 因此,高阶函数,只是将函数指针“对象化”了,并不是新鲜玩意。
多出的方法
从上面的例子可以看到,每一个被改造的方法,最终会得到两个方法: 一个隔离了外部依赖的独立函数,一个依赖外部服务的单行调用。独立函数便于测试,而单行调用通常通过联调来保证OK。这对软件测试是个福音,不过对于程序员来说,会不会是额外的负担呢?可能取决于各自的选择吧。至少在我看来,多一个方法,却能够更方便地测试,甩掉繁重的mock单测框架,是非常值得的。此外,通常还能从中挖掘出更通用的方法,消除重复的业务代码,也是另一个好消息。
工程隐患
在生产环境的工程中大量使用函数接口和lambda表达式,是否有隐患呢?目前还没有确切证据。如果有了,可以不断积累经验,但不应当因噎废食。一种新技术、新方式,总要踩上若干坑,才能成为成熟的技术,将软件开发推向一个新的里程碑。
在我所负责的订单导出工程里,已经大量使用了函数接口和lambda表达式。如果运行不稳定,那么也可以得到第一手的资料。且让我们拭目以待。
自动生成单测
一旦我们尽可能将依赖外部服务的函数转化为“非依赖于外部服务的独立函数+外部服务的单行调用”,编写单测的工作就变成了对独立函数的单测。而独立函数的单测是可以自动生成的。后续会专门有一篇文章来谈到Java单测类模板的自动生成。目前仅仅谈及思路。
单测的编写模板无非是:解析方法签名; 创建对象; 设置对象值; 设置外部服务返回数据; 检测返回结果。 解析方法签名通过可以使用正则表达式;创建对象和设置对象属性,可使用java反射机制; 设置外部服务返回数据, 可创建简单的 lambda 表达式来模拟。
原创文章,作者:Maggie-Hunter,如若转载,请注明出处:https://blog.ytso.com/98192.html