본문 바로가기
프로그래밍/Spring Boot

Spring Cloud OpenFeign 테스트하기

by 카프카뮈 2022. 3. 7.

본 글은 이전에 작성된 글 (링크) 에서 이어지는 글입니다.


개요: 테스트의 두 가지 방법

Feign 테스트하기: 직접 요청 보내기

직접 서버에 요청을 보내 테스트하면 다음의 이점을 누릴 수 있다.

  • 테스트용 서버를 통해 통합 테스트처럼 진행할 수 있다.
    • Mock Server를 통해 테스트할 경우, 실제 서버와 충돌이 일어나도 알 방법이 없다.
    • 그러나 서버와 직접 통신하게 되면, 기능 외적인 문제에 대해 미리 대응할 수 있다.

코드는 아래와 같다. (Java로 작성)

//테스트용 서버에 맞게 환경을 조절하고, logger Level을 FULL로 지정하여 상세한 로그를 출력한다.
@SpringBootTest(properties = {"env=test", "feign.client.config.default.loggerLevel=FULL"})
@ActiveProfiles({"beta"})
public class MemberApiClientTest {
    @Autowired
    TestApiClient testApiClient;

    @Test
    public void getMemberInfo() {
        Long id = 123456L; //테스트용 서버에 있는 값이어야 한다.
        TestData res = testApiClient.get(id);

        //이부분은 적당한 값으로 채워주면 된다. 값이 올바른지, 결과값의 id가 적절한지 등등....
        assertTrue(res.isSuccess());
        assertEquals(id, res.getResult().getMemberNo());
        assertEquals(ServiceCode.LINE_GIFT, res.getResult().getServiceCode());
    }
}

Mocking의 필요성

Feign 클라이언트의 다음과 같은 기능을 테스트한다고 가정해보자.

  • POST/PUT 요청을 통해 서버에서 엔티티를 생성 혹은 수정하도록 요청하는 기능
  • DELETE 요청을 통해 서버에서 엔티티를 삭제하도록 요청하는 기능

다음의 기능을 실제 서버와 연동하여 테스트할 경우, 아래와 같은 문제가 생긴다.

  • 해당 서버 및 데이터베이스에 테스트시마다 작업을 요청하게 된다.
  • 해당 서버나 데이터베이스가 작동하지 않고 있는 경우 테스트에 실패한다.
  • 테스트 코드를 잘못 작성하여 삭제하면 안되는 엔티티를 삭제하거나, 불필요한 엔티티를 생성할 수 있다.

또한 클라이언트의 기능을 테스트하는 것이기 때문에, Feign 클라이언트를 mocking하는 것은 당연히 고려할 수 없다.

그렇다면, 요청을 보낼 외부의 서버를 mocking하는 것은 어떨까?

WireMock을 통한 서버 Mocking

서버를 Mocking하여 테스트를 수행하기 위한 여러 라이브러리가 존재한다.

  • RestTemplate의 경우, MockRestServiceServer를 통해 테스트를 수행할 수 있다.
  • Feign의 경우는 MockRestServiceServer 대신, WireMock을 활용할 수 있다.

WireMock은 웹 서비스를 스터빙하여 테스트 수행을 돕는 라이브러리로, 실제 웹 서비스에 연결할 수 있는 HTTP 서버를 구성한다.

  • WireMock 서버를 구동시킬 경우 http://localhost:{portNumber}로 작동하는 HTTP 서버가 생성된다.
  • 포트번호는 직접 지정해줄 수도 있고, dynamicPort 옵션을 통해 랜덤한 포트번호를 지정할 수도 있다.
// 직접 포트번호 지정
wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().port(8080))
// 랜덤한 포트번호 지정
wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort())

이제 실제 테스트 코드를 보자.

코드

코드는 간결함을 위해 Kotlin으로 작성했으나, Java 코드와는 큰 차이가 없다.

  • 이 글을 쓰던 시점부터 다니던 회사의 kotlin migration이 진행되어서...글을 읽는 분들께는 양해를 부탁드린다.

예시 코드 (Kotlin)

// Test를 위한 Configuration Class
@Configuration
@EnableFeignClients
@EnableAutoConfiguration
class FeignTestConfiguration {
}

// Feign Class
@FeignClient(url = "\${api.url}")
interface MessageApiClient {
    @GetMapping(value = ["/info"])
    fun getInfo(request: Request): Response
}

// Test Class
@SpringBootTest(
    properties = ["env=test", "feign.client.config.default.loggerLevel=FULL"],
    classes = [FeignTestConfiguration::class]
)
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS)
class MessageApiClientTest {
    @Autowired
    lateinit var apiClient: ApiClient

