TA的每日心情 | 擦汗 3 小时前 |
---|
签到天数: 1047 天 连续签到: 5 天 [LV.10]测试总司令
|
重构代码时,我们常常纠结于这样的问题:
需要进一步抽象吗?会不会导致过度设计?
如果需要进一步抽象的话,如何进行抽象呢?有什么通用的步骤或者法则吗?
单元测试是我们常用的验证代码正确性的工具,但是如果只用来验证正确性的话,那就是真是 “大炮打蚊子”--大材小用,它还可以帮助我们评判代码的抽象程度与设计水平。本文还会提出一个以“可测试性”为目标,不断迭代重构代码的思路,利用这个思路,面对任何复杂的代码,都能逐步推导出重构思路。为了保证直观,本文会以一个 “生产者消费者” 的代码重构示例贯穿始终。最后还会以业务上常见的 Excel 导出系统为例简单阐述一个业务上的重构实例。阅读本文需要具有基本的单元测试编写经验(最好是 Java),但是本文不会涉及任何具体的单元测试框架和技术,因为它们都是不重要的,学习了本文的思路,可以将它们用在任意的单测工具上。
不可测试的代码
程序员们重构一段代码的动机是什么?可能众说纷纭:
·代码不够简洁?
· 不好维护?
· 不符合个人习惯?
· 过度设计,不好理解?
这些都是比较主观的因素,在一个老练程序员看来恰到好处的设计,一个新手程序员却可能会觉得过于复杂,不好理解。但是让他们同时坐下来为这段代码添加单元测试时,他们往往能够产生类似的感受,比如:
“单测很容易书写,很容易就全覆盖了”,那么这就是可测试的代码;
“虽然能写得出来,但是费了老大劲,使用了各种框架和技巧,才覆盖完全”,那么这就是可测试性比较差的代码;
“完全不知道如何下手写”,那么这就是不可测试的代码;
一般而言,可测试的代码一般都是同时是简洁和可维护的,但是简洁可维护的代码却不一定是可测试的,比如下面的“生产者消费者”代码就是不可测试的:
- public void producerConsumer() {
- BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
- Thread producerThread = new Thread(() -> {
- for (int i = 0; i < 10; i++) {
- blockingQueue.add(i + ThreadLocalRandom.current().nextInt(100));
- }
- });
- Thread consumerThread = new Thread(() -> {
- try {
- while (true) {
- Integer result = blockingQueue.take();
- System.out.println(result);
- }
- } catch (InterruptedException ignore) {
- }
- });
- producerThread.start();
- consumerThread.start();
- }
复制代码 上面这段代码做的事情非常简单,启动两个线程:
·生产者:将 0-9 的每个数字,分别加上 [0,100) 的随机数后通过阻塞队列传递给消费者;
· 消费者:从阻塞队列中获取数字并打印;
这段代码看上去还是挺简洁的,但是,算得上一段好代码吗?尝试下给这段代码加上单元测试。仅仅运行一下这个代码肯定是不够的,因为我们无法确认生产消费逻辑是否正确执行。我也只能发出“完全不知道如何下手”的感叹,这不是因为我们的单元测试编写技巧不够,而是因为代码本身存在的问题:
1、违背单一职责原则:这一个函数同时做了 数据传递,处理数据,启动线程三件事情。单元测试要兼顾这三个功能,就会很难写。
2、这个代码本身是不可重复的,不利于单元测试,不可重复体现在 需要测试的逻辑位于异步线程中,对于它什么时候执行?什么时候执行完?
都是不可控的;逻辑中含有随机数;消费者直接将数据输出到标准输出中,在不同环境中无法确定这里的行为是什么,有可能是输出到了屏幕上,也可能是被重定向到了文件中;因为第 2 点的原因,我们就不得不放弃单测了呢?其实只要通过合理的模块职责划分,依旧是可以单元测试。这种划分不仅仅有助于单元测试,也会“顺便”帮助我们抽象一套更加合理的代码。
可测试意味着什么?
所有不可测试的代码都可以通过合理的重构与抽象,让其核心逻辑变得可测试,这也重构的意义所在。本章就会详细说明这一点。
首先我们要了解可测试意味着什么,如果说一段代码是可测试的,那么它一定符合下面的条件:
可以在本地设计完备的测试用例,称之为 完全覆盖的单元测试;
只要完全覆盖的单元测试用例全部正确运行,那么这一段逻辑肯定是没有问题的;
第 1 点常会令人感到难以置信,但事实比想象的简单,假设有这样一个分段函数:
f(x) 看起来有无限的定义域,我们永远无法穷举所有可能的输入。但是再仔细想想,我们并不需要穷举,其实只要下面几个用例可以通过,那么就可以确保这个函数是没有问题的:
- ·<-50
- f(-51) == -100
- · [-50, 50]
- f(-25) == -50
- f(25) == 50
- · >50
- f(51) == 100
- · 边界情况
- f(-50) == -100
- f(50) == 100
复制代码 日常工作中的代码当然比这个复杂很多,但是没有本质区别,也是按照如下思路进行单元测试覆盖的:
· 每一个分段其实就是代码中的一个条件分支,用例的分支覆盖率达到了 100%;
· 像 2x 这样的逻辑运算,通过几个合适的采样点就可以保证正确性;
· 边界条件的覆盖,就像是分段函数的转折点;
但是业务代码依旧比 f(x) 要复杂很多,因为 f(x) 还有其他好的性质让它可以被完全测试,这个性质被称作引用透明:
· 函数的返回值只和参数有关,只要参数确定,返回值就是唯一确定的
现实中的代码大多都不会有这么好的性质,反而具有很多“坏的性质”,这些坏的性质也常被称为副作用:
· 代码中含有远程调用,无法确定这次调用是否会成功;
· 含有随机数生成逻辑,导致行为不确定;
· 执行结果和当前日期有关,比如只有工作日的早上,闹钟才会响起;
· 好在我们可以用一些技巧将这些副作用从核心逻辑中抽离出来。
高阶函数
“引用透明” 要求函数的出参由入参唯一确定,之前的例子容易让人产生误解,觉得出参和入参一定要是数据,让我们把视野再打开一点,出入参可以是一个函数,它也可以是引用透明的。
普通的函数又可以称作一阶函数,而接收函数作为参数,或者返回一个函数的函数称为高阶函数,高阶函数也可以是引用透明的。
对于函数 f(x) 来说,x 是数据还是函数,并没有本质的不同,如果 x 是函数的话,仅仅意味着 f(x) 拥有更加广阔的定义域,以至于没有办法像之前一样只用一个一维数轴表示。
对于高阶函数 f(g) (g 是一个函数)来说,只要对于特定的函数 g,返回逻辑也是固定,它就是引用透明的了, 而不用在乎参数 g 或者返回的函数是否有副作用。利用这个特性,我们很容易将一个有副作用的函数转换为一个引用透明的高阶函数。
一个典型的拥有副作用的函数如下:
- public int f() {
- return ThreadLocalRandom.current().nextInt(100) + 1;
- }
复制代码 它生成了随机数并且加 1,因为这个随机数,导致它不可测试。但是我们将它转换为一个可测试的高阶函数,只要将随机数生成逻辑作为一个参数传入,并且返回一个函数即可:
- public Supplier<Integer> g(Supplier<Integer> integerSupplier) {
- return () -> integerSupplier.get() + 1;
- }
复制代码 上面的 g 就是一个引用透明的函数,只要给 g 传递一个数字生成器,返回值一定是一个 “用数字生成器生成一个数字并且加1” 的逻辑,并且不存在分支条件和边界情况,只需要一个用例即可覆盖:
- public void testG() {
- Supplier<Integer> result = g(() -> 1);
- assert result.get() == 2;
- }
复制代码 实际业务中可以稍微简化一下高阶函数的表达, g 的返回的函数既然每次都会被立即执行,那我们就不返回函数了,直接将逻辑写在方法中,这样也是可测试的:
- public int g2(Supplier<Integer> integerSupplier) {
- return integerSupplier.get() + 1;
- }
复制代码 这里我虽然使用了 Lambda 表达式简化代码,但是 “函数” 并不仅仅是指 Lambda 表达式,OOP 中的充血模型的对象,接口等等,只要其中含有逻辑,它们的传递和返回都可以看作 “函数”。
因为这个例子比较简单,“可测试” 带来的收益看起来没有那么高,真实业务中的逻辑一般比 +1 要复杂多了,此时如果能构建有效的测试将是非常有益的。
|
|