Skip to content

WebClient 测试指南:让你的异步 HTTP 客户端测试如丝般顺滑 🚀

为什么需要专门的 WebClient 测试策略?

在现代微服务架构中,服务间的 HTTP 通信无处不在。WebClient 作为 Spring WebFlux 的核心 HTTP 客户端,帮助我们优雅地处理异步、非阻塞的网络请求。但是,测试这些网络交互代码却面临着独特的挑战:

> **核心痛点**:如何在不依赖真实外部服务的情况下,可靠地测试 WebClient 代码?

想象一下这些常见的测试困境:

  • 🔗 外部依赖:测试依赖真实的第三方服务,导致测试不稳定
  • 🐌 性能问题:网络延迟让测试变得缓慢
  • 💸 成本考量:频繁调用外部 API 可能产生费用
  • 🎭 场景限制:难以模拟网络异常、超时等边界情况

Mock Web Server:测试的救星 ✨

Spring 官方推荐使用 Mock Web Server 来解决这些问题。这是一种在测试环境中创建"假"HTTP 服务器的技术,它能够:

  • 🎯 完全控制:精确控制服务器的响应内容和行为
  • 🚀 高性能:本地运行,无网络延迟
  • 🎪 场景丰富:轻松模拟各种网络异常情况
  • 🔧 真实环境:使用与生产环境相同的 HTTP 客户端配置

主流 Mock Web Server 解决方案

1. OkHttp MockWebServer 🏆

特点:轻量级、易用、Spring 官方推荐

kotlin
// build.gradle.kts
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.springframework.boot:spring-boot-starter-webflux")
kotlin
@ExtendWith(MockitoExtension::class)
class UserServiceTest {

    private lateinit var mockWebServer: MockWebServer
    private lateinit var webClient: WebClient
    private lateinit var userService: UserService

    @BeforeEach
    fun setUp() {
        // 启动 Mock 服务器
        mockWebServer = MockWebServer()
        mockWebServer.start()
        // 配置 WebClient 指向 Mock 服务器
        val baseUrl = mockWebServer.url("/").toString()
        webClient = WebClient.builder()
            .baseUrl(baseUrl)
            .build()

        userService = UserService(webClient)
    }

    @AfterEach
    fun tearDown() {
        mockWebServer.shutdown()
    }
    @Test
    fun `should get user successfully`() {
        // 准备 Mock 响应
        val mockResponse = MockResponse()
            .setResponseCode(200)
            .setHeader("Content-Type", "application/json")
            .setBody("""{"id": 1, "name": "张三", "email": "[email protected]"}""")

        mockWebServer.enqueue(mockResponse) 

        // 执行测试
        val result = userService.getUserById(1L).block()

        // 验证结果
        assertThat(result).isNotNull
        assertThat(result?.name).isEqualTo("张三")

        // 验证请求
        val recordedRequest = mockWebServer.takeRequest()
        assertThat(recordedRequest.path).isEqualTo("/users/1")
        assertThat(recordedRequest.method).isEqualTo("GET")
    }
}

2. WireMock:功能更强大的选择 🎯

特点:功能丰富、支持复杂场景、配置灵活

kotlin
// build.gradle.kts
testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")
kotlin
@ExtendWith(MockitoExtension::class)
class PaymentServiceTest {

    private lateinit var wireMockServer: WireMockServer
    private lateinit var paymentService: PaymentService

    @BeforeEach
    fun setUp() {
        // 启动 WireMock 服务器
        wireMockServer = WireMockServer(wireMockConfig().port(8089))
        wireMockServer.start()

        val webClient = WebClient.builder()
            .baseUrl("http://localhost:8089")
            .build()

        paymentService = PaymentService(webClient)
    }

    @AfterEach
    fun tearDown() {
        wireMockServer.stop()
    }
    @Test
    fun `should handle payment timeout gracefully`() {
        // 配置延迟响应模拟超时
        wireMockServer.stubFor(
            post(urlEqualTo("/payments"))
                .willReturn(
                    aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBody("""{"status": "success", "transactionId": "tx123"}""")
                        .withFixedDelay(5000) // 5秒延迟
                )
        )
        // 测试超时处理
        assertThrows<TimeoutException> {
            paymentService.processPayment(
                PaymentRequest(amount = 100.0, currency = "CNY")
            ).block(Duration.ofSeconds(3)) // 3秒超时
        }
        // 验证请求确实发送了
        wireMockServer.verify(
            postRequestedFor(urlEqualTo("/payments"))
                .withHeader("Content-Type", equalTo("application/json"))
        )
    }
}

实际业务场景:电商订单服务测试 🛒

让我们通过一个完整的电商订单服务示例,看看如何在真实业务场景中应用 Mock Web Server:

