【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解决。重要的还是要有单测和提高代码可测试性的意识吧…

相关文章
|
4天前
|
JavaScript NoSQL Java
接替此文【下篇-服务端+后台管理】优雅草蜻蜓z系统JAVA版暗影版为例-【蜻蜓z系列通用】-2025年全新项目整合搭建方式-这是独立吃透代码以后首次改变-独立PC版本vue版搭建教程-优雅草卓伊凡
接替此文【下篇-服务端+后台管理】优雅草蜻蜓z系统JAVA版暗影版为例-【蜻蜓z系列通用】-2025年全新项目整合搭建方式-这是独立吃透代码以后首次改变-独立PC版本vue版搭建教程-优雅草卓伊凡
142 96
接替此文【下篇-服务端+后台管理】优雅草蜻蜓z系统JAVA版暗影版为例-【蜻蜓z系列通用】-2025年全新项目整合搭建方式-这是独立吃透代码以后首次改变-独立PC版本vue版搭建教程-优雅草卓伊凡
|
22天前
|
前端开发 JavaScript 测试技术
使用ChatGPT生成登录产品代码的测试用例和测试脚本
使用ChatGPT生成登录产品代码的测试用例和测试脚本
75 35
|
22天前
|
JavaScript 前端开发 Java
使用ChatGPT生成关于登录产品代码的单元测试代码
使用ChatGPT生成关于登录产品代码的单元测试代码
41 16
|
1月前
|
SQL Java 数据库连接
如何在 Java 代码中使用 JSqlParser 解析复杂的 SQL 语句?
大家好,我是 V 哥。JSqlParser 是一个用于解析 SQL 语句的 Java 库,可将 SQL 解析为 Java 对象树,支持多种 SQL 类型(如 `SELECT`、`INSERT` 等)。它适用于 SQL 分析、修改、生成和验证等场景。通过 Maven 或 Gradle 安装后,可以方便地在 Java 代码中使用。
229 11
|
1月前
|
JSON Java 数据挖掘
利用 Java 代码获取淘宝关键字 API 接口
在数字化商业时代,精准把握市场动态与消费者需求是企业成功的关键。淘宝作为中国最大的电商平台之一,其海量数据中蕴含丰富的商业洞察。本文介绍如何通过Java代码高效、合规地获取淘宝关键字API接口数据,帮助商家优化产品布局、制定营销策略。主要内容包括: 1. **淘宝关键字API的价值**:洞察用户需求、优化产品标题与详情、制定营销策略。 2. **获取API接口的步骤**:注册账号、申请权限、搭建Java开发环境、编写调用代码、解析响应数据。 3. **注意事项**:遵守法律法规与平台规则,处理API调用限制。 通过这些步骤,商家可以在激烈的市场竞争中脱颖而出。
|
6月前
|
Java 测试技术 开发者
在软件开发中,测试至关重要,尤以单元测试和集成测试为然
在软件开发中,测试至关重要,尤以单元测试和集成测试为然。单元测试聚焦于Java中的类或方法等最小单元,确保其独立功能正确无误,及早发现问题。集成测试则着眼于模块间的交互,验证整体协作效能。为实现高效测试,需编写可测性强的代码,并选用JUnit等合适框架。同时,合理规划测试场景与利用Spring等工具也必不可少。遵循最佳实践,可提升测试质量,保障Java应用稳健前行。
68 1
|
3月前
|
测试技术 开发者 UED
探索软件测试的深度:从单元测试到自动化测试
【10月更文挑战第30天】在软件开发的世界中,测试是确保产品质量和用户满意度的关键步骤。本文将深入探讨软件测试的不同层次,从基本的单元测试到复杂的自动化测试,揭示它们如何共同构建一个坚实的质量保证体系。我们将通过实际代码示例,展示如何在开发过程中实施有效的测试策略,以确保软件的稳定性和可靠性。无论你是新手还是经验丰富的开发者,这篇文章都将为你提供宝贵的见解和实用技巧。
|
6月前
|
JSON Dubbo 测试技术
单元测试问题之增加JCode5插件生成的测试代码的可信度如何解决
单元测试问题之增加JCode5插件生成的测试代码的可信度如何解决
72 2
单元测试问题之增加JCode5插件生成的测试代码的可信度如何解决
|
5月前
|
IDE 测试技术 持续交付
Python自动化测试与单元测试框架:提升代码质量与效率
【9月更文挑战第3天】随着软件行业的迅速发展,代码质量和开发效率变得至关重要。本文探讨了Python在自动化及单元测试中的应用,介绍了Selenium、Appium、pytest等自动化测试框架,以及Python标准库中的unittest单元测试框架。通过详细阐述各框架的特点与使用方法,本文旨在帮助开发者掌握编写高效测试用例的技巧,提升代码质量与开发效率。同时,文章还提出了制定测试计划、持续集成与测试等实践建议,助力项目成功。
115 5
|
6月前
|
JSON 测试技术 数据格式
单元测试问题之使用JCode5插件生成测试类如何解决
单元测试问题之使用JCode5插件生成测试类如何解决
238 3

热门文章

最新文章