Skip to content

Spring 单元测试:让你的代码更可靠 🧪

什么是单元测试?为什么它如此重要?

NOTE

单元测试是软件开发中最基础也是最重要的测试类型,它专注于测试代码的最小可测试单元(通常是一个方法或类)。

想象一下,你正在建造一座房子。你会等到整座房子建完后才检查每块砖头是否合格吗?当然不会!你会在使用每块砖头之前就确保它的质量。单元测试就是软件开发中的"砖头质量检查"。

单元测试的核心价值 💎

  1. 快速反馈:发现问题的成本随时间呈指数级增长
  2. 代码质量保证:强制你思考代码的设计和边界条件
  3. 重构信心:有了测试保护,你可以大胆地优化代码
  4. 文档作用:测试代码本身就是最好的使用示例

Spring 框架中的单元测试哲学 🎯

Spring 框架的依赖注入特性天然地支持单元测试。让我们看看为什么:

kotlin
class OrderService {
    private val orderRepository = OrderRepository() 
    private val emailService = EmailService() 
    
    fun processOrder(order: Order): Boolean {
        // 业务逻辑
        orderRepository.save(order)
        emailService.sendConfirmation(order.email)
        return true
    }
}

// 测试时的痛点:
// 1. 无法控制依赖对象的行为
// 2. 测试会真实地保存数据和发送邮件
// 3. 测试变慢且不稳定
kotlin
@Service
class OrderService(
    private val orderRepository: OrderRepository, 
    private val emailService: EmailService
) {
    fun processOrder(order: Order): Boolean {
        // 相同的业务逻辑
        orderRepository.save(order)
        emailService.sendConfirmation(order.email)
        return true
    }
}

// 测试时的优势:
// 1. 可以注入 Mock 对象
// 2. 完全控制依赖行为
// 3. 测试快速且可靠

TIP

依赖注入的核心思想是"不要调用我们,我们会调用你"(Don't call us, we'll call you)。这种控制反转让测试变得轻而易举。

Spring 提供的测试工具箱 🛠️

Spring 为我们提供了丰富的测试支持工具,主要分为两大类:

1. Mock 对象系列

2. 测试工具类系列

实战演练:Environment 测试 🌍

场景:配置驱动的服务

假设我们有一个根据环境配置决定行为的服务:

kotlin
@Service
class PaymentService(
    private val environment: Environment
) {
    fun getPaymentGateway(): String {
        return when {
            environment.activeProfiles.contains("prod") -> "stripe"
            environment.activeProfiles.contains("test") -> "mock"
            else -> "sandbox"
        }
    }
    
    fun getMaxRetryCount(): Int {
        return environment.getProperty("payment.retry.max", Int::class.java) ?: 3
    }
}

使用 MockEnvironment 进行测试

kotlin
import org.springframework.mock.env.MockEnvironment
import org.springframework.mock.env.MockPropertySource
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class PaymentServiceTest {
    
    @Test
    fun `should return stripe gateway for production profile`() {
        // 创建 Mock 环境
        val mockEnvironment = MockEnvironment()
        mockEnvironment.setActiveProfiles("prod") 
        
        val paymentService = PaymentService(mockEnvironment)
        
        assertEquals("stripe", paymentService.getPaymentGateway())
    }
    
    @Test
    fun `should return mock gateway for test profile`() {
        val mockEnvironment = MockEnvironment()
        mockEnvironment.setActiveProfiles("test") 
        
        val paymentService = PaymentService(mockEnvironment)
        
        assertEquals("mock", paymentService.getPaymentGateway())
    }
    
    @Test
    fun `should use custom retry count from properties`() {
        val mockEnvironment = MockEnvironment()
        
        // 添加自定义属性
        val propertySource = MockPropertySource()
        propertySource.setProperty("payment.retry.max", "5") 
        mockEnvironment.propertySources.addFirst(propertySource)
        
        val paymentService = PaymentService(mockEnvironment)
        
        assertEquals(5, paymentService.getMaxRetryCount())
    }
}

IMPORTANT

MockEnvironment 让我们能够完全控制应用的环境配置,无需创建真实的配置文件或设置系统属性。

