面向单元测测试的代码重构

前言 重构代码时,我们常常纠结于这样的问题:

  • 需要进一步抽象吗?会不会导致过度设计?
  • 如果需要进一步抽象的话,如何进行抽象呢?有什么通用的步骤或者法则吗?
单元测试是我们常用的验证代码正确性的工具,但是如果只用来验证正确性的话,那就是真是 “大炮打蚊子”--大材小用,它还可以帮助我们评判代码的抽象程度与设计水平。本文还会提出一个以“可测试性”为目标,不断迭代重构代码的思路,利用这个思路,面对任何复杂的代码,都能逐步推导出重构思路。
为了保证直观,本文会以一个 『生产者消费者』 的代码重构示例贯穿始终。
示例 重构 & 单测
在开始『生产者消费者』 的代码重构示例前,先聊一聊重构。
程序员们重构一段代码的动机是什么?可能众说纷纭:
  • 代码不够简洁?
  • 不好维护?
  • 不符合个人习惯?
  • 过度设计,不好理解?
概括来说,就是降低代码和架构的腐化速度,降低维护和升级的成本。在我看来,保证软件质量和复杂度的唯一手段就是『持续重构』。
这里又引出一个问题,什么样代码/架构是好维护的?业界针对此问题有很多设计准则,比如开闭原则、单一职责原则、依赖倒置原则等等。但是今天想从另外一个角度来说,是不是可测性也可以做为衡量代码好坏的标准。一般而言,可测试的代码一般都是同时是简洁和可维护的,但是简洁可维护的代码却不一定是可测试的。
所以,从这个角度来说说重构是为了增强代码的可测性,即面向单测重构。另一方面,一段没有单测的代码,你敢重构么?这么看来,单测和重构的关系密不可分。
接下来我们看一个简单的例子,体会面向单测的重构。
public void producerConsumer() { BlockingQueue 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(); }

上面这段代码主要可以划分为3部分:
  • 生产者:往阻塞队列里添加10个数据,具体逻辑:将 0-9 的每一个数字,分别加上 [0,100) 的随机数
  • 消费者:从阻塞队列中获取数字,并打印
  • 主线程:启动两个线程,分别是生产者和消费者
这段代码看上去还是挺简洁的,但是,算得上一段好代码吗?尝试下给这段代码加上单元测试。仅仅运行一下这个代码肯定是不够的,因为我们无法确认生产消费逻辑是否正确执行。我也只能发出“完全不知道如何下手”的感叹,这不是因为我们的单元测试编写技巧不够,而是因为代码本身存在的问题:
  1. 违背单一职责原则:这一个函数同时做了 数据传递,处理数据,启动线程三件事情。单元测试要兼顾这三个功能,就会很难写。
  2. 这个代码本身是不可重复的,不利于单元测试,不可重复体现在
    • 需要测试的逻辑位于异步线程中,对于它什么时候执行?什么时候执行完?都是不可控的
    • 逻辑中含有随机数
    • 消费者直接将数据输出到标准输出中,在不同环境中无法确定这里的行为是什么,有可能是输出到了屏幕上,也可能是被重定向到了文件中
说到这里,我们先停一停,讨论一个点,『可测试意味着什么』?因为前面说到,重构的目的是让代码可测试,这里有必要重点讨论下这个概念。
可测试意味着什么?
首先我们要了解可测试意味着什么,如果说一段代码是可测试的,那么它一定符合下面的条件:
  1. 可以在本地设计完备的测试用例,称之为完全覆盖的单元测试;
  2. 只要完全覆盖的单元测试用例全部正确运行,那么这一段逻辑肯定是没有问题的;
再进一步,如果一个函数的返回值只和参数有关,只要参数确定,返回值就是唯一确定的,那么这样的函数一定能被完全覆盖。这个好的特性叫引用透明。
但是现实中的代码大多都不会有这么好的性质,反而具有很多“坏的性质”,这些坏的性质也常被称为副作用:
  1. 代码中含有远程调用,无法确定这次调用是否会成功;
  2. 含有随机数生成逻辑,导致行为不确定;
  3. 执行结果和当前日期有关,比如只有工作日的早上,闹钟才会响起;
好在我们可以用一些技巧将这些副作用从核心逻辑中抽离出来。
“引用透明” 要求函数的出参由入参唯一确定,之前的例子容易让人产生误解,觉得出参和入参一定要是数据,让我们把视野再打开一点,出入参可以是一个函数,它也可以是引用透明的。
普通的函数又可以称作一阶函数,而接收函数作为参数,或者返回一个函数的函数称为高阶函数,高阶函数也可以是引用透明的。
对于高阶函数 f(g) (g 是一个函数)来说,只要对于特定的函数 g,返回逻辑也是固定,它就是引用透明的了, 而不用在乎参数 g 或者返回的函数是否有副作用。利用这个特性,我们很容易将一个有副作用的函数转换为一个引用透明的高阶函数。
一个典型的拥有副作用的函数如下:
public int f() { return ThreadLocalRandom.current().nextInt(100) + 1; }

