마이크로서비스 아키텍처 (MSA) 가 일반화된 요즘의 개발 환경에서는 어플리케이션 외부의 다양한 서비스와 여러 방식으로 통신하게 됩니다. 기능 구현을 위해서 한 어플리케이션 내에서 아무리 적어도 서너개 이상의 외부 서비스와 연동하게 되곤 하는데, 내 서비스의 API 를 노출할 때와 다른 서비스의 API 를 사용할 때 모두 HTTP 프로토콜을 사용하는 것이 가장 일반적이라 사용할 API 제공자가 정의한 스펙에 맞춰 해당 HTTP API 를 호출하는 코드를 작성하는 상황이 상당히 자주 일어납니다.
이와 관련해 언어나 프레임워크 별로 어떤 HTTP 클라이언트를 사용할지, 어떤 방식으로 예외를 처리할지, 다수의 API 를 호출해야 하는 상황에서 성능은 어떻게 개선할지 등 여러 중요한 엔지니어링적 고민사항들이 많이 생겨나곤 합니다. 이번 포스트에서는 그 중에서도 외부 API 클라이언트의 테스트에 대해 이야기해보고, 스프링 프레임워크 기반의 개발 환경에서의 테스트 사례도 공유해드리도록 하겠습니다.
외부 API 클라이언트 테스트가 필요한가요?
외부 API 클라이언트에 대한 테스트를 작성한다고 했을 때, 그 필요성 자체에 의문을 품는 분들이 종종 있습니다. 외부 API 는 우리 어플리케이션에서 제어 불가능한 외부 프로세스 자원이기 때문에 테스팅을 하는 것이 의미가 없다고요. 우리 어플리케이션의 정상 동작이 결국 실 환경에서 외부 API 의 상태와 기능 변경에 영향을 받기 때문에 개발이나 스테이징 환경에서의 통합 테스트 정도만 의미가 있다고 말하기도 합니다.

