최근 테스트를 학습하면서 기존에 진행했던 프로젝트에 테스트 코드를 작성하고 있었는데,
이 과정에서 @BeforeAll과 @Mock을 동시에 사용하며 문제가 발생했고, 이 문제가 왜 발생했는 지, 어떻게 해결했는 지를 중점으로 글을 작성하려고 한다!
@BeforeAll
테스트 코드를 작성하다 보면 테스트 수행 전에 초기 설정이 필요하거나 초기화 해줘야 하는 경우가 있다.
나의 경우, @BeforeAll 메서드 내에서 하나의 Member를 저장하고 이를 사용했는데 이유는 다음과 같다.
1. 저장된 Member를 수정하는 메서드(테스트)가 없기 때문에, 하나의 Member를 저장하고 사용하는 게 효율적이라고 생각
2. Member를 생성할 때, 연관된 엔티티인 TechTag와 RegionTag 모두 설정해주어야 하는데 이는 각 테스트마다 Member 뿐 만 아니라 태그까지 생성해야 하기 때문에 많은 쿼리가 중복됨
3. 로그인 되어 있는 유저를 테스트하기 위한 메서드마다 매번 Member를 생성해야 함
이런 이유로 하나의 Member를 저장하여 이를 사용하여 테스트 했다. (테스트 해야하는 메서드가 복잡하지 않았기 때문에 이렇게 구현했지만, 사실 이렇게 되면 각 테스트가 독립적이지 못하게 된다. 상황에 맞게 적절하게 테스트를 고려해야 함.)
@BeforeEach와 @BeforeAll 중 @BeforeAll을 사용한 이유는 각 테스트마다 Member(Tag 포함)를 생성하고 제거하면 시간이 오래걸렸기 때문이다. (위쪽이 @BeforeAll, 아래쪽이 @BeforeEach이고 약 두 배 차이가 난다)
@Mock
MemberService에는 AWS S3를 통해 이미지를 업로드하는 작업이 포함되어 있어 S3UploadUtil이라는 빈을 주입하여 사용해야 했다. 하지만 테스트에서 외부 API를 직접 사용하는 것보다 Mock을 통해 테스트하는게 더 적절해보였다.
따라서 다음과 같이 설정해주었다.
@SpringBootTest
@Transactional
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class MemberServiceTest {
...
@Mock
public S3UploadUtil s3UploadUtil;
private MemberService memberService;
@BeforeAll
void setUp() {
memberService = new MemberService(memberRepository, passwordEncoder, s3UploadUtil);
Member member = memberRepository.save(MemberFixtures.정우());
setupMemberTags(member);
}
}
@BeforeAll을 사용하기 앞서 @TestInstance(TestInstance.Lifecycle.PER_CLASS) 어노테이션을 적용하여 하나의 테스트 인스턴스를 모든 테스트 메서드에서 재사용하도록 했다.
@BeforeAll 과 @Mock를 동시에 사용했을 때 발생할 수 있는 문제
@Mock이 적용된 S3UploadUtil 객체를 사용했을 때 문제가 발생한다.
아래는 S3UploadUtil을 사용하는 테스트 메서드이다.
@DisplayName("프로필 이미지를 변경할 수 있다")
@WithMockUser(username = MemberFixtures.정우_이메일)
@Test
void changeProfileImage() {
// given
String imageUrl = "mockImageUrl";
MultipartFile imageFile = mock(MultipartFile.class);
given(s3UploadUtil.upload(any(MultipartFile.class))).willReturn(imageUrl);
// when
memberService.changeProfileImage(imageFile);
Member currentMember = memberRepository.findByEmail(MemberFixtures.정우_이메일).get();
// then
Assertions.assertThat(currentMember.getProfile().getProfileImageUrl()).isEqualTo(imageUrl);
}
모든 테스트를 실행시키면 S3UploadUtil을 사용하는 테스트 메서드에서만 에러가 발생한다.
에러가 발생한 이유는 mock 객체의 행동을 정의하는 부분인 아래 코드가 정상적으로 동작하지 않았기 때문이었다.
given(s3UploadUtil.upload(any(MultipartFile.class))).willReturn(imageUrl);
여기서 해맸던 부분은 전체 테스트를 한 번에 돌리지 않고, 문제가 발생하던 changeProfileImage() 만 단독으로 테스트 할 때는 또 정상적으로 동작한다는 것이다.
이것 때문에 문제를 찾는 데 어려움을 겪었다..
먼저 @BeforeAll을 @BeforeAll로 바꾸어본 결과, @BeforeEach일 때 정상적으로 동작했기 때문에 문제는 @BeforeAll 이 확실했다.
객체의 참조 값을 확인해보면서 문제를 찾을 수 있었고,
@BeforeAll을 적용했을 때, @Mock으로 선언된 S3UploadUtil 객체와 @BeforeAll 내에서 초기화된 S3UploadUtil 객체가 다르기 때문에 발생한 문제였다
문제가 발생했던 이유는 다음과 같다.
1. 각 테스트가 실행되기 전 @Mock으로 선언된 S3UploadUtil 필드가 매번 초기화 된다
2. setUp() 메서드는 초기화 된 S3UploadUtil을 주입하는데, @BeforeAll이므로 한 번만 실행된다
3. 처음 실행되는 테스트는 동일한 S3UploadUtil을 가지고 있지만, 이 후 실행되는 모든 테스트는 @Mock으로 선언된 S3UploadUtil 객체를 다시 초기화 된다
4. 따라서 @Mock으로 선언된 S3UploadUtil 객체와 setUp()에 의해 초기화 된 S3UploadUtil 객체가 불일치하게 된다
그렇기 때문에!
given(s3UploadUtil.upload(any(MultipartFile.class))).willReturn(imageUrl;
이 구문에서 사용되는 s3UploadUtil 객체와 실제 테스트 되어지는 부분인 memberService.changeProfileImage(imageFile) 메서드 내의 s3UploadUtil 객체가 동일하지 않으므로 given().willReturn() 이 정상적으로 동작하지 않는 것이다.
changeProfileImage() 메서드만 단독으로 테스트 했을 때 문제가 발생하지 않은 이유는 처음 @Mock으로 초기화 된 객체를 사용했기 때문이다.
해결 방법
@MockBean
해당 테스트는 통합 테스트로 @SpringBootTest를 이용한다. 이 경우 @MockBean을 사용하여 초기화 시키면 해당 객체는 스프링 빈으로 관리되고, 스프링 빈은 싱글톤으로 관리되기 때문에 s3UploadUtil 객체는 모두 동일하게 된다.
@BeforeEach
각 테스트마다 초기화를 통해 @Mock 선언된 빈과 생명주기가 같게 해주면 된다. 대신 성능은 안좋아질 수 있다. 하지만 위의 테스트의 경우 사실 @BeforeEach를 사용해도 됐을 것 같다.(@BeforeEach 사용할걸..)
결론
@BeforeEach와 @BeforeAll 어떤 차이점이 있는지, 어떤 상황에서 사용해야 하는 지 알 수 있었고 각 방식에 따라 장단점을 알 수 있었다.
또한, @Mock 과 @MockBean의 차이점을 정확하게 알지 못해서 언제 @Mock을 써야하는 지, @MockBean을 써야할 지 감이 잘 안잡혔는데 아래와 같이 기준을 정할 수 있게 되었다.
- @Mock은 단위 테스트에 적합하다(스프링 어플리케이션 컨텍스트를 사용하지 않는)
- @MockBean은 통합 테스트에 적합하다(스프링 어플리케이션 컨텍스트를 사용하는 = @SpringBootTest)
- 위의 문제가 발생했던 상황과 같이 테스트 인스턴스의 생명주기가 클래스 단위인 경우, @Mock 객체만 매번 초기화되어 의도대로 동작하지 않을 수 있다. 이 때는 그냥 싱글톤으로 관리되는 @MockBean으로 등록하고 사용하자
테스트를 학습하면서 어노테이션이 어떻게 동작하는 지 반드시 숙지하고 사용해야 된다는 걸 다시 한번 느꼈고,
어노테이션을 사용할 때는 생각 없이 사용하지 말고 어떻게 동작하는 지 반드시 숙지하고 사용해야겠다.