항해 팀 프로젝트 중 세미나 성격으로 발표해야할 일이 생겨서 자세하게 주석으로 설명했기에 따로 설명은 하지 않고 flow대로 포스팅합니다.
WebFlux Controller Test
MemberChannelControllerTest 주석 있는 버전
@WebFluxTest(controllers = MemberChannelController.class) // 1. 어떤 컨트롤러 클래스를 테스트할건지?
@AutoConfigureWebTestClient // 2. @WithMockUser를 사용하기 위해서
class MemberChannelControllerTest {
@Autowired
private WebTestClient webTestClient; // 3. WebFlux 환경에서 가상의 요청을 만들 수 있는 객체
@MockBean
private MemberChannelService memberChannelService; // 4. 테스트하고자하는 Controller가 사용하는 객체들이 가짜 빈으로 필요합니다.
@Test
// 5. WithMockUser 어노테이션이 없으면 security filter chain에 걸려서 항상 401 응답이 나온다.
@WithMockUser(username = "username")
void getChannelsByOnAirTrue() {
/** given
테스트를 위해 주어진 상태
테스트 동작을 위해서 주어지는 환경과 조건을 정의
현재 테스트하고자하는 것은 MemberChannelController의 getChannelsByOnAirTrue() 메서드의 동작 여부 뿐이므로
MemberChannelService가 제대로 동작하지 않더라도, Controller가 정상 동작한다면 테스트의 결과는 통과하여야한다.
따라서 Service 클래스를 실제로 wire 하지 않고, MockBean으로 가짜 객체를 주입받아서 사용한다.
중요! "내가 Controller를 사용함으로써 실행되는 Service 클래스의 함수에 대하여 미리 응답을 정해줘야한다."
*/
// 6. MemberChannelService 클래스에서 가짜로 받을 응답을 getChannelsByOnAirTrue() 메서드의 리턴 형식을 참고하여 작성,
List<MemberChannelResponseDto> memberChannelResponseDtoList = new ArrayList<>();
memberChannelResponseDtoList.add(
// 7. builder 패턴을 적용하여 ResponseDto를 쉽게 생성할 수 있도록 함.
// 8. builder 패턴을 적용하기 위한 MemberChannelResponseDto의 변경사항을 확인하세요.
MemberChannelResponseDto.builder().channelId(1L).title("title").streamerNickname("nickname").build());
memberChannelResponseDtoList.add(
MemberChannelResponseDto.builder().channelId(2L).title("title2").streamerNickname("nickname2").build());
Mono<ResponseEntity<List<MemberChannelResponseDto>>> expectReturn = Mono.just(
ResponseEntity.ok(memberChannelResponseDtoList));
// 9. MemberChannelController의 getChannelsByOnAirTrue() 함수를 호출하는 경우 실제 비즈니스로직에서는
// MemberChannelService.getChannelsByOnAirTrue() 함수가 실행될 것입니다.
// 현재 단위테스트에서는 MemberChannelService의 정상 작동 여부는 Aspect가 아니므로
// 테스트환경에서 MemberChannelService.getChannelsByOnAirTrue()함수가 실행되는 경우의 응답값을 지정해줍니다.
given(memberChannelService.getChannelsByOnAirTrue()).willReturn(expectReturn);
/** when
* 테스트하고싶은 로직을 직접 실행하는 부분입니다. 실행하고싶은 Controller 클래스의 메소드를 실행시키도록 하는
* 요청 uri, 요청할때 필요한 data 등을 전달한 후 기대하는 응답이 맞는지 검사합니다.
*/
// when
List<MemberChannelResponseDto> responseChannelList = webTestClient.get()
.uri("/api/channels") // 10. 실행하고자하는 Controller의 메서드와 매핑된 요청 uri
.exchange()
.expectStatus()
.isOk() // 11. expectStatus().isOk() : Controller test 간에 인증/인가 관련 예외를 테스트하는 경우가 아니라면 보통 200 응답을 기대함
// 12. ResponseBody 안의 데이터는 MemberChannelResponseDto의 리스트 형식을 기대합니다.
// 13. MemberChannelResponseDto에 매개변수가 없는 기본 생성자가 있어야 가능합니다. MemberChannelResponseDto 참고.
.expectBodyList(MemberChannelResponseDto.class)
.returnResult()
.getResponseBody(); // 14. ResponseBody 내부의 데이터를 꺼냅니다. 현재는 responseChannelList 변수에 값을 대입합니다.
/** then
* controller 함수의 반환값이 유효한지 확인합니다.
* 여러 패키지에서 assert~ 의 함수가 정의되어있어 import 할때 조심해야합니다.
* assertNotNull 메서드의 경우 import static org.junit.jupiter.api.Assertions.*;
* assertThat 메서드의 경우 import static org.assertj.core.api.Assertions.* 를 import 해야합니다.
*/
assertNotNull(responseChannelList); // 14. null-check
// 15. 6~7번에서 정의한 내용 대로 응답이 나왔는지 체크합니다.
assertThat(responseChannelList.size()).isEqualTo(2);
assertThat(responseChannelList.get(0).getTitle()).isEqualTo("title");
assertThat(responseChannelList.get(1).getStreamerNickname()).isEqualTo("nickname2");
}
}
6번에서 참고하는 MemberChannelService 클래스의 일부분
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberChannelService {
private final AwsService awsService;
private final MemberChannelRepository memberChannelRepository;
private final MemberRepository memberRepository;
public Mono<ResponseEntity<List<MemberChannelResponseDto>>> getChannelsByOnAirTrue() {
return memberChannelRepository.findAllByOnAirIsTrue()
.switchIfEmpty(Mono.error(new NoOnAirChannelException()))
.flatMap(this::convertToMemberChannelResponseDto)
.collectList()
.map(ResponseEntity::ok);
}
}
controller 테스트 도중 실행될 MemberChannelService.getChannelsByOnAirTrue 메서드의 리턴 형식은 Mono<ResponseEntity<List<MemberChannelResponseDto>> 임을 확인할 수 있습니다.
8번, 13번에서 참고하는 MemberChannelResponseDto 클래스
@Getter
@Builder // 8-1. Builder 어노테이션을 적용하여 귀찮은 빌더함수 생성을 Lombok에게 맡깁니다.
@AllArgsConstructor(access = AccessLevel.PROTECTED) // 8-2. builder 어노테이션을 사용하는 경우 모든 필드를 매개변수로 하는 생성자가 있어야합니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 13-1. test code 리턴 형식 검증에 사용됩니다.
public class MemberChannelResponseDto {
private Long channelId;
private String streamerNickname;
private String title;
private String thumbnailUrl;
public MemberChannelResponseDto(MemberChannel memberChannel, String thumbnailUrl) {
this.channelId = memberChannel.getId();
this.streamerNickname = memberChannel.getMember().getNickname();
this.title = memberChannel.getTitle();
this.thumbnailUrl = thumbnailUrl;
}
}
사실 @Builder 어노테이션을 사용함으로써 AllArgsConstructor를 사용한 것과 같은 효과를 원래는 볼 수 있지만, 이미 다른 생성자가 정의되어있는 경우에는 AllArgsConstructor를 추가해야합니다.
이해가 되셨다면 주석 없는 버전으로 확인해보세요
MemberChannelControllerTest 주석 없는 버전
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.*;
@WebFluxTest(controllers = MemberChannelController.class)
@AutoConfigureWebTestClient
class MemberChannelControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private MemberChannelService memberChannelService;
@Test
@WithMockUser(username = "username")
void getChannelsByOnAirTrue() {
List<MemberChannelResponseDto> memberChannelResponseDtoList = new ArrayList<>();
memberChannelResponseDtoList.add(
MemberChannelResponseDto.builder().channelId(1L).title("title").streamerNickname("nickname").build());
memberChannelResponseDtoList.add(
MemberChannelResponseDto.builder().channelId(2L).title("title2").streamerNickname("nickname2").build());
Mono<ResponseEntity<List<MemberChannelResponseDto>>> expectReturn = Mono.just(
ResponseEntity.ok(memberChannelResponseDtoList));
given(memberChannelService.getChannelsByOnAirTrue()).willReturn(expectReturn);
List<MemberChannelResponseDto> responseChannelList = webTestClient.get()
.uri("/api/channels")
.exchange()
.expectStatus()
.isOk()
.expectBodyList(MemberChannelResponseDto.class)
.returnResult()
.getResponseBody();
assertNotNull(responseChannelList);
assertThat(responseChannelList.size()).isEqualTo(2);
assertThat(responseChannelList.get(0).getTitle()).isEqualTo("title");
assertThat(responseChannelList.get(1).getStreamerNickname()).isEqualTo("nickname2");
}
}
Service 클래스 테스트
MemberChannelServiceTest-성공테스트 주석 있는 버전
package com.hanghae.lemonairservice.service;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.*;
@ExtendWith(MockitoExtension.class) // 1. Mockito 관련 초기화, MockBean의 초기화 등등을 자동 초기화 해줍니다.
class MemberChannelServiceTest {
// 2. 테스트하고자하는 객체입니다. 현재 우리의 케이스에서는 memberChannelService 객체가 의존하는 객체는
// AwsService, MemberChannelRepository, MemberRepository의 3개입니다.
// @InjectMocks 어노테이션이 붙은 이유는 의존하는 실제 객체 대신에 @Mock 어노테이션이 붙은 객체들을 Mock 객체로 주입받기 위함입니다.
@InjectMocks
private MemberChannelService memberChannelService;
// 3. @Mock 어노테이션이 붙은 객체들은 테스트하고자하는 Service 객체가 사용(의존)하는 객체들이며,
// 이들의 정상 동작 여부는 테스트 결과에 영향을 끼치지 않도록 테스트 코드를 작성합니다.
@Mock
private AwsService awsService;
@Mock
private MemberChannelRepository memberChannelRepository;
@Mock
private MemberRepository memberRepository;
/**
* getChannelsByOnAirTrue 성공 시나리오를 테스트합니다.
*/
@Test
void getChannelsByOnAirTrueSuccessTest() {
// given
// 4. MemberChannelService 클래스를 참고하여 수행되는 로직이 어떤 흐름인지 먼저 파악해야합니다.
// 저희 비즈니 로직의 순서는 다음 같습니다.
/**
* 1. MemberChannelService.getChannelsByOnAirTrue() 함수가 실행된다.
* 2. MemberChannelRepository.findAllByOnAirIsTrue() 함수가 실행된다.
* 3. MemberChannelService.convertToMemberChannelResponseDto() 함수가 실행된다.
* 4. AwsService.getThumbnailCloudFrontUrl() 함수가 실행된다.
*/
// 5. 따라서 6~7에서 실제 테스트하고자하는 getChannelsByOnAirTrue() 메서드의 정상 동작 여부를 위해 아래의 데이터들을 생성합니다.
// 6. MemberChannel : MemberChannelRepository.findAllByOnAirIsTrue() 의 리턴값 지정을 위해 필요합니다.
MemberChannel memberChannel1 = MemberChannel.builder().id(11L).memberId(1L).onAir(true).title("title1").build();
MemberChannel memberChannel2 = MemberChannel.builder().id(12L).memberId(2L).onAir(true).title("title2").build();
// 7. Member : memberRepository.findById({멤버id}) 의 리턴값 지정을 위해 필요합니다.
Member member1 = Member.builder().id(1L).email("email1").nickname("nickname1").loginId("loginId1").build();
Member member2 = Member.builder().id(2L).email("email2").nickname("nickname2").loginId("loginId2").build();
Flux<MemberChannel> memberChannelFlux = Flux.just(memberChannel1, memberChannel2);
// 8. 서비스 로직에서 실행될 목객체들의 메서드의 응답값을 지정해줍니다.
given(memberChannelRepository.findAllByOnAirIsTrue()).willReturn(memberChannelFlux);
given(memberRepository.findById(memberChannel1.getMemberId())).willReturn(Mono.just(member1));
given(memberRepository.findById(memberChannel2.getMemberId())).willReturn(Mono.just(member2));
given(awsService.getThumbnailCloudFrontUrl(member1.getLoginId())).willReturn("mytesturl1");
given(awsService.getThumbnailCloudFrontUrl(member2.getLoginId())).willReturn("mytesturl2");
// 9. Mono or Flux를 구독하며 테스트하기위해서 StepVerifier.create()가 사용됩니다.
// expectNext, expectNextMatches, verifyComplete 등의 메서드를 통해서 생성된 Mono, Flux의 데이터를 검증합니다.
// 현재는 Mono<ResponseEntity<List<MemberChannelResponseDto>>> 를 리턴하여 Mono 안에 List가 있는 특수한 형태이므로
// 따라서 expectNextMatches 실행시 Mono 안의 ResponseEntity<List<MemberChannelResponseDto>> 를 다룰 수 있게 됩니다.
StepVerifier.create(memberChannelService.getChannelsByOnAirTrue()).expectNextMatches(listResponseEntity -> {
List<MemberChannelResponseDto> body = listResponseEntity.getBody();
// 10. expectNextMatches 안에서는 junit, jupiter 패키지의 assert~ 메서드들을 이용하여 검증한 후 모든 assert문들을 통과하면 true를 return합니다.
assertNotNull(body);
assertThat(body.size()).isEqualTo(2);
assertThat(body.get(0).getTitle()).isEqualTo("title1");
assertThat(body.get(1).getThumbnailUrl()).isEqualTo("mytesturl2");
assertThat(body.get(1).getStreamerNickname()).isEqualTo("nickname2");
return true;
}).verifyComplete(); // 11. Mono or Flux로부터 모든 데이터 생성이 끝났는지 검증합니다.
// 12. 실행되었을 것으로 기대되는 각각의 Mock 객체들의 메서드를 정확히 그 매개변수까지 명시하며 실행되었는지 확인합니다.
verify(memberChannelRepository).findAllByOnAirIsTrue();
verify(memberRepository).findById(memberChannel1.getMemberId());
verify(memberRepository).findById(memberChannel2.getMemberId());
verify(awsService).getThumbnailCloudFrontUrl(member1.getLoginId());
verify(awsService).getThumbnailCloudFrontUrl(member2.getLoginId());
}
}
비즈니스 로직의 순서를 언급한 부분이 있었는데, 비즈니스로직을 따라가보면 순서는 아래와 같습니다.
1. MemberChannelService.getChannelsByOnAirTrue() - (테스트하고자 하는 메서드)
public Mono<ResponseEntity<List<MemberChannelResponseDto>>> getChannelsByOnAirTrue() {
return memberChannelRepository.findAllByOnAirIsTrue()
.switchIfEmpty(Mono.error(new NoOnAirChannelException()))
.flatMap(this::convertToMemberChannelResponseDto)
.collectList()
.map(ResponseEntity::ok);
}
2. MemberChannelRepository.findAllByOnAirIsTrue() 함수가 실행된다.
public interface MemberChannelRepository extends ReactiveCrudRepository<MemberChannel, Long> {
Flux<MemberChannel> findAllByOnAirIsTrue();
}
3. MemberChannelService.convertToMemberChannelResponseDto() 함수가 실행된다.
private Mono<MemberChannelResponseDto> convertToMemberChannelResponseDto(MemberChannel memberChannel) {
return memberRepository.findById(memberChannel.getMemberId())
.doOnNext(memberChannel::setMember)
.map(member -> new MemberChannelResponseDto(memberChannel,
awsService.getThumbnailCloudFrontUrl(member.getLoginId())));
}
4. AwsService.getThumbnailCloudFrontUrl() 함수가 실행된다.
public String getThumbnailCloudFrontUrl(String streamerLoginId) {
String uri = "/" + streamerLoginId + "/thumbnail/" + streamerLoginId + "_thumbnail.jpg";
return cloudFrontDomain + uri;
}
각 메서드의 리턴값에 따라 필요한 데이터를 생성했습니다.
MemberChannelServiceTest-성공테스트 주석 없는 버전
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.BDDMockito.*;
@ExtendWith(MockitoExtension.class) // 1. Mockito 관련 초기화, MockBean의 초기화 등등을 자동 초기화 해줍니다.
class MemberChannelServiceTest {
@InjectMocks
private MemberChannelService memberChannelService;
@Mock
private AwsService awsService;
@Mock
private MemberChannelRepository memberChannelRepository;
@Mock
private MemberRepository memberRepository;
@Test
void getChannelsByOnAirTrueSuccessTest() {
// given
MemberChannel memberChannel1 = MemberChannel.builder().id(11L).memberId(1L).onAir(true).title("title1").build();
MemberChannel memberChannel2 = MemberChannel.builder().id(12L).memberId(2L).onAir(true).title("title2").build();
Member member1 = Member.builder().id(1L).email("email1").nickname("nickname1").loginId("loginId1").build();
Member member2 = Member.builder().id(2L).email("email2").nickname("nickname2").loginId("loginId2").build();
Flux<MemberChannel> memberChannelFlux = Flux.just(memberChannel1, memberChannel2);
given(memberChannelRepository.findAllByOnAirIsTrue()).willReturn(memberChannelFlux);
given(memberRepository.findById(memberChannel1.getMemberId())).willReturn(Mono.just(member1));
given(memberRepository.findById(memberChannel2.getMemberId())).willReturn(Mono.just(member2));
given(awsService.getThumbnailCloudFrontUrl(member1.getLoginId())).willReturn("mytesturl1");
given(awsService.getThumbnailCloudFrontUrl(member2.getLoginId())).willReturn("mytesturl2");
// when
StepVerifier.create(memberChannelService.getChannelsByOnAirTrue()).expectNextMatches(listResponseEntity -> {
List<MemberChannelResponseDto> body = listResponseEntity.getBody();
assertNotNull(body);
assertThat(body.size()).isEqualTo(2);
assertThat(body.get(0).getTitle()).isEqualTo("title1");
assertThat(body.get(1).getThumbnailUrl()).isEqualTo("mytesturl2");
assertThat(body.get(1).getStreamerNickname()).isEqualTo("nickname2");
return true;
}).verifyComplete();
// then
verify(memberChannelRepository).findAllByOnAirIsTrue();
verify(memberRepository).findById(memberChannel1.getMemberId());
verify(memberRepository).findById(memberChannel2.getMemberId());
verify(awsService).getThumbnailCloudFrontUrl(member1.getLoginId());
verify(awsService).getThumbnailCloudFrontUrl(member2.getLoginId());
}
}
MemberChannelServiceTest-실패테스트 주석 있는 버전
/**
* 실패 테스트
* getChannelsByOnAir 메서드를 실행했을 때 발생 가능한 예외들또한 테스트합니다.
*
* getChannelsByOnAirTrue() 메서드를 실행했을 때 진행중인 방송이 없는 경우의 시나리오를 테스트합니다.
* 예외를 일부러 발생시키는 상황이다보니 필요한 조건들이 있습니다.
* 1. 서비스 로직 상 엣지 케이스에 대해서 적절한 예외를 throw해야한다.
* 2. 1번 조건을 만족하기위해 기존 코드에서 어떤 변경사항이 있었는지 확인하려면 MemberChannelService.getChannelsByOnAirTrue() 메서드를 참고하세요
* 3. 예외를 handle하는 exception handler가 필요합니다. GlobalExceptionHandler 클래스를 참고하세요
*/
@Test
void getChannelsByOnAirTrueThrows() {
// given
// 4. 검증하는 상황은 생방송중인 채널이 없어서 NoOnAirChannelException이 발생하는 상황입니다.
// 그러므로 MemberChannelService.getChannelsByOnAirTrue() 메소드 내에서 실행되는 로직은
// memberChannelRepository.findAllByOnAirIsTrue() 하나뿐입니다. 비어있는 Flux를 리턴하도록 지정합니다.
given(memberChannelRepository.findAllByOnAirIsTrue()).willReturn(Flux.empty());
// when then
// 5. StepVerifier로 검증하지만 expectNext 등의 메서드는 사용이 불가합니다.
// Mono or Flux에서 생성하는 도중 Mono.error(new NoOnAirChannelException())라는 publisher로 switch될것이므로
// StepVerifier.create로 결국 Mono.error를 생성하는 꼴이 되며, Mono.error가 생성되기를 기대하는 경우 verifyError를 활용합니다.
StepVerifier.create(memberChannelService.getChannelsByOnAirTrue())
.verifyError(NoOnAirChannelException.class);
}
2번에서 MemberChannelService.getChannelsByOnAirTrue() 의 변경사항은 다음과 같습니다.
@Service
@RequiredArgsConstructor
public class MemberChannelService {
private final AwsService awsService;
private final MemberChannelRepository memberChannelRepository;
private final MemberRepository memberRepository;
public Mono<ResponseEntity<List<MemberChannelResponseDto>>> getChannelsByOnAirTrue() {
return memberChannelRepository.findAllByOnAirIsTrue()
// 2-1. 기존 코드 : 에러 발생 상황 인식에 대해서 에러메세지에 의존하며, Exception handling이 까다롭습니다.
// .switchIfEmpty(Mono.error(new NotFoundException("현재 진행중인 방송이 없습니다.")))
// 2-2. 개선된 코드 : 개발자가 예외 발생 상황만 보고도 어떤 오류발생상황인지 알도록 Custom Exception Class를 Naming합니다.
.switchIfEmpty(Mono.error(new NoOnAirChannelException()))
// 3. Custom Exception Class의 구조를 확인하세요
.flatMap(this::convertToMemberChannelResponseDto)
.collectList()
.map(ResponseEntity::ok);
}
}
3번에서 말하는 Custom Exception Class는 다음과 같이 정의되어있습니다.
public class NoOnAirChannelException extends RuntimeException {
// 3-1. 개발자가 예외 발생시 확인할 수 있는 에러 메세지를 정의합니다.
// 실제로는 개발자만을 위한 에러메세지이지만, 저희 프로젝트에서는 개발 프로세스의 단순화를 위해서 최종 client에게도 아래의 메세지가 보입니다.
// 4. 이 Custom Exception Class가 어떻게 client에게 보일 수 있는지 확인하려면 exception 패키지의 GlobalExceptionHandler를 참고하세요
public static String errorMsg = "현재 진행중인 생방송이 없습니다.";
// 3-2. throw new NoOnAirChannelException() 구문으로 예외를 throw할 수 있도록 기본 생성자를 정의합니다.
public NoOnAirChannelException() {
super(errorMsg);
}
}
4번에서 말하는 GlobalExceptionHandler는 다음과 같이 정의되어 있습니다.
@ControllerAdvice // 4-1. 비즈니스로직에서 발생한 예외에 대해서 client가 적절한 응답을 받아야 하는경우 사용합니다.
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(NoOnAirChannelException.class) // 4-2. 어떤 예외 클래스가 발생했을 때 아래 메소드가 실행될 것인지, 예외 클래스를 지정합니다.
// @ResponseStatus(HttpStatus.NOT_FOUND) // 4-5. 4-4에서 HttpStatus를 정의하여 반환하고 있으므로 현재는 필요가 없습니다.
// 만약 아래 return 문을 제거하고 ResponseStatus 어노테이션 만을 사용한다면 에러메세지 없이 404 상태코드만 응답합니다.
protected ResponseEntity<String> handleNoOnAirChannelException() {
log.error("handle : " + NoOnAirChannelException.errorMsg); // 4-3. 서버단에서도 예외 발생 사실을 알 수 있도록 로깅합니다.
return new ResponseEntity<>(NoOnAirChannelException.errorMsg,
HttpStatus.NOT_FOUND); // 4-4. 최종적으로 client에게 전달될 ResponseEntity를 정의합니다.
}
}
MemberChannelServiceTest-실패테스트 주석 없는 버전
@Test
void getChannelsByOnAirTrueThrows() {
// given
given(memberChannelRepository.findAllByOnAirIsTrue()).willReturn(Flux.empty());
// when then
StepVerifier.create(memberChannelService.getChannelsByOnAirTrue())
.verifyError(NoOnAirChannelException.class);
}