Java 中常见的单元测试
我们为什么写不好单元测试
写不好单元测试的情况有很多,很多时候我们也是被需求压着身不由己的就开始 “ 胡编乱写” 了。甚至有的时候我们都不知道这个项目可以运行多长时间,项目刚发布完就可能进入到另一个项目的开发周期中,周而复始,更没有时间写单元测试了。
开发人员有一万种理由不写单元测试:
- 没有充分的时间:通常项目中迭代周期短,时间短任务重,领导昨天晚上的奇思妙想,恨不得今早上就能上线,开发人员疲于应付,哪有时间编写单元测试😤。
- 需求不确定:对于需求变化特别大的项目,今天写的单元测试,明天就不能用了,甚至刚写完单元测试,需求改了,什么有些是边开发边磨合需求,这样更没法提前写好单元测试或者事后补足。如果大家已经习惯了天天改需求,谁还会写单元测试呀 😈。
- 开发过分依赖测试团队:认为测试是测试团队的事情,如果不写两个 bug,他们的绩效怎么办 😂。
- 对单元测试的意识不强:又不是不能用,自己过了一遍就得了 😠。
- 对单元测试没有明确的要求。公司或者 QA 团队,甚至开发 Leader 对于单元测试没有明确的要求,所以不写单元测试。(大家都不写,我不能卷死他们呀 😔)
- 缺乏单元测试必要的技能和工具:大多数还停留在通过
main
和System.out
方法来做测试,效率不高,还留下了很多无用的方法 🐌。
当然不只是单元测试,其实开发连注释都不写的 😂🤣😂�。
单元测试的重要性
1. 代码质量
单元测试提高了代码的质量。在实际编码之前编写测试会让你去更多的思考方法或者对象的边界,使您编写更好的代码。
2. 及早发现软件缺陷
问题是在早期阶段发现的。由于单元测试是由在集成之前测试单个代码的开发人员执行的,因此可以很早就发现问题,并且可以在不影响其他代码的情况下解决问题。这既包括开发者实现中的bug,也包括单元规范中的缺陷或缺失部分。
3. 易于重构
完善的单元测试可以验证在重构代码或者更新某些依赖的情况下,确保整个系统依然能正常的工作。当然如果重构已经改变原来的整体逻辑,单元测试也要跟着改动
当开发者向软件添加越来越多的功能时,有时需要更改旧的设计和代码。然而,更改已经测试过的代码既有风险又代价高昂。如果我们有适当的单元测试,那么我们就可以自信地进行重构。
4. 简化调试过程
单元测试有助于简化调试过程。如果测试失败,那么只需要调试代码中的最新更改。
5. 提供文档
单元测试提供了系统的文档。希望了解单元提供什么功能以及如何使用它的开发人员可以查看单元测试,以获得对单元接口(API)的基本理解。
6. 设计
编写测试首先迫使您在编写代码之前仔细考虑您的设计以及它必须完成的任务。这不仅能让你集中注意力,还能让你创造更好的设计。测试一段代码迫使您定义该代码负责什么。如果您可以很容易地做到这一点,那就意味着代码的职责定义良好,因此它具有很高的内聚性。
当然有兴趣的可以看看「测试驱动开发 TDD」
7. 降低成本
由于bug很早就被发现了,单元测试有助于降低bug修复的成本。想象一下在开发的后期阶段,比如在系统测试或验收测试期间发现的bug的成本。当然,较早检测到的bug更容易修复,因为稍后检测到的bug通常是许多更改的结果,并且您不知道是哪一个导致了bug。
如何写单元测试
上面讲了这么多啰里啰嗦的问题,那我们应该怎么写呢?首先我们要明确我们写单元测试的目的和原则:
目的
- 在开发阶段提前减少 Bug
- 提高单元测试覆盖率
- 在重构时候,可以进行验证测试
原则
- 独立(可独立运行,不影响业务,且不要依赖于第三方服务的结果)
- 可重复(多次测试,结果是一样的)
- 自动化(总不能运行一次,改一次代码吧)
- 有明确预期(根据传参知道结果,总不能单元测试测试随机数)
一些技巧(让我们开始写单测吧 😈)
注意: 以下代码使用 Java 8 和 Maven 环境下运行,其他环境不保证不出错
放弃写 main 和 sysout 吧 😏
比如我们写了一个工具类(为了展示方便,删除了具体的实现),这是几个比较常用的
package com.example.ut.util;
import java.util.Objects;
public final class StringUtil {
private StringUtil() {}
public static String firstNonBlank(String... params) {}
public static String firstNonNull(String... params) {}
public static boolean isNullOrEmpty(String string) {}
public static boolean isBlank(String string) {}
public static boolean hasText(String string) {}
public static boolean hasLength(String string) {}
public static String commonPrefix(CharSequence a, CharSequence b) {}
public static String commonSuffix(CharSequence a, CharSequence b) {}
public static String lenientFormat(String template, Object... args) {}
}
比如我们可以看到很多通过直接在 StringUtil 里面通过 main 方法来测试一下各个方法能不能用,比如这样:
public final class StringUtil {
public static void main(String[] args) {
System.out.println(firstNonBlank(null, "", "b", "", "d"));
}
...
}
这样的测试有意义吗?或许当时写代码的时候确实可以用,但是如何检验正确性呢?如果重构的时候,如果发现已经和原来的行为不一致了呢?
使用 JUnit5 来进行简单的测试
What is JUnit 5?
Unlike previous versions of JUnit, JUnit 5 is composed of several different modules from three different sub-projects.
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
The JUnit Platform serves as a foundation for launching testing frameworks on the JVM. It also defines the TestEngine
API for developing a testing framework that runs on the platform. Furthermore, the platform provides a Console Launcher to launch the platform from the command line and a JUnit 4 based Runner for running any TestEngine
on the platform in a JUnit 4 based environment. First-class support for the JUnit Platform also exists in popular IDEs (see IntelliJ IDEA, Eclipse, NetBeans, and Visual Studio Code) and build tools (see Gradle, Maven, and Ant).
JUnit Jupiter is the combination of the new programming model and extension model for writing tests and extensions in JUnit 5. The Jupiter sub-project provides a TestEngine
for running Jupiter based tests on the platform.
JUnit Vintage provides a TestEngine
for running JUnit 3 and JUnit 4 based tests on the platform.
JUnit 是一个在 Java 比较基础的单元测试框架,主要为了单元测试而生,现在已经到了 JUnit 5, 这里也主要使用 JUnit 5,而不是 JUnit 4。
第一步:引入依赖
这里的版本随意,能用就行
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.6.3</version>
<scope>test</scope>
</dependency>
第二步:生成测试代码
在 IDEA 中,如果要为某个类或者方法写单元测试很简单,直接在指定的类或者方法 ctrl + enter
, 即可弹出生成代码的快捷提示,选择 Test 即可,这里选择 firstNonNull,hasText,commonPrefix 来测试一下。
自动生成的代码如下(如果你熟悉了就可以自己手写,但是 IDEA 能生成,我就不手写了),被标记 @Test 的方法可以单独测试执行,如果你在 IDEA 上可以看到侧边栏有绿色的带箭头的小圆圈,你可以点击对应的执行 run 或者 debug
import org.junit.jupiter.api.Test;
class StringUtilTest {
@Test
void firstNonBlank() {}
@Test
void hasText() {}
@Test
void commonPrefix() {}
}
第三步:使用 JUnit 5 写一些代码
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class StringUtilTest {
@Test
void firstNonBlank() {
// 调用方法得到第一个非空的字符串,这里应该 a
String shouldIsA = StringUtil.firstNonBlank("", null, "a", "c");
// 通过断言类来判定结果
Assertions.assertEquals("a", shouldIsA);
String shouldIsC = StringUtil.firstNonBlank("c", null, "a", "c");
Assertions.assertEquals("c", shouldIsC);
}
// 可以使用 DisplayName 来修改原型单元测试时的项目名称
@DisplayName("测试字符串是不是有文本,空白字符串不认为有文本")
@Test
void hasText() {
// 这里应该是 false, 因为 null 没有内容
Assertions.assertFalse(StringUtil.hasText(null));
// 这里应该是 false, 因为 空字符串 没有内容
Assertions.assertFalse(StringUtil.hasText(""));
// 这里应该是 false, 因为 空白字符串 没有内容
Assertions.assertFalse(StringUtil.hasText(" "));
// 这里应该是 true, 因为 a 没有内容
Assertions.assertTrue(StringUtil.hasText(" a "));
}
@DisplayName("测试公共前缀")
@Test
void commonPrefix() {
// 无公共前缀
Assertions.assertEquals("", StringUtil.commonPrefix(" a ", "b"));
Assertions.assertEquals(" ", StringUtil.commonPrefix(" a ", " b"));
Assertions.assertEquals("abab", StringUtil.commonPrefix("ababa", "ababc"));
Assertions.assertNotEquals("aba", StringUtil.commonPrefix("ababa", "ababc"));
}
}
在这里可以点 class 上的绿色按钮来运行下面的全部测试,也可以选择指定的进行测试。
这样一个最简单的单元测试就完成了,里面用到了: @Test
(必需) 标记这是一个需要测试的方法;@DispalyName
(可选)为测试方法或者类起一个好看的名字或者描述;Assertions
通过一系列的断言来判定结果是否正确,这步写不写代码都能通过,但是应该必须写,否则和 sout 有什么区别呢?
通过这三个的组合使用就能完成一系列的简单的单元测试,下面来看下 Assertions
具体支持什么判定操作。其提供了 282 个方法,其中大部分有重载,这里不再展示所有的重载方法,重载的方法只取最大的那个展示一下
一下内容来自于 org.junit.jupiter.api.Assertions 类中方法
参数说明:message 失败后提示的信息;expected 预期的结果;actual 实际的结果;
代码实现其实是只要 expected 和 actual 不相等就抛异常
方法签名 | 描述 | 用途 |
---|---|---|
fail(String message, Object expected, Object actual) | 直接调用,标识一个测试用例失败 | |
assertTrue(boolean condition, String message) | 判定一个结果必须是 true | |
assertFalse(boolean condition, String message) | 判定一个结果必须是 false | |
assertNull(Object actual, String message) | 结果不能为 null | |
assertEquals(Object expected, Object actual, String message) | 实际结果必须和预期结果相等 | |
assertNotEquals(Object expected, Object actual, String message) | 实际结果必须和预期结果不相等 | |
assertArrayEquals(Object[] expected, Object[] actual, Supplier<String> messageSupplier) | 两个数组必须相等 | |
assertIterableEquals(Iterable<?> expected, Iterable<?> actual, String message) | 两个迭代器必须相等 | |
assertSame(Object expected, Object actual, String message) | 实际结果必须和预期结果是同一个对象 | 比如单例的测试 |
assertNotSame(Object expected, Object actual, String message) | 实际结果必须和预期结果不是同一个对象 | 比如多例的测试 |
assertAll(Executable... executables) | 所有的 Executable 都执行且不抛出异常 | |
assertThrows(Class<T> expectedType, Executable executable, String message) | 必须抛出异常 | |
assertDoesNotThrow(Executable executable, String message) | 不能抛出异常 | |
assertTimeout(Duration timeout, Executable executable, String message) | 指定执行时间内执行完,Executable 和调用者在同一个线程执行 | 方法时长的判断 |
assertTimeoutPreemptively(Duration timeout, Executable executable, String message) | 指定执行时间内执行完,Executable 在新的线程执行 | 方法时长的判断 |
assertLinesMatch(List<String> expectedLines, List<String> actualLines, String message) | 对应行正则匹配相等,讲解麻烦,建议看代码,或者单独拿出一部分来讲 | |
在上面的例子中,使用了 assertEquals
、assertFalse
、assertTrue
、assertNotEquals
的使用,其他的也可以各自尝试一下,使用方法相同。
常见工具
- JUnit
- Mockito
- Assertj
- Hamrest
- 结合 Spring 的 ut
- Mock 对象
- DB