它生成了随机数并且加 1,因为这个随机数,导致它不可测试。但是我们将它转换为一个可测试的高阶函数,只要将随机数生成逻辑作为一个参数传入,并且返回一个函数即可:
public int g(Supplier integerSupplier) { return integerSupplier.get() + 1; }

上面的 g 就是一个引用透明的函数,只要给 g 传递一个数字生成器,返回值一定是一个 “用数字生成器生成一个数字并且加1” 的逻辑,并且不存在分支条件和边界情况,只需要一个用例即可覆盖:
public void testG() { Supplier result = g(() -> 1); assert result.get() == 2; }

【面向单元测测试的代码重构】这里我虽然使用了 Lambda 表达式简化代码,但是 “函数” 并不仅仅是指 Lambda 表达式,OOP 中的充血模型的对象,接口等等,只要其中含有逻辑,它们的传递和返回都可以看作 “函数”。
第一轮重构
我们本章回到开头的生产者消费者的例子,用上一章学习到的知识对它进行重构。
那段代码无法测试的第一个问题就是职责不清晰,它既做数据传递,又做数据处理。因此我们考虑将生产者消费者数据传递的代码单独抽取出来:
public voidproducerConsumerInner(Consumer> producer, Consumer> consumer) { BlockingQueue blockingQueue = new LinkedBlockingQueue<>(); new Thread(() -> producer.accept(blockingQueue::add)).start(); new Thread(() -> consumer.accept(() -> { try { return blockingQueue.take(); } catch (InterruptedException e) { throw new RuntimeException(e); } })).start(); }

这一段代码的职责就很清晰了,我们给这个方法编写单元测试的目标也十分明确,即验证数据能够正确地从生产者传递到消费者。但是很快我们又遇到了之前提到的第二个问题,即异步线程不可控,会导致单测执行的不稳定,用上一章的方法,我们将执行器作为一个入参抽离出去:
public voidproducerConsumerInner(Executor executor, Consumer> producer, Consumer> consumer) { BlockingQueue blockingQueue = new LinkedBlockingQueue<>(); executor.execute(() -> producer.accept(blockingQueue::add)); executor.execute(() -> consumer.accept(() -> { try { return blockingQueue.take(); } catch (InterruptedException e) { throw new RuntimeException(e); } })); }

这时我们就为它写一个稳定的单元测试了:
private void testProducerConsumerInner() { producerConsumerInner(Runnable::run, (Consumer>) producer -> { producer.accept(1); producer.accept(2); }, consumer -> { assert consumer.get() == 1; assert consumer.get() == 2; }); }

只要这个测试能够通过,就能说明生产消费在逻辑上是没有问题的。一个看起来比之前的分段函数复杂很多的逻辑,本质上却只是它定义域上的一个恒等函数(因为只要一个用例就能覆盖全部情况),是不是很惊讶。
如果不太喜欢上述的函数式编程风格,可以很容易地将其改造成 OOP 风格的抽象类
public abstract class ProducerConsumer {private final Executor executor; private final BlockingQueue blockingQueue; public ProducerConsumer(Executor executor) { this.executor = executor; this.blockingQueue = new LinkedBlockingQueue<>(); }public void start() { executor.execute(this::produce); executor.execute(this::consume); }abstract void produce(); abstract void consume(); protected void produceInner(T item) { blockingQueue.add(item); }protected T consumeInner() { try { return blockingQueue.take(); } catch (InterruptedException e) { throw new RuntimeException(e); } } }

此时单元测试就会像是这个样子:
private void testProducerConsumerAbCls() { new ProducerConsumer(Runnable::run) { @Override void produce() { produceInner(1); produceInner(2); }@Override void consume() { assert consumeInner() == 1; assert consumeInner() == 2; } }.start(); }

主函数
public void producerConsumer() { new ProducerConsumer(Executors.newFixedThreadPool(2)) { @Override void produce() { for (int i = 0; i < 10; i++) { produceInner(i + ThreadLocalRandom.current().nextInt(100)); } }@Override void consume() { while (true) { Integer result = consumeInner(); System.out.println(result); } } }.start(); }

第二轮重构
在第一轮重构中,我们仅仅保障了数据传递逻辑是正确的,在第二轮重构中,我们还将进一步扩大可测试的范围。
代码中影响我们进一步扩大测试范围因素还有两个:
  • 随机数生成逻辑
  • 打印逻辑
