惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Security Archives - TechRepublic
Security Archives - TechRepublic
罗磊的独立博客
T
The Blog of Author Tim Ferriss
The GitHub Blog
The GitHub Blog
Apple Machine Learning Research
Apple Machine Learning Research
The Register - Security
The Register - Security
J
Java Code Geeks
V2EX - 技术
V2EX - 技术
Vercel News
Vercel News
N
News and Events Feed by Topic
腾讯CDC
P
Proofpoint News Feed
N
News | PayPal Newsroom
www.infosecurity-magazine.com
www.infosecurity-magazine.com
爱范儿
爱范儿
O
OpenAI News
酷 壳 – CoolShell
酷 壳 – CoolShell
月光博客
月光博客
Martin Fowler
Martin Fowler
Engineering at Meta
Engineering at Meta
D
Docker
Y
Y Combinator Blog
博客园 - 聂微东
G
Google Developers Blog
S
Security @ Cisco Blogs
Simon Willison's Weblog
Simon Willison's Weblog
S
Schneier on Security
H
Hackread – Cybersecurity News, Data Breaches, AI and More
S
SegmentFault 最新的问题
云风的 BLOG
云风的 BLOG
阮一峰的网络日志
阮一峰的网络日志
C
CXSECURITY Database RSS Feed - CXSecurity.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
C
CERT Recently Published Vulnerability Notes
I
Intezer
G
GRAHAM CLULEY
有赞技术团队
有赞技术团队
Attack and Defense Labs
Attack and Defense Labs
V
Visual Studio Blog
博客园 - Franky
博客园 - 三生石上(FineUI控件)
W
WeLiveSecurity
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Hugging Face - Blog
Hugging Face - Blog
Scott Helme
Scott Helme
T
Troy Hunt's Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
L
LINUX DO - 最新话题
C
Cybersecurity and Infrastructure Security Agency CISA

Shiroha白羽的博客

Golang 踩坑 —— interface 为参数的时候传 nil 指针 Codeforces Round 925 (Div. 3) Codeforces Round 924 (Div. 2) Codeforces Round 923 (Div. 3) Codeforces Round 922 (Div. 2) Codeforces Round 921 (Div. 2) Educational Codeforces Round 161 (Rated for Div. 2) Codeforces Round 920 (Div. 3) Codeforces Round 919 (Div. 2) Hello 2024 Good Bye 2023 Codeforces Round 918 (Div. 4) 个人备份的常用 macOS 清理命令 Codeforces Round 917 (Div. 2) Pinely Round 3 (Div. 1 + Div. 2) Educational Codeforces Round 160 (Rated for Div. 2) Codeforces Round 915 (Div. 2) Codeforces Round 914 (Div. 2) Codeforces Round 913 (Div. 3) Educational Codeforces Round 159 (Rated for Div. 2) Codeforces Round 912 (Div. 2) Codeforces Round 911 (Div. 2) CodeTON Round 7 (Div. 1 + Div. 2, Rated, Prizes!) Educational Codeforces Round 158 (Rated for Div. 2) Codeforces Round 910 (Div. 2) Codeforces Round 909 (Div. 3) Codeforces Round 908 (Div. 2) Educational Codeforces Round 157 (Rated for Div. 2) C++自定义的字面量 Codeforces Round 907 (Div. 2) Codeforces Round 916 (Div. 3) 关于 LRU map 的一些灵感 2023 杭州站 ICPC 现场赛 反复横跳的 Clang-Tidy(cert-dcl21-cpp) Codeforces Round 906 (Div. 2) 一段奇怪的 CPP 代码 Codeforces Round 905 (Div. 3) Codeforces Round 904 (Div. 2) Codeforces Round 903 (Div. 3) Educational Codeforces Round 156 (Rated for Div. 2) Codeforces Round 902 (Div. 2, based on COMPFEST 15 - Final Round) Codeforces Round 901 (Div. 2) Codeforces Round 900 (Div. 3) Codeforces Round 899 (Div. 2) Educational Codeforces Round#155 (Div. 2) Codeforces Round 898 (Div. 4) CodeTON Round 6 (Div. 2) Codeforces Round 897 (Div. 2) Codeforces Round 896 (Div. 2) Codeforces Round 887 (Div. 2) Codeforces Round 895 (Div. 3) 左值-右值-将亡值 blog.mauve.icu Pinely Round 2 (Div. 1 + Div. 2) Harbour.Space Scholarship Contest 2023-2024 (Div. 1 + Div. 2) Codeforces Round 894 (Div. 3) Codeforces Round 888 (Div. 3) Educational Codeforces Round#153 (Div. 2) Codeforces Round 893 (Div. 2) OTPAUTH,两步验证中的通用协议 Codeforces Round 892 (Div. 2) Codeforces Round 891 (Div. 3) Codeforces Round 890 (Div. 2) Educational Codeforces Round#152 (Div. 2) blog.mauve.icu Java Script 的 null 和 undefined 随想 记一次 SQL LEFT JOIN 没有得到预期结果的错误 Codeforces Round#789(Div. 2) GCC/G++ 预编译头性能优化 centOS 防火墙 docker-compse 的问题 C++ 语言实现动态变化的线程池 Codeforces Round#744 (Div. 3) 计算机图形学 Windows 通过网络访问 WSL2 原生 JavaScript 实现图片裁剪 面试复习(计算机图形学) 面试复习(算法) Codeforces Round#706(Div. 2)-Let's Go Hiking 面试复习(Java) 面试复习(Git) 面试复习(Linux) 面试复习(数据库) 面试复习(计算机网络) 面试复习(操作系统) 面试复习(C++) Codeforces Round#699 (Div. 2) 清理 WSL2 的磁盘占用 Codeforces Round#697 (Div. 3) Windows 下的 NTFS 驱动器索引 BUG 计算机网络复习 记一次 Navicat 连接 MySQL 一直报认证错误(Access denied) 计算机网络实验复习 WSL1 使用 Docker 一直无法启动 我的ACM脚印 2020牛客暑期多校训练营(第三场)D-Points Construction Problem——构造 2020牛客暑期多校训练营(第三场)E-Two Matchings——复杂思维与简单dp 2020牛客暑期多校训练营(第二场)I-Interval——最大流转对偶图求最短路 Educational Codeforces Round 80 D. Minimax Problem——二分+二进制处理 Codeforces Round 606 E. Two Fairs——图论 Codeforces Round 612 (Div. 2) C. Garland——DP
使用 Junit5 和 Mockito 实现 SpringBoot 的单元测试最优美的解决方案
Shiroha · 2022-04-22 · via Shiroha白羽的博客

