외부 API 연동 로직 개발하기
로직을 작성하다 보면 외부 API와의 연동은 피할 수 없게 되었습니다.
피할 수 없게 되었지만 테스트 코드를 짜기에는 아직도 까다로운 것도 사실입니다.
아직 Request날려볼 API 실체도 없다고....?
아직 연동 api의 실체도 없고 서로 약속한 문서만 있을 경우는 테스트 코드는 고수하고 로직 작성 조차도 더욱 까다로워집니다. (벌써 어지러워집니다...)😵💫😵💫😵💫😵💫
그래, 연동할 API 없다고 개발 못하는건 아니니까
연동할 API의 실체가 없다고 개발못하는건 아닙니다.
약속된 문서가 있기때문에 문서 기준으로 가능한 로직을 작성해 봅니다
실체가 없는 외부 API 연동 로직 먼저 개발하기
연동 API 명세 1. GET
- URL : http://user-api.com/users/{user_id}
- Method : GET
- response
{
"no" : 16,
"user_id" : "some_id",
"age" : 32,
"name" : "testname"
}
- response 관련 객체
@Builder
@Getter
public class UserInfo{
private String userId;
private Long age;
private String name;
}
로직 작성 Step 1. [있는 그대로 로직 작성하기]
public class UserService {
public static final String API_URL = "https://user-api.com";
private final RestTemplate restTemplate;
/**
* constructor
*/
public UserService(RestTemplateBuilder restTemplateBuilder) {
DefaultUriBuilderFactory uriBuilder = new DefaultUriBuilderFactory(apiUrl);
restTemplate = restTemplateBuilder
.uriTemplateHandler(uriBuilder)
.defaultHeader("Authorization", "Basic SomeToken")
.build();
}
/**
* user 정보 조회
*/
public UserInfo getUserInfo(String userId){
String userApipath = "/users" + userId;
ParameterizedTypeReference<UserInfo> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<UserInfo> exchange = restTemplate.exchange(
userApipath,
HttpMethod.POST,
requestEntity,
responseType
);
UserInfo userInfo = exchange.getBody();
return userInfo;
}
}
외부 API 연동 로직은 간단하게 작성할 수 있습니다.
하지만 위 코드는 테스트가 이루어지지 않은 상태입니다.
테스트 코드를 작성하려 해도 연동 API의 엔드포인트가 없다 보니 여의치 않습니다.
로직 작성 Step 2. [Mocking? Mock 서버?]
외부 API 연동 관련한 테스트 코드를 작성하려하면 아래 2가지 방법 정도가 동반되어야 할 겁니다.
RestTemplate
관련 로직 추출 + Mocking- 아래 코드처럼
RestTemplate
관련 로직들을 별도의 클래스로 추출하고, 추출한 클래스를 테스트 코드 상에서 Stubbing 하여 테스트할 수도 있습니다.
// UserService.java @RequiredArgsConstructor public class UserService { // UserApi 연동 로직을 담고 있는 userApiService private final UserApiService userApiService; /** * user 정보 조회 */ public UserInfo getUserInfo(String userId){ return userApiService.getUserInfo(userId); } } // UserService 테스트 코드 @ExtendWith(MockitoExtension.class) public class UserApiServiceMockTest { @Mock private UserApiService userApiService; @InjectMocks private UserService userService; @Test public void getUserInfoTest() { // given // userApi 연동 과정 stubbing Mockito.when(userApiService.getUserInfo(any())).then(invocation -> { return UserInfo.builder() .userId("someId") .age(59) .name("someName") .build(); }); // when UserInfo userInfo = userService.getUserInfo("somdId"); // then assertEquals(userInfo.getUserId(), "someId"); } }
- 하지만 추출한 클래스도 결국 테스트가 되지 않는다는 것은 동일하기 때문에 문제의 본질을 해결하는 과정은 아닙니다
- 아래 코드처럼
- Mock 서버 제작
- request를 받을 수 있는 Mock 서버를 제작하여 테스트 할 수도 있습니다.
- 하지만 이는 연동 API의 스펙에 따라 mock서버까지 유지보수해줘야 한다는 부담도 함께 합니다
RestClientTest : 클라이언트 입장에서의 API 연동 테스트
외부 API 연동 테스트에서 내가 진짜 테스트해야 할 것은?
먼저, API 연동에서 진짜 테스트해야 될 내용에 대해 정확히 해야 합니다.
실제로 신경 써야 할 부분은 아래 3가지 정도일 겁니다.
- API request 스펙에 맞는 url로 request 되는가
- API request 스펙에 맞는 body가 구성되었는가
- API response 스펙에 맞는 연동 로직이 구성되었는가
물론 서비스를 개발하는 입장에서 연동 API의 캡슐화된 로직과 전체적인 그림을 알고 있으면 좋겠지만
테스트를 작성하는 입장에서는 딱 위 3가지 정도만 검증할 수 있어도 연동 로직의 많은 오류를 잡아 낼 수 있습니다.
연동 API가 없는 상태에서 테스트 방법
간단히 단순화해서 생각하면 외부의 Mock서버를 이용하는 것이 아닌 테스트 코드 내부의 Mock서버를 만들어 request와 response에 대한 검증을 진행하면 됩니다.
@RestClientTest
@RestClientTest
는 테스트 코드 내에서 Mock 서버를 띄울 수 있게 합니다.
그리고 이 Mock 서버는 request에 대한 검증, request에 대한 response 사전 정의가 가능합니다.
@RestClientTest
를 이용해서 위의 UserService.java
의 테스트를 작성해보겠습니다.
Request URL 검증 테스트
@RestClientTest( value = {UserService.class} )
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private MockRestServiceServer mockServer;
@DisplayName("apiUserGetTest")
@Test
void getUserInfoTest(){
// given
String userId = "someUserId";
String expectedApiUrl = UserService.API_URL + "/users" + userId;
int expectedAge = 32;
String expectedName = "testName";
String expectedJsonResponse = "{\"no\":16,\"user_id\":\""+userId+"\",\"age\":"+expectedAge+",\"name\":\""+expectedName+"\"}";
mockServer
.expect(requestTo(expectedApiUrl))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(expectedJsonResponse, MediaType.APPLICATION_JSON));
// when
UserInfo userInfo = userService.getUserInfo(userId);
// then
assertEquals(userInfo.getUserId(), userId);
assertEquals(userInfo.getAge(), expectedAge);
assertEquals(userInfo.getName(), expectedName);
}
}
@RestClientTest
- 테스트 코드 안에서 Mock 서버를 띄울 수 있게 하는 어노테이션입니다.
@RestClientTest
를 설정하면 Mock서버를 Bean으로 등록하여 테스트 코드 내에서 Mock서버를 컨트롤할 수 있게 합니다. →MockRestServiceServer
@RestClientTest
는 스프링 컨텍스트 전체를 사용하지는 않기 때문에 테스트에 사용되는 클래스를 value에 전달하여 Bean으로 등록해야 합니다. →@RestClientTest( value = {UserService.class} )
mockServer.expect()
/andExpect()
- 클라이언트 입장에서 Mock서버에 어떤 Request를 발생시키는지에 대해 검증할 수 있는 기능입니다.
mockServer.expect( requestTo(expectedApiUrl) )
- expectedApiUrl이라는 URL로 request하는것을 검증합니다
- URL이 다를 시에 Test Fail
andExpect( method(HttpMethod.GET) )
- API에 GET 메소드로 request하는것을 검증합니다.
- 다른 메소드일경우 Test Fail
andRespond(withSuccess(expectedJsonResponse, MediaType.APPLICATION\_JSON))
- Mock 서버에서 response할 내용을 미리 정의해 놓는 기능입니다.
- 위 소스코드의 내용은 미리 정의해놓은 expectedJsonResponse 으로 200코드와 함께 application/json으로 response 될 것이라는 것을 미리 정의해놓는 내용입니다.
- when / then
- Mock 서버 대상으로 Request에 대한 검증과 미리 정의해놓은 response내용을 바탕으로 테스트할 로직을 실행하고 결과에 대해 검증합니다.
Given-When-Then 패턴과는 조금 다른데..?
Mock서버의 response를 미리 정의해놓는 것은 일반적인 Given-When-Then 패턴과 크게 다르지 않습니다.
하지만 Mock 서버에 전달되는 Request에 대해 검증하는 로직은 When 이전에 Then 로직을 먼저 작성하게 되면서 순서가 꼬이는 느낌이 듭니다.
저도 처음에는 다른 패턴이 있지 않을까 생각해봤지만 로직이 실행되기 전에 Mock서버는 먼저 세팅이 되어야 한다는 점만 유의한다면 약간의 예외로 가져가는 것이 크게 이질적이지는 않았습니다.
그만큼 Test Code안에서 Mock 서버를 띄울 수 있다는 것은 크나큰 장점이니까요 😎😎
QueryString 및 List 형태의 response 검증 테스트
public class UserService {
public static final String API_URL = "https://user-api.com";
private final RestTemplate restTemplate;
/**
* constructor
*/
public UserService(RestTemplateBuilder restTemplateBuilder) {
DefaultUriBuilderFactory uriBuilder = new DefaultUriBuilderFactory(apiUrl);
restTemplate = restTemplateBuilder
.uriTemplateHandler(uriBuilder)
.defaultHeader("Authorization", "Basic SomeToken")
.build();
}
public List<UserInfo> findUserListBy(Long age){
String userApiPath = "/users";
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("age", String.valueOf(age));
String urlWithQueryString = UriComponentsBuilder.fromUriString(userApiPath)
.queryParams(queryParams)
.build().encode()
.toUriString();
ParameterizedTypeReference<List<UserInfo>> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<List<UserInfo>> exchange = restTemplate.exchange(
urlWithQueryString,
HttpMethod.GET,
null,
responseType
);
List<UserInfo> userInfoList = exchange.getBody();
return userInfoList;
}
}
위 코드의 findUserListBy()
는 /users?age=32
형태로 쿼리스트링을 붙여서 request하고 API에서 사용자 정보 List를 response받아 연동하는 로직입니다.
@RestClientTest(value = {UserService.class} )
public class findUserListTest {
@Autowired
private UserService userService;
@Autowired
private MockRestServiceServer mockServer;
@Test
void findUserListByAgeTest(){
Long testAge = 30L;
String expectedApiUrl = UserService.API_URL + "/users";
URI uri = UriComponentsBuilder.fromUriString(expectedApiUrl)
.queryParam("age", testAge)
.build().encode()
.toUri();
String expectedUserId = "someId";
String expectedName = "testName";
String expectedJsonResponse = "[{\"no\":16,\"user_id\":\""+expectedUserId+"\",\"age\":"+testAge+",\"name\":\""+expectedName+"\"}]";
mockServer
.expect(requestTo(uri))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess(expectedJsonResponse, MediaType.APPLICATION_JSON));
List<UserInfo> userList = userService.findUserListBy(testAge);
assertEquals(userList.size(), 1);
assertEquals(userList.get(0), UserInfo.builder().no(16L).userId(expectedUserId).age(testAge).name(expectedName).build());
}
}
UriComponentsBuilder
UriComponentsBuilder
를 이용해 검증용 URL을 쿼리스트링을 붙인 형태로 만들어 냅니다.mockServer.expect()
를 이용해 이전 만든 URL&쿼리스트링을 검증합니다.
expectedJsonResponse
&andRespond()
- expectedJsonResponse 에 List형태의 JSON을 정의해 놓은 후
andRespond()
로 list를 response 할 수 있도록 합니다.
- expectedJsonResponse 에 List형태의 JSON을 정의해 놓은 후
- when / then
- List 형태의 json이 정상적으로
List<UserInfo>
형태로 변환되었는지 검증합니다.
- List 형태의 json이 정상적으로
POST 요청의 RequestBody 검증 테스트
public class UserService {
public static final String API_URL = "https://user-api.com";
private final RestTemplate restTemplate;
/**
* constructor
*/
public UserService(RestTemplateBuilder restTemplateBuilder) {
DefaultUriBuilderFactory uriBuilder = new DefaultUriBuilderFactory(apiUrl);
restTemplate = restTemplateBuilder
.uriTemplateHandler(uriBuilder)
.defaultHeader("Authorization", "Basic SomeToken")
.build();
}
/**
* user 생성
*/
public UserInfo createUser(UserCreate userCreate){
String userApiPath = "/users";
Map<String, Object> userCreateMap = objectMapper.convertValue(userCreate, new TypeReference<>() {});
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(userCreateMap, headers);
ParameterizedTypeReference<UserInfo> responseType = new ParameterizedTypeReference<>() {};
ResponseEntity<UserInfo> exchange = restTemplate.exchange(
userApiPath,
HttpMethod.POST,
request,
responseType
);
UserInfo createdUserInfo = exchange.getBody();
return createdUserInfo;
}
}
위 로직은 /users POST 요청으로 사용자를 생성하는 API의 연동 로직입니다.
@RestClientTest(value = {UserService.class} )
public class CreateUserTest {
@Autowired
private UserService userService;
@Autowired
private MockRestServiceServer mockServer;
@DisplayName("apiUserCreateTest")
@Test
void createUserTest(){
// given
String expectedApiUrl = UserService.API_URL + "/users";
String expectedUserId = "someId";
int expectedAge = 32;
String expectedName = "testName";
String expectedJsonRequest = "{\"user_id\":\""+expectedUserId+"\",\"age\":"+expectedAge+",\"name\":\""+expectedName+"\"}";
String expectedJsonResponse = "{\"no\":16,\"user_id\":\""+expectedUserId+"\",\"age\":"+expectedAge+",\"name\":\""+expectedName+"\"}";
UserCreate userCreate = UserCreate.builder()
.userId(expectedUserId)
.age((long)expectedAge)
.name(expectedName)
.build();
mockServer
.expect(requestTo(expectedApiUrl))
.andExpect(method(HttpMethod.POST))
.andExpect(content().json(expectedJsonRequest))
.andRespond(withSuccess(expectedJsonResponse, MediaType.APPLICATION_JSON));
// when
UserInfo userInfo = userService.createUser(userCreate);
// then
assertEquals(userInfo.getUserId(), expectedUserId);
assertEquals(userInfo.getAge(), expectedAge);
assertEquals(userInfo.getName(), expectedName);
}
}
expectedJsonRequest
- 사용자 생성 API에 전달할 JSON 형태의 Request Body를 미리 작성해놓습니다.
andExpect( content().json(expectedJsonRequest) )
- expectedJsonRequest 와 동일한 Request Body가 Request되는지를 검증합니다.
- when / then
- expectedJsonResponse 에 미리 정의된 response에 맞는
UserInfo
가 return 되었는지 검증합니다.
- expectedJsonResponse 에 미리 정의된 response에 맞는
'Java' 카테고리의 다른 글
멀티모듈 Exception 전략 (2) | 2022.09.12 |
---|---|
@SpringBootTest / @DataJpaTest 차이점 과 JPA 영속성 컨텍스트 (5) | 2021.09.12 |
람다(Lambda)와 익명 클래스 :: Lambda can be replaced with method reference (0) | 2021.08.01 |
람다 캡처링 :: Variable used in lambda expression should be final or effectively final의 이유 (7) | 2021.07.14 |
Mockito @Mock @MockBean @Spy @SpyBean 차이점 (27) | 2020.09.13 |