实战演练:Web 层测试 🌐

场景:RESTful API 控制器

kotlin
@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService
) {
    
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): ResponseEntity<User> {
        return userService.findById(id)
            ?.let { ResponseEntity.ok(it) }
            ?: ResponseEntity.notFound().build()
    }
    
    @PostMapping
    fun createUser(@RequestBody @Valid user: User): ResponseEntity<User> {
        val savedUser = userService.save(user)
        return ResponseEntity.status(HttpStatus.CREATED).body(savedUser)
    }
}

使用 Mock Servlet API 进行测试

kotlin
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.mock.web.MockHttpServletResponse
import org.springframework.http.MediaType
import org.mockito.kotlin.*
import org.junit.jupiter.api.Test

class UserControllerTest {
    
    private val userService = mock<UserService>()
    private val userController = UserController(userService)
    
    @Test
    fun `should return user when found`() {
        // 准备测试数据
        val userId = 1L
        val expectedUser = User(id = userId, name = "张三", email = "[email protected]")
        
        // 配置 Mock 行为
        whenever(userService.findById(userId)).thenReturn(expectedUser) 
        
        // 执行测试
        val response = userController.getUser(userId)
        
        // 验证结果
        assertEquals(HttpStatus.OK, response.statusCode)
        assertEquals(expectedUser, response.body)
        verify(userService).findById(userId) 
    }
    
    @Test
    fun `should return 404 when user not found`() {
        val userId = 999L
        
        // 配置 Mock 返回 null
        whenever(userService.findById(userId)).thenReturn(null) 
        
        val response = userController.getUser(userId)
        
        assertEquals(HttpStatus.NOT_FOUND, response.statusCode)
        assertNull(response.body)
    }
}

高级技巧:ReflectionTestUtils 的妙用 🔧

有时候我们需要测试一些私有字段或方法,ReflectionTestUtils 就派上用场了:

场景:测试包含私有状态的服务

kotlin
@Service
class CacheService {
    private var cacheHitCount = 0 // 私有字段,无法直接访问
    private val cache = mutableMapOf<String, Any>()
    
    fun get(key: String): Any? {
        return cache[key]?.also { 
            cacheHitCount++
        }
    }
    
    fun put(key: String, value: Any) {
        cache[key] = value
    }
    
    // 私有方法,用于内部统计
    private fun getCacheHitRate(): Double {
        return if (cache.isEmpty()) 0.0 
               else cacheHitCount.toDouble() / cache.size
    }
}

使用 ReflectionTestUtils 进行测试

kotlin
import org.springframework.test.util.ReflectionTestUtils
import org.junit.jupiter.api.Test

class CacheServiceTest {
    
    @Test
    fun `should track cache hit count correctly`() {
        val cacheService = CacheService()
        
        // 添加一些缓存数据
        cacheService.put("key1", "value1")
        cacheService.put("key2", "value2")
        
        // 模拟缓存命中
        cacheService.get("key1")
        cacheService.get("key1")
        cacheService.get("key2")
        
        // 使用反射获取私有字段值
        val hitCount = ReflectionTestUtils.getField(cacheService, "cacheHitCount") as Int 
        assertEquals(3, hitCount)
        
        // 调用私有方法进行验证
        val hitRate = ReflectionTestUtils.invokeMethod<Double>( 
            cacheService, 
            "getCacheHitRate"
        )
        assertEquals(1.5, hitRate) // 3 hits / 2 keys = 1.5
    }
    
    @Test
    fun `should be able to reset cache state for testing`() {
        val cacheService = CacheService()
        
        cacheService.put("test", "value")
        cacheService.get("test")
        
        // 重置私有字段用于测试
        ReflectionTestUtils.setField(cacheService, "cacheHitCount", 0) 
        
        val hitCount = ReflectionTestUtils.getField(cacheService, "cacheHitCount") as Int
        assertEquals(0, hitCount)
    }
}

WARNING

虽然 ReflectionTestUtils 很强大,但要谨慎使用。过度依赖反射测试可能表明你的代码设计存在问题。优先考虑通过公共接口进行测试。

测试最佳实践 ✅