什么是单元测试

单元测试就是一部分代码,但是它

  • 不会在正常的业务流程中被执行
  • 不被打包进入最终的编译程序
  • 不会被任何其他业务代码以任何方式导入
  • 不会影响正常的代码

当然,它通常还要满足下面这些条件

  • 自动化的,不需要人工输入任何数据即可完成
  • 独立的,任何两个单元测试之间都不应该发生调用关系
  • 可重复的,单元测试可以无限重复执行且结果应该一致

传统的单元测试,即是测试一个函数是否正确运行。单元测试可以为这个函数预先伪造一个测试环境,例如用户登录了,且已经有超管权限了,那么运行这个函数是否能够得到我们期望得到的结果

注意上面这段文字中的提到的「为这个函数预先伪造一个测试环境」,这似乎不是很难理解,让我来举个例子:

  • 例如我们现在希望测试登录能力,这是多数的服务中通常都有的能力,按照一般的编码规范,我们将会在 service 层进行逻辑判断。例如取出匹配此账号的数据库的值,并校验密码。
  • 这是非常传统的做法,也同样足够的有效。接下来,让我们来为这个测试伪造一个环境

    • 首先,我们在数据库中插入一个数据,当然,此时我们并不需要考虑到底插入了什么,因为我现在想要模拟假如没有此账号的似乎,登录的结果
    • 然后,我们请求对应的接口,使用新的随机数据,当然,这时候我们期望得到的是失败的登录请求,因为我们输入的数据就是不存在的。
  • 让我们来重新回顾整个过程,这个过程我们做了什么?我们访问了数据库!还修改了里面的数据!这太可怕了!

  • 假如这件事放在业务上,我们需要在发布环境通过单元测试来校验代码是否合理的时候,我们还需要插入一条数据!这仅仅只是一个登录失败的测试,这太可怕了!
  • 那有没有什么可能的方案来解决这个问题?
  • 接下来就轮到 Mock 来伪造这个过程了
  • 还是以登录失败为例,我们现在假定 service 是直接调用了 dao 层接口
    • 首先,我们 mock 了用户的 dao 层接口,并指定「获取用户」的接口若传入 “ABC” 这个字符串,则返回不存在这个用户
    • 然后我们调用用户的登录接口,并传入 “ABC” 作为账号
    • 当用户的 service 遇到需要调用用户的 dao 接口时,会被上面设定的规则将会导致不再请求数据库,而是直接返回不存在
    • service 收到不存在后,包装好返回值,并返回
  • 虽然看起来与刚才的,最终的结果是一样的,我们测试的代码几乎是相同的,但是我们却解决了最重要的问题——访问数据库

