토비의 스프링 정리
토비의 스프링 - 6.2 고립된 단위 테스트
ksb-dev
2022. 10. 6. 20:48
6.2.1 복잡한 의존관계 속의 테스트
- 작은 단위로 테스트 할 수록 논리적 오류를 찾기 쉬움
- 하지만, 다른 오브젝트와 환경에 의존하고 있다면 작은 단위 테스트의 장점을 얻기 힘듦
- UserService는 사용자 관리 로직만 갖는 아주 단순한 비즈니스 로직임에도, 세 가지의 의존 관계를 가지고 있음
- UserService는 UserDao, TransactionManager, MailSender 의존을 가지고 있음
- 문제는 세 가지 의존 오브젝트들이 자신의 코드만 실행되지 않음
- Jdbc를 이용해 UserDao를 구현한 UserDaoJdbc는 DataSource의 구현 클래스, DB 드라이버, DB 서버까지의 네트워크 통신, DB 테이블 등에 모두 의존함
- 따라서, UserService를 테스트 하지만 그 뒤의 오브젝트, 환경, 서버스, 서버, 네트워크 등등을 함께 테스트 하는 것임
- 이런 테스트는 준비하기 힘들고, 느린 테스트 수행 및 환경이 달라지면 테스트 결과를 내지 못할 수 있음
6.2.2 테스트를 위한 UserServiceImpl 고립
- 테스트의 대상은 환경, 외부 서버, 다른 클래스 코드에 종속되고 영향받지 않도로 고립할 필요가 있음
- 고립을 하는 방법은 테스트 대역(스텁, 목)을 사용하면 됨
- PlatformTransactionManager는 트랜잭션 코드를 독립하여 UserServiceImpl가 더 이상 의존하지 않기 때문에 별다른 고립을 할 필요가 없음
- 따라서 UserService가 의존하는 세 가지 중 UserDao 와 MailSender만 고립하면 됨
- void형인 upgradeLevels()는 결과를 반환 받을 수 없음
- UserDao는 고립된 상태에서 void형인 upgradeLevels()의 테스트 결과를 검증해야 하기 때문에 테스트 대상이 정상적으로 수행되도록 도와주는 스텁이 아닌, 결과를 검증하는 목으로 만들어야 함
- 기존에는 DB에 새로 저장된 값을 가져와 검증을 했음
- 그러나, 의존 오브젝트나 외부 서브에서 의존하지 않는 UserServiceImpl 고립 테스트는 수행 결과가 DB에 저장되지 않기 때문에, 기존의 방법으로 작업 결과를 검증하기 힘듦
- 이럴때는 DB에 결과가 반영되지 않지만, UserDao의 update() 메소드를 호출하는 것으로 검증을 하면 됨
- 이유는 update()가 충분히 테스트 되었고, 해당 메소드를 호출하면 정상적으로 결과가 반영될 것이라 예측할 수 있기 때문임
6.2.3 고립된 단위 테스트 활용
- 기존의 upgradeLevels()는 다섯 단계의 작업으로 구성됨
- UserDao를 통해 테스트용 정보를 DB에 넣음. DB를 이용해 정보를 가져오기 때문에 의존 대상인 DB에 정보를 넣어야 함
- 메일 발송 여부를 확인하기 위해 MailSender 목 오브젝트를 DI함
- 실제 대상인 userService의 메소드 실행
- 결과가 DB에 반영됐는지 확인하기 위해 UserDao를 이용해 DB에서 데이터를 가져와 결과 확인
- 못 오브젝트를 통해 UserService에 의한 메일 발송이 있었는지 확인
- 첫 번째 작업은 의존관계를 따라 마지막에 동작하는 DB를 준비
- 두 번째 작업은 테스트를 의존 오브젝트와 서버 등에서 고립하는 목 오브젝트 준비
- 세 번째는 메소드 실행
- 네 번째와 다섯 번째는 코드 실행후 결과 확인
//주석으로 설명
public class UserServiceTest {
@Test
@DirtiesContext
public void upgradeLevels() {
//DB 테스트 데이터 준비
userDao.deleteAll();
for (User user : users) userDao.add(user);
//메일방송 여부 확인을 위한 목 오브젝트 DI
MockMailSender mockMailSender = new MockMailSender();
userServiceImpl.setMailSender(mockMailSender);
//테스트 대상 실행
userService.upgradeLevels();
//DB에 저장된 결과 확인
checkLevelUpgraded(users.get(0), false);
checkLevelUpgraded(users.get(1), true);
checkLevelUpgraded(users.get(2), false);
checkLevelUpgraded(users.get(3), true);
checkLevelUpgraded(users.get(4), false);
//목 오브젝트를 이용한 결과 확인
List<String> request = mockMailSender.getRequests();
assertThat(request.size(), is(2));
// assertThat(request.get(0), is(users.get(1).getEmail()));
// assertThat(request.get(0), is(users.get(1).getEmail()));
}
...
}
6.2.4 UserDao 목 오브젝트
- 목 오브젝트는 스텁과 같은 방식으로 테스트 대상을 통해 사용될 필요한 기능을 지원해야 함
- 고립하기 위해 UserDao와 어떤 정보를 주고 받는지 알아야 함
- upgradeLevels()에서 userDao를 사용하는 경우는 두 가지임
public class UserServiceImpl implements UserService {
...
public void upgradeLevels() {
List<User> users = userDao.getAll(); //첫 번째
for (User user : users) {
if (canUpgradeLevel(user)) {
upgradeLevel(user);
}
}
}
protected void upgradeLevel(User user) {
user.upgradeLevel();
userDao.update(user); //두 번째
sendUpdateEmail(user);
}
}
- getAll()은 레벨 업그레이드 후보 사용자의 목록이므로, DB에서 읽어온 것처럼 미리 준비된 사용자 목록을 제공만 하면 됨
- update()는 호출의 리턴값이 없어 특별히 준비할 것이 없어 빈 메소드로 만들면 됨
- 하지만, update()는 upgradeLevels()의 핵심 로직인 사용자 레벨 업그레이드 핵심 로직이므로 변경에 해당하는 부분을 검증할 수 있도록 해야 함
- update()는 충분히 테스트 된 메소드 이므로, 메소드가 호출되는 것으로 검증할 수 있음
- 즉 getAll()은 테스트 스텁, update()는 목 오브젝트로서의 테스트 대역이 필요
- 클래스 이름을 MockUserDao로 하고, UserService에서 스태틱 클래스로 생성
- MockUserDao는 UserDao 구현 클래스(UserDaoJdbc)를 대체 해야 하므로 UserDao를 구현
- 사용하지 않는 메소드는 UnsupportedOperationException를 던짐
- 운영 환경에서 getAll() 메소드가 호출되면 DB에서 가져온것을 돌려줘야 하지만, 목 오브젝트를 통해 메모리에 저장된 값을 되돌려 주면 됨
- 목의 getUpdated()는 검증을 위해 저장된 리스트 반환
public class UserServiceImpl implements UserService {
public static class MockUserDao implements UserDao {
//레벨 업그레이드 후보 User 오브젝트 목록
private List<User> users;
//업그레이드 대상 오브젝트를 저장해둘 목록
private List<User> updated = new ArrayList<>();
private MockUserDao(List<User> users) {
this.users = users;
}
public List<User> getUpdated() {
return this.updated;
}
@Override
public List<User> getAll() {
return this.users;
}
@Override
public void update(User user) {
updated.add(user);
}
@Override
public void add(User user) {throw new UnsupportedOperationException();}
@Override
public User get(String id) {throw new UnsupportedOperationException();}
@Override
public void deleteAll() {throw new UnsupportedOperationException();}
@Override
public int getCount() {throw new UnsupportedOperationException();}
}
}
6.2.5 MockUserDao를 사용햐서 만든 고립 테스트
- 고립 테스를 만들기 전의 테스트 대상은 UserService 타입의 빈이었음
- DI를 통해 많은 의존 오브젝트와 서비스 및 외부 환경에 의존하고 있음
- 테스트의 upgradeLevels()를 완전히 고립된 테스트를 만들면서 스프링 컨테이너의 빈을 가져올 필요가 없어졌음
- 또한, DB를 의존하지 않게 되면서 사용자 정보를 삭제 하는 deleteAll() 및 등록을 하는 add()가 필요가 없어 번거로운 작업을 하지 않아도 됨
- 고립 테스트를 위한 MockUserDao는 수정자 메소드를 통해 수동 DI를 하면 됨
- 목의 update()는 업그레이드 조건에 맞는 경우에만 호출되기 때문에 두 번 호출 됨
- 테스트 수행 시간이 매우 빨라 졌음
- 💡 책에서 500배 이상의 수행 속도 차이를 보이고 있음
public class UserServiceTest {
...
@Test
public void upgradeLevels() {
UserServiceImpl userServiceImpl = new UserServiceImpl();
UserServiceImpl.MockUserDao mockUserDao =
new UserServiceImpl.MockUserDao(this.users);
userServiceImpl.setUserDao(mockUserDao);
MockMailSender mockMailSender = new MockMailSender();
userServiceImpl.setMailSender(mockMailSender);
userServiceImpl.upgradeLevels();
List<User> updated = mockUserDao.getUpdated();
assertThat(updated.size(), is(2));
checkUserAndLevel(updated.get(0), "k2", Level.SILVER);
checkUserAndLevel(updated.get(1), "k4", Level.GOLD);
List<String> request = mockMailSender.getRequests();
assertThat(request.size(), is(2));
// assertThat(request.get(0), is(users.get(1).getEmail()));
// assertThat(request.get(0), is(users.get(1).getEmail()));
}
private void checkUserAndLevel(User updated, String expectedId,
Level expectedLevel) {
assertThat(updated.getId(), is(expectedId));
assertThat(updated.getLevel(), is(expectedLevel));
}
}
💡 고립된 테스트를 만들기 위해서 목 오브젝트 작성과 같은 수고를 해야 하지만, 의존 영향을 받지 않고 수행 시간이 매우 빨라 그 보상은 충분함
6.2.6 단위 테스트와 통합 테스트
- 단위 테스트의 단위는 정하기 나름임
- 앞으로 테스트 대상 클래스를 테스트 대역을 이용해 의존 오브젝트나 외부 리소스를 사용하지 않도록 고립하는 테스트를 단위 테스트라 하겠음
- 두개 이상의, 성격이나 계층이 다른 오브젝트가 연동 되거나 외부 자원이 참여하는 테스트를 통합 테스트라 하겠음
- 스프링 테스트 컨텍스트 프레임워크를 사용하는 것은 스프링 설정 자체도 테스트 대상이기 때문에 통합 테스트임
- 가이드 라인
- 항상 단위 테스를 먼저 고려
- 최대한 의존 관계를 차단하고 필요에 따라 테스트 대역을 이용하도록 함
- 외부 리소스 사용 및 여러 의존 관계를 지닐 때 통합 테스트로 작성
- DAO와 같이 단위 테스트로 만들기 힘든 테스트는 통합 테스트로 작성. 외부 DB에 실제 연동되야 테스트가 정상적으로 동작하는지 확인할 수 있기 때문임
6.2.7 목 프레임워크
- 단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트를 사용하는 것이 필수적임
- 단위 테스트는 많은 장정이 있지만, 목 오브젝트를 만드는 일이 가장 번거로움
- MockUserDao 처럼 테스트에서는 사용하지 않는 인터페이스 모두 구현을 해야함
- 특히 테스트 메소드별로 다른 검증 기능이 필요하면 같은 의존 인터페이스를 구형한 여러 목 오브젝트를 선언해야 함
- 이를 해결하기 위한 Mockito 프레임워크가 있음
6.2.8 Mockito 프레임워크
- Mockito 프레임워크는 사용하기도 편리하고, 코드도 직관적임
- 간단한 메소드 호출만으로 다이내믹하게 특정 인터페이스를 구현한 테스트용 목 오브젝트 생성 가능
- 목 오브젝트를 생성하는 메소드는 mock()임
- mock() 메소드는org.mockito.Mockito 클래스에 정의된 스태틱 메소드임
- 스태틱 임포트를 사용해 로컬 메소드처럼 호출할 수 있음
- mock()을 이용한 목 오브젝트는 아무런 기능이 없음. getAll() 처럼 사용자 목록을 리턴하도록 스텁 기능을 추가해야 함
UserDao mockUserDao = mock(UserDao.class);
- mockUserDao.getAll)()이 호출됐을 때(when), users 리스트를 리턴하라(thenReturn) 선언임
when(mockUserDao.getAll()).thenReturn(this.users);
- update()는 두번 호출하면 됨
verify(mockUserDao, times(2)).update(any(User.class))
- User 타입의 오브젝트를 파라미터로 받으며 update() 메소드가 두번 호출(time(2))됐는지 확인(verify) 선언임
- any()는 파라미터의 내용은 무시하고 호출 횟수만 확인할 수 있음
- Mockito 사용 순서
- 인터페이스를 이용해 목 오브젝트 생성
- 목 오브젝트가 리턴할 값이 있으면 이를 지정. 메소드가 호출되면 예외를 강제로 던지게 할 수 있음
- 테스트 대상 오브젝트에 DI해서 목 오브젝트가 테스트 중에 사용되도록 함
- 테스트 대상 오브젝트를 사용한 후 목 오브젝트의 특성 메소드가 호출 됐는지, 어떤 값을 가지고 몇 번 호출 됐는지 검증
6.2.9 Mockito를 적용한 테스트 코드
- UserDao의 목 오브젝트를 생성하고 getAll()이 호출되면 리턴값을 설정하고 DI를 함
- mock()을 사용하면 UserDao 인터페이스를 구현해서 목 클래스를 따로 정의하지 않아도 됨
- MailSender도 따로 정의하지 않아도 됨
- DI를 마치면 userServiceImoke 오브젝트는 고립 테스트가 가능함
- times()는 메소드 호출 횟수를 검증함
- any()를 사용하면 파라미터 내용 무시한 채로 호출횟수 확인 가능
- MailSender의 경우 ArgumentCapture를 사용해 실제 MailSender 목 오브젝트에 전달된 파라미터를 가져와 내용을 검증
- ArgumentCapture는 파라미터를 직접 비교하기보다 파라미터 내부의 정보를 검증하는데 유용함
public class UserServiceTest {
...
@Test
public void mockUpgradeLevels(){
UserServiceImpl userServiceImpl = new UserServiceImpl();
//다이내믹한 목 오브젝트 생서와 메소드의 리턴 값 설정
//그리고 DI까지 세줄이면 충분함
UserDao mockUserDao = mock(UserDao.class);
when(mockUserDao.getAll()).thenReturn(this.users);
userServiceImpl.setUserDao(mockUserDao);
//리턴 값이 없는 메소드를 가진 목 오브젝트는 더욱 간단함
MailSender mockMailSender = mock(MailSender.class);
userServiceImpl.setMailSender(mockMailSender);
userServiceImpl.upgradeLevels();
//목 오브젝트가 제공하는 검증 기능을 통해서 어떤 메소드가 몇 번 호출 됐는지,
//파라미터는 무엇인지 확인
verify(mockUserDao,times(2)).update(any(User.class));
verify(mockUserDao,times(2)).update(any(User.class));
verify(mockUserDao).update(users.get(1));
assertThat(users.get(1).getLevel(), is(Level.SILVER));
verify(mockUserDao).update(users.get(3));
assertThat(users.get(3).getLevel(), is(Level.GOLD));
//ArgumentCaptor는 파라미터 내부 값 확인할 때 사용
ArgumentCaptor<SimpleMailMessage> mailMessageArg =
ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mockMailSender, times(2)).send(mailMessageArg.capture());
List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues();
// assertThat(mailMessages.get(0), is(users.get(1).getEmail()));
// assertThat(mailMessages.get(0), is(users.get(1).getEmail()));
}
}