단위테스트에서 @InjectMocks, @Mock, @Spy에 대한 것을 알아봤다.
📌 서론
요즘 개발하면서 단위 테스트를 많이 작성하는데 갑작스레 궁금증이 생겼다.
단위 테스트에서는 주로 @SpringBootTest가 아니라 @InjectMocks를 사용하는데 이 애노테이션의 이름만 보면 "Inject + Mocks = 목 객체들을 주입한다." 이렇게 해석되는데 이것도 객체에 Mock을 주입하는 거니까 스프링의 의존성 주입과는 뭐가 다를지 알아보고 싶었다.
이렇게 시작된 궁금증으로 직접 단위 테스트를 작성해 보면서 주로 보게 되는 @InjectMocks, @Mock, @Spy 이것들의 관계성을 알아봤다.
또한 테스트 과정에서 인터페이스는 @Mock을 사용해야 했는데 왜 @Spy는 사용할 수 없는지 이것에 대해서도 실험해 보고 그 내용을 작성했다.
내용에는 잘못된 부분이 있을 수도 있지만 이 글이 단위 테스트를 작성하는데 도움이 되었으면 좋겠다.
1. 테스트에 사용된 코드 설명
테스트를 위한 서비스 클래스
- 헥사고날 아키텍처로 작성된 서비스 로직이다.
- curd에 대한 useCase를 모두 구현했지만 이 글을 작성하기 위해 create에 대한 메서드만 남겨두고 모두 제거했다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class MemberService implements CreateMemberUseCase, FindMemberUseCase, UpdateMemberUseCase, DeleteMemberUseCase {
private final CreateMemberPort createMemberPort;
private final FindMemberPort findMemberPort;
private final DeleteMemberPort deleteMemberPort;
private final MemberMapper memberMapper;
/**
* @param member 회원
* @return 생성된 회원
* @apiNote 회원 생성
*/
@Override
public MemberResponseDTO createMember(Member member) {
member.checkCreateRequiredValue();
Member savedMember = createMemberPort.createMember(member);
return memberMapper.domainToResponseDTO(savedMember);
}
}
도메인 내부 비즈니스 로직 (데이터 검증 작업)
- 멤버 도메인 내부에는 회원 가입 시 필수값을 체크하는 비즈니스 로직을 작성해 뒀다.
- 비즈니스 설명: 도메인 모델 내부에서 필수 데이터를 검증하고, 규칙을 어기는 경우 예외를 발생시킨다.
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Member {
private Long id; // PK
private String email; // 이메일
private String password; // 소셜 로그인 사용자는 NULL일 수 있음
private String name; // 이름
// ... 멤버 변수 생략
/**
* @apiNote 회원 가입시 필수 값 체크
*/
public void checkCreateRequiredValue() {
if (ObjectUtils.isEmpty(this.email)) {
throw new MemberException(ErrorCode.MEMBER_CREATE_EMAIL_NOT_FOUND);
}
if (ObjectUtils.isEmpty(this.password)) {
throw new MemberException(ErrorCode.MEMBER_CREATE_PASSWORD_NOT_FOUND);
}
if (ObjectUtils.isEmpty(this.name)) {
throw new MemberException(ErrorCode.MEMBER_CREATE_NAME_NOT_FOUND);
}
}
}
테스트 코드는 다음과 같다.
- 하단의 테스트는 회원 생성에 메서드에 대한 단위 테스트를 작성한 코드다.
- happy (성공) 케이스와 bad (예외 상황)에 대한 케이스로 분리하여 작성하였다.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private FindMemberPort findMemberPort;
@Mock private DeleteMemberPort deleteMemberPort;
@Mock private MemberMapper memberMapper;
@InjectMocks
private MemberService sut;
@DisplayName("[happy] 모든 데이터가 제공되면 회원이 성공적으로 생성된다.")
@Test
void createMember() {
//given
Member member = Member.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
MemberResponseDTO responseDTO = MemberResponseDTO.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
given(createMemberPort.createMember(any(Member.class))).willReturn(member);
given(memberMapper.domainToResponseDTO(any(Member.class))).willReturn(responseDTO);
// when
MemberResponseDTO result = sut.createMember(member);
// then
Assertions.assertThat(result).isNotNull();
Assertions.assertThat(result.getId()).isEqualTo(1L);
}
@DisplayName("[bad] 이메일 데이터가 누락되면 회원 생성에 실패한다.")
@Test
void createMemberException1() {
//given
Member member = Member.builder().id(1L).name("test").password("1234").build();
// when & then
Assertions.assertThatThrownBy(() -> sut.createMember(member))
.isInstanceOf(MemberException.class)
.hasMessageContaining(ErrorCode.MEMBER_CREATE_EMAIL_NOT_FOUND.getMessage());
}
@DisplayName("[bad] 이름 데이터가 누락되면 회원 생성에 실패한다.")
@Test
void createMemberException2() {
//given
Member member = Member.builder().id(1L).email("test@test.com").password("1234").build();
// when & then
Assertions.assertThatThrownBy(() -> sut.createMember(member))
.isInstanceOf(MemberException.class)
.hasMessageContaining(ErrorCode.MEMBER_CREATE_NAME_NOT_FOUND.getMessage());
}
@DisplayName("[bad] 비밀번호 데이터가 누락되면 회원 생성에 실패한다.")
@Test
void createMemberException3() {
//given
Member member = Member.builder().id(1L).name("test").email("test@test.com").build();
// when & then
Assertions.assertThatThrownBy(() -> sut.createMember(member))
.isInstanceOf(MemberException.class)
.hasMessageContaining(ErrorCode.MEMBER_CREATE_PASSWORD_NOT_FOUND.getMessage());
}
}
2. 테스트 코드 분석
happy 케이스 테스트 코드를 분석해 보자
- 회원가입에 필요한 모든 데이터가 전달되고 이 테스트는 문제없이 성공하게 된다.
- port, mapper의 동작은 BDDMockito를 사용하여 given().willReturn()으로 동작을 정의해 줬다. 지금부터 자세히 알아보자.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private FindMemberPort findMemberPort;
@Mock private DeleteMemberPort deleteMemberPort;
@Mock private MemberMapper memberMapper;
@InjectMocks
private MemberService sut;
@DisplayName("[happy] 모든 데이터가 제공되면 회원이 성공적으로 생성된다.")
@Test
void createMember() {
//given
Member member = Member.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
MemberResponseDTO responseDTO = MemberResponseDTO.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
given(createMemberPort.createMember(any(Member.class))).willReturn(member);
given(memberMapper.domainToResponseDTO(any(Member.class))).willReturn(responseDTO);
// when
MemberResponseDTO result = sut.createMember(member);
// then
Assertions.assertThat(result).isNotNull();
Assertions.assertThat(result.getId()).isEqualTo(1L);
}
}
테스트 클래스 최상단을 확인해 보자
- 코드를 보면 클래스 최상단에 @ExtendWith(MockitoExtension.class)를 적어준다.
- @ExtendWith(MockitoExtension.class)는 JUnit 5에서 Mockito를 확장하여 사용할 수 있도록 해주는 애노테이션이다. 이 애노테이션을 통해, Mockito의 @Mock, @InjectMocks, @Spy와 같은 기능들이 JUnit 테스트에서 자동으로 동작하게 된다.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
}
테스트 클래스 내부에 선언된 멤버 변수를 확인해 보자
- 서비스 클래스의 필드에 선언된 클래스들은 테스트 클래스 내부에도 그대로 작성되어 있다.
- 다른 점은 테스트 클래스 필드에 선언된 클래스들은 @Mock 애노테이션을 사용한다.
- 제일 중요한 테스트 메서드를 가진 클래스는 @InjectMocks 애노테이션을 사용하고 있다.
- 참고로 sut = system under test다.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private FindMemberPort findMemberPort;
@Mock private DeleteMemberPort deleteMemberPort;
@Mock private MemberMapper memberMapper;
@InjectMocks
private MemberService sut;
}
3. @InjectMocks, @Mock이 뭔데?
@InjectMocks
- @InjectMocks은 Mockito가 자동으로 모킹 된 객체(@Mock으로 선언된 객체)를 주입해 주는 애노테이션이다. 이로 인해 테스트하려는 객체(예: 서비스 클래스)에서 필요로 하는 의존성 필드들을 자동으로 주입해 준다. 이 애노테이션을 사용하면, 테스트하려는 객체의 모든 의존성이 자동으로 주입되므로 수동으로 의존성을 설정할 필요가 없다.
@Mock
- @Mock은 Mockito가 모킹 한 객체를 생성하기 위해 사용되는 애노테이션이다. 모킹 된 객체는 실제 객체의 동작을 시뮬레이션할 수 있으며, 이를 통해 테스트 시 특정 객체의 메서드를 원하는 방식으로 동작하도록 제어할 수 있다. 이로 인해 외부 의존성(데이터베이스, 네트워크, 파일 시스템 등)에 대한 접근 없이 테스트를 수행할 수 있다.
- @Mock에 대한 상세한 설명은 하단의 4번 목차에서 확인할 수 있다.
테스트 코드를 다시 보자
- 클래스 최상단에 @ExtendWith를 작성해서 @Mock, @InjectMocks, @Spy와 같은 기능들이 동작한다.
- 하단의 @InjectMocks를 적어준 클래스는 실제 테스트를 하려는 MemberService 클래스이고 이 클래스는 @InjectMocks을 통해 @Mock, @Spy로 선언된 필드를 자동으로 주입받는다.
- 나머지 @Mock을 사용하는 클래스들은 MemberService 클래스에서 필요로 하는 의존성 클래스들이다. 이 @Mock을 적어줘야만 @InjectMocks에서 이 클래스들을 의존성 주입 해준다.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private FindMemberPort findMemberPort;
@Mock private DeleteMemberPort deleteMemberPort;
@Mock private MemberMapper memberMapper;
@InjectMocks
private MemberService sut;
}
궁금증이 생겼다. 만약 테스트 하려는 실제 클래스가 필요로 하지 않는 의존성 객체들을 @Mock으로 선언한다면 어떻게 될까?
- @Mock을 사용해서 MemberService에서는 주입받지 않는 클래스 2개를 선언해 봤다. 이후 테스트 코드를 실행해 봤더니 문제없이 성공한다.
- 알아봤더니 @InjectMocks가 주입을 시도하는 객체(@Mock 객체)가 주입될 클래스(테스트할 메서드를 가진 클래스)의 필드에 없으면, 그 @Mock 객체는 그냥 무시된다. 따라서, 필요하지 않은 의존성을 @Mock으로 선언했다고 해서 테스트가 실패하지는 않는다.
- 그래도 테스트 코드의 가독성을 위해서 정말 테스트에 필요한 의존성 객체만 @Mock으로 선언하도록 하자.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private FindMemberPort findMemberPort;
@Mock private DeleteMemberPort deleteMemberPort;
@Mock private MemberMapper memberMapper;
// 이 2가지 객체는 실제 서비스에서는 선언되어 있지 않다.
@Mock private AuthService authService;
@Mock private RefreshTokenMapper refreshTokenMapper;
@InjectMocks private MemberService sut;
}
4. @InjectMocks에 @Spy를 쓰면 뭐가 다른데?
@Mock과 @Spy의 차이점
- @InjectMocks는 Mockito에서 주입할 객체를 자동으로 생성하고, 주입할 클래스의 필드에 @Mock 또는 @Spy로 선언된 객체들을 주입한다. 그러나 @Mock과 @Spy의 차이는 객체의 동작 방식에 있다. 이를 이해하면 @InjectMocks와 함께 @Spy를 사용할 때 어떤 차이가 발생하는지 알 수 있다.
@Mock
- 인터페이스에 적용 가능.
- 객체의 모든 메서드를 모킹(기본적으로 아무 동작도 하지 않음).
- 명시적으로 동작을 설정해야만 동작함.
@Spy
- 구체적인 구현체에만 적용 가능.
- 객체의 실제 메서드가 호출되며, 필요시 특정 메서드만 모킹 가능.
- 인터페이스에는 적용할 수 없음.
@Spy를 테스트 코드에 사용해 보자
- 기존 테스트 코드에서 @Mock으로 사용하던 memberMapper를 @Spy로 변경했다.
- 여기서 자세히 보면 또 다른 점이 있는데 @Spy를 사용하게 되었기에 memberMapper 인터페이스를 MemberMapperImpl 구현체로 변경했다는 점이다. (인터페이스를 사용하면 오류가 발생하니 주의하자!)
- 기존 테스트에서는 given().willReturn()으로 memberMapper 인터페이스의 메서드 동작을 정의했지만 이제는 실제 메서드가 알아서 처리해 줄 것이기에 given() 코드를 제거했다. (spy는 필요시 실제 동작 대신 stub을 해줘도 된다.)
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private FindMemberPort findMemberPort;
@Mock private DeleteMemberPort deleteMemberPort;
// spy로 작성
@Spy private MemberMapperImpl memberMapper;
@InjectMocks private MemberService sut;
@DisplayName("[happy] 모든 데이터가 제공되면 회원이 성공적으로 생성된다.")
@Test
void createMember() {
//given
Member member = Member.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
given(createMemberPort.createMember(any(Member.class))).willReturn(member);
// when
MemberResponseDTO result = sut.createMember(member);
// then
Assertions.assertThat(result).isNotNull();
Assertions.assertThat(result.getId()).isEqualTo(1L);
}
}
5. 인터페이스에는 @Spy가 아닌 @Mock을 적용하는 이유
메서드 시그니처 기반
- 인터페이스는 메서드 시그니처(메서드의 이름, 반환 타입, 파라미터 목록 등)를 정의한다. 따라서, 인터페이스에 @Mock을 적용하면, Mockito는 이 인터페이스의 모든 메서드에 대해 기본 동작을 설정할 수 있다.
프록시 객체 생성
- Mockito는 인터페이스에 대한 프록시 객체를 생성한다. 이 프록시 객체는 인터페이스에 정의된 메서드를 호출할 수 있지만, 실제로는 구현이 없기 때문에, 기본적으로 아무 동작도 하지 않고, 반환 타입에 따른 기본값을 반환한다.
구현체와의 차이
- 구현체에 @Mock을 사용하면, 해당 클래스의 실제 메서드 구현이 무시되고, 모킹 된 결과를 반환하게 된다. 하지만 인터페이스는 애초에 구현체가 없기 때문에, 메서드 시그니처에 따라 기본값을 반환한다.
결과 값의 기본값
- 만약 인터페이스에 @Mock을 적용하면, 다음과 같은 기본값을 반환한다.
- boolean: false
- int: 0
- Object: null
- Collection: 빈 컬렉션 등
@Spy는 실제 동작을 기반으로 한다
- @Spy는 특정 메서드를 모킹 하기 전에는 기본적으로 실제 객체의 메서드가 호출되도록 한다. 인터페이스는 메서드의 구현이 없으므로, @Spy가 수행할 실제 동작이 존재하지 않게 된다. 그래서 @Spy는 인터페이스가 아닌, 실제 구현체가 있는 클래스에만 사용할 수 있다.
최종 정리
- 인터페이스에 @Mock을 적용하면, 그 인터페이스의 모든 메서드를 호출할 수 있지만, 기본적으로는 아무런 동작도 수행하지 않으며, 반환 타입에 맞는 기본값을 반환한다. 이는 인터페이스의 메서드 시그니처가 존재하기 때문에 가능하며, 이 메서드 시그니처를 기반으로 Mockito가 프록시 객체를 생성하기 때문이다.
- 반면 @Spy는 실제 객체를 사용하고, 그 객체의 메서드를 호출할 때 실제로 동작하도록 하면서, 특정 메서드만 모킹 할 수 있도록 해준다. 하지만 인터페이스는 단순히 메서드의 시그니처만 정의할 뿐, 실제 구현체가 없기 때문에, @Spy로 감싸려면 구체적인 클래스(즉, 실제 객체)가 필요하다. 따라서 @Spy는 구체적인 동작을 포함하는 실제 객체가 있어야 하기 때문에 인터페이스에는 사용할 수 없고, 대신 클래스에 적용하여 부분 모킹을 사용할 수 있다.
6. @InjectMocks는 실제 테스트할 객체가 필요로 하는 모든 의존성을 주입받아야 하나?
테스트하고자 하는 실제 서비스 코드를 다시 확인해 보자
- 클래스를 살펴보면 4개의 외부 클래스를 의존성 주입으로 받고 있는 것을 알 수 있다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class MemberService implements CreateMemberUseCase, FindMemberUseCase, UpdateMemberUseCase, DeleteMemberUseCase {
private final CreateMemberPort createMemberPort;
private final FindMemberPort findMemberPort;
private final DeleteMemberPort deleteMemberPort;
private final MemberMapper memberMapper;
}
그럼 테스트에서 2개만 주입해 주면 어떻게 될까?
- 나는 스프링의 의존성 주입과 비슷하게 동작하지 않을까? 이런 생각을 했었다.
- 그래서 4개가 다 주입되어야 동작할 것이라고 생각하고 2개의 의존성을 주석 처리하고 테스트를 진행해 봤다.
- 결과는 테스트 메서드에서 필요로 하는 2개의 의존성만 @Mock으로 주입해 줘도 테스트에 성공한다.
@ExtendWith(MockitoExtension.class)
@DisplayName("[Service] 멤버 서비스 단위 테스트")
class MemberServiceTest {
@Mock private CreateMemberPort createMemberPort;
@Mock private MemberMapper memberMapper;
// @Mock private FindMemberPort findMemberPort;
// @Mock private DeleteMemberPort deleteMemberPort;
@InjectMocks private MemberService sut;
@DisplayName("[happy] 모든 데이터가 제공되면 회원이 성공적으로 생성된다.")
@Test
void createMember() {
//given
Member member = Member.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
MemberResponseDTO responseDTO = MemberResponseDTO.builder().id(1L).name("test").email("Test@test.com").password("1234").build();
given(createMemberPort.createMember(any(Member.class))).willReturn(member);
given(memberMapper.domainToResponseDTO(any(Member.class))).willReturn(responseDTO);
// when
MemberResponseDTO result = sut.createMember(member);
// then
Assertions.assertThat(result).isNotNull();
Assertions.assertThat(result.getId()).isEqualTo(1L);
}
}
7. 스프링과 Mockito의 의존성 주입의 차이점
스프링의 의존성 주입 (DI)
- 스프링 DI는 애플리케이션의 전반적인 의존성을 관리하고 주입하는 방법이다. 이를 통해 애플리케이션이 실행될 때 필요한 모든 의존성이 주입된다.
- 스프링의 생성자 주입 방식에서는 모든 final 필드가 반드시 초기화되어야 하며, 스프링은 애플리케이션 실행 시 의존성을 자동으로 주입한다. 만약 어떤 의존성이 주입되지 않으면, 스프링은 오류를 발생시키며 애플리케이션이 정상적으로 실행되지 않는다.
- 스프링에서 null이 주입되는 경우, 이는 의존성 미주입과 동일하게 간주되며, 런타임에서 오류가 발생할 수 있다.
Mockito의 의존성 주입
- Mockito는 주로 단위 테스트에서 객체 간의 의존성을 모킹(mocking)하고 주입하는 데 사용된다. 이로 인해 실제 의존성을 모킹 하여 독립적으로 테스트할 수 있다.
- 주입할 클래스의 필드가 final로 선언된 경우 @InjectMocks는 클래스의 생성자를 통해 필요한 의존성들을 주입한다. 만약 생성자에서 요구하는 필드가 @Mock으로 선언되지 않았거나 주입할 객체가 없다면, 그 필드는 null로 전달된다. 즉, 생성자를 호출할 때, 주입 가능한 @Mock 객체는 주입되고, 나머지는 null로 주입되어 객체가 생성된다.
결론
- 스프링 DI는 애플리케이션 실행 시 모든 의존성이 주입되어야 하며, 주입되지 않은 의존성에 대해 오류를 발생시킨다.
- Mockito는 @InjectMocks를 사용하면 사용자가 모킹(@Mock)한 의존성 필드만 주입하고, 모킹(@Mock) 하지 않은 의존성 필드는 자동으로 null로 주입해서 sut 객체를 생성한다.
'Spring > 테스트 코드' 카테고리의 다른 글
[Spring] 예외 테스트의 중요성: 바인딩 오류 (5) | 2024.02.03 |
---|---|
Spring 통합테스트: Validation 문제 해결과 깊은 고민 (30) | 2024.01.10 |
[Spring] 테스트: @ParameterizedTest 사용방법 (29) | 2023.12.24 |
[Spring] 테스트 코드: @MockBean/@SpyBean 사용방법 (66) | 2023.12.24 |
[Spring] 테스트 코드: @SpyBean으로 @EventListener 검증하기 (40) | 2023.12.23 |