设计模式之美——单元测试和代码可测性

news2024/9/22 9:52:29

最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(Unit Testing)。

什么是单元测试?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。

单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是 Code Review)。

写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程。

在把测试用例翻译成代码的时候,我们可以利用单元测试框架,来简化测试代码的编写。比如,Java 中比较出名的单元测试框架有 Junit、TestNG、Spring Test 等。这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种 Assert 判断函数)等。借助它们,我们在编写测试代码的时候,只需要关注测试用例本身的编写即可。在这里插入图片描述
在这里插入图片描述

编写可测试代码案例实战

其中,Transaction 是经过我抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出。


public class Transaction {
  private String id;
  private Long buyerId;
  private Long sellerId;
  private Long productId;
  private String orderId;
  private Long createTimestamp;
  private Double amount;
  private STATUS status;
  private String walletTransactionId;
  
  // ...get() methods...
  
  public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
    if (preAssignedId != null && !preAssignedId.isEmpty()) {
      this.id = preAssignedId;
    } else {
      this.id = IdGenerator.generateTransactionId();
    }
    if (!this.id.startWith("t_")) {
      this.id = "t_" + preAssignedId;
    }
    this.buyerId = buyerId;
    this.sellerId = sellerId;
    this.productId = productId;
    this.orderId = orderId;
    this.status = STATUS.TO_BE_EXECUTD;
    this.createTimestamp = System.currentTimestamp();
  }
  
  public boolean execute() throws InvalidTransactionException {
    if ((buyerId == null || (sellerId == null || amount < 0.0) {
      throw new InvalidTransactionException(...);
    }
    if (status == STATUS.EXECUTED) return true;
    boolean isLocked = false;
    try {
      isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
      if (!isLocked) {
        return false; // 锁定未成功,返回false,job兜底执行
      }
      if (status == STATUS.EXECUTED) return true; // double check
      long executionInvokedTimestamp = System.currentTimestamp();
      if (executionInvokedTimestamp - createdTimestap > 14days) {
        this.status = STATUS.EXPIRED;
        return false;
      }
      WalletRpcService walletRpcService = new WalletRpcService();
      String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
      if (walletTransactionId != null) {
        this.walletTransactionId = walletTransactionId;
        this.status = STATUS.EXECUTED;
        return true;
      } else {
        this.status = STATUS.FAILED;
        return false;
      }
    } finally {
      if (isLocked) {
       RedisDistributedLock.getSingletonIntance().unlockTransction(id);
      }
    }
  }
}

在这里插入图片描述
测试用例 1 的代码实现:


public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
}

在这里插入图片描述
我们通过继承 WalletRpcService 类,并且重写其中的 moveMoney() 函数的方式来实现 mock。具体的代码实现如下所示。通过 mock 的方式,我们可以让 moveMoney() 返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。


public class MockWalletRpcServiceOne extends WalletRpcService {
  public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
    return "123bac";
  } 
}

public class MockWalletRpcServiceTwo extends WalletRpcService {
  public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
    return null;
  } 
}

现在我们再来看,如何用 MockWalletRpcServiceOne、MockWalletRpcServiceTwo 来替换代码中的真正的 WalletRpcService 呢?因为 WalletRpcService 是在 execute() 函数中通过 new 的方式创建的,我们无法动态地对其进行替换。也就是说,Transaction 类中的 execute() 方法的可测试性很差,需要通过重构来让其变得更容易测试。

该如何重构这段代码呢?在依赖反转中,我们讲到,依赖注入是实现代码可测试性的最有效的手段。我们可以应用依赖注入,将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,再注入到 Transaction 类中。

依赖注入是实现代码可测试性的最有效的手段:通过DI实现反转,将对象的创建交给业务调用方,这样就可以随意控制输出的结果,从而达到"mock"数据的目的,这样的思路太赞了。。。(补充下:不存在外部依赖的类对象可以直接通过new来创建)

重构之后的 Transaction 类的代码如下所示:


public class Transaction {
  //...
  // 添加一个成员变量及其set方法
  private WalletRpcService walletRpcService;
  