完整的订单服务测试示例
kotlin
// 订单服务类
@Service
class OrderService(
    private val webClient: WebClient,
    @Value("${external.inventory.url}") private val inventoryServiceUrl: String,
    @Value("${external.payment.url}") private val paymentServiceUrl: String
) {

    suspend fun createOrder(orderRequest: OrderRequest): OrderResponse {
        // 1. 检查库存
        val inventoryCheck = checkInventory(orderRequest.productId, orderRequest.quantity)
        if (!inventoryCheck.available) {
            throw InsufficientInventoryException("库存不足")
        }
        // 2. 处理支付
        val paymentResult = processPayment(orderRequest.paymentInfo)
        if (!paymentResult.success) {
            throw PaymentFailedException("支付失败:${paymentResult.errorMessage}")
        }
        // 3. 创建订单
        return OrderResponse(
            orderId = generateOrderId(),
            status = OrderStatus.CONFIRMED,
            totalAmount = orderRequest.totalAmount
        )
    }
    private suspend fun checkInventory(productId: String, quantity: Int): InventoryResponse {
        return webClient.get()
            .uri("$inventoryServiceUrl/inventory/{productId}", productId)
            .retrieve()
            .awaitBody<InventoryResponse>()
    }
    private suspend fun processPayment(paymentInfo: PaymentInfo): PaymentResponse {
        return webClient.post()
            .uri("$paymentServiceUrl/payments")
            .bodyValue(paymentInfo)
            .retrieve()
            .awaitBody<PaymentResponse>()
    }
}

// 测试类
@SpringBootTest
@TestPropertySource(properties = [
    "external.inventory.url=http://localhost:8081",
    "external.payment.url=http://localhost:8082"
])
class OrderServiceIntegrationTest {

    @Autowired
    private lateinit var orderService: OrderService

    private lateinit var inventoryMockServer: MockWebServer
    private lateinit var paymentMockServer: MockWebServer

    @BeforeEach
    fun setUp() {
        // 启动库存服务 Mock
        inventoryMockServer = MockWebServer()
        inventoryMockServer.start(8081)
        // 启动支付服务 Mock
        paymentMockServer = MockWebServer()
        paymentMockServer.start(8082)
    }
    @AfterEach
    fun tearDown() {
        inventoryMockServer.shutdown()
        paymentMockServer.shutdown()
    }
    @Test
    fun `should create order successfully when all services respond correctly`() = runTest {
        // 准备库存服务响应
        inventoryMockServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "application/json")
                .setBody("""{"available": true, "stock": 50}""")
        )
        // 准备支付服务响应
        paymentMockServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "application/json")
                .setBody("""{"success": true, "transactionId": "tx123456"}""")
        )
        // 执行订单创建
        val orderRequest = OrderRequest(
            productId = "PROD001",
            quantity = 2,
            totalAmount = 199.99,
            paymentInfo = PaymentInfo(
                cardNumber = "4111111111111111",
                amount = 199.99
            )
        )

        val result = orderService.createOrder(orderRequest)

        // 验证结果
        assertThat(result.status).isEqualTo(OrderStatus.CONFIRMED)
        assertThat(result.totalAmount).isEqualTo(199.99)

        // 验证服务调用
        val inventoryRequest = inventoryMockServer.takeRequest()
        assertThat(inventoryRequest.path).isEqualTo("/inventory/PROD001")

        val paymentRequest = paymentMockServer.takeRequest()
        assertThat(paymentRequest.path).isEqualTo("/payments")
    }

    @Test
    fun `should throw exception when inventory is insufficient`() = runTest {
        // 模拟库存不足
        inventoryMockServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "application/json")
                .setBody("""{"available": false, "stock": 0}""") 
        )
        val orderRequest = OrderRequest(
            productId = "PROD001",
            quantity = 5,
            totalAmount = 499.99,
            paymentInfo = PaymentInfo(cardNumber = "4111111111111111", amount = 499.99)
        )
        // 验证异常抛出
        assertThrows<InsufficientInventoryException> {
            runBlocking { orderService.createOrder(orderRequest) }
        }

        // 验证没有调用支付服务
        assertThat(paymentMockServer.requestCount).isEqualTo(0)
    }

    @Test
    fun `should handle network timeout gracefully`() = runTest {
        // 模拟网络超时
        inventoryMockServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setBody("""{"available": true, "stock": 50}""")
                .setBodyDelay(10, TimeUnit.SECONDS) 
        )
        val orderRequest = OrderRequest(
            productId = "PROD001",
            quantity = 1,
            totalAmount = 99.99,
            paymentInfo = PaymentInfo(cardNumber = "4111111111111111", amount = 99.99)
        )
        // 验证超时异常
        assertThrows<TimeoutException> {
            withTimeout(5000) { // 5秒超时
                orderService.createOrder(orderRequest)
            }
        }
    }
}