只要将这两个逻辑像之前一样抽出来即可:
public class NumberProducerConsumer extends ProducerConsumer {private final Supplier numberGenerator; private final Consumer numberConsumer; public NumberProducerConsumer(Executor executor, Supplier numberGenerator, Consumer numberConsumer) { super(executor); this.numberGenerator = numberGenerator; this.numberConsumer = numberConsumer; }@Override void produce() { for (int i = 0; i < 10; i++) { produceInner(i + numberGenerator.get()); } }@Override void consume() { while (true) { Integer result = consumeInner(); numberConsumer.accept(result); } } }

这次采用 OOP 和 函数式 混编的风格,也可以考虑将 numberGenerator 和 numberConsumer 两个方法参数改成抽象方法,这样就是更加纯粹的 OOP。
它也只需要一个测试用例即可实现完全覆盖:
private void testProducerConsumerInner2() { AtomicInteger expectI = new AtomicInteger(); producerConsumerInner2(Runnable::run, () -> 0, i -> { assert i == expectI.getAndIncrement(); }); assert expectI.get() == 10; }

此时主函数变成:
public void producerConsumer() { new NumberProducerConsumer(Executors.newFixedThreadPool(2), () -> ThreadLocalRandom.current().nextInt(100), System.out::println).start(); }

经过两轮重构,我们将一个很随意的面条代码重构成了很优雅的结构,除了更加可测试外,代码也更加简洁抽象,可复用,这些都是面向单测重构所带来的附加好处。
你可能会注意到,即使经过了两轮重构,我们依旧不会直接对主函数 producerConsumer 进行测试,而只是无限接近覆盖里面的全部逻辑,因为我认为它不在“测试的边界”内,我更倾向于用集成测试去测试它,集成测试则不在本篇文章讨论的范围内。下一章则重点讨论测试边界的问题。
单元测试的边界 边界内的代码都是单元测试可以有效覆盖到的代码,而边界外的代码则是没有单元测试保障的。
上一章所描述的重构过程本质上就是一个在探索中不断扩大测试边界的过程。但是单元测试的边界是不可能无限扩大的,因为实际的工程中必然有大量的不可测试部分,比如 RPC 调用,发消息,根据当前时间做计算等等,它们必然得在某个地方传入测试边界,而这一部分就是不可测试的。
理想的测试边界应该是这样的,系统中所有核心复杂的逻辑全部包含在了边界内部,然后边界外都是不包含逻辑的,非常简单的代码,比如就是一行接口调用。这样任何对于系统的改动都可以在单元测试中就得到快速且充分的验证,集成测试时只需要简单测试下即可,如果出现问题,一定是对外部接口的理解有误,而不是系统内部改错了。
清晰的单元测试边界划分有利于构建更加稳定的系统核心代码,因为我们在推进测试边界的过程中会不断地将副作用从核心代码中剥离出去,最终会得到一个完整且可测试的核心,就如同下图的对比一样:

好代码从来都不是一蹴而就的,都是先写一个大概,然后逐渐迭代和重构的,从这个角度来说,重构别人的代码和写新代码没有很大的区别。
从上面的内容中,我们可以总结出一个简单的重构工作流:

按照这个方法,就能够逐步迭代出一套优雅且可测试的代码,即使因为时间问题没有迭代到理想的测试边界,也会拥有一套大部分可测试的代码,后人可以在前人用例的基础上,继续扩大测试边界。
过度设计 再谈一谈过度设计的问题。
按照本文的方法是不可能出现过度设计的问题,过度设计一般发生在为了设计而设计,生搬硬套设计模式的场合,但是本文的所有设计都有一个明确的目的--提升代码的“可测试性”,所有的技巧都是在过程中无意使用的,不存在生硬的问题。
而且过度设计会导致“可测试性”变差,过度设计的代码常常是把自己的核心逻辑都给抽象掉了,导致单元测试无处可测。如果发现一段代码“写得很简洁,很抽象,但是就是不好写单元测试”,那么大概率是被过度设计了。
和TDD的区别 本文到这里都还没有提及到 TDD,但是上文中阐述的内容肯定让不少读者想到了这个名词,TDD 是 “测试驱动开发” 的简写,它强调在代码编写之前先写用例,包括三个步骤:
  • 红灯:写用例,运行,无法通过用例
  • 绿灯:用最快最脏的代码让测试通过
  • 重构:将代码重构得更加优雅
在开发过程中不断地重复这三个步骤。
但是会实践中会发现,在繁忙的业务开发中想要先写测试用例是很困难的,可能会有以下原因:
  • 代码结构尚未完全确定,出入口尚未明确,即使提前写了单元测试,后面大概率也要修改
  • 产品一句话需求,外加对系统不够熟悉,用例很难在开发之前写好
因此本文的工作流将顺序做了一些调整,先写代码,然后再不断地重构代码适配单元测试,扩大系统的测试边界。
不过从更广义的 TDD 思想上来说,这篇文章的总体思路和 TDD 是差不多的,或者标题也可以改叫做 “TDD 实践”。

    推荐阅读