    companion object {
        @JvmStatic
        val WIREMOCK_PORT = SocketUtils.findAvailableTcpPort()

        @JvmStatic
        lateinit var wireMockServer: WireMockServer

        /**
         * url을 변경해주는 메소드. @DynamicPropertySource를 통해 변경한다.
         * 변경값이 application context에 영향을 주기 때문에, @DirtiesContext 설정이 클래스에 추가되어 있다.
         */
        @JvmStatic
        @DynamicPropertySource
        fun addUrlProperties(registry: DynamicPropertyRegistry) {
            registry.add("api.url") { "localhost:$WIREMOCK_PORT" }
        }

        /**
         * @BeforeAll 로 작성된 코드는 static 메소드여야 한다.
         * 그렇기 때문에, companion object 내에 포함하였다.
         * addUrlProperties 메소드 내에서 wireMockServer 초기화시 해당 메소드의 역할이 불분명해 질 수 있다.
         * 그렇기 때문에, @BeforeAll로 별도 지정하여 테스트 시작 전에 wireMockServer를 초기화 및 구동한다.
         */
        @JvmStatic
        @BeforeAll
        fun setUp() {
            wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().port(WIREMOCK_PORT))
            wireMockServer.start()
            WireMock.configureFor("localhost", WIREMOCK_PORT)
        }

        /**
         * @AfterAll 로 작성된 코드는 static 메소드여야 한다.
         * afterTest 메소드는 테스트가 종료되면, wireMockServer를 종료시킨다.
         */
        @JvmStatic
        @AfterAll
        fun afterTest() {
            wireMockServer.stop()
        }
    }

    @AfterEach
    fun afterEach() {
        // 각 테스트 종료시마다 wireMockServer를 reset하여 stub된 내용을 지운다.
        wireMockServer.resetAll()
    }

    @Test
    @DisplayName("요청을 보내면, 적절한 응답이 돌아온다.")
    fun test() {
		val request = Request(
            userCode = "123abcd"
        )
        stubSuccessResponse()
        val response = apiClient.getInfo(request)

        Assertions.assertTrue(response.success)
        Assertions.assertEquals(response.name, "KAFKA")
        Assertions.assertEquals(response.isMarried, false)
    }

    // stub 로직을 별도 메소드로 분리했다. wireMockServer에 가상의 요청에 대한 가상의 답변을 준비한다.
    private fun stubSuccessResponse() {
        val responseJson = JsonBuilderFactory.buildObject()
            .add("success", true)
            .add("name", "KAFKA")
            .add("isMarried", false))
            .json.toString()
        wireMockServer.stubFor(
            WireMock.post(WireMock.urlPathEqualTo("/person"))
                .withRequestBody(WireMock.containing("\"userCode\":\"123abcd\""))
                .willReturn(
                    WireMock.aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json; charset=utf-8")
                        .withBody(responseJson)
                )
        )
    }
}

로직이 매우 복잡해 보인다. 그 이유는, 해당 테스트가 아래의 과정을 거치기 때문이다.

  • 우선 static으로 지정된 @DynamicPropertySource 메소드가 작동하여 feign이 사용할 url 정보를 변경한다.
    • Feign.Builder를 통해 테스트하면 이 과정이 필요하지 않다. 다만 실제 사용하는 Feign Client와 최대한 근접한 환경에서 테스트하려면 이런 과정이 필요할 수 있다.
    • 또한 이 과정은, 별도의 .properties나 .yml 파일에 url을 저장해 Feign에서 사용한다고 가정한다.
  • 이후 테스트 코드가 시작할 때, @BeforeAll로 지정된 wireMockServer init 과정이 수행된다.
  • 테스트를 진행한다. 각 테스트는 시작하면서 stub 로직을 실행한다.
  • 개개의 테스트가 끝나면 @AfterEach로 지정된 메소드가 실행되면서 wireMockServer를 리셋한다. 이 과정에서 테스트 중 수행한 stub가 사라진다.
  • 모든 테스트가 끝나면 @AfterAll로 지정된 메소드가 실행되면서 wireMockServer가 종료된다.

마치며

RestTemplate에 비해 Feign의 테스트에 대한 레퍼런스가 적어 구현하는데에 어려움이 많았다.

이 글을 참고하여, 이후 작성시 참고할 수 있다면 좋겠다.

 

잘못된 내용이 있다면 댓글 부탁드립니다. 늘 감사합니다. 

반응형

댓글