高级测试技巧与最佳实践 💡

1. 测试不同的 HTTP 状态码

kotlin
@Test
fun `should handle different HTTP status codes`() = runTest {
    val testCases = listOf(
        TestCase(404, "用户不存在", UserNotFoundException::class),
        TestCase(500, "服务器内部错误", InternalServerException::class),
        TestCase(503, "服务不可用", ServiceUnavailableException::class)
    )
    testCases.forEach { testCase ->
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(testCase.statusCode)
                .setBody(testCase.errorMessage)
        )
        assertThrows(testCase.expectedException.java) {
            runBlocking { userService.getUserById(1L) }
        }
    }
}

data class TestCase(
    val statusCode: Int,
    val errorMessage: String,
    val expectedException: KClass<out Exception>
)

2. 验证请求头和请求体

kotlin
@Test
fun `should send correct headers and request body`() = runTest {
    mockWebServer.enqueue(
        MockResponse()
            .setResponseCode(201)
            .setBody("""{"id": 1, "status": "created"}""")
    )

    val user = User(name = "李四", email = "[email protected]")
    userService.createUser(user)

    val recordedRequest = mockWebServer.takeRequest()

    // 验证请求方法和路径
    assertThat(recordedRequest.method).isEqualTo("POST")
    assertThat(recordedRequest.path).isEqualTo("/users")

    // 验证请求头
    assertThat(recordedRequest.getHeader("Content-Type"))
        .isEqualTo("application/json")
    assertThat(recordedRequest.getHeader("Authorization"))
        .startsWith("Bearer ")
    // 验证请求体
    val requestBody = recordedRequest.body.readUtf8()
    val actualUser = objectMapper.readValue(requestBody, User::class.java)
    assertThat(actualUser.name).isEqualTo("李四")
    assertThat(actualUser.email).isEqualTo("[email protected]")
}

3. 模拟网络异常情况

kotlin
@Test
fun `should handle connection reset`() = runTest {
    // 模拟连接重置
    mockWebServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
    assertThrows<ConnectException> {
        runBlocking { userService.getUserById(1L) }
    }
}

@Test
fun `should retry on temporary failures`() = runTest {
    // 第一次请求失败
    mockWebServer.enqueue(MockResponse().setResponseCode(503))
    // 第二次请求成功
    mockWebServer.enqueue(
        MockResponse()
            .setResponseCode(200)
            .setBody("""{"id": 1, "name": "重试成功"}""")
    )

    val result = userService.getUserWithRetry(1L)

    assertThat(result?.name).isEqualTo("重试成功")
    assertThat(mockWebServer.requestCount).isEqualTo(2) // 验证重试了一次
}

性能测试与监控 📊

kotlin
@Test
fun `should measure response time`() = runTest {
    mockWebServer.enqueue(
        MockResponse()
            .setResponseCode(200)
            .setBody("""{"id": 1, "name": "性能测试"}""")
            .setBodyDelay(100, TimeUnit.MILLISECONDS) // 模拟网络延迟
    )

    val startTime = System.currentTimeMillis()
    val result = userService.getUserById(1L)
    val endTime = System.currentTimeMillis()

    val responseTime = endTime - startTime
    assertThat(responseTime).isGreaterThan(100) // 至少100ms
    assertThat(responseTime).isLessThan(200)    // 不超过200ms

    println("响应时间: ${responseTime}ms") // 输出性能指标
}

最佳实践总结 🎯

> **测试设计原则**

  1. 隔离性:每个测试都应该独立运行,不依赖其他测试的状态
  2. 可重复性:测试结果应该是确定的,多次运行应该得到相同结果
  3. 快速性:使用 Mock Server 避免真实网络调用,提高测试速度
  4. 全面性:覆盖正常流程、异常情况、边界条件等各种场景

> **常见陷阱**

  • 忘记在测试结束后关闭 Mock Server,可能导致端口占用
  • Mock 响应与真实服务响应格式不一致,导致测试通过但生产环境失败
  • 过度依赖 Mock,缺少端到端的集成测试

> **记住这个测试金字塔**:

  • 🔺 单元测试(70%):使用 Mock Server 测试单个服务的逻辑
  • 🔺 集成测试(20%):测试多个组件的协作
  • 🔺 端到端测试(10%):少量的真实环境测试

通过合理使用 Mock Web Server,我们可以构建出既快速又可靠的 WebClient 测试套件,确保我们的异步 HTTP 客户端代码在各种情况下都能正确工作! 🚀✨