【Java设计模式 规范与重构】 二 重构的保障:单元测试,以及如何提高代码可测试性

这篇具有很好参考价值的文章主要介绍了【Java设计模式 规范与重构】 二 重构的保障:单元测试,以及如何提高代码可测试性。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

其实之前的工作中强调过很多次自己做测试的重要性,例如讲单元测试的:【C#编程最佳实践 一】单元测试实践,讲单元测试规范的【阿里巴巴Java编程规范学习 四】Java质量安全规约,讲接口测试的:【C#编程最佳实践 十三】接口测试实践,这里旧事重提就不再详细展开了,回顾下单元测试的基本概念,重点来看如何提升代码的可测试性。

单元测试

依次从WHAT,WHY,HOW,HOW去了解。

什么是单元测试

单元测试由RD自己来编写,用来测试自己写的代码的正确性。它与集成测试的区别是测试粒度

  • 集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试
  • 单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试

单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。

写单元测试的好处

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

  • 写单元测试能有效地发现代码中的 bug,通过写单元测试可以节省很多 fix 低级 bug 的时间,能够有时间去做其他更有意义的事情。
  • 写单元测试能发现代码设计上的问题,代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等
  • 单元测试是对集成测试的有力补充,程序运行的 bug 往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟
  • 写单元测试的过程本身就是代码重构的过程,持续重构应该作为开发的一部分来执行,写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚编写单元测试就相当于对代码的一次自我 Code Review,在这个过程中,可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构
  • 阅读单元测试能帮助我们快速熟悉代码,文档结合单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。
  • 单元测试是 TDD 可落地执行的改进方案,测试驱动开发(Test-Driven Development,简称 TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写,但很难落地,不如先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码,变相落实TDD,测试驱动重构与FIX

其实单元测试就是对代码设计和功能逻辑的一次反思,粗粒度的CR,结合CR,推动和保障持续重构。

如何编写单元测试

物理上可以借助Java 中比较出名的单元测试框架有 Junit、TestNG、Spring Test 等。这些框架提供了通用的执行流程(比如执行测试用例的 TestCaseRunner)和工具类库(比如各种 Assert 判断函数),而主观经验上要有这样的意识

  • 编写单元测试尽管繁琐,但并不是太耗时,不同测试用例之间的代码差别可能并不是很大,简单 copy-paste 改改就行
  • 可以稍微放低对单元测试代码质量的要求,命名稍微有些不规范,代码稍微有些重复,也都是没有问题的,但代码规范意识要时刻有,要达到想降低要求都降不了的水平
  • 覆盖率作为衡量单元测试质量的重要但不是唯一标准,更重要的是要看测试用例是否覆盖了所有可能的情况
  • 单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。切不可为了追求覆盖率,逐行阅读代码
  • 通过单元测试框架无法测试,多半是因为代码的可测试性不好,需要思考下代码的设计

单元测试就是一个透明测试,我们只关注功能而无需关注实现细节,关注的功能要关注是否覆盖了所有可能场景

单元测试为何难落地执行

一方面,写单元测试本身比较繁琐,技术挑战不大,很多程序员不愿意去写;另一方面,研发比较偏向“快、糙、猛”,容易因为开发进度紧,导致单元测试的执行虎头蛇尾。最后,关键问题还是团队没有建立对单元测试正确的认识,觉得可有可无,单靠督促很难执行得很好

代码可测试性

简而言之,代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好.

提高代码可测试性:依赖注入和二次封装

如何让代码可测试性更好呢?最常用的方式就是依赖注入了,通过DI实现反转,将对象的创建交给业务调用方,这样就可以随意控制输出的结果,从而达到mock数据的目的,最好搭配多态,让mock类之间基于父类注入,例如将Mock数据注入到类中进行测试。例如


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

通过依赖注入注入到主类


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

使用时直接注入Mock类


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

对于一些需要外部依赖的远程服务,因为我们不能修改远程服务代码,所以需要组合mock、二次封装、依赖注入等方式解决,也就是类不直接依赖于远程类,而是依赖于封装在远程类之上的本地方法,测试的时候mock封装类即可。例如在远程调用的RedisDistributedLock之上封装一层而不是直接调用


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

我们在测试时就可以直接将Mock的封装类注入主类测试

// 二次封装远程调用类,然后将mock的封装类DI注入到主类中进行测试
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());
}

哪些代码可测试性不好

下面整理一些影响代码可测试性的问题

1 未决行为

所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码,例如:


public class Demo {
  public long caculateDelayDays(Date dueTime) {
    long currentTimestamp = System.currentTimeMillis();
    if (dueTime.getTime() >= currentTimestamp) {
      return 0;
    }
    long delayTime = currentTimestamp - dueTime.getTime();
    long delayDays = delayTime / 86400;
    return delayDays;
  }
}

这是一段计算延期时间的代码,当前时间一直在变,而这种变化不是通过参数传递进来的,所以随着时间的推移,单元测试的输出结果会有不同,所以为了提高代码可测试性,可以把currentTimestamp 这个参数当成局部变量传进来:


public class Demo {
  public long caculateDelayDays(Date dueTime, Date currentTime) {
    if (dueTime.getTime() >= currentTime..getTime()) {
      return 0;
    }
    long delayTime = currentTime..getTime() - dueTime.getTime();
    long delayDays = delayTime / 86400;
    return delayDays;
  }
}

2 全局变量

全局变量是一种面向过程的编程风格,有种种弊端。滥用全局变量也让编写单元测试变得困难,同一个全局变量被多个单元测试用例访问并设置值,会让单元测试结果不准确,例如


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