하지만 이 말은 절반은 맞고 절반은 틀렸습니다. 외부 API 는 분명히 우리 어플리케이션에서 제어 불가능한 외부 자원이기 때문에 우리 어플리케이션에서 아무리 잘 테스트 하더라도 항상 정상임을 보증할 수 없습니다. 그러나 테스트에서 확인하고자 하는 것은 언제까지나 우리가 작성한 코드이지 외부의 서비스가 아닙니다.
외부 HTTP API 연동 클라이언트를 작성해 본 경험이 있는 분들이라면, 꽤나 많이 직렬화 (Serialization) 와 역직렬화 (Deserialization) 관련된 오류를 경험해 본 적이 있으실 겁니다. DTO 클래스의 필드명을 잘못 입력해서 요청/응답의 JSON 매핑에서 예외가 발생할 수도 있고, 더 심한 경우 매퍼의 설정에 따라 누락된 DTO 필드가 기본값으로 초기화 되어서 버그를 눈치채지 못하는 경우도 있습니다.
따라서 우리는 외부 서비스 제공자가 정의한 API 요청 및 인증 스펙에 맞춰 우리가 ‘잘’ 요청하고 있는지, 그들이 제공하기로 한 응답을 우리가 ‘잘’ 가져오고 있는지 즉, 계약 (Contract) 를 잘 준수하고 있는지 테스트 해야합니다. 이는 외부 서비스의 정상 작동여부와 완전히 별개의 문제이지요.
테스트는 또 하나의 문서
테스트를 작성하는 것의 또 다른 장점은 그 자체가 문서로 기능할 수 있다는 것입니다. 많은 경우 처음 API 를 연동할 때 연동 클라이언트를 개발한 개발자 이외에는 해당 외부 서비스에 대한 정보가 없는 상황이 대부분일 것입니다. 나중에 해당 클라이언트에 대한 유지 보수가 필요한 상황이 발생했을 때 클라이언트 코드가 예외 처리등 풍부한 상황에 대응하는 코드를 개발해두지 않았다면, 새로 유지보수를 맡은 개발자는 직접 따로 API 를 호출해보거나 사내 포털을 뒤적이면서 API 제공 부서의 문서를 찾아다닌 후에야 코드에서 제대로 드러나지 않는 API 스펙을 유추해 낼 수 있을 것입니다.
// API 에서 예외가 발생한다면 예외는 어떤 형식이지..?
public ProductDto getProduct(Long id) {
return client.exchange("/product/{id}", HttpEntity.EMPTY, ProductDto.class, id).getBody();
}
그러나 만약 해당 클라이언트에 대한 테스트 코드가 작성되어 있다면, 해당 테스트 코드를 통해 어떤 API 를 호출하는지, 어떤 예외가 발생할 수 있는지, 어떤 응답을 받을 수 있는지 등을 쉽게 파악할 수 있을 것입니다. 최종적으로 API 제공 부서의 문서를 참고해야 할 지라도 기본적인 기능을 파악하는데 드는 시간을 훨씬 단축시킬 수 있을 것입니다.
MockServer 를 활용한 단위 테스트
MockServer 를 활용하면 외부 API 를 호출하는 클라이언트 코드를 테스트할 때, 외부 API 서버를 실제로 호출하지 않고도 테스트를 할 수 있습니다. Spring 에서 제공하는 @RestClientTest
가 RestTemplate 및 RestTemplateBuilder 가 사용된 코드만을 테스트 가능한 것에 비해 클라이언트 라이브러리 중립적으로 Mock 테스트가 가능하다는 장점이 있습니다.
Dependency
dependencies {
testImplementation 'org.mock-server:mockserver-netty:5.15.0'
}
Client Code
HTTP Client 는 Spring Boot 3.2 부터 사용 가능한 RestClient
로 구현하였습니다.
public record ProductDto (
Long id,
String name,
String description,
Integer price
) {}
@Component
public class RestProductClient {
private final RestClient client;
public RestProductClient(String host) {
this.client = RestClient.create(host);
}
public ProductDto getProduct(Long id) {
return client.get()
.uri("/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
}
Test Code
class RestProductClientTest {
private ClientAndServer mockServer;
private RestProductClient client;
@BeforeEach
void setUp() {
mockServer = ClientAndServer.startClientAndServer(9876);
}
@AfterEach
void tearDown() {
mockServer.stop();
}
@Test
void getProductById() {
// Given
var client = new RestProductClient("http://localhost:9876"); // (1)
var productId = 1L;
var responseBody = """
{
"id": 1,
"name": "iPhone 12",
"description": "Apple iPhone 12 64GB",
"price": 1000000
}
""";
// When
mockServer
.when(
request() // (2)
.withMethod("GET")
.withPath("/products/1")
).respond(
response() // (3)
.withStatusCode(200)
.withBody(json(responseBody))
);
var result = client.getProduct(1L); // (4)
var expected = new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
);
// Then
assertEquals(expected, result); // (5)
}
}
- (1) : mockserver 와 동일한 포트로 접속하도록 클라이언트 인스턴스를 생성합니다.
- (2) : 요청을 받길 기대하는 요청 메서드와 경로를 지정합니다.
- (3) : 요청에 대한 응답을 지정합니다.
- (4) : 클라이언트 코드를 실행하고, 응답을 바인딩합니다.
- (5) : 응답이 기대하는 결과와 일치하는지 검증합니다.
API 클라이언트가 협력 객체인 경우

만약 API 클라이언트 구현 자체에 대한 테스트가 아니라 위에서 구현한 RestProductClient
와 같은 클라이언트가 다른 비즈니스 객체의 협력 객체로 사용되는 테스트라면 어떻게 해야 할까요? 마찬가지로 MockServer 를 이용해서 HTTP 통신은 Mocking 할 수 있지만, 테스트의 핵심 관심사는 HTTP 통신이 아니라 외부 서비스로부터 받은 응답을 바탕으로 기대하는 추가적인 비즈니스 로직이 제대로 수행되는지이기 때문에 MockServer 를 사용하는건 다소 불필요하고 번거로운 일일 수 있습니다. 우리가 Repository 와 협력관계에 있는 클래스를 테스트할 때 DB 를 Mocking 하지 않는 것을 상상해보면 이해하기 쉽습니다.
@Service
public class ProductService {
private final RestProductClient client;
public ProductService(RestProductClient client) {
this.client = client;
}
public Integer getProductPrice(Long id) {
return client.getProduct(id).price();
}
}
Mock 을 활용한 단위 테스트
ProductService 에 대한 단위 테스트를 수행할 경우, 협력 객체인 RestResourceClient 를 Mocking 해서 테스트를 수행할 수 있습니다.
class ProductServiceTest {
private ProductService productService;
private RestProductClient productClient;
@Test
void getProductPrice() {
// Given
productClient = mock(RestProductClient.class);
given(productClient.getProduct(1L)).willReturn(new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
));
productService = new ProductService(productClient);
// When
var result = productService.getProductPrice(1L);
// Then
assertEquals(1000000, result);
}
}
MockBean 을 활용한 통합 테스트
스프링 어플리케이션에 대한 통합 테스트를 수행할 경우, @MockBean
을 활용해서 협력 객체를 Mocking 할 수 있습니다.
@SpringBootTest
class ProductServiceIntegrationTest {
@Autowired
private ProductService service;
@MockBean
private RestProductClient client;
@Test
void getProductPrice() {
// Given
given(client.getProduct(1L)).willReturn(new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
));
// When
var result = service.getProductPrice(1L);
// Then
assertEquals(1000000, result);
}
}
인터페이스를 추가하고 직접 테스트 더블 구현하기

