feign 테스트 - wireMock을 활용하여 mocking하기

WireMock이란?

WireMock은 HTTP 기반 API 서비스를 테스트하기 위한 도구입니다. 서비스에서 발생하는 HTTP 요청에 대해 원하는 대로 응답을 설정할 수 있습니다.

따라해보기


사용 기술 및 라이브러리

  • JDK 17
  • Kotlin 1.8.22
  • Spring Boot 3.1.3
  • Gradle 8.2.1
  • Wiremock 4.0.4
  • Feign 4.0.4

예시 프로젝트

wiremock-example

Gradle

testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock:4.0.4")

HTTP API

feign을 통해 외부 API를 정의하였다.

@FeignClient(
  name = "another",
  url = "\${app.another.url}"
)
interface AnotherClient {

  @GetMapping("/api/another/nickname/{nickname}")
  fun hasNickname(@PathVariable("nickname") nickname: String): Boolean

  @PutMapping("/api/another/{id}")
  fun updateNickname(
    @PathVariable("id") id: String,
    @RequestParam("nickname") nickname: String
  ): AnotherDTO

  @PostMapping("/api/another")
  fun createAnother(
    @RequestBody another: AnotherDTO
  ): AnotherDTO
}

Service

@Service
class ExampleService(
  private val anotherClient: AnotherClient
) {

  fun exampleFunctionA(nickname: String): Result<String> {
    return runCatching {
      /* Some codes... */
      if (anotherClient.hasNickname(nickname)) {
        "A"
      } else {
        "B"
      }
    }
  }

  fun exampleFunctionB(id: String, nickname: String): Result<AnotherDTO> {
    return runCatching {
      /* Some codes... */
      anotherClient.updateNickname(id, nickname)
    }
  }

  fun exampleFunctionC(anotherDTO: AnotherDTO): Result<AnotherDTO> {
    return runCatching {
      /* Some codes... */
      anotherClient.createAnother(anotherDTO)
    }
  }
}

Mocking

HTTP request method와 정규식을 통한 URL 매핑(urlPathMatching)을 지원한다. 또한 mock의 우선순위(atPriority)를 정할 수 있다.

주요 메서드

stubFor(mappingBuilder: *MappingBuilder*): StubMapping

  • mocking을 위해 미리 준비된 HTTP API를 작성을 하기 위한 메서드입니다.

get(urlPattern: UrlPattern): MappingBuilder

  • HTTP 메서드 중 GET을 매핑하는 메서드, 이외에 PUT, POST, DELETE, PATCH 등 다양한 HTTP 메서드를 지원합니다.

urlPathMatching(urlRegex: String): UrlPathPattern

  • URL의 경로가 정규식에 일치하는지를 확인합니다.

urlMatching(urlRegex: String): UrlPattern

  • URL의 경로와 쿼리 매개변수(query param)까지 정규식에 일치하는지 확인합니다.

MappingBuilder.atPriority(priority: Int)

  • URL 매칭의 우선순위를 정할 수 있습니다. 숫자가 작을 수록 우선순위가 높습니다. 해당 우선순위를 제공하지 않을 경우 기본적으로 5의 우선순위를 갖게 됩니다.

MappingBuilder.withQueryParam(key: String, queryParamPattern: StringValuePattern)

  • 쿼리 매개변수를 설정합니다. 다수의 쿼리 매개변수가 필요한 경우 withQueryParams 메서드를 활용할 수 있습니다.
  • 쿼리 매개변수 값에 containing, matching, equalTo 등 다양한 메서드를 활용하여 설정할 수 있습니다.

MappingBuilder.*withRequestBody(bodyPattern: ContentPattern<?>)

  • 요청 본문을 설정할 수 있습니다.
  • 본문 값에 equalToJsonObjectMapper를 통해 객체를 설정할 수 있다.

ResponseDefinitionBuilder.aResponse()

  • 응답을 정의하기 위한 빌더를 제공합니다.

ResponseDefinitionBuilder.withStatus(status: Int)

  • 응답 코드를 설정할 수 있습니다.

ResponseDefinitionBuilder.withHeader(key: String, values: String...)

  • 응답 헤더를 설정할 수 있습니다.

