created_at | updated_at | slug | tags | ||||
---|---|---|---|---|---|---|---|
2022-03-05 05:14:47 -0800 |
2022-03-05 05:14:47 -0800 |
java-unit-test-tools |
|
工欲善其事,必先利其器。写单元测试,需要三类工具
- 测试平台:JUnit、TestNG
- Mock框架:Mockito、MockK
- 断言框架:AssertJ、Assertk
当开发语言为kotlin时,推荐JUnit5 + MockK + Assertk的组合。
测试框架承载单元测试运行的环境,基础技术。一般会提到JUnit和TestNG,Spring默认集成了JUnit5,后者由于没有具体使用过,不大好作评论,但是网上文章搜了一圈,相比于JUnit5,TestNG并没有看出什么优势,具有差不多的功能,但是需要写xml。处于好奇看了下两者的发布时间,JUnit5诞生的日期、更新的频繁程度都较高,所以倾向使用JUnit5,至于JUnit4,现在已经过时了。
框架 | 首版发布日期 | 首个正式版发布日期 | 最近一个正式版发布日期 | 更新频率 |
---|---|---|---|---|
JUnit4 | 2014-7-29 | 2014-7-29 | 2021-2-14 | 一年左右 |
JUnit5 | alpha版2016-2-1 | 2017-10-03 | 2021-11-29 | 一两个月一次 |
TestNG | 2010往前 | 2010往前 | 2022-1-3 | 一年左右 |
注意区分它有4、5两个版本,后者为最新版,也是极力推广的版本,功能丰富了不少。组成如下
JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage
JUnit Platform:在JVM中启动测试的基础;同时提供开发测试引擎的API
JUnit Jupiter:是写单元测试的编程模型、扩展模型的结合;提供跑基于juipter测试的测试引擎
JUnit Vintage:提供在当前平台跑JUnit3和JUnit4的测试引擎
JUnit5最低支持JDK8
有关JUnit的全部功能,参考官方手册,建议从头到尾看一遍,了解一下有哪些型号的锤子。
掌握三个基本概念
-
测试类
如下三种类可以被称作静态类,并且它们必须至少包含一个测试方法
- 顶级类
- 静态内部类
- 被@Nested注解的非静态内部类
-
测试方法
被@Test,@RepeatedTest, @ParameterizedTest, @TestFactory, or @TestTemplate注解的实例方法
-
生命周期方法
被@BeforeAll,@AfterAll, @BeforeEach, or @AfterEach注解的方法
注意事项
- 除@TestFactory注解的方法外,测试方法不能有返回值
- 测试类、测试方法不必是public的,但也不能是private的
- 对于Java来说,JUnit建议省略掉类和方法的public关键字
JUnit有21个以上的注解,我们挑选其中最常用的看
注解 | 说明 |
---|---|
@Test | 标记一个测试方法 |
@ParameterizedTest | 参数化测试,与数据源注解协同使用 |
@RepeatedTest | 重复执行的方法,即一个方法被执行多次 |
@DisplayName | 显示在测试报告中的名称 注:部分注解有name属性,也可以指定 |
@Nested | 表明被注解的类是非静态的嵌套类;主要用来分组 |
@Tag | 标签,用于过滤 和TestNG中的组概念类似 和JUnit4中的分类概念类似 |
@BeforeAll @AfterAll @BeforeEach @AfterEach |
生命周期方法 |
@Timeout | 方法执行的超时时间,超时则报错 |
@ExtendWith | 声明式扩展 |
@RegisterExtension | 编程式扩展 |
使用Kotlin编写的两个工具测试方法
/**
* 将类S的同名属性填充到D中
* 要求源对象的属性必须有getter方法,且,目标对象的属性必须有setter方法,否则复制不会成功
*/
fun <S, D> D.fillWith(source: S): D = apply {
BeanUtils.copyProperties(source, this)
}
/**
* 从一个链接中解析出oss的object key,如果它不是oss的链接,解析结果为null
*/
fun String.parseOssObjectKey(): String? {
if (this.startsWith("http", true)) {
return URL(this).path.trim('/')
}
return null
}
对应的单元测试如下
@DisplayName("全局扩展方法测试")
class ExtensionMethodTest {
companion object {
@BeforeAll
@JvmStatic
fun beforeAll() {
println("全局开始")
}
@AfterAll
@JvmStatic
fun afterAll() {
println("全局结束")
}
}
@BeforeEach
fun beforeEach(info: TestInfo) {
println("${info.displayName}开始")
}
@AfterEach
fun afterEach(info: TestInfo) {
println("${info.displayName}结束")
}
@Nested
@DisplayName("D.fillWith(S)")
inner class FillWith {
@Test
fun `fail case`() {
val source = NonInheritanceValClass(
field1 = "1",
field2 = 1,
field3 = false,
fieldA = listOf<String>()
)
val destination = NonInheritanceValClass()
destination.fillWith(source)
assertAll {
assertThat(destination.field1).isNull()
assertThat(destination.field2).isNull()
assertThat(destination.field3).isNull()
assertThat(destination.fieldA).isNull()
}
}
}
@Nested
@DisplayName("String.parseOssObjectKey()")
inner class ParseOssObjectKey {
@ParameterizedTest(name = ParameterizedTest.ARGUMENTS_WITH_NAMES_PLACEHOLDER)
@CsvSource(
value = [
"https://mylogs-oss.wemore.com/image/hello.jpg, image/hello.jpg",
"https://mylogs-oss.moumoux.com/image/hello.jpg, image/hello.jpg",
"http://mylogs-oss.moumoux.com/image/hello.jpg, image/hello.jpg"
], nullValues = ["null"]
)
fun `just test`(input: String, expectedOutput: String?) {
val result = input.parseOssObjectKey()
assertThat(result).isEqualTo(expectedOutput)
}
}
}
运行整个测试类,能够得到如下输出
全局开始
fail case()开始
fail case()结束
input=https://mylogs-oss.wemore.com/image/hello.jpg, expectedOutput=image/hello.jpg开始
input=https://mylogs-oss.wemore.com/image/hello.jpg, expectedOutput=image/hello.jpg结束
input=https://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg开始
input=https://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg结束
input=http://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg开始
input=http://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg结束
全局结束
全局扩展方法测试 > D.fillWith(S) > fail case() PASSED
全局扩展方法测试 > String.parseOssObjectKey() > input=https://mylogs-oss.wemore.com/image/hello.jpg, expectedOutput=image/hello.jpg PASSED
全局扩展方法测试 > String.parseOssObjectKey() > input=https://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg PASSED
全局扩展方法测试 > String.parseOssObjectKey() > input=http://mylogs-oss.moumoux.com/image/hello.jpg, expectedOutput=image/hello.jpg PASSED
上面的例子可以总结出几个问题,我们一个一个看
- 如何启动那个测试类?可能在IEAD中有启动按钮,但是如果只是给了一个类文件,我们要如何启动呢?在CI构建时要如何启动呢?
- 为什么要用嵌套类?
- @BeforeAll的使用看起来很不方便?
- 测试case的生命周期是怎样的?
- 参数化测试,还支持别的设置参数的方式吗?
三种启动方式
-
Console Launcher
提供一个可执行文件 junit-platform-console-standalone-1.8.2.jar ,用如下命令执行测试
java -jar junit-platform-console-standalone-1.8.2.jar <额外选项>
这种场景还没用过
-
IDEA插件 —— 开发最常用
到IDEA的插件市场搜索junit,安装插件就能直接测试(可以看到,junit插件是软件绑定的,默认已经安装了,甚至没有卸载选项)
此时写的测试类和方法上就会有运行按钮
-
gradle插件 —— CI最常用
使用gradle构建时需要添加junit插件。同样,gradle已经将JUnit添加到test任务的默认支持工具中,同样支持的还有JUnit4、TestNG。详情参考gradle的测试文档,可配置的参数比较多,一个较为简单的配置如下
test { // 使用JUnit5进行测试 useJUnitPlatform() // 测试线程数:2 maxParallelForks(2) // 日志配置 testLogging { // level=LIFECYCLE的配置项 events "passed", "skipped", "failed" exceptionFormat "full" } }
一个测试类启动后,生命周期从上到下为(以上面的例子为例)
- 测试类ExtensionMethodTest加载,执行被@BeforeAll注解的静态方法
- 测试类创建:ExtensionMethodTest、嵌套类FillWith
- 执行@BeforeEach注解的方法
- 执行@Test或@ParameterizedTest注解的方法
- 执行@AfterEach注解的方法
- 测试类被丢弃
- 执行另一个测试方法时,从第二步开始再执行
- 执行被@AfterAll注解的静态方法
所以重点是
- 执行每个测试方法,都会重新创建测试类实例。而不是多个测试方法共用一个实例
- 基于上面的原因,@BeforeAll、@AfterAll注解的方法只能在类加载期间执行,即只能注解到静态方法上。这也解释了为什么非静态嵌套类中无法使用@BeforeAll,因为它无法定义静态方法呀
理解单元测试:单元测试的基本要求之一是测试case之间相互不影响,测试方法可能使用了测试类中定义的变量,为了保证不受其它使用该变量的测试方法的影响,最好的方式就是为该方法专门创建一个测试类实例。这就是JUnit的默认行为,一定要理解。
也十分推荐这么做,这才是真的单元测试。
对于那些多个测试方法确实需要共享一个测试类实例的情况,JUnit也提供支持。使用时要慎重,此时@BeforeAll的行为也会改变。
@TestInstance(Lifecycle.PER_CLASS) class ExtensionMethodTest {
JUnit提供嵌套类的支持,是为了提供给用户更好的测试之间组关系的支持。
上例中,对每个待测方法,都有多个case需要执行,为了将二者分为两组,我为每个待测方法建立了一个嵌套对象,测试case作为嵌套对象的方法,这样在嵌套类上添加公共的说明,得到的测试报告也更加层次化。
可以想见,如果没有这样的支持,我需要在每个方法的显示名中加上说明,多么麻烦。
利器之二,最典型的应用场景就是针对各种不同输入的测试,参考上例ExtensionMethodTest.ParseOssObjectKey.just test()
,特点
-
使用注解@ParameterizedTest,可以通过name指定测试名称。name指定的字符串中有几个可以使用的占位符
Placeholder Description {displayName}
方法的名称 {index}
当前调用的参数在参数列表中的索引 {arguments}
完整的测试方法参数值,逗号分隔 {argumentsWithNames}
完整的测试方法的参数名和参数值,格式 key1=value1,key2=value2... {0}
,{1}
, …具体的参数 -
有定义的现成名称可以使用,如:
ParameterizedTest#DISPLAY_NAME_PLACEHOLDER
-
必须和数据源注解一起使用,支持的注解源(可以叠加使用)
-
@ValueSource:最常用,单个值的列表
-
@NullAndEmptySource、@NullSource。。。:最常用,null或空串
-
@EnumSource
-
@MethodSource:数据来源于一个方法
一个例子如下,重点是方法要是静态的(除非LifeCycle.PER_CLASS);返回Arguments的集合类型
// 定义 @JvmStatic fun provideLegalResource(): List<Arguments> { return PathMatchingResourcePatternResolver().getResources(LEGAL_RESOURCE_PATTERN).map { resource -> resource.file.name to publicObjectMapper.readTree(resource.file) }.map { (fileName, mockResources) -> mockResources.map { mockResource -> val resourceType = fileName.removeSuffix(RESOURCE_FILE_SUFFIX) val comment = (mockResource as ObjectNode).remove(COMMENT_KEY).asText() val content = """{"resources": [${mockResource.toJsonString()}]}""" // 上面都忽略,这才是关键 Arguments.of(resourceType, comment, content) } }.flatten() } // 使用 @ParameterizedTest(name = "{displayName} {argumentsWithNames}") @MethodSource("xxx.ResourceProvider#provideLegalResource") fun `200 when legal resource`(type: String, comment: String, content: String) {
-
@CsvSource:以CSV的方式提供多个值
-
@CsvFileSource
-
@ArgumentsSource:数据源于一个ArgumentsProvider,这个其实是最通用的方法,上面的所有注解都是用ArgumentsProvider实现的,比如下面这个
@ArgumentsSource(NullArgumentsProvider.class) public @interface NullSource { }
-
注意上面的beforeEach()方法的参数TestInfo,之所以方法执行时能够获得这个参数,是因为JUnit执行了注入操作。
JUnit支持向测试类的构造器和所有成员方法执行注入操作,对应的API是ParameterResolver
。默认实现有三个,有需要可以自己增加
TestInfoParameterResolver
RepetitionInfoParameterResolver
TestReporterParameterResolver
如果有多个类都有相同的测试前置操作、测试case,可以将这些内容抽取成为一个接口。JUnit的注解效果是可以继承的,这是个利器。合理使用。
举例:controller层的单元测试中,接口鉴权是公共的case,就很适合抽出来,每个接口都实现它。
更多内容请参考JUnit用户手册,值得探究的内容
测试顺序
测试方法之间默认没有顺序,但可通过添加@Order的方式声明顺序
并行执行
JUnit默认使用一个线程执行所有测试,我们可以指定多个以提升测试执行速度,不过要注意并发问题
条件测试
测试case在条件满足的情况下才执行
测试模板和动态测试
当需要动态生成测试方法时,不妨考虑考虑它们
单元测试不像集成测试,”单元“二字是关键,一次只测试单个逻辑。其它级联的方法调用,都可以通过mock解决。与之对应的两个概念
- mock:完全伪造目标,目标可以是对象、静态方法、kotlin的object等
- spy:在现有的目标上进行mock
如果用Kotlin,mockk一定是个很好的尝试。相对于Mockito,MockK功能全面,支持DSL,书写流畅简单。下面通过一些场景介绍。
MockK的完整使用方法包括
- 声明mock对象
- mock对象的行为
- 执行被测方法
- 验证结果、行为、过程参数
如果使用JUnit5,MockK提供MockkExtension,同MockitoExtension,Mockito中对应的@Mock、@Spy、@InjectMocks分别变成@MockK、@SpyK、@InjectMockKs。一个典型的例子
@ExtendWith(MockKExtension::class)
class MediaServiceTest {
// 声明mock对象
@MockK
private lateinit var miscService: MiscService
// 声明spy对象,同时声明spy的具体对象:mockMediaProperties
@SpyK
private var mediaProperties: MediaProperties = mockMediaProperties
// 声明被测对象,mediaService会被创建,并使用miscService和mediaProperties注入其构造方法
@InjectMockKs
private lateinit var mediaService: MediaService
@Test
fun `test`() {
val mockUser = MOCK_USER_ID
val mockDir = "mockDir"
// 临时声明的mock对象,GetOssDirMetaResp类的对象
val mockOssDirMetaResp = mockk<GetOssDirMetaResp>()
val mockDirMeta = mockk<GetOssDirMetaResp.DirMeta>()
// 临时创建spy对象,基于被测对象mediaService创建,目标是mock被测对象的行为
val spyMediaService = spyk(mediaService)
// mock行为
every { spyMediaService.parseDir(any()) } returns mockDir
every { spyMediaService.mediaStub.getOssDirMeta(any()) } returns mockOssDirMetaResp
every { mockOssDirMetaResp.dirMetaList } returns listOf(mockDirMeta)
every { mockDirMeta.totalSize } returns 0
// 调用被测方法
spyMediaService.getOccupiedSpaceOfUser(mockUser)
val slot = slot<GetOssDirMetaReq>()
// 捕获spyMediaService.mediaStub.getOssDirMeta()的参数
verify { spyMediaService.mediaStub.getOssDirMeta(capture(slot)) }
// 验证捕获到的参数
assertThat(slot.captured.app).isEqualTo(mediaProperties.appId)
assertThat(slot.captured.getDir(0)).isEqualTo(mockDir)
}
}
解释
-
miscService需要凭空mock,mediaProperties需要在提供的对象基础上mock
-
mediaService的创建依赖于miscService和mediaProperties
-
除了MockKExtension,也可以直接使用
MockKAnnotations.init(this::class)
来使其生效 -
本例测试的是某个参数,使用slot()方法抓取该参数
Mockito中,如果不对mock对象的行为做预设,行为默认返回对应返回类型的空值。MockK略有不同,不提供默认行为的mock,但可手动选择开启。
- @RelaxedMockK : 备注接的对象带有默认值
- @MockK(relaxed = true):同上
- mockk<MyObject>(relaxed = true):手动mock出来的MyObject对象行为带有默认值
object RequestContext {
val currentUser: Int = 12
}
@Test
fun test() {
mockkObject(RequestContext)
every { RequestContext.currentUser } returns 1
println(RequestContext.currentUser) // 输出1
}
值得一提的是,mock枚举对象也是通过mockkObject完成,毕竟,每个枚举项就是一个单例。
object RequestContext {
@JvmStatic
fun getUserId() = userIdTL.get()
}
@Test
fun test() {
mockkStatic(RequestContext::getUserId)
every { RequestContext.getUserId() } returns 1
println(RequestContext.getUserId()) // 输出1
}
比如下面这个,MembershipLevelStrategySelector创建,然后调用select()方法,我想要mock select()方法的行为,就要mockMembershipLevelStrategySelector的构造方法
fun doRedeem(currentUser: Int, currentAward: InvitationAwardModel) {
... ...
MembershipLevelStrategySelector(existSubscription, addPrivilegedBo).select().handle()
... ...
}
于是mock这样写
mockkConstructor(MembershipLevelStrategySelector::class)
every { anyConstructed<MembershipLevelStrategySelector>().select() } returns mockk<AddPrivilegedStrategy>(relaxed = true)
有springmock项目为MockK集成到SpringBoot中提供支持,主要是支持@MockKBean注解啦。
还有功能如下,不再详述,详情参考官方手册
- mock链式调用,即一次性mock
a.method1().method2()
,而不需要单独mock - 带有层次结构的mock
- 带有顺序的verify
- verify某个方法未被调用(很有用)
- 自定义answer等
与AssertJ对应的,是AssertK。下面展示简单用法,具体参考官方手册
assertThat(person.name).isEqualTo("Hello")
assertThat(person.age, "age").isGreaterThan(20) // age是报错时的显示名
assertThat(nullString).isNotNull().hasLength(4) // 可空判断和字符串判断
assertThat(string).all {
startsWith("L")
hasLength(3)
} // 多个同时判断
assertAll {
assertThat(false).isTrue()
assertThat(true).isFalse()
} // 更通用的多个同时判断
assertThat { throw Exception("error") }.isFailure().hasMessage("wrong")
assertThat { throw Exception("error") }.isFailure().matchPreticate {
// 自己的判断
}
个人认为断言库的两个关键点是使用的简单性和报错信息的展示,AssertK做的都还不错。
如果觉得不够用,也有自定义断言可选。
SpringBoot的test模块,默认包含的测试工具JUnit5、Mockito、AssertJ。相对于此,额外的功能是提供Spring上下文。
在测试类上添加此注解是最为简单的方式。它做了如下几件事
-
通过SpringApplication创建ApplicationContext
-
默认情况下它不会启动server。但是可以通过webEnvironment指定环境,默认为MOCK
-
MOCK:创建一个web类型的ApplicationContext、一个web类型的Environment。SpringBoot内嵌的Server不会启动。如果类路径中没有web相关的类可提供。则回滚创建一个非web的ApplicationContext。
可以结合@AutoConfigureMockMvc和@AutoConfigWebTestClient使用。前者提供一个服务mock,后者提供一个客户端mock终端
-
RANDOM_PORT:创建WebApplicationContext,创建真实的Server,内嵌Server被启动,端口随机
-
DEFINED_PORT:同上,只不过端口跟随配置文件
-
NONE:创建普通的ApplicationContext
-
-
当Spring MVC、Spring Webflux任何一个被检测到,就创建对应的web环境。如果都存在,则创建MVC环境。此时想要使用webflux,只能@SpringBootTest(properties = "spring.main.web-application-type=reactive")
可见,@SpringBootTest非常重,会扫描并加载所有Bean。但实际我们往往只会测试一部分内容,对此Spring提供了部分装载的功能,相当于@SpringBootTest的子集。它基于Spring的自动配置机制,现有支持可在这里查看。
依据实际使用的经验,Spring提供的测试机制并不友好,往往过于复杂,所以能不用就不用。相反,它更适合集成测试时使用,而不是单元测试。
被此二者注解的属性,会使用Mockito生成一个mock对象,然后注入到容器中,其他地方可以自由注入。以@MockBean为例,它有如下特性
-
使用@SpringBootTest时,该功能默认开启。其它情况使用时,需要手动开启。添加两个监听器
@ContextConfiguration(classes = MyConfig.class) @TestExecutionListeners({ MockitoTestExecutionListener.class, ResetMocksTestExecutionListener.class }) class MyTests
-
它定义的是Mockito的Mock
-
每个test方法结束后,被Mock的Bean会被reset
-
例子如下:Reverser使用真实对象;RemoteService使用Mock出来的Bean(覆盖原对象中的Bean)
@SpringBootTest class MyTests { @Autowired private Reverser reverser; @MockBean private RemoteService remoteService; @Test void exampleTest() { given(this.remoteService.getValue()).willReturn("spring"); String reverse = this.reverser.getReverseValue(); // Calls injected RemoteService assertThat(reverse).isEqualTo("gnirps"); } }
前文说过,使用MockK后,换成@MockKBean,由springmock库提供支持
MockMvc是个好东西,与之相对的,是端到端测试(即服务启动,然后使用客户端手动访问)。使用它能够很好滴对Spring MVC构建的端点进行测试,同时支持Kotlin DSL,一个简单的例子
@ControllerTest(SyncController::class)
class SyncControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@MockkBean
private lateinit var resourceService: ResourceService
@Test
fun `test`() {
mockMvc.post("/v2/resources/push") {
contentType = MediaType.APPLICATION_JSON
content = PushReqV2Dto().apply { resources = null }.toJsonString()
}.andExpect {
status {
isBadRequest()
}
header {
exists("TOKEN")
}
content {
contentType(MediaType.APPLICATION_JSON)
}
}
}
}
DSL的使用也相当简单,整体而言包括两个
- 填充请求参数
- 请求方式:get、post,或者中性的perform
- 请求路径
- 请求参数:头部、参数、body
- 填充预期内容
- 状态码
- 响应结果:头部、body等
通过代码提示,也能知道,有三种类型的API调用
- andReturn():返回执行结果,以MvcResult封装
- andExpect{}:DSL,填充一些断言
- andDo{}:DSL,这里可以做一些中性的实行,比如打印出响应结果。
这是Spring构建的Web应用的测试利器
但正如Spring手册所分类的那样,其实这玩意儿被分类在集成测试的
想法1:眼高手低。收集资料时想着有好多东西可以写;写的时候又发现如果要把那些都写完,衍生出来的知识太多了,根本无暇顾及。于是拖延,但一想不行,要继续写呀,于是一减再减,成了现在看到的这个样子,不甚满意。但总好过没有。
想法2:写这类学习的文章,还是要趁新鲜。一些内容,刚看到时会有惊艳的感觉,觉得值得一写。习惯之后又觉得理所当然,有什么好写的。殊不知,数月后,对那些认为理所当然的内容,我又恢复成萌新的状态。又要从头开始学习,到时连个回顾的纲要都没有。或许从另一面证明了:写这玩意儿,叫做沉淀🤔