위의 두 방법은 구체 클래스인 RestProductClient
를 Mocking 하는 방법이었습니다. 하지만 매 테스트 코드마다 외부 의존성을 가지는 클래스를 mocking 하는 것은 쉽지 않은 일이고, @MockBean
을 사용할 경우 매 테스트마다 Application Context 가 재생성되기 때문에 테스트 수행 시간이 길어질 수 있습니다. 이런 경우에는 인터페이스를 추가하고 이를 구현하는 테스트 더블 구현체를 만들어서 테스트를 수행하는 것도 하나의 방법이 될 수 있습니다.
public interface ProductClient {
ProductDto getProduct(Long id);
}
@Profile({"dev", "production"})
@Component
public class RestProductClient implements ProductClient {
private final RestClient client;
public RestProductClient(@Value("${product.host}") String host) {
this.client = RestClient.create(host);
}
@Override
public ProductDto getProduct(Long id) {
return client.get()
.uri("/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
}
// 테스트 환경에서만 사용할 Stub 구현체
@Profile("test")
@Component
public class StubProductClient implements ProductClient {
@Override
public ProductDto getProduct(Long id) {
return new ProductDto(id, "Product " + id, "Description " + id, 100 * id.intValue());
}
}
In today’s development environment where microservice architecture (MSA) has become mainstream, applications communicate with various external services in multiple ways. When implementing features, an application typically integrates with at least three or four external services. Since HTTP protocol is most commonly used both for exposing your service’s API and consuming other services’ APIs, situations where you need to write code that calls HTTP APIs according to specifications defined by API providers occur quite frequently.
This brings up several important engineering considerations specific to different languages and frameworks: which HTTP client to use, how to handle exceptions, how to improve performance when calling multiple APIs, and more. In this post, I’ll discuss testing external API clients specifically, and share testing examples in Spring Framework-based development environments.
Do We Need to Test External API Clients?
When it comes to writing tests for external API clients, some people question the necessity itself. They argue that testing external APIs is meaningless because external APIs are external process resources that are uncontrollable from our application. They say that since our application’s normal operation is ultimately affected by the state and functional changes of external APIs in production, only integration tests in development or staging environments are meaningful.

However, this statement is half right and half wrong. External APIs are certainly external resources uncontrollable from our application, so no matter how well we test in our application, we cannot guarantee they will always work correctly. However, what we want to verify in tests is always our own code, not external services.
If you have experience writing external HTTP API integration clients, you’ve probably encountered quite a few serialization and deserialization related errors. You might have exceptions during JSON mapping of requests/responses due to incorrect DTO class field names, or in worse cases, missing DTO fields might be initialized with default values according to mapper settings, making bugs hard to notice.
Therefore, we need to test whether we are making requests ‘correctly’ according to the API request and authentication specifications defined by external service providers, and whether we are ‘correctly’ receiving the responses they promised to provide - in other words, whether we are properly adhering to the contract. This is completely separate from whether the external service is operating normally.
Tests as Documentation
Another advantage of writing tests is that they can function as documentation themselves. In many cases, when initially integrating an API, there’s typically no information about that external service except for the developer who developed the integration client. When maintenance of that client becomes necessary later, if the client code doesn’t include rich exception handling for various situations, the new developer taking over maintenance will only be able to infer API specifications that aren’t properly revealed in the code after directly calling the API separately or searching through internal portals for documentation from the API providing department.
// If an exception occurs in the API, what format would the exception be...?
public ProductDto getProduct(Long id) {
return client.exchange("/product/{id}", HttpEntity.EMPTY, ProductDto.class, id).getBody();
}
However, if test code for that client is written, you can easily understand which API is being called, what exceptions might occur, what responses can be received, etc., through the test code. Even if you ultimately need to refer to documentation from the API providing department, you can significantly reduce the time needed to understand basic functionality.
Unit Testing with MockServer
Using MockServer, you can test client code that calls external APIs without actually calling external API servers. Compared to Spring’s @RestClientTest
which can only test code using RestTemplate and RestTemplateBuilder, it has the advantage of enabling mock testing in a client library-neutral way.
Dependency
dependencies {
testImplementation 'org.mock-server:mockserver-netty:5.15.0'
}
Client Code
The HTTP Client is implemented using RestClient
which is available from Spring Boot 3.2.
public record ProductDto (
Long id,
String name,
String description,
Integer price
) {}
@Component
public class RestProductClient {
private final RestClient client;
public RestProductClient(String host) {
this.client = RestClient.create(host);
}
public ProductDto getProduct(Long id) {
return client.get()
.uri("/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
}
Test Code
class RestProductClientTest {
private ClientAndServer mockServer;
private RestProductClient client;
@BeforeEach
void setUp() {
mockServer = ClientAndServer.startClientAndServer(9876);
}
@AfterEach
void tearDown() {
mockServer.stop();
}
@Test
void getProductById() {
// Given
var client = new RestProductClient("http://localhost:9876"); // (1)
var productId = 1L;
var responseBody = """
{
"id": 1,
"name": "iPhone 12",
"description": "Apple iPhone 12 64GB",
"price": 1000000
}
""";
// When
mockServer
.when(
request() // (2)
.withMethod("GET")
.withPath("/products/1")
).respond(
response() // (3)
.withStatusCode(200)
.withBody(json(responseBody))
);
var result = client.getProduct(1L); // (4)
var expected = new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
);
// Then
assertEquals(expected, result); // (5)
}
}
- (1): Create a client instance to connect to the same port as the mockserver.
- (2): Specify the request method and path you expect to receive.
- (3): Specify the response to the request.
- (4): Execute the client code and bind the response.
- (5): Verify that the response matches the expected result.
When API Client is a Collaborating Object

What if it’s not a test of the API client implementation itself, but a test where a client like the RestProductClient
implemented above is used as a collaborating object of other business objects? While you can similarly use MockServer to mock HTTP communication, since the core concern of the test is not HTTP communication but whether the expected additional business logic is properly performed based on responses received from external services, using MockServer might be somewhat unnecessary and cumbersome. This is easy to understand if you imagine not mocking the DB when testing a class that has a collaborative relationship with a Repository.
@Service
public class ProductService {
private final RestProductClient client;
public ProductService(RestProductClient client) {
this.client = client;
}
public Integer getProductPrice(Long id) {
return client.getProduct(id).price();
}
}
Unit Testing with Mocks
When performing unit tests for ProductService, you can perform tests by mocking the collaborating object RestResourceClient.
class ProductServiceTest {
private ProductService productService;
private RestProductClient productClient;
@Test
void getProductPrice() {
// Given
productClient = mock(RestProductClient.class);
given(productClient.getProduct(1L)).willReturn(new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
));
productService = new ProductService(productClient);
// When
var result = productService.getProductPrice(1L);
// Then
assertEquals(1000000, result);
}
}
Integration Testing with MockBean
When performing integration tests for Spring applications, you can mock collaborating objects using @MockBean
.
@SpringBootTest
class ProductServiceIntegrationTest {
@Autowired
private ProductService service;
@MockBean
private RestProductClient client;
@Test
void getProductPrice() {
// Given
given(client.getProduct(1L)).willReturn(new ProductDto(
1L,
"iPhone 12",
"Apple iPhone 12 64GB",
1000000
));
// When
var result = service.getProductPrice(1L);
// Then
assertEquals(1000000, result);
}
}
Adding Interfaces and Implementing Test Doubles Directly

The above two methods involved mocking the concrete class RestProductClient
. However, mocking classes with external dependencies in every test code is not easy, and when using @MockBean
, the Application Context is recreated for every test, which can make test execution time longer. In such cases, adding an interface and creating test double implementations that implement it can be one approach to performing tests.
public interface ProductClient {
ProductDto getProduct(Long id);
}
@Profile({"dev", "production"})
@Component
public class RestProductClient implements ProductClient {
private final RestClient client;
public RestProductClient(@Value("${product.host}") String host) {
this.client = RestClient.create(host);
}
@Override
public ProductDto getProduct(Long id) {
return client.get()
.uri("/products/{id}", id)
.retrieve()
.body(ProductDto.class);
}
}
// Stub implementation for test environment only
@Profile("test")
@Component
public class StubProductClient implements ProductClient {
@Override
public ProductDto getProduct(Long id) {
return new ProductDto(id, "Product " + id, "Description " + id, 100 * id.intValue());
}
}