UserService에 진짜 단위 테스트 적용하기

김세준·2022년 6월 9일
0

project-issue

목록 보기
1/3

1. 원인

UserService.java 클래스를 생성하고 테스트 코드를 작성하는 도중에 문제를 만났다. 회원가입하기 위해 단순히 User 객체를 추가하는 로직이였는데, DAO를 거치다보니 자연스럽게 단위 테스트가 아닌 통합 테스트가 되고 있는 문제였다.

@SpringBootTest
@Transactional
class UserServiceTest {

	@Autowired
	private UserService userService;

	private List<UserSaveForm> formList = new ArrayList<>();

	@BeforeEach
	void initData() {
		formList = Arrays.asList(
			UserSaveForm.builder().loginId("testId").password("test-password").name("testName")
				.role(String.valueOf(Role.CLIENT)).status(String.valueOf(UserStatus.ACTIVE))
				.phoneNumber("01012345779").email("test1@gmail.com")
				.streetAddress("testStreetAddress1").detailAddress("testDetailAddress").build(),

			UserSaveForm.builder().loginId("testId").password("test-password").name("testName")
				.role(String.valueOf(Role.CLIENT)).status(String.valueOf(UserStatus.ACTIVE))
				.phoneNumber("01012345779").email("test1@gmail.com")
				.streetAddress("testStreetAddress1").detailAddress("testDetailAddress").build()
		);
	}

	@Test
	void addUser() {
		//given
		//when
		User findUser = userService.addUser(formList.get(0));
		//then
		assertThat(formList.get(0).getLoginId()).isEqualTo(findUser.getLoginId());
	}
}

테스트 코드만 봤을 때는 UserService에 대해서만 테스트하는 것으로 보이지만 UserService 클래스를 들여다보면,

	public User addUser(UserSaveForm userSaveForm) {
		validateDuplicatedUser(userSaveForm.getLoginId());
		userSaveForm.setPassword(passwordEncoder.encode(userSaveForm.getPassword()));
		User user = userSaveForm.toEntity();
		userMapper.insertUser(user);
		return user;
	}

보다시피 UserMapper(MyBatis)를 사용하고 있다. 즉 고작 addUser()를 테스트하기 위한 코드가 내부에서는

  1. UserService
  2. UserMapper
  3. MySQL DB

를 거치고 있었던 것이다. @SpringBootTest만 해도 스프링 컨테이너 실행 때문에 느린데, DB 접근까지 시도하다보니 테스트 속도는 눈에 띄게 느려졌다.

BUILD SUCCESSFUL in 4s
4 actionable tasks: 1 executed, 3 up-to-date
오후 11:26:01: Execution finished ':test --tests "com.flab.foodrun.domain.user.service.UserServiceTest"'.

고작 하나 테스트하는데 4초나 걸린다. 테스트 코드를 수정하고자 결심한 이유는 느린 테스트 시간 뿐만 아니라, 이것을 단위 테스트라고 말하긴 어려웠기 때문이다. addUser() 메서드 하나를 테스트하는데 UserService, UserMapper, MySQL DB Connection 3개가 관여한다. 이렇게 되면 단위 테스트가 아니라 통합 테스트가 돼버린다.

2. 해결 과정

  • UserService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

	private final UserMapper userMapper;
	private final PasswordEncoder passwordEncoder;

	public User addUser(UserSaveForm userSaveForm) {
		validateDuplicatedUser(userSaveForm.getLoginId());
		userSaveForm.setPassword(passwordEncoder.encode(userSaveForm.getPassword()));
		User user = userSaveForm.toEntity();
		userMapper.insertUser(user);
		return user;
	}

	private void validateDuplicatedUser(String loginId) {
		int loginIdCount = userMapper.countByLoginId(loginId);
		if (loginIdCount > 0) {
			throw new IllegalStateException("이미 존재하는 회원입니다");
		}
	}
}

현재 서비스 클래스가 UserMapperPasswordEncoder 를 외부에서 주입받고 있기 때문에 단위 테스트를 하려면 해당 부분을 테스트 스텁용으로 교체해야 한다. 테스트 스텁을 만들기 위해서는 스프링에서 제공하는 목(mock) 라이브러리를 사용하면 된다.

