必学!Spring Boot 单元测试、Mock 与 TestContainer 的高效使用技巧
在现代软件开发中,测试的必要性日益凸显。无论是单元测试还是集成测试,它们都扮演着保障软件质量的重要角色。特别是在复杂的应用程序中,确保各个组件之间的有效协作是至关重要的。本文将详细探讨如何使用 Mockito 进行单元测试,以及如何在 Spring Boot 中通过测试容器实现集成测试。通过对这些工具的深入分析,我们将学习如何提高测试覆盖率、提升代码可靠性,确保在引入新逻辑时,系统的稳定性不会受到影响。
我要讲述一个真实的故事。 我们正在和团队成员一起进行一个庞大的项目。当时,产品经理提出了一个由全球团队交办的新任务。在大型项目中,每一行代码都至关重要,因此我们在开发过程中必须非常谨慎。当我们开始编码时,发现其他人编写的某个逻辑在单元测试(UNIT TEST)中未能正确运行。
此外,我们的代码和逻辑也是基于这段未正常运行的代码设计的。如果我们没有编写单元测试,或者如果我们和其他工程师从未考虑编写单元测试,结果会怎样?答案很明确:我们可能会完成任务,但到最后,我们会在生产环境中发现这个错误。最终,这个错误会以成本的形式回到我们身上。在软件行业,你可能会听到许多类似的故事,有时甚至更糟。在本文中,我们将讨论单元测试、Mock、集成测试、Spring中的参数捕获(Argument Capture)以及单元测试的重要性。
现实生活中的示例 - 1
在现实生活中,产品负责人(Product Owner)与分析师一起创建任务。通常,分析师会分析产品的需求。最后,工程师们会分享这些任务。
**赵一:**我完成了我的任务。不幸的是,我没有时间编写单元测试,因此我手动测试了一切,所有功能都正常。
张三: 好的,Nick!恭喜!我们可以在下一个冲刺中为单元测试安排新的任务。
他们原本打算在下一个冲刺中为单元测试分配新任务,但显然事情并不总是按照计划进行。大多数时候,工程师会忘记编写测试。最后,当他们开始添加新的业务逻辑和代码时,他们无法在开发过程中预测问题。这就是为什么我们需要单元测试和集成测试。
更容易理解的是,公司想要完成项目,公司需要处理产品经理和分析师,公司也需要雇佣工程师。但公司没有给开发者足够的开发时间,工程师们没有编写测试,结果就是失败和遗憾。最终,单元测试是必不可少的。否则,扩展新的业务逻辑和代码可能变得不可行。即使可行,也可能影响其他业务逻辑,而由于缺乏单元测试和集成测试,这些问题无法被预测到。
现实生活中的示例 - 2
在第二种场景中,工程师们有时间编写单元测试和集成测试。这在开发过程中和新功能开发时非常有帮助,可以提高代码的清晰度和保证。当需要添加新的功能和代码时,可以观察到代码的行为。
更容易理解的是,公司想要完成项目,公司需要处理产品经理和分析师,公司也需要雇佣工程师,并且公司给开发者足够的开发时间。工程师们编写测试,公司想要添加新功能,工程师们添加了新功能,并编写了单元测试和集成测试。最终,一切都很顺利并成功完成了。工程师们很高兴,产品经理很高兴,分析师也很高兴,老板也很高兴。
Spring Boot中的测试策略
通常,我们团队更喜欢使用Kotlin编写单元测试。与Java相比,Kotlin在单元测试中提供了更多的清晰性和效率。
使用Mockito
Kotlin提供了更多的清晰性和效率
Kotlin提供了清晰的方法命名
测试覆盖率应得到提升
集成测试必不可少
对于void方法,应使用Mockito的参数捕获功能(Argument Capture)
什么是Mockito
Mockito是一个流行的开源Java框架,允许开发者使用简单清晰的API编写测试。其主要目的是创建Mock对象。
Mockito创建模拟对象,以模拟真实对象的行为,这意味着它提供了一种干净、简单的方式来进行单元测试。
单元测试步骤
思考可能的场景。如果你想不到任何场景,可以使用Chat GPT或Google Bard来帮助你。例如:成功、因X失败、因Y失败、因Z成功等。
确定必须模拟的服务。例如,当你要为用户服务编写单元测试时,你必须模拟repository接口、mapper接口(如果你使用MapStruct)和其他由用户服务调用的服务。
如果模拟的服务不返回任何值,你可以使用参数捕获(Argument Capture),它可以帮助你确定捕获到的参数。
你可以使用verify()来验证服务的哪个方法被调用。此外,你还可以使用Assertions来比较值。
当我们查看UserService中的create方法时,可以清楚地理解在插入数据之前会检查电子邮件和电话号码是否已存在,然后调用UserPrepare类的prepareUser()函数,此外还会调用save()和map()函数。根据这些信息,我们可以创建三种场景:
1-) 创建操作成功
2-) 因重复的电子邮件导致创建操作失败
3-) 因重复的电话号码导致创建操作失败
完整代码
package beratyesbek.icoderoad.mock.service
import beratyesbek.icoderoad.mock.UserPrepare
import beratyesbek.icoderoad.mock.model.User
import beratyesbek.icoderoad.mock.model.dto.user.UserCreateDTO
import beratyesbek.icoderoad.mock.model.dto.user.UserReadDTO
import beratyesbek.icoderoad.mock.model.mapper.UserMapper
import beratyesbek.icoderoad.mock.repository.UserRepository
import beratyesbek.icoderoad.mock.service.email.EmailService
import beratyesbek.icoderoad.mock.service.email.user.UserEmailService
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito.*
import org.springframework.test.context.junit.jupiter.SpringExtension
@ExtendWith(SpringExtension::class)
class UserServiceTest {
@Mock
private lateinit var userMapper: UserMapper
@Mock
private lateinit var userRepository: UserRepository
@Mock
private lateinit var userPrepare: UserPrepare
@Mock
private lateinit var userEmailService: UserEmailService
@InjectMocks
private lateinit var userService: UserServiceImpl
@Test
fun `create user success test`() {
val userCreateDto = mock(UserCreateDTO::class.java)
val user = mock(User::class.java)
val readDTO = mock(UserReadDTO::class.java)
`when`(userRepository.existsByEmail(userCreateDto.email)).thenReturn(false)
`when`(userRepository.existsByPhone(userCreateDto.phone)).thenReturn(false)
`when`(userPrepare.prepareUserForCreation(userCreateDto)).thenReturn(user)
doNothing().`when`(userEmailService).sendVerificationEmail(any(), any(), any())
`when`(userRepository.save(user)).thenReturn(user)
`when`(userMapper.mapToReadDTO(user)).thenReturn(readDTO)
val result = userService.create(userCreateDto)
verify(userRepository, times(1)).existsByEmail(userCreateDto.email)
verify(userRepository, times(1)).existsByPhone(userCreateDto.email)
verify(userRepository, times(1)).save(user)
Assertions.assertEquals(user.email, result.email)
Assertions.assertEquals(user.phone, result.phone)
}
@Test
fun `create use failure because of duplicate email`() {
val userCreateDTO = mock(UserCreateDTO::class.java)
val user = mock(User::class.java)
val readDTO = mock(UserReadDTO::class.java)
// 模拟测试,当调用该方法时返回true
// 用于测试失败的情况
// 当调用该方法时将抛出异常
`when`(userRepository.existsByEmail(userCreateDTO.email)).thenReturn(true)
`when`(userRepository.existsByPhone(userCreateDTO.phone)).thenReturn(false)
doNothing().`when`(userEmailService).sendVerificationEmail(any(), any(), any())
`when`(userPrepare.prepareUserForCreation(userCreateDTO)).thenReturn(user)
`when`(userRepository.save(user)).thenReturn(user)
`when`(userMapper.mapToReadDTO(user)).thenReturn(readDTO)
val exception = Assertions.assertThrows(RuntimeException::class.java) {
userService.create(userCreateDTO)
}
// 它将检查该方法是否被调用
verify(userRepository, never()).save(any())
Assertions.assertTrue(exception.message?.contains("该邮箱对应的用户已存在") ?: false)
}
@Test
fun `create user failure because of duplicate phone`() {
val userCreateDTO = mock(UserCreateDTO::class.java)
val user = mock(User::class.java)
val readDTO = mock(UserReadDTO::class.java)
// 模拟测试,当调用该方法时返回 true
// 用于测试失败的情况
// 当调用该方法时将抛出异常
`when`(userRepository.existsByPhone(userCreateDTO.phone)).thenReturn(true)
`when`(userRepository.existsByEmail(userCreateDTO.email)).thenReturn(false)
doNothing().`when`(userEmailService).sendVerificationEmail(any(), any(), any())
`when`(userPrepare.prepareUserForCreation(userCreateDTO)).thenReturn(user)
`when`(userRepository.save(user)).thenReturn(user)
`when`(userMapper.mapToReadDTO(user)).thenReturn(readDTO)
val exception = Assertions.assertThrows(RuntimeException::class.java) {
userService.create(userCreateDTO)
}
// 这将检查该方法是否被调用
verify(userRepository, never()).save(any())
Assertions.assertTrue(exception.message?.contains("此电话号码已存在用户") ?: false)
}
}
测试说明
@Mock 注解用于创建一个被模拟的对象。
@InjectMock 注解用于自动将模拟对象注入到测试类的字段中,特别是标记为的字段。它创建了一个 UserServiceImpl 的实例。
UserCreateDTO、UserReadDTO 和 User 使用 mock() 函数进行模拟,这意味着这些对象被操控以模拟真实对象的行为。
when()
方法顾名思义,模拟对象被调用时的实际行为,并执行该对象执行的操作。这里的对象在运行后会返回一个使用 thenReturn
指定的对象。如我们在方法中所见,doNothing
用于无返回值的方法。
verify()
检查方法是否被调用。times()
检查方法被调用的次数。
Assertions.assertEquals()
比较两个参数,即预期值和实际值。如果它们匹配,返回 true;否则,返回 false。
注意:您必须在单元测试和服务端的 when()
中传递相同的引用或值。如果在服务中给定一个会改变的值,那么模拟将无法正确模拟它。因此,您的测试可能无法正常工作。
参数捕获
在 Mockito 中,参数捕获是一种技术,允许在测试中捕获传递给方法的参数,这是一种非常有用的技术。
package beratyesbek.icoderoad.mock.service.email
import beratyesbek.icoderoad.mock.service.email.model.*
import beratyesbek.icoderoad.mock.service.email.user.UserEmailServiceImpl
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.*
import org.mockito.Mockito.verify
import org.springframework.test.context.junit.jupiter.SpringExtension
import kotlin.test.assertEquals
@ExtendWith(SpringExtension::class)
class UserEmailServiceTest {
@Mock
private lateinit var emailService: EmailService
@InjectMocks
private lateinit var userEmailService: UserEmailServiceImpl
@Captor
private lateinit var emailRequestCaptor: ArgumentCaptor<EmailRequest>
@Test
fun `send verification email successfully`() {
val email = "berat@gmail.com"
val firstName = "Berat"
val lastName = "Yesbek"
userEmailService.sendVerificationEmail(email, firstName, lastName)
// 它用于捕获所有后续调用的参数
verify(emailService, Mockito.times(1)).send(emailRequestCaptor.capture())
// 返回捕获到的参数的值
val capturedEmailRequest = emailRequestCaptor.value
val capturedContext = capturedEmailRequest.context
assertEquals(email, capturedEmailRequest.to)
assertEquals(EmailTemplates.EMAIL_VERIFY, capturedEmailRequest.template)
assertEquals(EmailSubjects.COMPLETE_YOUR_REGISTRATION, capturedEmailRequest.subject)
assertEquals(firstName, capturedContext.getVariable("firstName"))
assertEquals(lastName, capturedContext.getVariable("lastName"))
assertEquals(EmailMessage.VERIFY_ACCOUNT.message, capturedContext.getVariable("message"))
}
}
@Captor
注解在 Mockito 中简化了 ArgumentCaptor
实例在测试类中的创建和使用。
@Captor
注解用于在方法调用期间捕获参数,以便后续验证。测试方法检查 UserEmailServiceImpl
的 sendVerificationEmail
方法是否正确与模拟的 EmailService
进行交互,确保 send
方法以预期的参数被调用,并断言捕获的 EmailRequest
中的特定值。代码的最后部分比较了捕获的值和期望的值,以检查这些值是否已正确设置。
Spring Boot 中使用测试容器的集成测试
测试容器是实现集成测试的隔离环境。集成测试确实旨在组合并运行软件的不同单元或组件,以确保它们作为一个整体正常工作。
大多数情况下,集成测试是为仓库实现的。然而,我将展示如何在 UserController
上进行集成测试。
在跳入集成测试之前,我们必须配置我们的测试容器。
1-) 实现我们需要的依赖
// 测试 Gradle 文件依赖。
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.7.20'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:postgresql:1.19.1'
testImplementation 'org.testcontainers:testcontainers:1.15.3'
testImplementation 'org.testcontainers:junit-jupiter:1.15.3'
2-) 创建测试容器类
package beratyesbek.icoderoad.mock.integrationtest.testcontainers
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.testcontainers.containers.PostgreSQLContainer
@TestConfiguration
open class TestContainer {
@Bean
@ServiceConnection
open fun postgresSQLContainer(): PostgreSQLContainer<Nothing> {
val container = PostgreSQLContainer<Nothing>("postgres:16-alpine")
container.withDatabaseName("test")
container.withUsername("test")
container.withPassword("test")
return container
}
}
3-) 创建测试容器配置文件
您必须在 src/main/resources/application-test-containers.properties 下创建一个测试容器配置文件。
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.generate-ddl=true
spring.datasource.url=${JDBC_DATABASE_URL:jdbc:tc:postgresql:16-alpine:///test}
spring.datasource.username=${JDBC_DATABASE_USERNAME:test}
spring.datasource.password=${JDBC_DATABASE_PASSWORD:test}
spring.flyway.baseline-on-migrate=true
4-) 创建想实现集成测试的控制器、服务或仓库
package beratyesbek.icoderoad.mock.integrationtest
import beratyesbek.icoderoad.mock.controller.UserController
import beratyesbek.icoderoad.mock.model.dto.user.UserCreateDTO
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension
@ActiveProfiles("test-containers")
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class UserControllerIntegrationTest {
@Autowired
private lateinit var userController: UserController
@Test
fun `create user successful`() {
val userCreateDTO = UserCreateDTO.builder()
.email("berat@gmail.com")
.firstname("Berat")
.lastname("Yesbek")
.phone("1234563443")
.password("123456")
.build()
val result = userController.create(userCreateDTO)
Assertions.assertNotNull(result)
Assertions.assertNotNull(result.body)
Assertions.assertNotNull(result.body?.id)
Assertions.assertEquals(result.statusCode, HttpStatus.OK)
Assertions.assertEquals(result.body?.email, userCreateDTO.email)
Assertions.assertEquals(result.body?.firstname, userCreateDTO.firstname)
Assertions.assertEquals(result.body?.lastname, userCreateDTO.lastname)
Assertions.assertEquals(result.body?.phone, userCreateDTO.phone)
}
}
@ActiveProfiles 注解用于在测试期间激活特定的 Spring 配置文件。
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
此注解用于指定测试应加载整个 Spring 应用程序上下文。
webEnvironment = SpringBootTest.WebEnvironment.NONE
表示测试不会涉及 web 服务器。应用程序上下文将在不启动服务器的情况下加载。通常用于集成测试,在此情况下,您希望测试应用程序的组件,而不必担心正在运行的 web 服务器的开销。(
结语
通过本文的学习,我们认识到有效的测试不仅是软件开发过程中的一项重要任务,更是提升产品质量和用户满意度的关键。Mockito 提供了强大的功能,帮助开发者轻松地模拟对象和捕获参数,而 Spring Boot 的测试容器则为集成测试提供了灵活的环境配置。无论是在单元测试还是集成测试中,确保代码的行为符合预期,是每个开发者应当追求的目标。未来,我们应持续关注测试覆盖率的提升,确保在业务逻辑变更时,系统仍然能够稳定运行,从而推动软件的健康发展。