EC-技术例会-单元测试
目录
3-4-1简单断言
3-4-2 组合断言
3-4-3 异常断言
3-4-4 超时断言
3-4-5 快速失败
4-3-1 Mock方法
4-3-2 对 Mock 出来的对象进行行为验证和结果断言
4-3-3 给 Mock 对象打桩
4-4-1 可以代替 Mock 方法的 @Mock 注解
4-4-2 Spy 方法与 @Spy 注解
TDD(Test-Driven Development ,测试驱动开发)
1 单元测试定义
什么是单元测试?
单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。至于“单元”的大小或范围,并没有一个明确的标准,“单元”可以是一个函数、方法、类、功能模块或者子系统,总的来说,单元就是人为规定的最小的被测功能模块。单元测试是在软件开发过程中要进行的最低级别的测试活动,软件的独立单元将在与程序的其他部分相隔离的情况下进行测试。
举个通俗的例子:工厂在组装一台电脑,为确保电脑能正常工作,在组装之前会对电脑的每个组成元件都进行测试,此时的每个组成原件可以被称为一个单元,进行的测试就可以称为单元测试。
2 关于单元测试的三个问题
2-1 Why?
有的同学可能会说:为什么要进行单元测试,单元测试很浪费时间,我对我自己写的代码很有信心,肯定不会有bug的。
我想说的是我们都是人,是人就会出错,人人都不可能一开始就写出完美的代码。而且其实我们每天都在做单元测试。你写了一个函数,除了极简单的外,总是要执行一下,看看功能是否正常,有时还要想办法输出些数据,如弹出信息窗口什么的,这也是单元测试,把这种单元测试称为临时单元测试。只进行了临时单元测试的软件,针对代码的测试很不完整,代码覆盖率要超过70%都很困难,未覆盖的代码可能遗留大量的细小的错误,这些错误还会互相影响,当BUG暴露出来的时候难于调试,大幅度提高后期测试和维护成本。可以说我们越早检测到代码中的错误,修复的速度也就越快,成本也就越低,下图中Google统计出来的数据也就验证了这一点:
左边一列是软件测试的各个阶段,右边一列是指在各个阶段发现bug去解决需要多大花销,可以发现单元测试解决bug的成本是很低的,而越到上面集成测试、系统测试的解决bug成本是单元测试的上百上千倍,而且看右边的金字塔可以发现单元测试解决问题也是最快的。
对于我们程序员来说,如果养成了对自己写的代码进行单元测试的习惯,不但可以写出高质量的代码,而且还能提高编程水平。
2-2 Who?
单元测试由谁来做?
前面介绍了单元测试简单理解就是对开发人员所编写的代码进行测试,既然和代码相关我们第一感觉那应该是“开发人员来做”;再一看单元测试包含“测试”两个字,那么“测试人员来做”也应该是合理的。单元测试谁来做并没有一个绝对的标准,要根据公司的实际情况来决定。以下为开发人员或测试人员做单元测试的优缺点分析:
开发人员做单元测试:
优点:开发人员对代码最熟悉,而且开发人员编程技能相对比较强,所以开发人员自己写单元测试效率上和覆盖率上都比较高
缺点:开发人员平时写业务代码就要花费很多时间,有时候确实没有时间写单元测试;而且大部分开发人员没有太好的测试思想,单元测试可能只是写个最简单的用例就完了;自己写的代码自己测,往往都是不靠谱!
测试人员做单元测试:
优点:测试人员有比较系统的测试思想,可以更好地保证用例的覆盖。而且通过写单测测试能更好地了解具体代码结构、流程,对于后续的业务测试也非常有利。
缺点:测试人员的编程技能相对比较弱,如果不懂编程是无法开展单元测试的。并且测试人员对代码没有开发人员熟悉,效率会比较低。
我个人认为的话单元测试与其他测试不同,单元测试可看作是编码工作的一部分,应该由程序员完成,也就是说,经过了单元测试的代码才是已完成的代码,提交产品代码时也要同时提交测试代码。测试部门可以作一定程度的审核,测试人员应该更多承担的是项目中的性能测试、功能测试、验收测试等。
2-2 What?
单元测试要做什么?
单元测试的实现方式包括:人工静态检查、动态执行跟踪
人工静态检查:就是通常所说的“代码走读”,主要是保证代码逻辑的正确性
动态执行跟踪:就是把程序代码运行起来,检查实际的运行结果和预期结果是否一致
人工静态检查包含的主要内容:
检查算法的逻辑正确性
模块接口的正确性检查
输入参数有没有作正确性检查
调用其他方法接口的正确性
异常错误处理
保证表达式、SQL语句的正确性
检查常量或全局变量使用的正确性
程序风格的一致性、规范性
检查代码注释是否完整
动态执行跟踪需要编写测试脚本调用业务代码进行测试,为了更好的管理维护测试脚本,一般会采用单元测试框架来管理,不同的语言有不同的单元测试框架:
Java:JUnit、Mockito、TestNG
Python:UintTest、pyTest
单元测试的一个重要的衡量标准就是代码覆盖率,尽量做到代码的全覆盖。常见单元测试覆盖标准:
语句覆盖
分支覆盖
条件覆盖
分支-条件覆盖
条件组合覆盖
路径覆盖
3 技术理论体系—JUnit5
3-1 介绍
JUnit 是 Java 社区中知名度最高的单元测试工具。上图为Github上的java项目中使用最多的包的前20名排行,可以看到junit稳居第一的位置,而且里面有很多也都是与测试相关的,比如mockito、spring-test、testng等。
JUnit5作为最新版本的JUnit框架,JUnit5与之前版本的Junit框架有很大的不同。由三个不同子项目的几个不同模块组成。
JUnit Platform : Junit Platform是在JVM上启动测试框架的基础,不仅支持Junit自制的测试引擎,其他测试引擎也都可以接入。
JUnit Jupiter : JUnit Jupiter提供了JUnit5的新的编程模型,是JUnit5新特性的核心。内部包含了一个测试引擎 ,用于在Junit Platform上运行。
JUnit Vintage : 由于JUint已经发展多年,为了照顾老的项目,JUnit Vintage提供了兼容JUnit4.x,Junit3.x的测试引擎。
基于上面的介绍,可以参考下图对 JUnit 5 的架构和模块有所了解:
最核心的就是平台层:IDE 和构建工具都是作为客户端和这个平台层交互,以达到在项目中运行测试的目的。TestEngine 的实现在平台层中用于发现和运行测试,并且输出测试报告,并通过平台层返回给客户端。
核心关注点是扩展能力:不仅仅只是存在于测试类级别,在整个测试平台级别,都提供了足够的扩展能力。只需要实现框架本身对 TestEngine 的接口,任何测试框架都可以在 JUnit Platform 上运行,这代表着 JUnit5 将会有着很强的拓展性。只需要一点点工作,通过这一个扩展点,框架就能得到所有 IDE 和构建工具在测试上的支持。这对于新框架来说绝对是好事,在测试和构建这块的门槛更低。如 JUnit Vintage 就是一个 TestEngine 实现,用于执行 JUnit4 的测试。
3-2 为什么需要JUnit5
自从有了类似 JUnit 之类的测试框架,Java 单元测试领域逐渐成熟,开发人员对单元测试框架也有了更高的要求:更多的测试方式,更少的其他库的依赖。因此,大家期待着一个更强大的测试框架诞生,JUnit 作为Java测试领域的领头羊,推出了 JUnit 5 这个版本,主要特性:
提供全新的测试注解和断言,支持测试类内嵌
更丰富的测试方式:支持动态测试,重复测试,参数化测试等
实现了模块化,让测试执行和测试发现等不同模块解耦,减少依赖
提供对 Java 8 的支持,如 Lambda 表达式,Sream API等
SpringBoot整合了JUnit,在创建项目的时候会自动建立好test文件夹,达到与产品隔离的目的
编写测试方法只需加上@Test标注即可,Junit类具有Spring的功能,比如自动装配@Autowired,比如 @Transactional 标注测试方法,测试完成后自动回滚
3-3 常用注解
注解 | 说明 |
---|---|
@Test | 表示方法是测试方法(与 JUnit4 的 @Test 不同,它的职责非常单一,不能声明任何属性,拓展的测试将会由 Jupiter 提供额外注解) |
@ParameterizedTest | 表示方法是参数化测试 |
@RepeatedTest | 表示方法可重复执行 |
@DisplayName | 为测试类或者测试方法设置展示名称 |
@BeforeEach | 表示在每个测试方法之前执行 |
@AfterEach | 表示在每个测试方法之后执行 |
@BeforeAll | 只执行一次,执行时机是在所有测试方法和 @BeforeEach 注解方法之前,static 静态方法 |
@AfterAll | 只执行一次,执行时机是在所有测试方法和 @AfterEach 注解方法之后,static 静态方法 |
@Tag | 表示单元测试类别。类似于 JUnit4 中的 @Categories |
@Disabled | 表示测试类或测试方法不执行。类似于 JUnit4 中的 @Ignore |
@Timeout | 表示测试方法运行如果超过了指定时间将会返回错误 |
@ExtendWith | 为测试类或测试方法提供扩展类引用 |
@DisplayName("junit5功能测试类")
public class JUnit5Test {
@DisplayName("测试displayname注解")
@Test
void testDisplayName() {
System.out.println(1);
}
@BeforeEach
void testBeforeEach() {
System.out.println("测试就要开始了...");
}
@AfterEach
void testAfterEach() {
System.out.println("测试结束了...");
}
@BeforeAll
static void testBeforeAll() {
System.out.println("所有测试就要开始了...");
}
@AfterAll
static void testAfterAll() {
System.out.println("所有测试已经结束了...");
}
}
public class JUnit5Test2 {
@Disabled
@DisplayName("测试方法2")
@Test
void test2() {
System.out.println(2);
}
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)
@Test
void testTimeout() throws InterruptedException {
Thread.sleep(600);
}
}
3-4 断言(assertions)
断言(assertions)是测试方法中的核心部分,用来对测试需要满足的条件进行验证,检查业务逻辑返回的数据是否合理。所有的测试运行结束以后,会有一个详细的测试报告。
JUnit 5 内置的断言可以分成如下几个类别:
3-4-1简单断言
用来对单个值进行简单的验证。如:
方法 | 说明 |
---|---|
assertEquals | 判断两个对象或两个原始类型是否相等 |
assertNotEquals | 判断两个对象或两个原始类型是否不相等 |
assertSame | 判断两个对象引用是否指向同一个对象 |
assertNotSame | 判断两个对象引用是否指向不同的对象 |
assertTrue | 判断给定的布尔值是否为 true |
assertFalse | 判断给定的布尔值是否为 false |
assertNull | 判断给定的对象引用是否为 null |
assertNotNull | 判断给定的对象引用是否不为 null |
assertArrayEquals | 判断两个对象或原始类型的数组是否相等 |
public class JUnitTest3 {
@Test
@DisplayName("simple assertion")
public void simple() {
assertEquals(3, 1 + 2, "出错啦~");
assertNotEquals(3, 1 + 1);
assertNotSame(new Object(), new Object());
Object obj = new Object();
assertSame(obj, obj);
assertFalse(1 > 2);
assertTrue(1 < 2);
assertNull(null);
assertNotNull(new Object());
assertArrayEquals(new int[]{1, 2}, new int[] {1, 2});
}
}
3-4-2 组合断言
assertAll 方法接受多个 org.junit.jupiter.api.Executable 函数式接口的实例作为要验证的断言,可以通过 lambda 表达式很容易的提供这些断言
@Test
@DisplayName("组合断言")
public void all() {
assertAll("Math1",
() -> assertEquals(2, 1 + 1),
() -> assertTrue(1 > 0)
);
}
3-4-3 异常断言
JUnit5提供了Assertions.assertThrows() 断言来测试方法的异常情况。
@Test
@DisplayName("异常测试")
public void exceptionTest() {
ArithmeticException exception = Assertions.assertThrows(
//断定会抛出算术异常
ArithmeticException.class, () -> System.out.println(1 % 0),
"业务逻辑居然正常运行?!");
}
3-4-4 超时断言
Junit5还提供了Assertions.assertTimeout() 为测试方法设置了超时时间。
@Test
@DisplayName("超时测试")
public void timeoutTest() {
//如果测试方法时间超过1s将会异常
Assertions.assertTimeout(Duration.ofMillis(1000), () -> Thread.sleep(500));
}
3-4-5 快速失败
当判断业务达到某一预期时,可以通过 fail 方法直接使得测试失败
@Test
@DisplayName("fail")
public void shouldFail() {
if(1==1){
fail("This should fail");
}
}
3-5 前置条件
JUnit 5 中的前置条件(assumptions【假设】)类似于断言,不同之处在于不满足的断言会使得测试方法失败 ,而不满足的前置条件只会使得测试方法的执行终止而跳过 。前置条件可以看成是测试方法执行的前提,当该前提不满足时,就没有继续执行的必要。
assumeTrue 和 assumFalse 确保给定的条件为 true 或 false,不满足条件会使得测试执行终止。
public class Test4 {
private final String environment = "DEV";
@Test
@DisplayName("simple")
public void simpleAssume() {
assumeTrue(Objects.equals(this.environment, "DEV"));
assumeFalse(Objects.equals(this.environment, "PROD"));
}
@Test
public void simpleAssume2() {
assumeFalse(Objects.equals(this.environment, "DEV"));
}
}
3-6 嵌套测试
JUnit 5 可以通过 Java 中的内部类和@Nested 注解实现嵌套测试,从而可以更好的把相关的测试方法组织在一起。在内部类中可以使用@BeforeEach 和@AfterEach 注解,嵌套测试情况下,外层的测试不能驱动内层的Before(After)Each/All之类的方法提前/之后运行,内层的test可以驱动外层的Before(After)Each/all之类的方法提前/之后运行。
@DisplayName("嵌套测试")
public class Test5 {
Stack<Object> stack;
@Test
@DisplayName("new Stack()")
void isInstantiatedWithNew() {
// stack=new Stack<>();
// 嵌套测试情况下,外层的test不能驱动内层的BeforeEach/All之类的方法提前/之后运行
assertNull(stack);
// assertTrue(stack.isEmpty());
}
@Nested
@DisplayName("when new")
class WhenNew {
@BeforeEach
void createNewStack() {
stack = new Stack<>();
}
@Test
@DisplayName("is empty")
void isEmpty() {
assertTrue(stack.isEmpty());
}
@Test
@DisplayName("throws EmptyStackException when popped")
void throwsExceptionWhenPopped() {
assertThrows(EmptyStackException.class, stack::pop);
}
@Test
@DisplayName("throws EmptyStackException when peeked")
void throwsExceptionWhenPeeked() {
assertThrows(EmptyStackException.class, stack::peek);
}
@Nested
@DisplayName("after pushing an element")
class AfterPushing {
String anElement = "an element";
@BeforeEach
void pushAnElement() {
stack.push(anElement);
}
// 内层的test可以驱动外层的Before(After)Each/all之类的方法提前/之后运行
@Test
@DisplayName("it is no longer empty")
void isNotEmpty() {
assertFalse(stack.isEmpty());
}
@Test
@DisplayName("returns the element when popped and is empty")
void returnElementWhenPopped() {
assertEquals(anElement, stack.pop());
assertTrue(stack.isEmpty());
}
@Test
@DisplayName("returns the element when peeked but remains not empty")
void returnElementWhenPeeked() {
assertEquals(anElement, stack.peek());
assertFalse(stack.isEmpty());
}
}
}
}
3-7 参数化测试
参数化测试是JUnit5很重要的一个新特性,它使得用不同的参数多次运行测试成为了可能,也为我们的单元测试带来许多便利。
利用\ @ValueSource等注解,指定入参,我们将可以使用不同的参数进行多次单元测试,而不需要每新增一个参数就新增一个单元测试,省去了很多冗余代码。
@ValueSource : 为参数化测试指定入参来源,支持八大基础类以及String类型,Class类型
@NullSource : 表示为参数化测试提供一个null的入参
@EnumSource : 表示为参数化测试提供一个枚举入参
@CsvFileSource :表示读取指定CSV文件内容作为参数化测试入参
@MethodSource :表示读取指定方法的返回值作为参数化测试入参(注意方法返回需要是一个流)
当然如果参数化测试仅仅只能做到指定普通的入参还达不到让我觉得惊艳的地步。让我真正感到他的强大之处的地方在于他可以支持外部的各类入参。如:CSV,YML,JSON 文件甚至方法的返回值也可以作为入参 。只需要去实现ArgumentsProvider接口,任何外部文件都可以作为它的入参。
public class Test6 {
@DisplayName("Parameter Count : 1")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void test1(int num1) {
assertTrue(num1 < 4);
}
@DisplayName("Parameter Count : 2")
@ParameterizedTest(name = "{index} ==> fruit=''{0}'', qty={1}")
@CsvSource({
"apple, 1",
"banana, 2"
})
void test2(String fruit, int qty) {
assertTrue(true);
System.out.println(fruit+qty);
}
@DisplayName("Parameter Count : 3")
@ParameterizedTest(name = "{index} ==> fruit=''{0}'', qty={1}, price={2}")
@CsvSource({
"apple, 1, 1.99",
"banana, 2, 2.99"
})
void test3(String fruit, int qty, BigDecimal price) {
assertTrue(true);
}
/***
csv文件内容:*
name, age*
shawn, 24*
/
@DisplayName("参数化测试-从csv文件获取")
@ParameterizedTest
@CsvFileSource(resources="/test.csv", numLinesToSkip=1) // 指定csv文件位置,并忽略标题行
public void parameterizedTestWithCsv(String name, Integer age) {
System.out.println("name:" + name + ", age:" + age);
Assertions.assertNotNull(name);
Assertions.assertNotNull(age);
}
}
4 其他相关技术体系—Mockito
4-1 Mockito介绍
Mockito是一个Java单元测试的模拟框架,它可以使用简洁的API编写测试。
4-2 为什么要使用Mock
如上图,当我们在测试类 A 时,类 A 需要调用类 B 和类 C,而类 B 和类 C 又需要调用其他类如 D、E、F 等,假如类 D、E、F 构造很耗时又或者调用很耗时的话是非常不便于测试的(比如是 DAO 类,每次访问数据库都很耗时)。所以我们引入 Mock 对象。
如上图,我们将类 B 和类 C 替换成 Mock 对象,在调用类 B 和类 C 的方法时,用 Mock 对象的方法来替换(当然我们要自己设定参数和期望结果)而不用实际去调用其他类。这样测试效率会高很多。
结论:因为我们实际编写程序都不会是一个简单类,而是有着复杂依赖关系的类,Mock 对象让我们在不依赖具体对象的情况下完成测试。**
Mock带来的好处:***
可以预先创建测试即TDD(测试驱动开发)*
团队可以并行工作*
可以为无法访问的资源编写测试*
模拟可以交付给客户*
可以隔离系统
4-3 Mockito 中常用方法
4-3-1 Mock方法
使用 mock 方法 mock 一个类
Random random = Mockito.mock(Random.class);
4-3-2 对 Mock 出来的对象进行行为验证和结果断言
@Test
void check() {
Random random = Mockito.mock(Random.class);
System.out.println(random.nextInt());
Mockito.verify(random,Mockito.times(1)).nextInt();
Assertions.assertEquals(0, random.nextInt());
}
Mockito的验证方法verify可以验证对象是否发生过某些行为,并且可以配合Mockito.times(n)可以校验行为发生是否发生了n次。断言使用到的是JUnit提供的Assertions断言机制。
注意:当使用 mock 对象时,如果不对其行为进行定义,则 mock 对象方法的返回值为返回类型的默认值。
4-3-3 给 Mock 对象打桩
打桩可以理解为 mock 对象规定一种行为,使其按照我们的要求来执行具体的操作。在 Mockito 中,常用的打桩方法有
方法 | 含义 |
---|---|
when().thenReturn() | Mock 对象在触发指定行为后返回指定值 |
when().thenThrow() | Mock 对象在触发指定行为后抛出指定异常 |
when().doCallRealMethod() | Mock 对象在触发指定行为后调用真实的方法 |
@Test
void check() {
Random random = Mockito.mock(Random.class);
Mockito.when(random.nextInt()).thenReturn(100);
Assertions.assertEquals(100, random.nextInt());
}
4-4 Mockito 中常用注解
4-4-1 可以代替 Mock 方法的 @Mock 注解
mock 注解需要搭配 MockitoAnnotations.openMocks(testClass) 方法一起使用。
@Mock
private Random random;
@Test
void check2() {
MockitoAnnotations.openMocks(this);
Mockito.when(random.nextInt()).thenReturn(100);
Assertions.assertEquals(100, random.nextInt());
}
4-4-2 Spy 方法与 @Spy 注解
spy:间谍,mock:模仿
spy() 方法与 mock() 方法不同的是
-
被 spy 的对象会走真实的方法,而 mock 对象不会
-
spy() 方法的参数是对象实例,mock 的参数是 class
示例:spy 方法与 Mock 方法的对比
@Test
void check3() {
demo demo1 = Mockito.spy(new demo());
int res = demo1.add(1, 2);
Assertions.assertEquals(3, res);
demo demo2 = Mockito.mock(demo.class);
int res1 = demo2.add(1, 2);
Assertions.assertEquals(3, res1);
}
输出结果:
// 第二个 Assertions 断言失败,因为没有给 checkAuthority1 对象打桩,因此返回默认值
org.opentest4j.AssertionFailedError:
Expected :3
Actual :0
使用 @Spy
注解和mock方法类似,代码示例如下:
@Spy
private demo demo1;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void check() {
int res = demo1.add(1, 2);
Assertions.assertEquals(3, res);
}
5 改进方向
TDD(Test-Driven Development ,测试驱动开发)
在传统软件的开发流程中,软件开发人员先开发好功能代码,再针对这些功能设计测试用例、实现测试脚本,以此保证开发的这些功能的正确性和稳定性。
TDD并不是一门技术,而是一种开发理念。它的核心思想,是在开发人员实现功能代码前,先设计好测试用例的代码,然后再根据测试用例的代码编写产品的功能代码,最终目的是让开发前设计的测试用例代码都能够顺利执行通过。**
TDD的实施流程:**
TDD的优势:
- 保证开发的功能一定是符合实际需求的。*
更加灵活的迭代方式。*
保证系统的可扩展性。*
更好的质量保证。*
测试用例即文档。***
6 在Jetlinks上的应用
Jetlinks基于Spring Boot 2.3开发。
而Spring Boot 2.2.0 版本开始引入 JUnit 5 作为单元测试默认库。
单元测试已成为程序员编写程序时必要的测试手段。***
7 参考文献
外部文件:
单元测试百度百科
https://baike.baidu.com/item/单元测试/1917084?fr=aladdin
尚硅谷SpringBoot2笔记第七章单元测试JUnit5
https://www.yuque.com/atguigu/springboot/ksndgx#Agmrl
Mock 模拟测试简介及 Mockito 使用入门
https://blog.youkuaiyun.com/wwh578867817/article/details/51934404
测试驱动开发(TDD)