  public void setWalletRpcService(WalletRpcService walletRpcService) {
    this.walletRpcService = walletRpcService;
  }
  // ...
  public boolean execute() {
    // ...
    // 删除下面这一行代码
    // WalletRpcService walletRpcService = new WalletRpcService();
    // ...
  }
}

现在,我们就可以在单元测试中,非常容易地将 WalletRpcService 替换成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 了。重构之后的代码对应的单元测试如下所示:


public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  // 使用mock对象来替代真正的RPC服务
  transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
  assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

WalletRpcService 的 mock 和替换问题解决了,我们再来看 RedisDistributedLock。它的 mock 和替换要复杂一些,主要是因为 RedisDistributedLock 是一个单例类。单例相当于一个全局变量,我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。

如果 RedisDistributedLock 是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,或者定义一个接口,比如 IDistributedLock,让 RedisDistributedLock 实现这个接口。这样我们就可以像前面 WalletRpcService 的替换方式那样,替换 RedisDistributedLock 为 MockRedisDistributedLock 了。但如果 RedisDistributedLock 不是我们维护的,我们无权去修改这部分代码,这个时候该怎么办呢?

我们可以对 transaction 上锁这部分逻辑重新封装一下。具体代码实现如下所示:


public class TransactionLock {
  public boolean lock(String id) {
    return RedisDistributedLock.getSingletonIntance().lockTransction(id);
  }
  
  public void unlock() {
    RedisDistributedLock.getSingletonIntance().unlockTransction(id);
  }
}

public class Transaction {
  //...
  private TransactionLock lock;
  
  public void setTransactionLock(TransactionLock lock) {
    this.lock = lock;
  }
 
  public boolean execute() {
    //...
    try {
      isLocked = lock.lock();
      //...
    } finally {
      if (isLocked) {
        lock.unlock();
      }
    }
    //...
  }
}

这样,我们就能在单元测试代码中隔离真正的 RedisDistributedLock 分布式锁这部分逻辑了。


public void testExecute() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  
  TransactionLock mockLock = new TransactionLock() {
    public boolean lock(String id) {
      return true;
    }
  
    public void unlock() {}
  };
  
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  transaction.setWalletRpcService(new MockWalletRpcServiceOne());
  transaction.setTransactionLock(mockLock);
  boolean executedResult = transaction.execute();
  assertTrue(executedResult);
  assertEquals(STATUS.EXECUTED, transaction.getStatus());
}

现在,我们再来看测试用例 3:交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。


public void testExecute_with_TransactionIsExpired() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
  transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
  boolean actualResult = transaction.execute();
  assertFalse(actualResult);
  assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

上面的代码看似没有任何问题。我们将 transaction 的创建时间 createdTimestamp 设置为 14 天前,也就是说,当单元测试代码运行的时候,transaction 一定是处于过期状态。但是,如果在 Transaction 类中,并没有暴露修改 createdTimestamp 成员变量的 set 方法(也就是没有定义 setCreatedTimestamp() 函数)呢?

你可能会说,如果没有 createTimestamp 的 set 方法,我就重新添加一个呗!实际上,这违反了类的封装特性。在 Transaction 类的设计中,createTimestamp 是在交易生成时(也就是构造函数中)自动获取的系统时间,本来就不应该人为地轻易修改,所以,暴露 createTimestamp 的 set 方法,虽然带来了灵活性,但也带来了不可控性。因为,我们无法控制使用者是否会调用 set 方法重设createTimestamp,而重设 createTimestamp 并非我们的预期行为。

那如果没有针对 createTimestamp 的 set 方法,那测试用例 3 又该如何实现呢?实际上,这是一类比较常见的问题,就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,我们只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可,具体的代码实现如下所示:


public class Transaction {

  protected boolean isExpired() {
    long executionInvokedTimestamp = System.currentTimestamp();
    return executionInvokedTimestamp - createdTimestamp > 14days;
  }
  
  public boolean execute() throws InvalidTransactionException {
    //...
      if (isExpired()) {
        this.status = STATUS.EXPIRED;
        return false;
      }
    //...
  }
}

针对重构之后的代码,测试用例 3 的代码实现如下所示:


public void testExecute_with_TransactionIsExpired() {
  Long buyerId = 123L;
  Long sellerId = 234L;
  Long productId = 345L;
  Long orderId = 456L;
  Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
    protected boolean isExpired() {
      return true;
    }
  };
  boolean actualResult = transaction.execute();
  assertFalse(actualResult);
  assertEquals(STATUS.EXPIRED, transaction.getStatus());
}

通过重构,Transaction 代码的可测试性提高了。之前罗列的所有测试用例,现在我们都顺利实现了。不过,Transaction 类的构造函数的设计还有点不妥。


  public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
    if (preAssignedId != null && !preAssignedId.isEmpty()) {
      this.id = preAssignedId;
    } else {
      this.id = IdGenerator.generateTransactionId();
    }
    if (!this.id.startWith("t_")) {
      this.id = "t_" + preAssignedId;
    }
    this.buyerId = buyerId;
    this.sellerId = sellerId;
    this.productId = productId;
    this.orderId = orderId;
    this.status = STATUS.TO_BE_EXECUTD;
    this.createTimestamp = System.currentTimestamp();
  }

我们发现,构造函数中并非只包含简单赋值操作。交易 id 的赋值逻辑稍微复杂。我们最好也要测试一下,以保证这部分逻辑的正确性。为了方便测试,我们可以把 id 赋值这部分逻辑单独抽象到一个函数中,具体的代码实现如下所示:


  public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
    //...
    fillTransactionId(preAssignId);
    //...
  }
  
  protected void fillTransactionId(String preAssignedId) {
    if (preAssignedId != null && !preAssignedId.isEmpty()) {
      this.id = preAssignedId;
    } else {
      this.id = IdGenerator.generateTransactionId();
    }
    if (!this.id.startWith("t_")) {
      this.id = "t_" + preAssignedId;
    }
  }

Transaction 类中 isExpired() 函数就不用测试了吗?对于 isExpired() 函数,逻辑非常简单,肉眼就能判定是否有 bug,是可以不用写单元测试的。

其他常见的 Anti-Patterns

上面通过一个实战案例,讲解了如何利用依赖注入来提高代码的可测试性,以及编写单元测试中最复杂的一部分内容:如何通过 mock、二次封装等方式解依赖外部服务。现在,我们再来总结一下,有哪些典型的、常见的测试性不好的代码,也就是我们常说的 Anti-Patterns。

1. 未决行为

代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。

2. 全局变量

全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。

RangeLimiter 表示一个[-5, 5]的区间,position 初始在 0 位置,move() 函数负责移动 position。其中,position 是一个静态全局变量。RangeLimiterTest 类是为其设计的单元测试。


public class RangeLimiter {
  private static AtomicInteger position = new AtomicInteger(0);
  public static final int MAX_LIMIT = 5;
  public static final int MIN_LIMIT = -5;

  public boolean move(int delta) {
    int currentPos = position.addAndGet(delta);
    boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
    return betweenRange;
  }
}

public class RangeLimiterTest {
  public void testMove_betweenRange() {
    RangeLimiter rangeLimiter = new RangeLimiter();
    assertTrue(rangeLimiter.move(1));
    assertTrue(rangeLimiter.move(3));
    assertTrue(rangeLimiter.move(-5));
  }

  public void testMove_exceedRange() {
    RangeLimiter rangeLimiter = new RangeLimiter();
    assertFalse(rangeLimiter.move(6));
  }
}

上面的单元测试有可能会运行失败。假设单元测试框架顺序依次执行 testMove_betweenRange() 和 testMove_exceedRange() 两个测试用例。在第一个测试用例执行完成之后,position 的值变成了 -1;再执行第二个测试用例的时候,position 变成了 5,move() 函数返回 true,assertFalse 语句判定失败。所以,第二个测试用例运行失败。

当然,如果 RangeLimiter 类有暴露重设(reset)position 值的函数,我们可以在每次执行单元测试用例之前,把 position 重设为 0,这样就能解决刚刚的问题。

不过,每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行,有的是并发执行。对于并发执行的情况,即便我们每次都把 position 重设为 0,也并不奏效。如果两个测试用例并发执行,第 16、17、18、23 这四行代码可能会交叉执行,影响到 move() 函数的执行结果。在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/32997.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【PyTorch】Torchvision Models