ResponseDefinitionBuilder.withBody(body: String)

  • 응답 본문을 설정할 수 있습니다.

GET

mockServer.stubFor(
  get(urlPathMatching("/api/another/nickname/\\w{4,12}$"))
    .atPriority(999) 
    .willReturn(
      aResponse()
        .withStatus(HttpStatus.OK.value())
        .withHeader("Content-Type", "application/json")
        .withBody(objectMapper.writeValueAsString(false))
    )
)

PUT

mockServer.stubFor(
  put(urlPathMatching("/api/another/f0161e50-4428-4e36-a6e0-a35b63cdb3cf"))
    .atPriority(1)
    .withQueryParam("nickname", matching(".*"))
    .willReturn(
      aResponse()
        .withStatus(HttpStatus.OK.value())
        .withHeader("Content-Type", "application/json")
        .withBody(
          objectMapper.writeValueAsString(
            AnotherDTO(
              "f0161e50-4428-4e36-a6e0-a35b63cdb3cf",
              "홍길동",
              "yearnlune",
              LocalDateTime.of(2023, 9, 1, 0, 0, 0)
            )
          )
        )
    )
)

POST

mockServer.stubFor(
  post(urlPathMatching("/api/another"))
    .atPriority(1)
    .withRequestBody(
      equalToJson(
        objectMapper.writeValueAsString(
          AnotherDTO(
            name = "raymond",
            nickname = "yearnlune"
          )
        )
      )
    )
    .willReturn(
      aResponse()
        .withStatus(HttpStatus.OK.value())
        .withHeader("Content-Type", "application/json")
        .withBody(
          objectMapper.writeValueAsString(
            AnotherDTO(
              "f0161e50-4428-4e36-a6e0-a35b63cdb3cf",
              "raymond",
              "yearnlune",
              LocalDateTime.of(2023, 9, 1, 0, 0, 0)
            )
          )
        )
    )
)

Test

@AutoConfigureWireMock을 추가하여 WireMock을 적용한다.

@Suppress("SpringJavaInjectionPointsAutowiringInspection")
@SpringBootTest
@AutoConfigureWireMock
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ActiveProfiles("test")
class ExampleServiceTest @Autowired constructor(
  private val exampleService: ExampleService,
  private val wireMockServer: WireMockServer
) {

  @BeforeAll
  fun setup() {
    WireMockSupport.setupFeignClient(wireMockServer)
  }

  @Test
  @DisplayName("exampleFunctionA")
  fun exampleFunctionA_succeed() {
    /* GIVEN */
    val nickname = "yearnlune"

    /* WHEN */
    val result = exampleService.exampleFunctionA(nickname)

    /* THEN */
    assertThat(
      result.getOrThrow(),
      `is`("B")
    )
  }

  @Test
  @DisplayName("exampleFunctionB")
  fun exampleFunctionB_succeed() {
    /* GIVEN */
    val id = "f0161e50-4428-4e36-a6e0-a35b63cdb3cf"
    val nickname = "yearnlune"

    /* WHEN */
    val result = exampleService.exampleFunctionB(id, nickname)

    /* THEN */
    assertThat(
      result.getOrThrow(),
      `is`(
        AnotherDTO(
          "f0161e50-4428-4e36-a6e0-a35b63cdb3cf",
          "raymond",
          "yearnlune",
          LocalDateTime.of(2023, 9, 1, 0, 0, 0)
        )
      )
    )
  }
  
  @Test
  @DisplayName("exampleFunctionC")
  fun exampleFunctionC_succeed() {
    /* GIVEN */
    val another = AnotherDTO(
      name = "raymond",
      nickname = "yearnlune"
    )
  
    /* WHEN */
    val result = exampleService.exampleFunctionC(another)
  
    /* THEN */
    assertThat(
      result.getOrThrow(),
      `is`(
        AnotherDTO(
          "f0161e50-4428-4e36-a6e0-a35b63cdb3cf",
          "raymond",
          "yearnlune",
          LocalDateTime.of(2023, 9, 1, 0, 0, 0)
        )
      )
    )
  }
}

참고 문헌

WireMock docs

Leave a comment