1- 스프링 부트 컨테이너 종속성 제거

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

	List<UserSaveForm> formList = new ArrayList<>();

	@Mock
	UserMapper mockUserMapper;

	@Mock
	PasswordEncoder mockPasswordEncoder;
    
    UserService userService = null;
    
    @BeforeEach
	void initData() {
		userService = new UserService(mockUserMapper, mockPasswordEncoder);

		formList = Arrays.asList(
			UserSaveForm.builder().loginId("testId1").password("test-password").name("testName")
				.role(String.valueOf(Role.CLIENT)).status(String.valueOf(UserStatus.ACTIVE))
				.phoneNumber("01012345779").email("test1@gmail.com")
				.streetAddress("testStreetAddress1").detailAddress("testDetailAddress").build(),

			UserSaveForm.builder().loginId("testId2").password("test-password").name("testName")
				.role(String.valueOf(Role.CLIENT)).status(String.valueOf(UserStatus.ACTIVE))
				.phoneNumber("01012345779").email("test1@gmail.com")
				.streetAddress("testStreetAddress1").detailAddress("testDetailAddress").build());
	}
    
    ...

@SpringBootTest는 테스트할 때 편리하지만 테스트 하기 전에 몇 단계를 거쳐야하기 때문에 속도가 느리다.

  1. ApplicationContext 에 모든 Bean을 등록한다.
  2. 스프링 부트 애플리케이션을 실행했을 때와 동일하게 컨테이너에 Bean들을 등록한다.

테스트할 때 필요한 모든 재료들은 가짜로 교체할 것이기 때문에 굳이 무거운 @SpringBootTest 가 필요없다. 다만 단위 테스트를 할 때 Mockito 를 사용할 것이기 때문에 @ExtendWith(MockitoExtension.class) 애노테이션을 추가한다.

그리고 userService를 테스트할 때 필요한 userMapperPasswordEncoder를 모두 목 인터페이스로 대체한다.

2 - 회원 추가 테스트 코드 추가

	@Test
	void addUser() {
		//given
		when(mockUserMapper.countByLoginId(anyString())).thenReturn(0);
		when(mockUserMapper.insertUser(any(User.class))).thenReturn(1);

		//when
		User findUser1 = userService.addUser(formList.get(0));
		User findUser2 = userService.addUser(formList.get(1));
		
		//then
		verify(mockUserMapper, times(2)).countByLoginId(anyString());
		verify(mockUserMapper, times(2)).insertUser(any(User.class));
		assertThat(findUser1.getLoginId()).isEqualTo(formList.get(0).getLoginId());
		assertThat(findUser2.getLoginId()).isEqualTo(formList.get(1).getLoginId());
	}

원래라면 MyBatis 는 insert 구문을 실행하면 삽입한 행의 개수를 리턴해준다. 현재는 회원 추가 비즈니스 로직이 제대로 잘 동작되는지가 단위 테스트의 핵심이기 때문에 thenReturn(1) 로 무조건 1을 리턴하도록 만들어줬다.

countByLoginId 인터페이스를 목으로 구현한 이유는 아이디 중복 체크의 validateDuplicatedUser() 때문이다. 실제 서비스에선 같은 login_id 컬럼이 발견되서 1 이상을 리턴하면 아이디가 등록되지 않도록 예외를 던진다. 하지만 지금은 회원 추가 자체에만 집중하면 되므로 단순히 0을 리턴하도록 했다.

3 - 회원 중복 체크 테스트 코드 추가

아이디 중복 체크 테스트는 간단하다. countByLoginId 가 리턴하는 값이 1이면 예외를 던지므로 1을 리턴해주도록 조정하면 된다.

	@Test
	void validateDuplicatedUser() {
		//given
		when(mockUserMapper.countByLoginId(anyString())).thenReturn(1);

		//when
		//then
		verify(mockUserMapper, times(0)).countByLoginId(anyString());
		assertThatThrownBy(() -> {
			User findUser1 = userService.addUser(formList.get(0));
		});
	}

이 테스트코드에서 mockUserMapper.insertUser() 가 필요없는 이유는 addUser() 첫 줄에서 이미 validateDuplicatedUser() 가 실행되기 때문이다. 메서드 시작의 첫 줄에서 부터 예외가 터지는 데 굳이 차례가 오지도 않는 insertUser() 를 구현해줄 이유가 없다.

이 테스트는 예외가 터지는 것이 목적이므로 assertThatThrownBy() 를 이용해 테스트 코드를 작성한다.

결과

빌드에 4초씩이나 걸리던 테스트 코드가 1초로 줄어들었다.
@SpringBootTest 로 모든 Bean 을 검색해 주입하고 DB에 접속해 테스트 결과를 받아오던 때와 비교하면 4배 정도 빨라졌다.

BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
오후 10:25:14: Execution finished ':test --tests "com.flab.foodrun.domain.user.service.UserServiceTest"'.

0개의 댓글