文章目录六、Torchvision Models1、VGG1.1 add1.2 modify2、Save and Load2.1 模型结构 模型参数2.2 模型参数&#xff08;官方推荐&#xff09;2.3 Trap六、Torchvision Models 1、VGG VGG参考文档&#xff1a;https://pytorch.org/vision/stable/models/vgg.html 以VGG16为…

apache-atlas-hbase-hook源码分析

元数据类型 Hbase元数据类型, 包括命令空间、表、列族、列 public enum HBaseDataTypes {// ClassesHBASE_NAMESPACE,HBASE_TABLE,HBASE_COLUMN_FAMILY,HBASE_COLUMN;public String getName() {return name().toLowerCase();} }Hbase元数据采集实现 1&#xff09;批量采集HBa…

【windows】实战部署二(使用)SVNserver服务端+SVNclient客户端

SVN服务器应用 创建版本库 1、打开VisualSVN Server&#xff1a; 2、建立版本库&#xff1a; 需要右键单击左边窗口的Repositores,在弹出的右键菜单中选择Create New Repository或者新建-Repository 3、默认选择&#xff0c;点击 “下一步” 按钮&#xff1a; Regular FSFS…

物联网安全年报漏洞情况

物联网 威胁分析漏洞篇物联网威胁分析—漏洞篇 引言 本章将从漏洞利用角度对物联网威胁进行分析。首先&#xff0c;我们分析了 NVD和 Exploit-DB中的物联网 年度漏洞及利用 1 变化趋势&#xff1b;之后统计了绿盟威胁捕获系统捕获到的物联网漏洞利用的整体情况&#xff1b;最…

Matlab深度学习实战一:LeNe-5图像分类篇MNIST数据集分十类且matlab提供模型框架全网为唯一详细操作流程

1.数据集简介下载与准备 2.matlab搭建模型相关知识 3.matlab软件的操作过程&#xff1a; &#xff08;1&#xff09;界面操作 &#xff08;2&#xff09;深度学习设计器使用 &#xff08;3&#xff09;图像数据导入 &#xff08;4&#xff09;训练可视化 一、数据集简介下载与…

mysql基本命令操作

MySQL数据库管理 SQL语句 SQL语句用于维护管理数据库&#xff0c;包括数据查询、数据更新、访问控制、对象管理等功能 DDL&#xff1a;数据定义语言&#xff0c;用于创建数据库对象&#xff0c;如库、表、索引等 DML&#xff1a;数据操纵语言&#xff0c;用于对表中的数据进行…

[附源码]计算机毕业设计JAVA民宿网站管理系统

[附源码]计算机毕业设计JAVA民宿网站管理系统 项目运行 环境配置&#xff1a; Jdk1.8 Tomcat7.0 Mysql HBuilderX&#xff08;Webstorm也行&#xff09; Eclispe&#xff08;IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持&#xff09;。 项目技术&#xff1a; SSM mybati…

Android 10.0 11.0 12.0 启动模拟器教程

Android 10.0 11.0 12.0 启动模拟器教程 一、android 12.0 模拟器二、创建模拟器设备三、创建删除路经文件夹avd和配置环境变量四、启动模拟器一、android 12.0 模拟器 Android 10.0 11.0 12.0 启动模拟器都行,我选择android 12.0 模拟器 二、创建模拟器设备 第一步骤:在 …

i211网卡在Monterey及以上驱动方法

两种方法&#xff1a;一、驱动换成别人修改后的AppleIGB.kext。这么做一般情况用着没问题。但是如果你虚拟机桥接到这个网卡&#xff0c;可以获取到IP,网关等所有参数&#xff0c;就是不能上网 二、刷网卡固件&#xff0c;将i211刷成i210&#xff0c;直接免驱。 macos下操作 …

Brooks曾经在UMLChina网站留过言-回忆和送别(1)

&#xff08;抱歉&#xff0c;有点标题党。&#xff09; 《人月神话》作者Frederick Phillips Brooks Jr. 于2022年11月17日逝世&#xff0c;享年91岁。 图1 摘自 https://christianityandscholarship.org/event/making-things-ncsu-10-27-2015/ 这个岁数即使在今天也算是“…

玩转MySQL:都2022年了,这些数据库技术你都知道吗

引言 MySQL数据库从1995年诞生至今&#xff0c;已经过去了二十多个年头了&#xff0c;到2022.04.26日为止&#xff0c;MySQL8.0.29正式发行了GA版本&#xff0c;在此之前版本也发生了多次迭代&#xff0c;发行了大大小小N多个版本&#xff0c;其中每个版本中都有各自的新特性&…

亚马逊差评怎么删?常用的几种删差评方法介绍

正常情况下每个电商产品都是有好评和差评的&#xff0c;如果一味的都是好评&#xff0c;那么也显的很假&#xff0c;但是差评太多也会影响销售&#xff0c;特别是面对那些恶意差评&#xff0c;这会严重的影响客户下单&#xff0c;因此对于恶意差评&#xff0c;我们还是的想办法…

【Java八股文总结】之Java Web

文章目录Java Web一、Java Web介绍Q&#xff1a;什么是Java Web&#xff1f;Q&#xff1a;Java Web的工作原理&#xff1f;Q&#xff1a;Java Web的知识体系二、JDBC1、JDBC的使用步骤2、JDBC API详解1、DriverManager2、Connection3、Statement4、ResultSet5、PreparedStateme…

连续词袋模型(Continous bag of words, CBOW)

将高维度的词表示转换为低纬度的词表示方法我们称之为词嵌入&#xff08;word embedding&#xff09;。 CBOW是通过周围词去预测中心词的模型。&#xff08;Skip-gram是用中心词预测周围词&#xff09; CBOW模型的结构 最左边为上下文词&#xff0c;这些词用One-hot编码表示&a…

codeforces:C. Set Construction【构造 + 入度观察】

目录题目截图题目分析ac code总结题目截图 题目分析 题目要找n个集合给出一个矩阵b如果bij 1&#xff0c;表示第i个集合为第j个集合的真子集bij 0&#xff0c;表示不是真子集寻找集合间的关系&#xff0c;g记录下一个更大的集合&#xff0c;smaller表示被本集合包含的集合的…

以数据为中心的标记语言-->yaml

目录 一.yaml 介绍 二.yaml 基本语法 三.数据类型 1.字面量 2.对象 3.数组 四.yaml 应用实例 1.需求: 2.需求图解 3.代码实现 五.yaml 使用细节 一.yaml 介绍 YAML 是"YAML Aint a Markup Language"(YAML 不是一种标记语言) 的递归缩写。在开发 的这种语言…

每日一练2——C++排序子序列问题倒置字符串问题

文章目录排序子序列问题思路&#xff1a;代码&#xff1a;倒置字符串思路&#xff1a;方法一&#xff1a;代码&#xff1a;方法二&#xff1a;代码&#xff1a;排序子序列问题 题目链接 这道题题意不难理解&#xff0c;但是想写对还是有很多细节的。 本题要求解的是排序子序列…

python之正则表达式【简化版】

大家好&#xff0c;我们今天说一说正则表达式&#xff0c;在之前我们也介绍了关于正则表达式&#xff0c;今天&#xff0c;我们来深入的了解一下。我们知道正则表达式是处理字符串的强大工具&#xff0c;它有自己的语法结构&#xff0c;什么匹配啊&#xff0c;都不算什么。 正…

JavaIO流:BIO梳理

BIO&#xff08;blocking I/O&#xff09; &#xff1a; 同步阻塞&#xff0c;服务器实现模式为一个连接一个线程&#xff0c;即客户端有连接请求时服务器端就需要启动一个线程进行处理&#xff0c;如果这个连接不做任何事情会造成不必要的线程开销&#xff0c;可以通过线程池机…

Java8新特性 Stream流

Stream流 在Java 8中&#xff0c;得益于Lambda所带来的函数式编程&#xff0c;引入了一个全新的Stream概念&#xff0c;用于解决已有集合类库既有的弊端。 传统集合的多步遍历代码几乎所有的集合&#xff08;如 Collection 接口或 Map 接口等&#xff09;都支持直接或间接的遍…