JUnit5, AssertJ 단위 테스트하기(given, when, then)
테스트와 TDD(Test Driven Development)Test란?테스트란 개발된 코드가 기대한 대로 동작하는지 검증하는 일련의 과정이다.테스트를 통해 버그를 사전에 방지하고, 코드의 신뢰성과 유지보수성을 높일
constant1601.tistory.com
지난번에 JUnit과 AssertJ를 이용해서 단위 테스트 하는 법을 알아봤다.
이번에는 현재 진행 중인 프로젝트(자바 + 스프링 기반)에 단위테스트를 작성해 봤다.
Mockito란?
Mockito는 Java기반의 목(mock) 객체 생성 프레임워크이다.
한마디로 개발자가 직접 제어할 수 있는 가짜 객체를 생성해 줌으로써
테스트를 하기 용이하게 도와주는 프레임워크이다.
Mockito 주요 기능
mock(클래스) : 지정한 클래스의 가짜 객체(Mock 객체) 생성
when(...).thenReturn(...) : 특정 메서드 호출 시 원하는 값을 리턴하게 설정
when(...).thenThrow(...) : 특정 호출에서 예외를 던지게 설정
verify(mock) : 지정한 메서드가 호출되었는지 검증
verify(mock, times(n)) : 메서드가 n번 호출되었는지 검증
verifyNoMoreInteractions(...) : 이후 더 이상 호출이 없어야 함을 검증
@Mock : 필드에 mock 객체 자동 생성(JUnit + MockitoExtension 필요)
@InjectMocks : Mock 객체들을 주입한 실제 테스트 대상 객체 생성
spy(Object) : 실제 객체를 감싼 spy 객체 생성(일부 메서드만 mocking 가능)
mockStatic(Class) : static 메서드를 mock 할 때 사용(Mockito 3.4+ 필요)
...
이 외에도 테스트에 필요한 다양한 기능들을 제공한다.
문서를 통해 전체 기능을 확인할 수 있다.
@Mock, @InjectMocks, @ExtendWith(MockitoExtension.class)
@ExtendWith(MockitoExtension.class)
class AuctionServiceTest {
@Mock
private AuctionRepository auctionRepository;
@Mock
private ProductRepository productRepository;
@Mock
private UserRepository userRepository;
@InjectMocks
private AuctionService auctionService;
@Mock
테스트할 클래스에 주입이 필요한 객체들을 Mock객체로 생성하기 위한
애노테이션이다. @Mock은 단지 이 필드가 Mock 객체라고 표시해 주는 애노테이션일 뿐
실제로 이 필드에 Mockito가 Mock 객체를 주입하려면 초기화 과정이 필요하다.
이 초기화를 자동으로 처리해 주는 것이 @ExtendWith(MockitoExtension.class)이다.
JUnit5에서는 테스트 실행 전/후 어떤 작업을 수행하려면 확장(Extension) 기능을 써야 한다.
Mockito는 MockitoExtension이라는 확장 클래스를 제공하는데, 이걸 등록하면 자동으로
@Mock 이 붙은 필드를 찾아서 mock()으로 객체 생성 후 주입,
@InjectMocks가 붙은 필드에 필요한 Mock들을 주입,
테스트 실행 전후에 필요한 초기화 코드 자동 수행
등의 작업을 Mockito가 알아서 해준다.
@InjectMocks
Mockito에서 테스트 대상 클래스의 인스턴스를 생성하고, 그 안에 있는 필드나 생성자 파라미터로
Mock 객체를 주입해 주는 애노테이션이다.
위의 코드에서 테스트 대상 클래스인 AuctionService에 Mock 객체인 auctionRepository,
productRepository, userRepository를 주입해 준다.
이때 Mockito는 1. 생성자 주입, 2. 필드 주입, 3. Setter 주입 순으로 주입 방식을 결정한다.
따라서 유연하게 테스트 객체를 구성할 수 있다.
@ExtendWith(MockitoExtension.class)
class AuctionServiceTest {
@Mock
private AuctionRepository auctionRepository;
@Mock
private ProductRepository productRepository;
@Mock
private UserRepository userRepository;
@InjectMocks
private AuctionService auctionService;
@Test
@DisplayName("입찰 사용자가 본인이 맞는 경우")
void verifyUserBidOwnership_success() {
// given
Long userId = 1L;
Long auctionId = 100L;
UserEntity user = UserFixture.withId(userId);
AuctionEntity auction = AuctionFixture.withUserId(userId);
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(auctionRepository.findById(auctionId)).thenReturn(Optional.of(auction));
// when & then
assertThatCode(() -> auctionService.verifyUserBidOwnership(userId, auctionId))
.doesNotThrowAnyException();
}
@Test
@DisplayName("입찰 사용자가 본인이 아닌 경우")
void verifyUserBidOwnership_fail() {
// given
Long requestUserId = 1L;
Long realUserId = 2L;
Long auctionId = 100L;
UserEntity user = UserFixture.withId(requestUserId);
AuctionEntity auction = AuctionFixture.withUserId(realUserId);
when(userRepository.findById(requestUserId)).thenReturn(Optional.of(user));
when(auctionRepository.findById(auctionId)).thenReturn(Optional.of(auction));
// when & then
assertThatThrownBy(() -> auctionService.verifyUserBidOwnership(requestUserId, auctionId))
.isInstanceOf(WebException.class)
.satisfies(ex -> {
WebException we = (WebException) ex;
assertThat(we.getBaseErrorCode()).isEqualTo(AuctionException.BID_USER_NOT_AUTHORIZED);
});
}
@Test
@DisplayName("조기 마감 상태인지 확인하는데 조기마감 맞을때")
void isEarlyClosure_true() {
// given
Long productId = 1L;
ProductEntity product = mock(ProductEntity.class);
when(productRepository.findById(productId)).thenReturn(Optional.of(product));
when(product.isEarly()).thenReturn(true);
// when & then
assertThat(auctionService.isEarlyClosure(productId)).isTrue();
}
@Test
@DisplayName("조기 마감 상태인지 확인하는데 조기마감이 아닐때")
void isEarlyClosure_false() {
// given
Long productId = 1L;
ProductEntity product = mock(ProductEntity.class);
when(productRepository.findById(productId)).thenReturn(Optional.of(product));
when(product.isEarly()).thenReturn(false);
// when & then
assertThat(auctionService.isEarlyClosure(productId)).isFalse();
}
@Test
@DisplayName("정상적으로 BidHistoryResponse 생성")
void getAuctionHistoryResponses_success() {
// given
ProductEntity product = ProductFixture.withId(1L);
AuctionEntity auction = AuctionFixture.withProductAndAuctionId(product, 1L);
List<AuctionEntity> auctions = List.of(auction);
MockedStatic<EmdNameReader> emdNameReaderMockedStatic = mockStatic(EmdNameReader.class);
emdNameReaderMockedStatic.when(() -> EmdNameReader.getEmdName(any())).thenReturn("test동");
when(productRepository.findById(any())).thenReturn(Optional.of(product));
// when
List<BidHistoryResponse> responses = auctionService.getAuctionHistoryResponses(auctions);
// then
assertThat(responses).hasSize(1);
assertThat(responses.get(0).auctionId()).isEqualTo(auction.getId());
emdNameReaderMockedStatic.close();
}
@Test
@DisplayName("최고 입찰자 정상 반환")
void findByHighestBidder(){
// given
Long productId = 1L;
AuctionEntity auction = AuctionFixture.withPrice(10000);
when(auctionRepository.findHighestBidder(productId)).thenReturn(Optional.of(auction));
// when
AuctionEntity result = auctionService.findByHighestBidder(productId);
// then
assertThat(result.getPrice()).isEqualTo(auction.getPrice());
}
@Test
@DisplayName("거래 완료시 Auction의 status 변경")
void updateAuctionStatusToAwarded() {
// given
Long productId = 1L;
AuctionEntity auction = AuctionFixture.withPrice(1000);
when(auctionRepository.findHighestBidder(productId)).thenReturn(Optional.of(auction));
// when
auctionService.updateAuctionStatusToAwarded(productId);
// then
assertThat(auction.getStatus()).isEqualTo(AuctionStatus.AWARDED);
}
@Test
@DisplayName("입찰 내역이 존재할 경우 모두 withdraw() 호출")
void withdraw_withAuctions() {
// given
UserEntity user = UserFixture.withId(1L);
AuctionEntity auction1 = mock(AuctionEntity.class);
AuctionEntity auction2 = mock(AuctionEntity.class);
when(auctionRepository.findByUser(user)).thenReturn(List.of(auction1, auction2));
// when
auctionService.withdraw(user);
// then
verify(auction1, times(1)).withdraw();
verify(auction2, times(1)).withdraw();
}
}
given when then 패턴에 맞춰 테스트 코드를 작성하면 전체적인 테스트 코드는 위와 같다.
자세히 보면 어떤 객체는 mock() 메서드를 사용해서 mock객체로 생성하고,
어떤 객체는 Fixture 객체로 생성한 것을 볼 수 있다.
Fixture 객체란?
import java.lang.reflect.Field;
public class UserFixture {
public static UserEntity withId(Long id) {
UserEntity user = UserEntity.create("socialId", Provider.KAKAO, Role.USER);
try {
Field field = UserEntity.class.getDeclaredField("id");
field.setAccessible(true);
field.set(user, id);
} catch (Exception e) {
throw new RuntimeException(e);
}
return user;
}
}
Fixture 객체는 테스트에서 자주 사용되는 고정된 형태의 객체를 미리 만들어두고, 필요한 테스트마다 재사용하는
객체이다. Fixture를 사용하면 반복적인 객체 생성을 줄이고, 테스트 코드의 가독성과 유지보수성을 높일 수 있다.
예를 들어, 위의 Fixture 메서드는 테스트에 필요한 UserEntity를 생성하기 위한 코드이다.
이때 리플렉션을 사용하여 private 필드에 접근해 값을 설정할 수도 있다.
이처럼 단순히 mock()을 이용해 가짜객체를 생성하는 것보다, 실제 값을 가진 객체를 생성하여 테스트
시나리오를 보다 현실적으로 구성하기 위해 Fixture를 사용한다.
또한 mock()을 사용하면 객체의 필드 값들은 기본값(null, 0 등)으로 초기화되는데, 만약 테스트 대상 코드가
특정 필드 값을 사용하는 경우 mock() 객체로는 테스트를 적절히 수행하기 힘들다.
이런 경우에는 Fixture를 사용하여 테스트를 진행하는 것이 더 적절하다.
정적 메서드 Mocking
public class EmdNameReader {
public static String getEmdName(UserEntity user){
String emdName = user.getActivityAreas().get(0).getEmdArea().getEmdName();
return emdName;
}
}
테스트 코드를 짜다가 위의 정적 메서드를 mocking 해야 하는 경우가 발생했다.
일반적인 동적 메서드와는 다르게 정적 메서드의 경우 객체가 아니라 클래스 자체에 종속되어 있어 JVM이 클래스를
로드하면서 메서드까지 함께 고정되기 때문에 쉽게 바꿀 수 없다.
따라서 보통 일반적인 인스턴스 메서드는 객체를 생성한 뒤, 해당 객체의 행동을 mock()으로 바꿀 수 있지만,
정적 메서드는 그렇게 하기 어렵다. 대부분의 mocking 프레임워크에서 기본적으로 정적 메서드는 지원하지 않지만,
Mockito는 3.4 버전 이후 정적 메서드도 mockStatic()을 사용해 모킹 할 수 있게 되었다.
mockStatic()
@Test
@DisplayName("정상적으로 BidHistoryResponse 생성")
void getAuctionHistoryResponses_success() {
// given
ProductEntity product = ProductFixture.withId(1L);
AuctionEntity auction = AuctionFixture.withProductAndAuctionId(product, 1L);
List<AuctionEntity> auctions = List.of(auction);
MockedStatic<EmdNameReader> emdNameReaderMockedStatic = mockStatic(EmdNameReader.class);
emdNameReaderMockedStatic.when(() -> EmdNameReader.getEmdName(any())).thenReturn("test동");
when(productRepository.findById(any())).thenReturn(Optional.of(product));
// when
List<BidHistoryResponse> responses = auctionService.getAuctionHistoryResponses(auctions);
// then
assertThat(responses).hasSize(1);
assertThat(responses.get(0).auctionId()).isEqualTo(auction.getId());
emdNameReaderMockedStatic.close();
}
위와 같이 정적 메서드를 mocking 하여 원하는 응답이 오게 설정하고 테스트를 진행할 수 있다.
단 이때 주의해야 할 것은 테스트가 끝나면 꼭 .close()를 이용하거나, try-with-resources 구문을 활용하여
MockStatic인스턴스를 닫아주어야 한다. 그렇지 않으면 메모리 누수와 같은 이슈가 발생할 수 있다.
정적 메서드 Mocking 괜찮을까?
정적 메서드 모킹에 관해 찾아보다 여러 블로그에서 정적 메서드 모킹이 안티패턴이며,
만약 정적 메서드를 모킹해야 하는 상황이 발생했다면 설계상의 문제가 있는 것은 아닌지 체크하는 게
현재 테스트 코드에 작성된 메서드가 단순 유틸리티성 정적 메서드이고,
이를 동적메서드로 충분히 교체할 수 있었다. 그럼에도 정적 메서드를 mocking 해서 테스트 코드를 작성한
이유는 우선 해당 메서드를 내가 작성한 것이 아니고, 이미 몇 군데에서 참조하고 있었다.
만약 이 프로젝트가 매우 거대한 프로젝트였고 해당 정적 메서드가 매우 많은 곳에서
참조되고 있다면 이를 테스트 코드 작성을 위해 임의로 동적 메서드로 바꾸기는 쉽지 않았을 것이라 판단했다.
그래서 만약 그런 상황에서 어떻게든 테스트 코드만을 작성하려고 하면 어떻게 해야 하는지를
알아보기 위해 정적 메서드를 mocking 하고 테스트를 진행했다. 물론 앞으로는
최대한 이런 상황이 발생하지 않도록 설계단계에서부터 주의하고, 테스트 코드를 작성할 때
만약 정적 메서드가 모킹 되어야 하는 상황이 발생하면 코드가 오염되지는 않았는지
신중하게 판단해봐야 할 것 같다.
참고
https://code-kirin.me/blog/spring/static-method-test/
'Project' 카테고리의 다른 글
WebSocket HTTP Connection 문제 해결하기 (0) | 2025.03.05 |
---|---|
Redis를 이용하여 최근 조회수 기반 인기공연 제공하기 (0) | 2025.02.27 |
Quartz 스케줄러 이용하여 경매 마감 관리하기 (1) | 2025.01.10 |
Embeddis Redis 적용하기(feat. M1) (0) | 2024.11.05 |