事实上,很多时候 mock 并不是解决这个问题的。我们希望单元测试能够单独测试一个函数是否逻辑正确,那么我们仅需要测试这个函数即可,当这个函数需要调用其他函数的时候,我们会对函数进行 mock 使得得到我们期望的值。这样就可以实现仅仅校验此函数的逻辑是否正确了

单元测试的意义

  • 在不需要启动服务的情况下,检查代码逻辑是否有问题
  • 保证代码在后续的迭代过程中,被其他人更新后导致原来可以正常运行的结果变得不正确了

因为单元测试是负责完成代码测试的,所以当完整的单元测试写完之后,我们就可以通过单元测试来校验代码逻辑是否有问题

同时单元测试将会一直存在与源代码中,后续每一次需要进行校验发布时,都可以通过运行一次单元测试来检查是否因为本次修改,导致之前的逻辑出现错误

单元测试的标准

  • 单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元 测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。
  • 保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
  • 单元测试是可以重复执行的,不能受到外界环境的影响。
  • 对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。
  • 单元测试代码必须写在如下工程目录:src/test/java,不允许写在业务代码目录下。
  • 单元测试应当包含「边界值测试」、「正确的输入」、「强制错误信息输入」的测试,而不是仅仅以满足覆盖率为标准

你需要会哪些代码知识

本博客的知识范围是 SpringBoot 框架,所以你必须要掌握下面的技能

  • 能够熟练使用 Java 语言编写代码
  • 了解 SpringBoot 的 AOP 的设计思想,会使用依赖注入
  • 能够看懂上面的基本概念,了解单元测试的重要性
  • 会使用 maven,并知道如何使用 maven
  • 能够阅读中文,并能看懂本博客

开始写单元测试

单元测试的代码应该位于你的项目目录 src/test/java 下,接下来所有的内容目录都指此目录

导入 maven 依赖

我们需要了解下面几个重要的依赖,但是并非都是需要添加的,请继续阅读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- Junit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<!-- Mockito 核心 -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
</dependency>
<!-- Mockito 对 static 支持 -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
</dependency>
<!-- Spring 对单元测试支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>

以上这些依赖的相互依赖关系如下

graph LR
c([mockito-inline]) ----> b([mockito-core])
d([spring-boot-starter-test]) ----> b([mockito-core])
d([spring-boot-starter-test]) ----> a([junit-jupiter])

所以,实际上你只需要最后两个依赖即可完成本片博客的所有内容,但是还是有必要详细解释一下这些依赖在本博客中起到的作用

  • junit5:必须的组件,提供了最重要的注解和单元测试能力
  • mockito-core:必须的组件,提供了最重要的 Mock 的能力
  • mockito-inline:非必须组件,提供了对静态方法的 Mock 能力,如果不需要对静态方法进行 Mock 则可以不需要
  • spring-boot-starter-test:非必须组件,提供了对类的 private 变量的赋值能力,实际上反射也可以做到,但是通常为了方便期间,可以直接使用已经有的轮子

当你确定好需要的依赖之后,将其最新版本添加到你的 maven 里吧

创建测试类

首先,需要进行逻辑测试的永远是某个实现类,而不是接口,因为接口并不是需要测试的,我们需要测试的是实现的过程是否有问题

创建类用来编写你的单元测试。通常我们会根据需要测试的类进行单独建测试类,即每一个类对应一个测试类,每一个测试类,仅测试对应类的方法。例如,我们有 src/main/java/com/example/service/impl/UserServiceImpl.java 类,那么我们创建 src/test/java/com/example/service/impl/UserServiceImplTest.java 用于测试 UserServiceImpl 类。

例如,我们创建了 src/test/java/com/example/service/impl/UserServiceImplTest.java 类用于测试对应的类。接下来我们需要介绍一些注解和类。

1
2
3
4
5
6
7
@ExtendWith() // 来自 junit 5 的注解,用于测试类上,表示此测试需要额外使用什么扩展工具
MockitoExtension // 来自 Mockito-core 的类,是 Mockito 的扩展工具,用于 junit 5 使用,junit 4 并不是这个
@BeforeAll // 来自 junit5 的注解,用于 static 方法上,表示在进行此类的所有测试方法前,执行一次此函数,仅一次
@AfterAll // 来自 junit 5 的注解,与 @BeforeAll 类似,但是表示所有测试方法结束后执行一次,仅一次
@BeforeEach // 来自 junit 5 的注解,用于非 static 方法上,表示在此类的所有测试方法将被执行前,每个都执行一次
@AfterEach // 来自 junit 5 的注解,与 @BeforeEach 类似,但是是在每个测试方法结束后,都执行一次
@Test // 来自 junit 5 的注解,用于非 static 方法上,表示此方法是一个测试

