본 포스팅은 MSA 공부 간 작성한 포스팅 입니다.
아래 포스팅을 먼저 보고 오시면 이해에 도움이 되시니 참고 부탁드립니다.
https://byunsw4.tistory.com/39
본 포스팅에 사용된 코드는 아래 github 주소를 참고해주시면 감사드리겠습니다.
https://github.com/dongha-byun/springboot-shoppingmall
1. Feign Client 테스트 방법
마이크로 서비스 간 통신을 위해 Spring Cloud 의 Open Feign 을 사용했습니다.
모놀리식 때와는 달리, 비지니스 로직 도중에 마이크로 서비스를 호출하게 되기 때문에 이를 테스트 코드로 어떻게 처리하면 좋을지에 대한 고민을 해봐야 합니다.
저의 경우, 아래 2가지 케이스를 각각 다른 테스트 방법을 도입하고자 했습니다.
1-1. FeignClient 를 사용하는 Business Logic Service 테스트 - FeignClient 를 Mocking
FeignClient 를 사용하는 Service 를 테스트하는데에 있어 중요한 부분은 "Service 의 비지니스 로직 '만' 테스트 하는 것" 일 것입니다.
즉, Service 가 FeignClient 를 포함하고 있다면, 이 또한 외부에 제약을 받을 수 있는 구조기에 Service 내에 있는 FeignClient 를 Mocking 하여 순수 Service 로직 만을 테스트 하기 위한 방법입니다.
왜 이렇게 해야될까요? 그냥 외부 API 를 호출하는 테스트를 그대로 진행하면 안되는 걸까요?
예를 들어, 오전에 테스트 코드를 실행할 때 성공했던 케이스가, 외부 API 서비스의 서비스 점검으로 인해 오후에 잠시 서비스를 중단시켰다면 오후에는 테스트 코드가 실패하는 결과가 발생하게 됩니다. 이는 좋은 테스트 코드를 작성하는 원칙, FIRST 원칙 중 "같은 테스트는 실행할 때 마다 같은 결과를 만들어내야 한다." 에 부합하지 않고, 이 원칙은 "테스트는 외부 요인에 변화에 따라 결과가 달라서는 안된다." 라는 의미를 내포하고 있습니다.
1-2. FeignClient 테스트 - Mock Server 사용
위에서 FeignClient 를 Mocking 했다면, 이번엔 FeignClient 그 자체를 테스트해야 합니다.
하지만, 위에서 언급했듯 FeignClient 와 같이 외부 API 를 호출하는 로직을 그냥 테스트하게 되면, 실제로 호출하는 외부 API 서비스에 영향을 받게 됩니다. 그래서 이러한 문제를 해소하기 위해, 테스트할 때 실제 호출 대상을 대신할 가상의 서버를 테스트 코드 내에서 생성해서 사용하기로 했습니다.
이렇게 가상의 서버를 생성하는 기능을 지원해주는 라이브러리 중 WireMock 이라는 외부 라이브러리가 있습니다.
외부 라이브러리 라고 한 이유는, wireMock 자체는 java 나 spring 에서 공식지원하는 라이브러리가 아니기 때문입니다. (롬복처럼)
하지만, Spring Cloud 를 테스트하기 위한 Spring Cloud Contract 내에는 wireMock 을 자동으로 의존해주는 spring-cloud-contract-stub-runner 가 있기 때문에, 이번 테스트에서 한 번 사용해보기로 했습니다.
2. Spring Cloud Contract Stub Runner
MSA 에서 마이크로 서비스 간의 요청/응답을 정의하고 테스트하는 도구를 Spring Cloud Contract 라고 부릅니다.
그리고 여기서 정의되는 요청/응답을 Spring Cloud 에선 Contract(계약)이라고 부릅니다.
https://spring.io/projects/spring-cloud-contract
즉, Spring Cloud Contract 를 사용하면 마이크로 서비스를 호출하는 과정에서 특정 요청에 대한 기대되는 응답을 사전에 정의하고, 테스트 과정에서 그 요청을 보냈을 때, 사전에 정의된 응답이 넘어오는지를 테스트하게 됩니다.
이번 테스트에서는 Spring Cloud Contract 에서 제공하는 방법 중 Spring Cloud Contract WireMock 을 사용합니다.
3. 테스트 코드 작성
테스트 작성을 위해 아래와 같은 Service 가 구현되어 있다고 가정해보겠습니다.
@RequiredArgsConstructor
@Transactional
@Service
public class CouponService {
private final CouponRepository couponRepository;
private final UserServiceClient userServiceClient;
public Long create(CouponCreateDto couponCreateDto) {
// 이 부분이 Mocking 될 예정
List<Long> targetUserList = userServiceClient.getUserIdsAboveTheGrade(couponCreateDto.getGrade());
Coupon savedCoupon = couponRepository.save(couponCreateDto.toEntity());
targetUserList.forEach(
savedCoupon::addUserCoupon
);
return savedCoupon.getId();
}
}
해당 코드에서 의존하고 있는 UserServiceClient 는 아래와 같이 FeignClient 로 구현되어 있습니다.
@FeignClient(name = "user-service")
public interface UserServiceClient {
@GetMapping(value = "/users/above-grade")
List<Long> getUserIdsAboveTheGrade(@RequestParam("targetGrade") String targetGrade);
}
3-1. Service 테스트 시, Feign Client 를 Mocking 하기
FeignClient 를 적용한 interface 를 mocking 하는 방법은 아래와 같이 @MockBean 과 Mockito 를 사용합니다.
@Transactional
@SpringBootTest
class CouponServiceTest {
@Autowired
CouponService couponService;
@Autowired
CouponRepository couponRepository;
@MockBean
UserServiceClient userServiceClient;
@Test
@DisplayName("쿠폰 생성 - 특정 회원등급 이상의 회원들에게 쿠폰을 발급한다.")
void create_coupon_for_user() {
// 단골회원(REGULAR) 등급 이상인 회원들에게
// 사용기한이 2023-05-28 ~ 2023-07-28 인
// 할인율 7%의 쿠폰을 발급해준다.
// given
String name = "기념 할인 쿠폰";
LocalDateTime fromDate = LocalDateTime.of(2023, 5, 28, 0, 0, 0);
LocalDateTime toDate = LocalDateTime.of(2023, 7, 28, 23, 59, 59);
int discountRate = 5;
Long partnersId = 1L;
CouponCreateDto couponCreateDto = new CouponCreateDto(
name, fromDate, toDate, "REGULAR", discountRate, partnersId
);
Mockito.when(userServiceClient.getUserIdsAboveTheGrade(Mockito.any())).thenReturn(
Arrays.asList(100L, 200L, 300L)
);
// when
Long couponId = couponService.create(couponCreateDto);
// then
Coupon savedCoupon = couponRepository.findById(couponId).orElseThrow();
assertThat(savedCoupon).isNotNull();
assertThat(savedCoupon.getId()).isEqualTo(couponId);
assertThat(savedCoupon.getUserCoupons()).hasSize(3);
}
}
아래와 같이 Mockito 를 통해 MockBean 으로 처리한 userServiceClient.getUserIdsAboveTheGrade() 메서드의 결과 값을 지정해줄 수 있습니다.
Mockito.when(userServiceClient.getUserIdsAboveTheGrade(Mockito.any())).thenReturn(
Arrays.asList(100L, 200L, 300L)
);
3-2. Feign Client 자체를 Mock Server 로 테스트하기
테스트 환경에서 Spring Cloud Contract WireMock 을 사용하기 위해 spring-cloud-contract-stub-runner 의존성을 추가해줍니다.
dependencies {
// wireMock for feign client test
testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-stub-runner'
}
위 의존성을 추가하면, 아래와 같이 WireMock 라이브러리가 같이 추가됩니다.
의존성 추가 후, WireMockServer 를 사용하기 위해, 아래와 같은 TestConfiguration 을 생성해줍니다.
@TestConfiguration
public class TestWireMockConfig {
@Bean(initMethod = "start", destroyMethod = "stop")
public WireMockServer mockUserMicroService() {
return new WireMockServer(options().port(8881));
}
}
해당 설정은 테스트에서 사용될 WireMockServer 객체를 Bean 으로 선언한 것 입니다.
Bean 생성 시 start 라는 메서드를, 테스트가 끝나고 Bean 이 소멸되면 stop 이라는 메서드를 호출한다는 의미입니다.
참고로, WireMockServer 내에 정의된 start 메서드와 stop 메서드는 아래와 같은 형태로 구현되어 있습니다.
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
@SpringBootTest
@ActiveProfiles("test")
@EnableConfigurationProperties
@ContextConfiguration(classes = {TestWireMockConfig.class}) // 1
public class UserServiceClientWireMockTest {
@Autowired
UserServiceClient service;
@Autowired
ObjectMapper objectMapper;
@Autowired
WireMockServer mockUserMicroService; // 2
@Test
@DisplayName("쿠폰 발급 대상을 위해 특정 회원등급 이상인 회원들을 조회한다.")
void get_users_above_grade() throws JsonProcessingException {
// given
List<Long> userIds = Arrays.asList(1L, 2L, 3L);
String responseBody = objectMapper.writeValueAsString(userIds);
/* == 3 == */
mockUserMicroService
.stubFor(get(urlEqualTo("/users/above-grade?targetGrade=REGULAR"))
.willReturn(aResponse()
.withStatus(HttpStatus.OK.value())
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withBody(responseBody)));
// when
List<Long> result = service.getUserIdsAboveTheGrade("REGULAR");
// then
assertThat(result)
.hasSize(3)
.containsExactly(1L, 2L, 3L);
}
}
1. 앞에서 설정한 TestConfiguration 을 본 테스트에 적용한다.
2. Mock Server 를 구성하기 위해, WireMockServer 를 의존받는다.
3. Mock Server 를 통해 테스트할 요청과 응답을 정의한다.
위 코드에서 중요하게 봐야할 부분은 단연 "3. Mock Server 를 통해 테스트할 요청과 응답을 정의한다." 입니다.
WireMockServer class 내에 stubFor() 메서드를 통해 "어떠한 요청이 발생했을 때, 어떤 결과를 응답하겠다." 라는 내용을 정의할 수 있습니다.
WireMock class 내에 get() 메서드를 통해 get 요청임을 명시하고, urlEqualTo() 메서드를 통해 요청 URL 정보를 입력합니다.
willReturn() 메서드 내에 위에서 생성한 요청이 Mock Server 에 들어온 경우, 어떠한 응답을 내보낼지 명시합니다.
위 코드를 예로 들면, 1) 상태코드는 OK(200), 2) 응답의 MediaType은 JSON, 3) Response Body 부분은 테스트 코드 상단에서 정의한 List<Long> 타입을 serialize 한 결과 를 응답으로 처리한다는 의미입니다.
더 자세한 사용법에 대해서는 아래 공식 문서를 참고하시면 좋을 듯 합니다.
https://docs.spring.io/spring-cloud-contract/reference/index.html
'DDD&MSA' 카테고리의 다른 글
[Spring Cloud Gateway] 여러 도메인에 대해 CORS 설정하기 (0) | 2023.12.24 |
---|---|
[MSA] Feign Client - 마이크로 서비스 간 통신 구현하기 (0) | 2023.11.08 |
[MSA] API Gateway 에 인증 구현하기 - GatewayFilter 추가하기 (0) | 2023.10.08 |
[MSA] API Gateway - 마이크로 서비스 Routing 처리하기(feat. Spring Cloud Gateway) (0) | 2023.09.28 |
[MSA] Micro Service Architecture(MSA) 시작하기 - Eureka Server & Client (0) | 2023.09.10 |
댓글