position 是一个静态全局变量,第一个测试用例执行完成之后,position 的值变成了 -1;再执行第二个测试用例的时候,position 变成了 5,move() 函数返回 true,assertFalse 语句判定失败。所以,第二个测试用例运行失败

3 静态方法

静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock,因为静态方法没有多态的特性,没办法使用mock方法。

4 复杂继承

相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。

如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。对于层次很深(在继承关系类图中表现为纵向深度)、结构复杂(在继承关系类图中表现为横向广度)的继承关系,越底层的子类要 mock 的对象可能就会越多,这样就会导致,底层子类在写单元测试的时候,要一个一个 mock 很多依赖对象,而且还需要查看父类代码,去了解该如何 mock 这些依赖对象。

利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可

5 高耦合代码

如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的

总结一下

单元测试好像写的没有之前多了,更多的是写相对更粗粒度的接口测试,一个主要原因就是觉得写单元测试琐碎,写接口测试能通就证明主流程OK,就能提测了,事实上在比较紧凑的迭代节奏里这是常态,幸而测试同学的集成测试比较给力,没出什么问题,但其实也不能老依赖于接口测试和集成测试,单元测试也有必要写的,更多的是通过写单测CR下自己的代码吧,践行持续重构的理念。对于代码的可测试性而言,其实我们日常基于Spring去开发,本身大多数场景都是依赖注入,所以体会可能没有那么深,至于对于远程服务的依赖,使用二次封装是个不错的方法,但是代码侵入性还是有一些的,其实也能通过一些测试工具例如AnyMock解决。重要的还是要有单测和提高代码可测试性的意识吧…文章来源地址https://www.toymoban.com/news/detail-424744.html

到了这里,关于【Java设计模式 规范与重构】 二 重构的保障:单元测试,以及如何提高代码可测试性的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 【设计模式之美】重构(三)之解耦方法论:如何通过封装、抽象、模块化、中间层等解耦代码?

    重构可以分为大规模高层重构(简称“大型重构”)和小规模低层次重构(简称“小型重构”)。 通过解耦对代码重构,就是保证代码不至于复杂到无法控制的有效手段。   代码是否需要“解耦”? 看修改代码会不会牵一发而动全身。 依赖关系是否复杂 把模块与模块之间

    2024年01月16日
    浏览(45)
  • 设计模式之美——单元测试和代码可测性

    最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(Unit Testing)。 什么是单元测试? 单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒

    2024年02月12日
    浏览(35)
  • 关于Selenium的网页对象单元测试的设计模式

    写在前面:经过了实践总结一下经验,心得进行一个分享。 首先driver是可以单独抽出来的,变成一个driver函数放在driver.py。 然后我们新建page.py,在这里我们只处理页面的对象,因此我们创建页面类在此,当然这个页面类需要和HTML元素和驱动进行组合才能形成一个页面。 我

    2024年02月03日
    浏览(38)
  • JAVA设计模式----原型设计模式

    定义 :用原型实例指定创建对象的种类,并通过拷贝这些原型创建新的对象。 类型 :创建类模式 类图 : 原型模式主要用于对象的复制,它的核心是就是类图中的原型类Prototype。Prototype类需要具备以下两个条件: 实现Cloneable接口。在java语言有一个Cloneable接口,它的作用只

    2024年02月13日
    浏览(42)
  • Java设计模式 (三) 代理设计模式

    什么是代理设计模式? 代理设计模式是一种结构型设计模式,它允许创建一个代理对象,用于控制对其他对象的访问。代理模式通常用于在访问对象时添加一些附加操作,而不是直接访问真实对象。代理模式可以在不改变原始类代码的情况下,通过引入代理类来增强功能。 代

    2024年02月12日
    浏览(36)
  • 【设计模式】Java设计模式详细讲解

    一、概述 Java设计模式是Java程序设计中一种重要的最佳实践,它提供了一种框架和结构,可以帮助开发者更好地理解和设计复杂的系统。设计模式不仅仅是一种语法规则,更是一种思想和方法论,它能够帮助开发者更好地分析、设计和实现软件系统。 设计模式的概念最早由

    2024年02月10日
    浏览(44)
  • Java设计模式 (一) 模板方法设计模式

    什么是模板方法设计模式? 模板方法设计模式是一种行为型设计模式,它定义了一个算法的骨架,并将一些步骤的具体实现延迟到子类中。模板方法模式可以帮助确保在算法的不同部分中保持一致性,同时也允许子类根据需要进行具体实现。 模板方法模式的关键特点包括:

    2024年02月12日
    浏览(34)
  • Java 设计模式——命令模式

    (1)日常生活中,我们出去吃饭都会遇到下面的场景: (2)命令模式是一种行为型设计模式, 它通过将请求封装为一个对象,使得可以灵活地参数化客户端对象,同时也能够将请求队列或记录日志、撤销操作等操作进行处理 。命令模式的作用在于 解耦请求发送者和接收者

    2024年02月06日
    浏览(25)
  • Java设计模式-命令模式

    命令模式,将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化。对请求排队或记录请求日志,以及支持可撤销的操作。 命令模式乍一看,有点懵懵的。即使这个定义看完,也是不明所以。但是结合例子来讲的话,就比较容易理解了。 其实它就是把一个

    2024年02月15日
    浏览(28)
  • Java设计模式——工厂模式

    目录 设计模式系列文章 前言 一、简单工厂模式 二、工厂方法模式 三、抽象工厂模式 总结 最近在学习一些Java设计模式的概念,设计模式诞生的目的,我认为是可以使得写出的代码具有更好的逻辑性,减少了大量冗余代码来进行重复而繁琐的操作,也更好的提升了复用性。

    2023年04月13日
    浏览(37)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包