添加注解

接下来,按照上面的描述,为你的每个测试类都添加这些需要的注解,我们可以得到类似下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {

@BeforeEach
void setUp() {
// 这里的代码将会在每个测试前运行
}

@AfterEach
void tearDown() {
// 这里的代码将会在每个测试结束后运行
}

/**
* 测试登录,用户不存在的情况
*/
@Test
void testLoginWithNoSuchUser() {
// 这里编写你的测试代码
}
}

我们已经完成了最基本的类的创建,虽然我们还没有开始调用登录的函数,但是我们已经完成类绝大部分的任务。

注入类

接下来,让我们将需要测试的类注入进来

在类中最开头添加类似下面的代码

1
2
3
4
5
6
7

@InjectMocks
private UserServiceImpl userService; // 需要测试的类,需要用 @InjectMocks 注解

@Mock
private UserDaoImpl userDao; // 需要 mock 的类,需要用 @Mock 注解

然后,开始测试

1
2
3
4
5
6
7

@Test
void testLoginWithNoSuchUser() {
Boolean isSuccess = userService.login("handle", "password");
Assertions.assertFalse(isSuccess); // 校验返回值是否正确
}

但是这样肯定是不行的,因为你会发现,这样运行的结果会使得 isSuccessnull,而不是我们期望的结果。当然,我们也还没有配置 mock 的内容。

mock it!

接下来让我们开始 mock 吧,尝试类似下面的代码

1
2
3
4
5
6
7
8
9

@Test
void testLoginWithNoSuchUser() {
// 表示「当调用 userDao#selectUserByHandle 且参数为 "handle" 时,则返回 null」
Mockito.when(userDao.selectUserByHandle("handle")).thenReturn(null);
Boolean isSuccess = userService.login("handle", "password");
Assertions.assertFalse(isSuccess); // 校验返回值是否正确
}

再运行一次看看?是不是完美了?

回头看看我们做的过程,是否让单元测试变得更加简单了,编写单元测试仅需要三步

  • 编写 Mockito 内容
  • 调用函数
  • 校验返回值或者参数

下面将会介绍几种常见的情况

应对各种情况

通用匹配类型

有时候我们并不喜欢指明参数必须要是什么,例如无论什么调用时,都返回 null,此时,参数可以使用 Mockito.any() 来表示任意参数,例如:

1
Mockito.when(userDao.selectUserByHandle(Mockito.any())).thenReturn(null);

指定调用的目标函数的返回值

这已经在上面提及到了,也就是最常见的问题

让调用的目标函数抛出错误

thenReturn 改为 thenThrow 即可

让调用的目标函数做一些指定的事情

如果希望更加自定义函数的内容,譬如做点什么,则可以使用 thenAnswer 来解决

1
2
3
4
5
6
7
Mockito.when(userDao.selectUserByHandle(Mockito.any())).thenAnswer(invocationOnMock -> {
String handle = invocationOnMock.getArgument(0); // 获取第 0 个参数
if (handle == "handle") {
return null;
}
return 1;
})

如何应对没有返回值的函数

把 then 的部分向前提就行,并改为 do 系列

1
2
3
4
5
Mockito.doAnswer(invocationOnMock -> {
User argument = invocationOnMock.getArgument(0);
argument.setId(1);
return null; // 必须要返回些什么
}).when(userDao).insertAccount(Mockito.any());

如何控制那些静态的函数

假如我们有一个校验密码的静态方法 BCryptEncoder#encode,那么下面就是一个很好的例子

1
2
3
4
5
6
7
MockedStatic<BCryptEncoder> bCryptEncoderMockedStatic;
bCryptEncoderMockedStatic = Mockito.mockStatic(BCryptEncoder.class);
bCryptEncoderMockedStatic.when(() -> BCryptEncoder.encoder("abc")).thenReturn("123");

// do something

bCryptEncoderMockedStatic.close();

如何测试 private 的方法

private 方法不应该被测试,因为其他类不会调用这方法。应该通过 public 间接测试 private 方法

如何校验函数的参数

我们以注册用户的时候使用的插入用户至数据库为例

1
2
3
ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class); // 创建一个捕获类
Mockito.verify(userManager, Mockito.times(1)).insertAccount(userArgumentCaptor.capture()); // 第一次插入的时候,捕获参数
User userCP = userArgumentCaptor.getValue(); 获取被捕获的参数的值,后面就可以直接校验 userCP 了