Hyeonuk_.log

[Spring] @Service 단위테스트 본문

Dev_.log/Spring

[Spring] @Service 단위테스트

Hyeonuk_. 2022. 2. 21. 06:00

요즘 개발 관련 책, 블로그, 유튜브 등 다양한 곳에서 TDD(Test Driven Development)에 대해서 다루어지고 있다.

사실 요즘이 아니라 계속 중요성이 강조되었을 것이다. 내가 관심을 갖은게 최근....
매번 구글링 하는 것이 힘들어 블로그에 정리를 하려한다.


Service 레이어의 단위테스트는 서비스만을 테스트해야 한다고 생각한다.
그래서 Controller와 Repository의 연결을 끊어야한다. 연결을 끊지 않으면 데이터 준비, DB 연결, 셋팅 등등..배보다 배꼽이 더 큰 상황이 벌어지게 된다. 아래의 예제를 봐보자. 실제 테스트를 위해 Repository와의 의존성이 필요하고 데이터들도 필요하다.

@Service
public class MemberServiceImpl implements MemberService {

	private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
	
	MemberRepository memberRepository;
	
	@Autowired
        public MemberServiceImpl(MemberRepository memberRepository) {
            this.memberRepository = memberRepository;
        }

	@Override
	@Transactional(readOnly=true)
	public UserDetails loadUserByUsername(String email) {
		logger.info("param email ::" + email);
		Optional<Member> result = memberRepository.findByEmail(email);
		Member memberEntity = result.orElseThrow(() -> new CustomException(ErrorCode.NOTFOUND_CODE));

		List<GrantedAuthority> authorities = new ArrayList<>();
 		authorities.add(new SimpleGrantedAuthority(memberEntity.getMemberRole().toString()));
		logger.info("memberEntity email ::" + memberEntity.getEmail() + " pswd :: " + memberEntity.getPswd());

 		return new User(memberEntity.getEmail(), memberEntity.getPswd(), authorities);

	}

	@Override
	public ErrorResponse checkEmail(String email) {
		logger.info("param email " + email);
		Optional<Member> result = memberRepository.findByEmail(email);
		if(result.isPresent()) {
			return new ErrorResponse(ErrorCode.DUPEMAIL_CODE);
		} else {
			return new ErrorResponse(ErrorCode.SUCCESS_CODE);
		}
	}

	@Override
	@Transactional
	public ErrorResponse signUp(MemberDto memberDto) {
		
		logger.info("param memberDto " + memberDto.toString());
		Member member = memberDto.toEntity();
		logger.info("member ::" + member.toString());
		memberRepository.save(member);
		
		return new ErrorResponse(ErrorCode.SUCCESS_CODE);
	}

	@Override
	@Transactional
	public ErrorResponse reSign(MemberDto memberDto) {
		
		String email = memberDto.getEmail();
		logger.info("param email " + email);
		Optional<Member> result = memberRepository.findByEmail(email);
		Member memberEntity = result.orElseThrow(() -> new CustomException(ErrorCode.NOTFOUND_CODE));
		
		memberRepository.deleteById(memberEntity.getId());
		
		return new ErrorResponse(ErrorCode.SUCCESS_CODE);
	}

}
 

Controller는 Web모듈이므로 Service Test를 진행하려면 Web에 대한 의존성을 받으면 안된다. 따라서 @WebMvcTest, @SpringBootTest와 같은 테스트를 사용하면 Service만을 테스트하기가 어려워진다. 또한, Domain을 통해 비즈니스 로직은 수행해야하지만, 실제로 DB에 저장할 건 아니기 때문에 이 부분을 제거할 방법이 필요하다.


SpringBoot Test는 특정 객체를 가짜로 대체할 Mocking을 제공하고 있고, 아래와 같은 Annotation을 제공한다.

@Mock, @MockBean, @Spy, @SpyBean,@InjectMock

@Mock으로 선언한 객체는 의존하고 있는 실제 객체 대신에 @Mock으로 선언한 객체로 바꿔치기된다.
따라서 Service 내에 의존하고 있는 Repository를 @Mock으로 선언하면 Repository Bean에 의존하지 않고 테스트가 가능해진다.

@MockBean은 mock 객체를 스프링 컨텍스트에 등록하는 것이기 때문에 @SpringBootTest를 통해서 Autowired에 의존성이 주입되게 된다.

@InjectMocks로 선언함으로써, @Mock으로 선언된 가짜 객체들을 의존한 Service 객체가 생성된다. (@InjectMocks 라는 어노테이션을 사용한다면 해당 클래스가 필요한 의존성과 맞는 Mock 객체들을 감지하여 해당 클래스의 객체가 만들어질때 사용하여 객체를 만들고 해당 변수에 객체를 주입하게 된다.)

@Spy는 실제 객체의 스파이를 생성하여 실제 객체의 메소드를 호출 할 수 있게 한다.

@SpyBean은 @MockBean과 마찬가지로 스프링 컨테이너에 Bean으로 등록된 객체에 대해 Spy를 생성해준다. (주의 사항 : @SpyBean이 Interface일 경우 구현체가 반드시 Spring Context에 등록되어야 한다.)

 

단위테스트인만큼 @Mock과 @InjectMock으로 테스트 코드를 만들어보겠다.

