토비의 스프링 정리

토비의 스프링 - 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()는 다섯 단계의 작업으로 구성됨
    1. UserDao를 통해 테스트용 정보를 DB에 넣음. DB를 이용해 정보를 가져오기 때문에 의존 대상인 DB에 정보를 넣어야 함
    2. 메일 발송 여부를 확인하기 위해 MailSender 목 오브젝트를 DI함
    3. 실제 대상인 userService의 메소드 실행
    4. 결과가 DB에 반영됐는지 확인하기 위해 UserDao를 이용해 DB에서 데이터를 가져와 결과 확인
    5. 못 오브젝트를 통해 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 사용 순서
    1. 인터페이스를 이용해 목 오브젝트 생성
    2. 목 오브젝트가 리턴할 값이 있으면 이를 지정. 메소드가 호출되면 예외를 강제로 던지게 할 수 있음
    3. 테스트 대상 오브젝트에 DI해서 목 오브젝트가 테스트 중에 사용되도록 함
    4. 테스트 대상 오브젝트를 사용한 후 목 오브젝트의 특성 메소드가 호출 됐는지, 어떤 값을 가지고 몇 번 호출 됐는지 검증

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()));
    }
}