文章目录
一.什么是单元测试呢?
- 单元测试就是针对
最小的功能单元编写测试代码
。Java程序最小的功能单元是方法
,对Java程序进行单元测试就是针对单个Java方法的测试
。
二.测试驱动开发(TDD)
- 测试驱动开发就是指先编写接口,紧接着编写测试。编写完测试后,我们才开始真正编写实现代码。在编写实现代码的过程中,一边写,一边测,什么时候测试全部通过了,那就表示编写的实现完成了.
这就是测试驱动开发TDD(Test-Driven Development)
。是敏捷开发
中的一项核心实践和技术。
当然,这是一种理想情况
。大部分情况是我们已经编写了实现代码,需要对已有的代码进行测试。
三.JUnit框架
1.为什么需要JUnit框架?
一般情况下我们是用一个main()方法
在Main方法里面编写测试代码,但使用main()方法测试有很多缺点:
- 一是一个类只能有一个main()方法,
不能把测试代码分离
是没有打印出测试结果和期望结果
是很难编写一组通用的测试代码。
因此我们可以使用JUnit框架进行单元测试
2.什么是JUnit框架?
-
JUnit
是一个开源
的Java语言的单元测试标准框架
,专门针对Java设计
,使用最广泛
。 -
使用JUnit编写单元测试的好处在于: 可以非常简单地组织测试代码,随时运行它们,JUnit就会给出
成功的测试和失败的测试
,还可以生成测试报告
,不仅包含测试的成功率
,还可以统计测试的代码覆盖率
,即被测试的代码本身有多少经过了测试 。对于高质量的代码来说,测试覆盖率应该在80%以上
。 -
此外,
几乎所有的Java开发工具都集成了JUnit(如Eclipse,IDEA)
,这样我们就可以直接在IDE中编写并运行JUnit测试。JUnit目前最新版本是JUnit5
。
JUnit 5 这个版本,主要特性
- 提供全新的断言和测试注解,支持测试类内嵌
- 更丰富的测试方式:支持动态测试,重复测试,参数化测试等
- 实现了模块化,让测试执行和测试发现等不同模块解耦,减少依赖
- 提供对 Java 8 的支持,如 Lambda 表达式,Sream API等。
3,单元测试的好处
-
单元测试可以
确保单个方法按照正确预期运行
,如果修改了某个方法的代码,只需确保其对应的单元测试通过,即可认为改动正确。此外,测试代码本身就可以作为示例代码,用来演示如何调用该方法。
-
使用JUnit进行单元测试,我们可以使用
断言(Assert)
来测试期望结果,可以方便地组织和运行测试,并方便地查看测试结果。
在编写单元测试的时候,我们要遵循一定的规范
:
-
单元测试代码本身必须非常简单,能一下看明白,决不能再为
测试代码编写测试
; -
每个单元测试应当
互相独立
,不依赖运行的顺序
; -
测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如
输入为0,null,空字符串""
等情况。
四.使用Junit5框架
引入Junit5框架
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.5.2</version>
<scope>test</scope>
</dependency>
1.@Test/@DisplayName/@Tag
在方法上加上@Test注解,JUnit会把带有@Test的方法识别为测试方法
- @DisplayName: 测试类或方法的显示名称
- @Tag : 为测试类或方法添加标签
@Test
@DisplayName("测试方法")
@Tag("标签")
public void testJunit() {
System.out.println("HelloWorld");
}
2. 断言方法
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),即查看两个数组是否相等。
-
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
assertThat(String reason, T actual, Matcher matcher)
:要求matcher.matches(actual) == true,使用Matcher做自定义的校验
。
@Test
public void testAssert() {
// allOf: 所有条件都必须满足,相当于&&
assertThat("myname", allOf(startsWith("my"), containsString("name")));
// anyOf: 其中一个满足就通过, 相当于||
assertThat("myname", anyOf(startsWith("na"), containsString("name")));
// both: &&
assertThat("myname", both(containsString("my")).and(containsString("me")));
// either: 两者之一
assertThat("myname", either(containsString("my")).or(containsString("you")));
// everyItem: 每个元素都需满足特定条件
assertThat(Arrays.asList("my", "mine"), everyItem(startsWith("m")));
// hasItem: 是否有这个元素
assertThat(Arrays.asList("my", "mine"), hasItem("my"));
// hasItems: 包含多个元素
assertThat(Arrays.asList("my", "mine", "your"), hasItems("your", "my"));
// is: is(equalTo(x))或is(instanceOf(clazz.class))的简写
assertThat("myname", is("myname"));
assertThat("mynmae", is(String.class));
// anything(): 任何情况下,都匹配正确
assertThat("myname", anything());
// not: 否为真,相当于!
assertThat("myname", is(not("you")));
// nullValue(): 值为空
String str = null;
assertThat(str, is(nullValue()));
// notNullValue(): 值不为空
String str2 = "123";
assertThat(str2, is(notNullValue()));
// -------------------------字符串匹配
// containsString:包含字符串
assertThat("myname", containsString("na"));
// stringContainsInOrder: 顺序包含,“my”必须在“me”前面
assertThat("myname", stringContainsInOrder(Arrays.asList("my", "me")));
// endsWith: 后缀
assertThat("myname", endsWith("me"));
// startsWith: 前缀
assertThat("myname", startsWith("my"));
// isEmptyString(): 空字符串
assertThat("", isEmptyString());
// equalTo: 值相等, Object.equals(Object)
assertThat("myname", equalTo("myname"));
assertThat(new String[]{"a", "b"}, equalTo(new String[]{"a", "b"}));
// equalToIgnoringCase: 比较时,忽略大小写
assertThat("myname", equalToIgnoringCase("MYNAME"));
// equalToIgnoringWhiteSpace: 比较时, 首尾空格忽略, 比较时中间用单个空格
assertThat(" my \t name ", equalToIgnoringWhiteSpace(" my name "));
// isOneOf: 是否为其中之一
assertThat("myname", isOneOf("myname", "yourname"));
// isIn: 是否为其成员
assertThat("myname", isIn(new String[]{"myname", "yourname"}));
// toString() 返回值校验
assertThat(333, hasToString(equalTo("333")));
//------------------------ 数值匹配
// closeTo: [operand-error, operand+error], Double或BigDecimal类型
assertThat(3.14, closeTo(3, 0.5));
assertThat(new BigDecimal("3.14"), is(BigDecimalCloseTo.closeTo(new BigDecimal("3"), new BigDecimal("0.5"))));
// comparesEqualTo: compareTo比较值
assertThat(2, comparesEqualTo(2));
// greaterThan: 大于
assertThat(2, greaterThan(0));
// greaterThanOrEqualTo: 大于等于
assertThat(2, greaterThanOrEqualTo(2));
// lessThan: 小于
assertThat(0, lessThan(2));
// lessThanOrEqualTo: 小于等于
assertThat(0, lessThanOrEqualTo(0));
// -----------------------------------------------------集合匹配
// array: 数组长度相等且对应元素也相等
assertThat(new Integer[]{1, 2, 3}, is(array(equalTo(1), equalTo(2), equalTo(3))));
// hasItemInArray: 数组是否包含特定元素
assertThat(new String[]{"my", "you"}, hasItemInArray(startsWith("y")));
// arrayContainingInAnyOrder, 顺序无关,长度要一致
assertThat(new String[]{"my", "you"}, arrayContainingInAnyOrder("you", "my"));
// arrayContaining: 顺序,长度一致
assertThat(new String[]{"my", "you"}, arrayContaining("my", "you"));
// arrayWithSize: 数组长度
assertThat(new String[]{"my", "you"}, arrayWithSize(2));
// emptyArray: 空数组
assertThat(new String[0], emptyArray());
// hasSize: 集合大小
assertThat(Arrays.asList("my", "you"), hasSize(equalTo(2)));
// empty: 空集合
assertThat(new ArrayList<String>(), is(empty()));
// isIn: 是否为集合成员
assertThat("myname", isIn(Arrays.asList("myname", "yourname")));
// -------------------------------------------------Map匹配
Map<String, String> myMap = new HashMap();
myMap.put("name", "john");
// hasEntry: key && value匹配
assertThat(myMap, hasEntry("name", "john"));
// hasKey: key匹配
assertThat(myMap, hasKey(equalTo("name")));
// hasValue: value匹配
assertThat(myMap, hasValue("john"));
}
3. 使用Fixture
3.1@BeforeEach/@AfterEach
-
在一个单元测试中,我们经常
编写多个@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));
}
}
3.2 @BeforeAll/@AfterAll
方法 | 描述 |
---|---|
@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() {
//...
}
}
3.3.执行顺序
@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("我的第二个测试开始测试");
}
}
3.4使用Fixture小结
因此,我们总结出编写Fixture的套路如下:
-
对于
实例变量
,在@BeforeEach中初始化
,在@AfterEach中清理
,它们在各个@Test方法中互不影响,因为是不同的实例 -
对于
静态变量
,在@BeforeAll中初始化
,在@AfterAll中清理
,它们在各个@Test方法中均是唯一实例,会影响各个@Test方法
大多数情况下,使用@BeforeEach和@AfterEach就足够了。
只有某些测试资源初始化耗费时间太长,以至于我们不得不尽量“复用”
时才会用到@BeforeAll和@AfterAll。
- 实际上每次运行一个@Test方法前,
JUnit都会将当前方法创建一个XxxTest实例
(方法名+Test) - 因此,
每个@Test方法内部的成员变量都是独立的
,一个@Test方法不能调用另一个@Test方法的变量。
4. 异常测试: assertThrows
我们代码中对于带有异常的方法通常都是使用 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);
}
});
}
测试不通过
5.禁用/启用执行测试
测试类
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"));
}
- @DisabledOnJre() :根据jre运行环境来禁用当前标注单元测试
@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中,我们能很容易地看到没有执行的测试
6.参数化测试
-
如果待测试的方法
输入和输出
是一组数据: 可以把测试数据组织起来 用不同的测试数据调用相同的测试方法 -
参数化测试和普通测试稍微不同的地方在于,测试方法需要传入至少一个参数,然后,传入一组参数反复运行。
-
@ValueSource
是 JUnit 5 提供的最简单的数据参数源,支持Java 的八大基本类型和字符串
,Class,使用时赋值给注解上对应类型属性,以数组方式传递
-
接收单个参数
@ParameterizedTest
@ParameterizedTest
@ValueSource(strings = { "张三","李四","王五" })
void testEquals(String str) {
System.out.println("张三".equals(str));
}
@MethodSource
:接收多个参数@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
目录下,内容如下
7.重复性测试
@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
变量表示测试方法显示名称
,我们直接就可以使用这些内置的变量来重新定义测试方法重复运行时的名称。
8.超时操作的测试:assertTimeoutPreemptively
当我们希望测试耗时方法的执行时间,并不想让测试方法无限地等待时,就可以对测试方法进行超时测试,JUnit 5
对此推出了断言方法 assertTimeout
,提供了对超时的广泛支持。
假设我们希望测试代码在一秒内执行完毕,可以写如下测试用例
@Test
@DisplayName("超时方法测试")
void test_should_complete_in_one_second() {
Assertions.assertTimeoutPreemptively(Duration.of(1, ChronoUnit.SECONDS), () -> Thread.sleep(2000));
}
这个测试运行失败,因为代码执行将休眠两秒钟,而我们期望测试用例在一秒钟之内成功。但是如果我们把休眠时间设置一秒钟,测试仍然会出现偶尔失败的情况,这是因为测试方法执行过程中除了目标代码还有额外的代码和指令执行会耗时,所以在超时限制上无法做到对时间参数的完全精确匹配
9.内嵌测试类
@Nested :
- 当我们编写的类和代码逐渐增多,随之而来的需要测试的对应测试类也会越来越多。
- 为了解决测试类数量爆炸的问题,JUnit 5提供了
@Nested 注解
,能够以静态内部成员类的形式
对测试用例类进行逻辑分组
。 并且每个静态内部类
都可以有自己的生命周期
方法, 这些方法将按从外到内层次顺序执
行。 - 此外,嵌套的类也可以用@DisplayName 标记,这样我们就可以使用正确的测试名称。
@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("第二个内嵌测试类执行测试");
}
}
}