아래는 Service 레이어 단위테스트에 대해 알아보지 않고 테스트코드를 작성한 어리석은 코드이다. 이 코드를 리팩토링 해보도록 하겠다.

@SpringBootTest
public class MemberServiceImplTest {
	
	private final Logger logger = LoggerFactory.getLogger(this.getClass().getSimpleName());
	
	@Autowired
	MemberServiceImpl memberServiceImpl;
	
	MemberDto memberDto;
	
	@BeforeEach
	public void setUp() {
		
		//given
		memberDto = MemberDto.builder()
				.email("test@gmail.com")
				.pswd("1234")
				.age("30")
				.birth("19930519")
				.gender("M")
				.inflowChannel(InflowChannel.INSTAGRAM)
				.jobInfo("Programer")
				.memberRole(MemberRole.ADMIN)
				.name("TEST")
				.phoneNo("01012341234")
				.build();
				
		//when
		memberServiceImpl.signUp(memberDto);
	}
	
	@AfterEach
	public void cleanUp() {
		memberServiceImpl.deleteAll();
	}
	
	@Test
	@DisplayName("회원가입 확인")
	public void signUp() {
		
		logger.debug("signUp()");
		
		//given
		ErrorResponse errorResponse =  memberServiceImpl.checkEmail("test@gmail.com");
		
		//then
		assertEquals(errorResponse.getCode(), "C005");
		
	}
	
	@Test
	@DisplayName("기가입")
	public void alreadySignUp() {
		logger.debug("alreadySignUp()");
		assertThrows(DataIntegrityViolationException.class, () -> {
			// 두번째 화원 가입
			memberServiceImpl.signUp(memberDto);
 		});
	}
	
	@Test
	@DisplayName("회원탈퇴")
	public void resign() {
		logger.debug("resign()");
		// 회원 탈퇴
		memberServiceImpl.reSign(memberDto);
		// 회원탈퇴 여부 확인
		assertThrows(CustomException.class, () -> {
			// 두번째 화원 가입
			memberServiceImpl.loadUserByUsername(memberDto.getEmail());
		});
	}
	
}
 

 

단위테스트로 변경한 코드이다.

@ExtendWith(MockitoExtension.class)
public class MemberServiceImplTest {
	
	@Mock
	MemberRepository memberRepository;
	
	@InjectMocks
	MemberServiceImpl memberServiceImpl;
	
	public MemberDto setUpMemberDto() {
		return MemberDto.builder()
				.email("test@gmail.com")
				.pswd("1234")
				.age("30")
				.birth("19930519")
				.gender("M")
				.inflowChannel(InflowChannel.INSTAGRAM)
				.jobInfo("Programer")
				.memberRole(MemberRole.ADMIN)
				.name("TEST")
				.phoneNo("01012341234")
				.build();
	}
	
	public Member setUpMember() {
		return Member.builder()
				.id(1L)
				.email("test@gmail.com")
				.pswd("1234")
				.age("30")
				.birth("19930519")
				.gender("M")
				.inflowChannel(InflowChannel.INSTAGRAM)
				.jobInfo("Programer")
				.memberRole(MemberRole.ADMIN)
				.name("TEST")
				.phoneNo("01012341234")
				.build();
	}
	
	@Test
	@DisplayName("이메일중복체크")
	public void checkEmail() {
		
		//given
		Optional<Member> member = Optional.ofNullable(setUpMember());
		MemberDto memberDto = setUpMemberDto();
		when(memberRepository.findByEmail(memberDto.getEmail())).thenReturn(member);
		
		//when
		ErrorResponse errorResponse =  memberServiceImpl.checkEmail(memberDto.getEmail());
		
		//then
		assertEquals(errorResponse, new ErrorResponse(ErrorCode.DUPEMAIL_CODE));
		
	}
	
	@Test
	@DisplayName("회원가입 테스트")
	public void signUp() {
		
		//given
		MemberDto memberDto = setUpMemberDto();
		
		when(memberRepository.save(any())).thenReturn(memberDto.toEntity());
		
		//when
		ErrorResponse errorResponse =  memberServiceImpl.signUp(memberDto);
		
		//then
		assertEquals(errorResponse, new ErrorResponse(ErrorCode.SUCCESS_CODE));
		
	}
	
	@Test
	@DisplayName("회원탈퇴")
	public void resign() {
		
		//given
		Optional<Member> member = Optional.ofNullable(setUpMember());
		MemberDto memberDto = setUpMemberDto();
		given(memberRepository.findByEmail(memberDto.getEmail())).willReturn(member);
		
		// 회원 탈퇴
		memberServiceImpl.reSign(memberDto);
		
		// then
		Mockito.verify(memberRepository, times(1)).findByEmail(memberDto.getEmail());
		Mockito.verify(memberRepository, times(1)).deleteById(1L); 
	}
	
}

 

@Mock과 @InjectMocks를 활용하여 의존도 없이 단위테스트를 작성해보았다.

@ExtendWith(MockitoExtension.class)
SpringContainer를 로드하지않고 테스트를 위한 기능만 제공한다.

@Mock, @Spy 기능을 사용할 수 있다.
테스트에 Spring이 필요없이 순수한 단위 테스트만 필요하다면 위 코드를 추가하면 된다.

Comments