关于后端:面向单元测试的代码重构

3次阅读

共计 7470 个字符,预计需要花费 19 分钟才能阅读完成。

前言

重构代码时,咱们经常纠结于这样的问题:

  • 须要进一步形象吗?会不会导致适度设计?
  • 如果须要进一步形象的话,如何进行形象呢?有什么通用的步骤或者法令吗?

单元测试是咱们罕用的验证代码正确性的工具,然而如果只用来验证正确性的话,那就是真是“大炮打蚊子”– 大材小用,它还能够帮忙咱们评判代码的形象水平与设计程度。本文还会提出一个以“可测试性”为指标,一直迭代重构代码的思路,利用这个思路,面对任何简单的代码,都能逐渐推导出重构思路。

为了保障直观,本文会以一个『生产者消费者』的代码重构示例贯通始终。

示例

重构 & 单测

在开始『生产者消费者』的代码重构示例前,先聊一聊重构。

程序员们重构一段代码的动机是什么?可能七嘴八舌:

  • 代码不够简洁?
  • 不好保护?
  • 不合乎集体习惯?
  • 适度设计,不好了解?

概括来说,就是升高代码和架构的腐化速度,升高保护和降级的老本。在我看来,保障软件品质和复杂度的惟一伎俩就是『继续重构』。

这里又引出一个问题,什么样代码 / 架构是好保护的?业界针对此问题有很多设计准则,比方开闭准则、繁多职责准则、依赖倒置准则等等。然而明天想从另外一个角度来说,是不是可测性也能够做为掂量代码好坏的规范。一般而言,可测试的代码个别都是同时是简洁和可保护的,然而简洁可保护的代码却不肯定是可测试的。

所以,从这个角度来说说重构是为了加强代码的可测性,即面向单测重构。另一方面,一段没有单测的代码,你敢重构么?这么看来,单测和重构的关系密不可分。

接下来咱们看一个简略的例子,领会 面向单测的重构

   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();}

下面这段代码次要能够划分为 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<Integer> integerSupplier) {return integerSupplier.get() + 1;
    }

下面的 g 就是一个援用通明的函数,只有给 g 传递一个数字生成器,返回值肯定是一个“用数字生成器生成一个数字并且加 1”的逻辑,并且不存在分支条件和边界状况,只须要一个用例即可笼罩:

        public void testG() {Supplier<Integer> result = g(() -> 1);
        assert result.get() == 2;}

这里我尽管应用了 Lambda 表达式简化代码,然而“函数”并不仅仅是指 Lambda 表达式,OOP 中的充血模型的对象,接口等等,只有其中含有逻辑,它们的传递和返回都能够看作“函数”。

第一轮重构

咱们本章回到结尾的生产者消费者的例子,用上一章学习到的常识对它进行重构。

那段代码无奈测试的第一个问题就是职责不清晰,它既做数据传递,又做数据处理。因而咱们思考将生产者消费者数据传递的代码独自抽取进去:

         public <T> void  producerConsumerInner(Consumer<Consumer<T>> producer,
                                           Consumer<Supplier<T>> consumer) {BlockingQueue<T> 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 <T> void  producerConsumerInner(Executor executor,
                                      Consumer<Consumer<T>> producer,
                                      Consumer<Supplier<T>> consumer) {BlockingQueue<T> 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<Consumer<Integer>>) producer -> {producer.accept(1);
                    producer.accept(2);
                },
                consumer -> {assert consumer.get() == 1;
                    assert consumer.get() == 2;});
    }

只有这个测试可能通过,就能阐明生产生产在逻辑上是没有问题的。一个看起来比之前的分段函数简单很多的逻辑,实质上却只是它定义域上的一个恒等函数(因为只有一个用例就能笼罩全副状况),是不是很诧异。

如果不太喜爱上述的函数式编程格调,能够很容易地将其革新成 OOP 格调的抽象类

public abstract class ProducerConsumer<T> {

    private final Executor executor;

    private final BlockingQueue<T> 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<Integer>(Runnable::run) {
            @Override
            void produce() {produceInner(1);
                produceInner(2);
            }

            @Override
            void consume() {assert consumeInner() == 1;
                assert consumeInner() == 2;}
        }.start();}

主函数

 public void producerConsumer() {new ProducerConsumer<Integer>(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<Integer> {

    private final Supplier<Integer> numberGenerator;

    private final Consumer<Integer> numberConsumer;

    public NumberProducerConsumer(Executor executor,
                                  Supplier<Integer> numberGenerator,
                                  Consumer<Integer> 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 实际”。

正文完
 0