51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

查看: 603|回复: 0
打印 上一主题 下一主题

[转贴] 代码重构:面向单元测试

[复制链接]
  • TA的每日心情
    无聊
    昨天 09:03
  • 签到天数: 939 天

    连续签到: 1 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2022-8-4 09:57:46 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    重构代码时,我们常常纠结于这样的问题:
      需要进一步抽象吗?会不会导致过度设计?
      如果需要进一步抽象的话,如何进行抽象呢?有什么通用的步骤或者法则吗?
      单元测试是我们常用的验证代码正确性的工具,但是如果只用来验证正确性的话,那就是真是 “大炮打蚊子”--大材小用,它还可以帮助我们评判代码的抽象程度与设计水平。本文还会提出一个以“可测试性”为目标,不断迭代重构代码的思路,利用这个思路,面对任何复杂的代码,都能逐步推导出重构思路。为了保证直观,本文会以一个 “生产者消费者” 的代码重构示例贯穿始终。最后还会以业务上常见的 Excel 导出系统为例简单阐述一个业务上的重构实例。阅读本文需要具有基本的单元测试编写经验(最好是 Java),但是本文不会涉及任何具体的单元测试框架和技术,因为它们都是不重要的,学习了本文的思路,可以将它们用在任意的单测工具上。
      不可测试的代码
      程序员们重构一段代码的动机是什么?可能众说纷纭:
      ·代码不够简洁?
      · 不好维护?
      · 不符合个人习惯?
      · 过度设计,不好理解?
      这些都是比较主观的因素,在一个老练程序员看来恰到好处的设计,一个新手程序员却可能会觉得过于复杂,不好理解。但是让他们同时坐下来为这段代码添加单元测试时,他们往往能够产生类似的感受,比如:
      “单测很容易书写,很容易就全覆盖了”,那么这就是可测试的代码;
      “虽然能写得出来,但是费了老大劲,使用了各种框架和技巧,才覆盖完全”,那么这就是可测试性比较差的代码;
      “完全不知道如何下手写”,那么这就是不可测试的代码;
      一般而言,可测试的代码一般都是同时是简洁和可维护的,但是简洁可维护的代码却不一定是可测试的,比如下面的“生产者消费者”代码就是不可测试的:
    1. public void producerConsumer() {
    2.           BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>();
    3.           Thread producerThread  = new Thread(() -> {
    4.               for (int i = 0; i < 10; i++) {
    5.                   blockingQueue.add(i + ThreadLocalRandom.current().nextInt(100));
    6.               }
    7.           });
    8.           Thread consumerThread = new Thread(() -> {
    9.               try {
    10.                   while (true) {
    11.                       Integer result = blockingQueue.take();
    12.                       System.out.println(result);
    13.                   }
    14.               } catch (InterruptedException ignore) {
    15.               }
    16.           });
    17.           producerThread.start();
    18.           consumerThread.start();
    19.       }
    复制代码
    上面这段代码做的事情非常简单,启动两个线程:
      ·生产者:将 0-9 的每个数字,分别加上 [0,100) 的随机数后通过阻塞队列传递给消费者;
      · 消费者:从阻塞队列中获取数字并打印;
      这段代码看上去还是挺简洁的,但是,算得上一段好代码吗?尝试下给这段代码加上单元测试。仅仅运行一下这个代码肯定是不够的,因为我们无法确认生产消费逻辑是否正确执行。我也只能发出“完全不知道如何下手”的感叹,这不是因为我们的单元测试编写技巧不够,而是因为代码本身存在的问题:
      1、违背单一职责原则:这一个函数同时做了 数据传递,处理数据,启动线程三件事情。单元测试要兼顾这三个功能,就会很难写。
      2、这个代码本身是不可重复的,不利于单元测试,不可重复体现在 需要测试的逻辑位于异步线程中,对于它什么时候执行?什么时候执行完?
      都是不可控的;逻辑中含有随机数;消费者直接将数据输出到标准输出中,在不同环境中无法确定这里的行为是什么,有可能是输出到了屏幕上,也可能是被重定向到了文件中;因为第 2 点的原因,我们就不得不放弃单测了呢?其实只要通过合理的模块职责划分,依旧是可以单元测试。这种划分不仅仅有助于单元测试,也会“顺便”帮助我们抽象一套更加合理的代码。
      可测试意味着什么?
      所有不可测试的代码都可以通过合理的重构与抽象,让其核心逻辑变得可测试,这也重构的意义所在。本章就会详细说明这一点。
      首先我们要了解可测试意味着什么,如果说一段代码是可测试的,那么它一定符合下面的条件:
      可以在本地设计完备的测试用例,称之为 完全覆盖的单元测试;
      只要完全覆盖的单元测试用例全部正确运行,那么这一段逻辑肯定是没有问题的;
      第 1 点常会令人感到难以置信,但事实比想象的简单,假设有这样一个分段函数:

    f(x) 看起来有无限的定义域,我们永远无法穷举所有可能的输入。但是再仔细想想,我们并不需要穷举,其实只要下面几个用例可以通过,那么就可以确保这个函数是没有问题的:
    1. ·<-50
    2.      f(-51) == -100
    3.   · [-50, 50]
    4.      f(-25) == -50
    5.      f(25) == 50
    6.   · >50
    7.      f(51) == 100
    8.   · 边界情况
    9.      f(-50) == -100
    10.      f(50) == 100
    复制代码
    日常工作中的代码当然比这个复杂很多,但是没有本质区别,也是按照如下思路进行单元测试覆盖的:
      · 每一个分段其实就是代码中的一个条件分支,用例的分支覆盖率达到了 100%;
      · 像 2x 这样的逻辑运算,通过几个合适的采样点就可以保证正确性;
      · 边界条件的覆盖,就像是分段函数的转折点;
      但是业务代码依旧比 f(x) 要复杂很多,因为 f(x) 还有其他好的性质让它可以被完全测试,这个性质被称作引用透明:
      · 函数的返回值只和参数有关,只要参数确定,返回值就是唯一确定的
      现实中的代码大多都不会有这么好的性质,反而具有很多“坏的性质”,这些坏的性质也常被称为副作用:
      · 代码中含有远程调用,无法确定这次调用是否会成功;
      · 含有随机数生成逻辑,导致行为不确定;
      · 执行结果和当前日期有关,比如只有工作日的早上,闹钟才会响起;
      · 好在我们可以用一些技巧将这些副作用从核心逻辑中抽离出来。
      高阶函数
      “引用透明” 要求函数的出参由入参唯一确定,之前的例子容易让人产生误解,觉得出参和入参一定要是数据,让我们把视野再打开一点,出入参可以是一个函数,它也可以是引用透明的。
      普通的函数又可以称作一阶函数,而接收函数作为参数,或者返回一个函数的函数称为高阶函数,高阶函数也可以是引用透明的。
      对于函数 f(x) 来说,x 是数据还是函数,并没有本质的不同,如果 x 是函数的话,仅仅意味着 f(x) 拥有更加广阔的定义域,以至于没有办法像之前一样只用一个一维数轴表示。
      对于高阶函数 f(g) (g 是一个函数)来说,只要对于特定的函数 g,返回逻辑也是固定,它就是引用透明的了, 而不用在乎参数 g 或者返回的函数是否有副作用。利用这个特性,我们很容易将一个有副作用的函数转换为一个引用透明的高阶函数。
      一个典型的拥有副作用的函数如下:
    1. public int f() {
    2.           return ThreadLocalRandom.current().nextInt(100) + 1;
    3.       }
    复制代码
    它生成了随机数并且加 1,因为这个随机数,导致它不可测试。但是我们将它转换为一个可测试的高阶函数,只要将随机数生成逻辑作为一个参数传入,并且返回一个函数即可:
    1. public Supplier<Integer> g(Supplier<Integer> integerSupplier) {
    2.           return () -> integerSupplier.get() + 1;
    3.       }
    复制代码
    上面的 g 就是一个引用透明的函数,只要给 g 传递一个数字生成器,返回值一定是一个 “用数字生成器生成一个数字并且加1” 的逻辑,并且不存在分支条件和边界情况,只需要一个用例即可覆盖:
    1.  public void testG() {
    2.           Supplier<Integer> result = g(() -> 1);
    3.           assert result.get() == 2;
    4.       }
    复制代码
    实际业务中可以稍微简化一下高阶函数的表达, g 的返回的函数既然每次都会被立即执行,那我们就不返回函数了,直接将逻辑写在方法中,这样也是可测试的:
    1. public int g2(Supplier<Integer> integerSupplier) {
    2.           return integerSupplier.get() + 1;
    3.       }
    复制代码
    这里我虽然使用了 Lambda 表达式简化代码,但是 “函数” 并不仅仅是指 Lambda 表达式,OOP 中的充血模型的对象,接口等等,只要其中含有逻辑,它们的传递和返回都可以看作 “函数”。
      因为这个例子比较简单,“可测试” 带来的收益看起来没有那么高,真实业务中的逻辑一般比 +1 要复杂多了,此时如果能构建有效的测试将是非常有益的。




    本帖子中包含更多资源

    您需要 登录 才可以下载或查看,没有帐号?(注-册)加入51Testing

    x
    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-4-29 00:24 , Processed in 0.066700 second(s), 24 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表