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

相关文章
|
26天前
|
设计模式 消息中间件 搜索推荐
Java 设计模式——观察者模式:从优衣库不使用新疆棉事件看系统的动态响应
【11月更文挑战第17天】观察者模式是一种行为设计模式,定义了一对多的依赖关系,使多个观察者对象能直接监听并响应某一主题对象的状态变化。本文介绍了观察者模式的基本概念、商业系统中的应用实例,如优衣库事件中各相关方的动态响应,以及模式的优势和实际系统设计中的应用建议,包括事件驱动架构和消息队列的使用。
|
1月前
|
安全 IDE Java
Java常见规范及易忘点
遵循Java编程规范和注意易忘点是提高代码质量和可维护性的关键。通过规范的命名、格式、注释和合理的代码组织,可以让代码更加清晰和易于维护。同时,注意空指针检查、线程安全、集合框架和字符串操作等常见易忘点,可以减少程序错误,提高运行效率。结合单一职责原则、面向接口编程和合理的异常处理,能够编写出高质量的Java代码。希望本文能够帮助Java开发者提升编码水平,写出更高效、更可靠的代码。
25 2
|
1月前
|
设计模式 Java 数据库连接
Java编程中的设计模式:单例模式的深度剖析
【10月更文挑战第41天】本文深入探讨了Java中广泛使用的单例设计模式,旨在通过简明扼要的语言和实际示例,帮助读者理解其核心原理和应用。文章将介绍单例模式的重要性、实现方式以及在实际应用中如何优雅地处理多线程问题。
38 4
|
1月前
|
测试技术 开发者 UED
探索软件测试的深度:从单元测试到自动化测试
【10月更文挑战第30天】在软件开发的世界中,测试是确保产品质量和用户满意度的关键步骤。本文将深入探讨软件测试的不同层次,从基本的单元测试到复杂的自动化测试,揭示它们如何共同构建一个坚实的质量保证体系。我们将通过实际代码示例,展示如何在开发过程中实施有效的测试策略,以确保软件的稳定性和可靠性。无论你是新手还是经验丰富的开发者,这篇文章都将为你提供宝贵的见解和实用技巧。
|
1月前
|
Java 测试技术 Maven
Java一分钟之-PowerMock:静态方法与私有方法测试
通过本文的详细介绍,您可以使用PowerMock轻松地测试Java代码中的静态方法和私有方法。PowerMock通过扩展Mockito,提供了强大的功能,帮助开发者在复杂的测试场景中保持高效和准确的单元测试。希望本文对您的Java单元测试有所帮助。
213 2
|
2月前
|
Java 流计算
Flink-03 Flink Java 3分钟上手 Stream 给 Flink-02 DataStreamSource Socket写一个测试的工具!
Flink-03 Flink Java 3分钟上手 Stream 给 Flink-02 DataStreamSource Socket写一个测试的工具!
46 1
Flink-03 Flink Java 3分钟上手 Stream 给 Flink-02 DataStreamSource Socket写一个测试的工具!
|
2月前
|
Java 程序员 测试技术
Java|让 JUnit4 测试类自动注入 logger 和被测 Service
本文介绍如何通过自定义 IDEA 的 JUnit4 Test Class 模板,实现生成测试类时自动注入 logger 和被测 Service。
35 5
|
2月前
|
设计模式 Java 程序员
[Java]23种设计模式
本文介绍了设计模式的概念及其七大原则,强调了设计模式在提高代码重用性、可读性、可扩展性和可靠性方面的作用。文章还简要概述了23种设计模式,并提供了进一步学习的资源链接。
54 0
[Java]23种设计模式
|
2月前
|
存储 人工智能 Java
将 Spring AI 与 LLM 结合使用以生成 Java 测试
AIDocumentLibraryChat 项目通过 GitHub URL 为指定的 Java 类生成测试代码,支持 granite-code 和 deepseek-coder-v2 模型。项目包括控制器、服务和配置,能处理源代码解析、依赖加载及测试代码生成,旨在评估 LLM 对开发测试的支持能力。
56 1
|
1月前
|
设计模式 JavaScript Java
Java设计模式:建造者模式详解
建造者模式是一种创建型设计模式,通过将复杂对象的构建过程与表示分离,使得相同的构建过程可以创建不同的表示。本文详细介绍了建造者模式的原理、背景、应用场景及实际Demo,帮助读者更好地理解和应用这一模式。