使用Spring Boot、Kotlin和OpenFeign实现类型安全API测试


有多种方法可以测试你的 Spring Boot 应用程序的 API,虽然启动时间比MockMvc它稍长,但我更喜欢这种OpenFeign方法。
您可以在我的Github 页面上找到所有 4 种方法的完整示例代码。
 
1. 在 Spring Boot 应用程序中使用 MockMvc
为了更接近现实生活场景,Spring 提供了MockMvc. 无需启动成熟的 Web 服务器,它就可以让您访问几乎任何 HTTP API(GET、POST、HEAD...),并且您还可以使用匹配器来检查您的控制器是否返回预期的响应:

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

@SpringBootTest
@AutoConfigureMockMvc
class MockMvcTest(
    @Autowired private val mockMvc: MockMvc
) {

    @Test
    fun helloWorld() {
        val result = mockMvc.perform(get("/hello-world"))
            .andExpect(status().isOk)
            .andExpect(jsonPath(
"$.message").value("Hello World"))
            .andReturn()

        assertEquals(
           MediaType.APPLICATION_JSON_VALUE, 
           result.response.contentType
        )
    }
}
`

这里的问题是,MockMvc并不是在运行时执行的相同代码。它很接近,但并不一样,而且在有些情况下它的行为是不同的。最重要的是,你不能在这里通过电线做真正的HTTP请求,你没有完整的错误响应处理(当使用重定向时),以及更多。

MockMvc还有一个缺点。它需要大量的冗余代码,因为你必须手动输入所有的路由和字段名(当反序列化JSON时),与你的应用代码同步。当你的代码库增长和你的API进化时,这可能是很麻烦的。
 
2.使用TestRestTemplate
取自Spring官方文档的例子是,我们在真实环境中启动服务器(使用WebEnvironment.RANDOM_PORT而不是默认的WebEnvironment.MOCK),然后使用TestRestTemplate对我们的服务器执行真实的HTTP

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.web.server.LocalServerPort

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TestRestTemplateTest(
    @LocalServerPort private val localServerPort: Int,
    @Autowired private val restTemplate: TestRestTemplate
) {

    @Test
    fun helloWorld() {
        val url = "http://localhost:$localServerPort/hello-world"
        assertThat(restTemplate
            .getForObject(url, Message::class.java).message)
            .isEqualTo(
"Hello World")
    }
}


已经好多了,不再有模拟环境了,我们已经接近于一个类似生产的场景。

但是,我们仍然需要使用TestRestTemplate手动维护所有的路由,这也是下一个例子中的情况。
 
3.使用RestAssured
RestAssured是一个很棒的库,可以针对 REST API 创建自动化测试。设置与方法完全相同TestRestTemplate:

import io.restassured.RestAssured
import io.restassured.RestAssured.given
import org.assertj.core.api.Assertions.assertThat
import org.hamcrest.CoreMatchers.equalTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.web.server.LocalServerPort
import org.springframework.http.MediaType

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RestAssuredTest(
    @LocalServerPort private val localServerPort: Int
) {

    @BeforeEach
    fun setup() {
        RestAssured.port = localServerPort
    }

    @Test
    fun helloWorld() {
        given().get("/hello-world").then()
            .statusCode(200)
            .assertThat()
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .body(
"message", equalTo("Hello World"))
    }

    @Test
    fun helloWorldMapping() {
        assertThat(given().get(
"/hello-world").`as`(Message::class.java).message)
            .isEqualTo(
"Hello World")
    }
}

我在那里添加了两个测试,一个没有对象映射,一个使用Jackson自动映射到我们的Message对象 - 所以你有类似TestRestTemplate的可能性。

请注意,RestAssured的大部分内容是用Groovy编写的,这意味着运行时间会稍慢一些。不管怎么说,我确实比TestRestTemplate更喜欢这种语法,它使代码更易读。

总之,尽管我们通过HTTP与我们的服务器进行通信,我们仍然需要手动维护我们测试案例中的所有路由,并使它们与服务器保持同步。

幸运的是,也有一种方法可以解决这个问题,这就把我们引向了最终的解决方案。
 
4.使用声明式Feign客户端进行类型安全的API测试
OpenFeign的声明式REST客户端允许我们将路由和MVC映射信息保存在一个地方,并在我们的测试案例中重复使用所有这些信息。

在这之前,我们需要重构我们的服务器代码,并从HelloController中提取一个接口,其中包含所有SpringMVC注释,如@GetMapping,@PostMapping等。

interface HelloApi {
    @GetMapping("/hello-world")
    fun helloWorld(): Message
}

@RestController
class HelloController : HelloApi {
    override fun helloWorld(): Message {
        return Message(message =
"Hello World")
    }
}

现在,在 import 之后org.springframework.cloud:spring-cloud-starter-openfeign,我们可以编写以下测试:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.web.server.LocalServerPort
import org.springframework.cloud.openfeign.FeignClientBuilder
import org.springframework.context.ApplicationContext

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OpenFeignIntegrationTest(
    @LocalServerPort private val localServerPort: Int,
    @Autowired private val applicationContext: ApplicationContext
) {

    private val helloApi =
        FeignTestClientFactory.createClientApi(HelloApi::class.java, localServerPort, applicationContext)

    @Test
    fun helloWorld() {
        assertThat(helloApi.helloWorld().message).isEqualTo("Hello World")
    }
}

object FeignTestClientFactory {
    fun <T> createClientApi(apiClass: Class<T>, port: Int, clientContext: ApplicationContext): T {
        return FeignClientBuilder(clientContext)
            .forType(apiClass, apiClass.canonicalName)
            .url(
"http://localhost:$port")
            .build()
    }
}

我在这里提取了一个小的辅助类FeignTestClientFactory,以便更自如地使用FeignClientBuilder - 你可以在你的测试用例中重复使用这个工具。

测试用例本身仍然很短。

  • 我们再次使用WebEnvironment.RANDOM_PORT
  • @LocalServerPort是由Spring Boot注入的。
  • 基于我们的新接口HelloApi创建一个声明性的Feign客户端。OpenFeign读取我们的@GetMapping注解,包括所有路由信息,并在HelloApi的动态代理后面为我们创建一个HTTP客户端。
  • 这意味着我们现在可以调用HelloApi的所有接口方法,但我们不是直接调用我们的控制器(如第一个例子),而是做真正的HTTP请求,像其他客户端一样访问我们的服务器。

现在我们可以对Spring Boot服务器进行完全类型安全和重构安全的API测试。
  • 路由的定义只有一个地方。HelloApi。
  • 如果在API中添加了新的方法,或者更新了现有的方法,你可以在测试用例中立即获得这些变化。
  • 在你的IDE中,你有完整的重构支持。
  • 你也可以用你的IDE找到所有访问某个API的测试(通过搜索HelloApi的所有用法),这对大代码库特别有帮助。

Github page.