最小的功能单元编写测试代码
。Java程序最小的功能单元是方法
,对Java程序进行单元测试就是针对单个Java方法的测试
。
这就是测试驱动开发TDD(Test-Driven Development)
。是敏捷开发
中的一项核心实践和技术。
当然,这是一种理想情况
。大部分情况是我们已经编写了实现代码,需要对已有的代码进行测试。
一般情况下我们是用一个main()方法
在Main方法里面编写测试代码,但使用main()方法测试有很多缺点:
不能把测试代码分离
是没有打印出测试结果和期望结果
,例如,expected: 3628800, but actual: 123456是很难编写一组通用的测试代码。
因此我们可以使用JUnit框架进行单元测试
JUnit
是一个开源
的Java语言的单元测试标准框架
,专门针对Java设计
,使用最广泛
。
使用JUnit编写单元测试的好处在于: 可以非常简单地组织测试代码,随时运行它们,JUnit就会给出成功的测试和失败的测试
,还可以生成测试报告
,不仅包含测试的成功率
,还可以统计测试的代码覆盖率
,即被测试的代码本身有多少经过了测试 。对于高质量的代码来说,测试覆盖率应该在80%以上
。
此外,几乎所有的Java开发工具都集成了JUnit(如Eclipse,IDEA)
,这样我们就可以直接在IDE中编写并运行JUnit测试。JUnit目前最新版本是JUnit5
。
JUnit 5 这个版本,主要特性
- 提供全新的断言和测试注解,支持测试类内嵌
- 更丰富的测试方式:支持动态测试,重复测试,参数化测试等
- 实现了模块化,让测试执行和测试发现等不同模块解耦,减少依赖
- 提供对 Java 8 的支持,如 Lambda 表达式,Sream API等。
单元测试可以确保单个方法按照正确预期运行
,如果修改了某个方法的代码,只需确保其对应的单元测试通过,即可认为改动正确。此外,测试代码本身就可以作为示例代码,用来演示如何调用该方法。
使用JUnit进行单元测试,我们可以使用断言(Assert)
来测试期望结果,可以方便地组织和运行测试,并方便地查看测试结果。
在编写单元测试的时候,我们要遵循一定的规范
:
单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试
;
每个单元测试应当互相独立
,不依赖运行的顺序
;
测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为0,null,空字符串""
等情况。
引入Junit5框架
<dependency>
<groupId>org.junit.jupitergroupId>
<artifactId>junit-jupiter-engineartifactId>
<version>5.5.2version>
<scope>testscope>
dependency>
在方法上加上@Test注解,JUnit会把带有@Test的方法识别为测试方法
@Test
@DisplayName("测试方法")
@Tag("标签")
public void testJunit() {
System.out.println("HelloWorld");
}
Assert.assertEquals(expected, actual)是最常用的测试方法,它在Assertions类
中定义。Assertions
还定义了其他断言方法,例如:
assertEquals(expected, actual):查看两个对象是否相等。类似于字符串比较使用的equals()方法;
assertNotEquals(first, second):查看两个对象是否不相等。
assertNull(object):查看对象是否为空。
assertNotNull(object):查看对象是否不为空
assertSame(expected, actual):查看两个对象的引用是否相等,类似于使用“==”比较两个对象;
assertNotSame(unexpected, actual):查看两个对象的引用是否不相等,类似于使用“!=”比较两个对象。
assertTrue(String message, boolean condition) 要求condition == true,查看运行的结果是否为true;
assertFalse(String message, boolean condition) 要求condition == false,查看运行的结果是否为false。
assertArrayEquals(String message, XXX[] expecteds,XXX [] actuals) 要求expected.equalsArray(actual),即查看两个数组是否相等。
assertThat(String reason, T actual, Matcher matcher) :要求matcher.matches(actual) == true,使用Matcher做自定义的校验。
fail:能使测试立即失败,这种断言通常用于标记某个不应该被到达的分支。通常用于测试在应该抛出异常的时候确实会抛出异常。
实例代码
public class Factorial {
public static long fact(long n) {
long r = 1;
for (long i = 1; i <= n; i++) {
r = r * i;
}
return r;
}
@Test
public void testAssert() {
assertEquals(1, Factorial.fact(1));
assertEquals(2, Factorial.fact(2));
assertEquals(6, Factorial.fact(3));
assertEquals(3628800, Factorial.fact(10));
assertEquals(2432902008176640000L, Factorial.fact(20));
}
}
测试通过
如果测试结果与预期不符,assertEquals()会抛出异常: 预计返回1111,实际返回1
在一个单元测试中,我们经常编写多个@Test方法
,来分组
、分类
对目标代码进行测试。
在测试的时候,我们经常遇到·一个对象需要初始化,测试完可能还需要清理的情况
。· 如果每个@Test方法都写一遍这样的重复代码,显然比较麻烦。
JUnit提供处理测试前准备,和测试后清理的公共代码,我们称之为Fixture
。
使用当前这个类必须先实例化Calculator 对象,才能调用相关的方法,我们不必在每个测试方法中都创建Calculator 对象
通过@BeforeEach来初始化Calculator ,通过@AfterEach来回收Calculator
方法 | 描述 |
---|---|
@BeforeEach | 执行测试方法前调用 |
@AfterEach | 执行测试方法后调用 |
public class Calculator {
private long n = 0;
public long add(long x) {
n = n + x;
return n;
}
public long sub(long x) {
n = n - x;
return n;
}
}
修改后的代码
public class CalculatorTest {
Calculator calculator;
//执行测试方法前调用
@BeforeEach
public void setUp() {
this.calculator = new Calculator();
}
//执行测试方法后调用
@AfterEach
public void tearDown() {
this.calculator = null;
}
@Test
void testAdd() {
assertEquals(100, this.calculator.add(100));
assertEquals(150, this.calculator.add(50));
assertEquals(130, this.calculator.add(-20));
}
@Test
void testSub() {
assertEquals(-100, this.calculator.sub(100));
assertEquals(-150, this.calculator.sub(50));
assertEquals(-130, this.calculator.sub(-20));
}
}
方法 | 描述 |
---|---|
@BeforeAll | 执行所有@Test测试方法前调用一次,只能标注在静态方法 上面 |
@AfterAll | 执行所有@Test测试方法后调用 一次,只能标注在静态方法 上面 |
某些资源初始化和清理会会耗费较长的时间,全局只需要初始化和清理一次即可时
,例如初始化数据库。JUnit还提供了@BeforeAll和@AfterAll,它们在运行所有@Test前后运行
:
public class DatabaseTest {
static Database db;
//初始化数据库
@BeforeAll
public static void initDatabase() {
db = createDb(...);
}
//关闭数据库
@AfterAll
public static void closeDatabase() {
//...
}
}
@DisplayName("我的第一个测试用例")
public class MyFirstTestCaseTest {
@BeforeAll
public static void init() {
System.out.println("初始化数据");
}
@AfterAll
public static void cleanup() {
System.out.println("清理数据");
}
@BeforeEach
public void tearup() {
System.out.println("当前测试方法开始");
}
@AfterEach
public void tearDown() {
System.out.println("当前测试方法结束");
}
@DisplayName("我的第一个测试")
@Test
void testFirstTest() {
System.out.println("我的第一个测试开始测试");
}
@DisplayName("我的第二个测试")
@Test
void testSecondTest() {
System.out.println("我的第二个测试开始测试");
}
}
因此,我们总结出编写Fixture的套路如下:
对于实例变量
,在@BeforeEach中初始化
,在@AfterEach中清理
,它们在各个@Test方法中互不影响,因为是不同的实例
对于静态变量
,在@BeforeAll中初始化
,在@AfterAll中清理
,它们在各个@Test方法中均是唯一实例,会影响各个@Test方法
大多数情况下,使用@BeforeEach和@AfterEach就足够了。
只有某些测试资源初始化耗费时间太长,以至于我们不得不尽量“复用”
时才会用到@BeforeAll和@AfterAll。
JUnit都会将当前方法创建一个XxxTest实例
(方法名+Test)每个@Test方法内部的成员变量都是独立的
,一个@Test方法不能调用另一个@Test方法的变量。我们代码中对于带有异常的方法通常都是使用 try-catch 方式捕获处理,针对测试这样带有异常抛出的代码,而 JUnit 5 提供方法 Assertions#assertThrows(Class, Executable) 来进行测试,第一个参数为异常类型,第二个为函数式接口参数,跟 Runnable 接口相似,不需要参数,也没有返回,并且支持 Lambda表达式方式使用,具体使用方式可参考下方代码:
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, new Executable() {
@Override
public void execute() throws Throwable {
System.out.println(1/0);
}
});
}
测试类
public class Config {
public static String getConfigFile(String filename) {
String os = System.getProperty("os.name").toLowerCase();
if (os.contains("win")) {
return "C:\\" + filename;
}
if (os.contains("mac") || os.contains("linux") || os.contains("unix")) {
return "/usr/local/" + filename;
}
throw new UnsupportedOperationException();
}
}
@Disabled
:禁用当前标注单元测试 @Test
void testWindows1() {
System.out.println(Config.getConfigFile("test.ini"));
}
@Test
@Disabled
void testWindows2() {
System.out.println(Config.getConfigFile("test1111.ini"));
}
@EnabledOnOs
: 根据不同的系统启动当前标注单元测试 @Test
@EnabledOnOs(OS.WINDOWS)
void testWindows() {
assertEquals("C:\\test.ini", Config.getConfigFile("test.ini"));
}
@Test
@EnabledOnOs({ OS.LINUX, OS.MAC })
void testLinuxAndMac() {
assertEquals("/usr/local/test.cfg", Config.getConfigFile("test.cfg"));
}
@Test
void testWindows1() {
System.out.println(Config.getConfigFile("test.ini"));
}
@Test
@DisabledOnJre(JRE.JAVA_8)
void testWindows2() {
System.out.println(Config.getConfigFile("test1111.ini"));
}
@EnabledIfSystemProperty
根据操作系统判断当前标注单元测试是否启用
@EnableIf
: 可以执行任意Java语句并根据返回的boolean决定当前标注方法是否执行测试
@Test
@EnabledIf("java.time.LocalDate.now().getDayOfWeek()==java.time.DayOfWeek.SUNDAY")
void testOnlyOnSunday() {
// TODO: this test is only run on Sunday
}
当我们在JUnit中运行所有测试的时候,JUnit会给出执行的结果。在IDE中,我们能很容易地看到没有执行的测试
如果待测试的方法输入和输出
是一组数据: 可以把测试数据组织起来 用不同的测试数据调用相同的测试方法
参数化测试和普通测试稍微不同的地方在于,测试方法需要传入至少一个参数,然后,传入一组参数反复运行。
@ValueSource
是 JUnit 5 提供的最简单的数据参数源,支持 Java 的八大基本类型和字符串
,Class,使用时赋值给注解上对应类型属性,以数组方式传递
接收单个参数 @ParameterizedTest
@ParameterizedTest
@ValueSource(strings = { "张三","李四","王五" })
void testEquals(String str) {
System.out.println("张三".equals(str));
}
@MethodSource
:接收多个参数@MethodSource
注解,它允许我们编写一个同名的静态方法来提供测试参数@ParameterizedTest
@MethodSource
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
static List<Arguments> testCapitalize() {
return List.of( // arguments:
Arguments.arguments("abc", "Abc"), //
Arguments.arguments("APPLE", "Apple"), //
Arguments.arguments("gooD", "Good"));
}
@CsvSource
: 传入多个参数@CsvSource
,它的每一个字符串表示一行
,一行包含的若干参数用,
分隔@ParameterizedTest
@CsvSource({ "abc, Abc", "APPLE, Apple", "gooD, Good" })
void testCapitalizeCsv(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
@CsvFileSource
@CsvSourc
e就很不方便。这个时候,我们可以把测试数据提到一个独立的CSV
文件中,然后标注上@CsvFileSource@ParameterizedTest
@CsvFileSource(resources = { "/test-capitalize.csv" })
void testCapitalizeUsingCsvFile(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
JUnit只在classpath
中查找指定的CSV文件,因此,test-capitalize.csv
这个文件要放到test
目录下,内容如下
@RepeatedTest : 在 JUnit 5 里新增了对测试方法设置运行次数的支持
,允许让测试方法进行重复运行。当要运行一个测试方法 N次时,可以使用 @RepeatedTest 标记它
@DisplayName("重复测试")
@RepeatedTest(value = 3)
public void i_am_a_repeated_test() {
System.out.println("执行测试");
}
我们还可以对重复运行的测试方法名称进行修改,利用 @RepeatedTest 提供的内置变量,以占位符方式在其name
属性上使用,
@DisplayName("自定义名称重复测试")
@RepeatedTest(value = 3, name = "{displayName} 第 {currentRepetition} 次")
public void i_am_a_repeated_test_2() {
System.out.println("执行测试");
}
@RepeatedTes
t 注解内用currentRepetition
变量表示已经重复的次数
,totalRepetitions
变量表示总共要重复的次数
,displayName
变量表示测试方法显示名称
,我们直接就可以使用这些内置的变量来重新定义测试方法重复运行时的名称。
当我们希望测试耗时方法的执行时间,并不想让测试方法无限地等待时,就可以对测试方法进行超时测试,JUnit 5
对此推出了断言方法 assertTimeout
,提供了对超时的广泛支持。
假设我们希望测试代码在一秒内执行完毕,可以写如下测试用例
@Test
@DisplayName("超时方法测试")
void test_should_complete_in_one_second() {
Assertions.assertTimeoutPreemptively(Duration.of(1, ChronoUnit.SECONDS), () -> Thread.sleep(2000));
}
这个测试运行失败,因为代码执行将休眠两秒钟,而我们期望测试用例在一秒钟之内成功。但是如果我们把休眠时间设置一秒钟,测试仍然会出现偶尔失败的情况,这是因为测试方法执行过程中除了目标代码还有额外的代码和指令执行会耗时,所以在超时限制上无法做到对时间参数的完全精确匹配
@Nested :
@Nested 注解
,能够以静态内部成员类的形式
对测试用例类进行逻辑分组
。 并且每个静态内部类
都可以有自己的生命周期
方法, 这些方法将按从外到内层次顺序执
行。@DisplayName("内嵌测试类")
public class NestUnitTest {
@BeforeEach
void init() {
System.out.println("测试方法执行前准备");
}
@Nested
@DisplayName("第一个内嵌测试类")
class FirstNestTest {
@Test
void test() {
System.out.println("第一个内嵌测试类执行测试");
}
}
@Nested
@DisplayName("第二个内嵌测试类")
class SecondNestTest {
@Test
void test() {
System.out.println("第二个内嵌测试类执行测试");
}
}
}