본문 바로가기
개발 활동/스위프

외부 API 연동 - WireMock으로 테스트하기

by 다음에바꿔야지 2024. 5. 31.

개발을 할때 기능을 최대한 작게 만들고, 독립적으로(어느 기술에 의존하지 않고) 만들려고 노력한다. 이번 외부 API와 연동할때에도 'API와의 통신없이 실제와 같은 테스트를 할 수 있을까?'에 대해 고민하게 되었다.

 

일반적으로 테스트에는 의존성을 최소화하기 위해 Mockito를 사용하여 테스트를 진행하지만, 이는 완전한 해결법은 아닌 것 같았다. 외부 API를 사용할때는 http 요청과 응답, 응답값에 대한 역직렬화 과정 등이 발생하는데 Mockito는 단순히 목 객체를 주입받아서 메서드를 호출하는 방식으로 동작하기 때문이다. 목 객체를 주입받으면 실제 http 통신을 통해 받은 응답값을 확인할 수 없기 때문에 뭐랄까.. 테스트를 위한 테스트? 같은 느낌이었다. 나는 실제 통신까지 한 값을 받고 싶다고!

 

만약 실제 외부 API를 호출하는 방식은 어떨까? 외부 API 서버의 상태에 따라 테스트 결과가 달라지게 되니 좋은 테스트라고 할 수 없다.

 

열심히 서치를 하던 중 WireMock이라는 라이브러리를 통해 테스트하는 방식을 발견했다.

 

1. WireMock?

Http 기반의 API서비스를 Mocking하는 용도로 제공되는 Mock서버 라이브러리.

지정해둔 uri로 요청이 발생할 경우 목 서버로 http 요청이 발생하고 미리 지정해둔 형태의 http 응답이 반환된다.

이를 통해, 실제 외부 API에 의존하지 않으면서 http 요청/응답을 통한 테스트가 가능해진다. 또한 로컬 서버를 사용하기 때문에 테스트 속도도 빠르다.

 

2. 프로젝트에 적용

<build.gradle>

spring cloud에서 제공하는 의존성을 추가했다.

dependencies {
	testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner:4.1.2'
	}

 

<WireMockConfig>

@TestConfiguration
public class WireMockConfig {

    @Bean(initMethod = "start", destroyMethod = "stop")
    public WireMockServer wireMockService() {
        return new WireMockServer(0);
    }
}

API를 호출할 모의 서버를 빈으로 등록한다.

포트 번호는 랜덤으로 사용하기 위해 0으로 설정했다.

 

<application-test.yml>

openai:
  url: http://localhost:${wiremock.server.port}

테스트 url을 넣어준다.

로컬 서버를 사용하면서, 포트 번호는 랜덤이기 때문에 ${wiremock.server.port}를 사용했다.

 

<ai-response.json>

{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "created": 1677858242,
  "model": "gpt-3.5-turbo-0613",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "This is a test!"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 13,
    "completion_tokens": 7,
    "total_tokens": 20
  }
}

미리 생성한 응답을 만들어둔다. OpenAI에서 제공하는 AI 응답 예시를 넣어두었다.

 

파일 경로: src/test/resources/__files/payload/ai-response.json

파일을 생성한 Path 때문에 애를 먹었는데, 여러 블로그 글을 보니 __files 디렉토리에 대한 글을 못 찾았었다. 그래서 저게 뭐지... 자동으로 생성해주는 건가 싶어서 한참 고민하고 있었는데 그냥 직접 해당 디렉토리를 생성해주니 해결되었다.

 

공식 문서를 살펴보면 payload 디렉토리 하위 json 파일을 응답값으로 사용한다고 한다.

 

<feign-mapping.json>

{
  "request": {
    "method": "POST",
    "url": "/v1/chat/completions"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "bodyFileName": "payload/ai-response.json"
  }
}

WireMock으로 사용하는 로컬 서버에 stub을 설정하는 파일을 만들어둔다. 여기서는 목 서버를 사용하는 곳에서 명시적으로 stub을 세팅하기 위해 응답값만 json 파일로 만들어두었다.

  • stub: http 요청 path와 그에 대한 http 응답을 설정해서 제공하는 것.

쉽게 말해, 가짜 API를 만드는 것이다.

 

파일 경로: src/test/resources/mappings/feign-mapping.json

공식 문서에서 WireMock 서버는 mappings 디렉토리 하위 json 파일을 읽어서 stub을 설정한다고 한다.

 

여기까지 일단 정리해보면,

  1. 테스트에서 feign-mapping에 정의된 api로 WireMock에 요청하면
  2. WireMock은 그에 맞는 응답을 반환한다. 위에서는 ai-response.json이 반환되게 했다.

<AiMocks>

public class AiMocks {

    public static void setupAiMockResponse(WireMockServer mockService) {
        mockService.stubFor(post(urlPathEqualTo("/v1/chat/completions"))
                .withHeader("Authorization", equalTo("test-key"))
                .willReturn(aResponse()
                        .withStatus(200)
                        .withHeader("Content-Type", "application/json")
                        .withBodyFile("payload/ai-response.json")
                )
        );
    }
}

WireMock에서 제공하는 stubfor(), post()를 사용해 이 클래스에서 실제 stub을 설정해주었다.

임의로 만든 모의 서버를 호출하면 어떤 응답이 와야하는지 작성한다.

 

 

세팅은 끝났다.

이제 테스트코드만 열심히 작성하면 된다.

 

테스트 코드 및 기타 설정 확인은 => https://github.com/SpinLog/backend/blob/main/src/test/java/com/example/spinlog/ai/service/OpenAiClientTest.java

 

backend/src/test/java/com/example/spinlog/ai/service/OpenAiClientTest.java at main · SpinLog/backend

Contribute to SpinLog/backend development by creating an account on GitHub.

github.com

 

 

외부 API를 사용하는 코드를 어떻게 독립적으로 테스트를 할 수 있을까 고민하던 중, WireMock은 로컬 서버를 실제 api와 유사하게 통신할 수 있도록 도와줘서 잘 선택한 것 같다. 최대한 작게, 다른 서비스의 의존성 없이 코드를 만들 수 있어서 뿌듯하다.

하지만 설정이 꽤나 복잡하다는 점, 자료가 그리 많지 않다는 점(이건 잘 못 찾아본 것일 수 있음), 무엇보다 이 설정 때문에 한 3일 정도를 날렸다는 점에서 애증의 코드가 된 것 같다.

 

그럼 외부 API 연동은 여기서 끝!

 

 

참고 자료:

https://oopsys.tistory.com/293

https://forkyy.tistory.com/13