代码重构:面向单元测试

发表于:2022-8-03 09:46

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:阿里开发者    来源:阿里开发者

  重构代码时,我们常常纠结于这样的问题:
  需要进一步抽象吗?会不会导致过度设计?
  如果需要进一步抽象的话,如何进行抽象呢?有什么通用的步骤或者法则吗?
  单元测试是我们常用的验证代码正确性的工具,但是如果只用来验证正确性的话,那就是真是 “大炮打蚊子”--大材小用,它还可以帮助我们评判代码的抽象程度与设计水平。本文还会提出一个以“可测试性”为目标,不断迭代重构代码的思路,利用这个思路,面对任何复杂的代码,都能逐步推导出重构思路。为了保证直观,本文会以一个 “生产者消费者” 的代码重构示例贯穿始终。最后还会以业务上常见的 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 要复杂多了,此时如果能构建有效的测试将是非常有益的。
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号