1. 遵循 AAA 模式

kotlin
@Test
fun `should calculate discount correctly for VIP customers`() {
    // Arrange - 准备测试数据和环境
    val customer = Customer(type = CustomerType.VIP, totalSpent = 10000.0)
    val order = Order(amount = 1000.0)
    val discountService = DiscountService()
    
    // Act - 执行被测试的操作
    val discount = discountService.calculateDiscount(customer, order)
    
    // Assert - 验证结果
    assertEquals(100.0, discount) // VIP 客户 10% 折扣
}

2. 使用描述性的测试名称

kotlin
@Test
fun test1() { ... }

@Test
fun testDiscount() { ... }

@Test
fun discountTest() { ... }
kotlin
@Test
fun `should return 10 percent discount for VIP customers`() { ... }

@Test
fun `should throw exception when customer is null`() { ... }

@Test
fun `should return zero discount for new customers`() { ... }

3. 一个测试只验证一个行为

kotlin
// ❌ 不好的做法 - 测试多个行为
@Test
fun `test user service`() {
    val user = userService.create(userData)
    assertNotNull(user.id) // 测试创建
    
    val found = userService.findById(user.id!!)
    assertEquals(user.name, found?.name) // 测试查找
    
    userService.delete(user.id!!)
    assertNull(userService.findById(user.id!!)) // 测试删除
}

// ✅ 好的做法 - 每个测试专注一个行为
@Test
fun `should create user with generated ID`() {
    val user = userService.create(userData)
    assertNotNull(user.id)
}

@Test
fun `should find user by ID`() {
    val savedUser = userService.create(userData)
    val found = userService.findById(savedUser.id!!)
    assertEquals(savedUser.name, found?.name)
}

@Test
fun `should delete user successfully`() {
    val savedUser = userService.create(userData)
    userService.delete(savedUser.id!!)
    assertNull(userService.findById(savedUser.id!!))
}

常见陷阱与解决方案 ⚠️

陷阱 1:过度使用 Mock

kotlin
// ❌ 过度 Mock - 测试变得脆弱
@Test
fun `should process order`() {
    val order = mock<Order>()
    val item1 = mock<OrderItem>()
    val item2 = mock<OrderItem>()
    val items = listOf(item1, item2)
    
    whenever(order.items).thenReturn(items)
    whenever(item1.price).thenReturn(BigDecimal("10.00"))
    whenever(item2.price).thenReturn(BigDecimal("20.00"))
    whenever(order.total).thenReturn(BigDecimal("30.00"))
    
    // 测试逻辑...
}

// ✅ 使用真实对象 - 测试更可靠
@Test
fun `should process order`() {
    val order = Order(
        items = listOf(
            OrderItem(name = "商品1", price = BigDecimal("10.00")),
            OrderItem(name = "商品2", price = BigDecimal("20.00"))
        )
    )
    
    // 测试逻辑...
}

陷阱 2:测试实现细节而非行为

kotlin
// ❌ 测试实现细节
@Test
fun `should call repository save method`() {
    val user = User(name = "张三")
    userService.createUser(user)
    
    verify(userRepository).save(user) // 只关心方法调用
}

// ✅ 测试行为结果
@Test
fun `should create user and return with generated ID`() {
    val user = User(name = "张三")
    val createdUser = userService.createUser(user)
    
    assertNotNull(createdUser.id) // 关心业务结果
    assertEquals("张三", createdUser.name)
}

总结 🎉

Spring 的单元测试支持让我们能够:

  1. 轻松隔离依赖:通过依赖注入和 Mock 对象
  2. 快速验证逻辑:无需启动完整的应用上下文
  3. 灵活控制环境:使用各种 Mock 工具类
  4. 深入测试细节:利用反射工具类访问私有成员

TIP

记住,好的单元测试应该是:快速的独立的可重复的自验证的及时的(FIRST 原则)。

单元测试不仅仅是验证代码正确性的工具,更是驱动良好设计的催化剂。当你发现某个类很难测试时,往往意味着它承担了太多责任,需要重构。

让我们拥抱测试驱动开发,让每一行代码都有测试保护,让每一次重构都充满信心! 🚀