✅ 복잡한 의존관계 속 테스트
가장 편하고 좋은 테스트 방법은 가능한 한 작은 단위로 쪼개서 테스트하는 것입니다. 작은 단위의 테스트가 좋은 이유는 테스트가 실패했을 때 그 원인을 찾기 쉽기 때문입니다.
UserService는 엔터프라이즈 시스템의 복잡한 모듈과는 비교할 수 없을 만큼 간단한 기능만을 갖고 있습니다. 그럼에도 UserService의 구현 클래스들이 동작하기 위해서는 세 가지 타입의 객체가 필요합니다. UserDao 타입의 객체를 통해 DB와 데이터를 주고받아야 하고, MailSender를 구현한 객체를 이용해 메일을 발송해야 합니다. 마지막으로 트랜잭션 처리를 위해 PlatformTransactionManager와 커뮤니케이션이 필요합니다.
UserServiceTest를 테스트하고자 하는 대상인 UserService는 사용자 정보를 관리하는 비즈니스 로직의 구현 코드입니다. 따라서 UserService의 코드가 바르게 작성되어 있다면 성공하고, 아니라면 실패하면 됩니다. 따라서 테스트의 단위는 UserService 클래스여야 합니다.
하지만 UserService는 UserDao, TransactionManager, MailSender라는 세 가지의 의존 관계를 갖고 있습니다. 세 가지의 의존 객체는 자신의 코드를 실행하는 것은 물론, JDBC를 이용해 UserDao를 구현한 UserDaoJdbc는 DataSource 구현 클래스와 DB 드라이버, 그리고 DB 서버까지의 네트워크 통신과 DB 서버 자체 그리고 그 안에 정의된 테이블에 모두 의존하고 있습니다. 트랜잭션 매니저는 그나마 DataSource 방식이라 데이터 소스 구현에만 의존하고 있습니다. JT
따라서 UserService를 테스트하는 것처럼 보이지만 사실은 그 뒤에 존재하는 훨씬 더 많은 객체와 환경, 서비스, 서버, 심지어 네트워크까지 함께 테스트하는 셈이 됩니다. 그 어느 것이라도 바르게 셋업되어 있지 않거나, 코드에 문제가 있다면 그 때문에 UserService 대한 테스트가 실패해버리기 때문입니다.
✅ 테스트 대상 오브젝트 고립
테스트를 의존 대상으로부터 분리해서 고립시키는 방법은 MailSender에 적용해봤던 Mock 객체(test double, 정확하게는 Mock Object)을 사용한 것입니다. MailSender에는 DummyMailSender이라는 테스트 스텁(test stub)을 적용했습니다.
같은 방법으로 UserDao에도 적용하면, 위 그림처럼 두 개의 목 오브젝트만 의존하는 고립된 테스트를 할 수 있게 됩니다. 그 전에 기존의 테스트 코드를 한 번 살펴보겠습니다.
➡️ 이 테스트는 다섯 단계의 작업으로 구성됩니다.
- 테스트 실행 중에 UserDao를 통해 가져올 테스트용 정보를 DB에 넣습니다. UserDao는 DB를 이용해 정보를 가져오기 때문에 최후의 종속 대상인 DB에 직접 정보를 넣어주어야 합니다.
- 메일 발송 여부를 확인하기 위해 MailSender 목 오브젝트를 DI(Dependency Injection) 해줍니다.
- 실제 테스트 대상인 userService의 메소드를 실행합니다.
- 결과가 DB에 반영되었는지 확인하기 위해 UserDao를 이용해 DB에서 데이터를 가져와 결과를 확인합니다.
- 목 오브젝트를 통해 UserService에 한 메일 발송이 있었는지 확인하면 됩니다.
첫 번째와 네 번째 단계가 UserDao를 통해서 실제 DB와 커넥션된 로직을 수행합니다. 네 번째 단계에서 의존관계에 따라 최종 결과가 반영된 DB의 내용을 확인함으로 테스트를 수행하고 있습니다.
🔹 UserDao Mock Object
첫 번째와 네번째의 테스트 방식도, MailSender Mock Object와 같은 방법으로 바꾸어보겠습니다.
- UserServiceTest
//== upgradeLevels() 테스트에 사용될 UserDao Mock 객체 ==// static class MockUserDao implements UserDao { //업그레이드 후보와 업그레이드 된 결과를 저장할 변수 private List<User> users; private List<User> updated = new ArrayList(); //생성자 private MockUserDao(List<User> users) { this.users = users; } private List<User> getUpdated(){ return this.updated; } //== 스텁 기능 제공 ==// public List<User> getAll(){ return this.users; } //== Mock Oject 기능 제공 ==// public void update(User user) { updated.add(user); } //사용되지 않는 기능, UnsupportedOperationException을 발생시키는 것이 좋다 public void add(User user) { throw new UnsupportedOperationException(); } public void deleteAll() { throw new UnsupportedOperationException(); } public Optional<User> get(String id) { throw new UnsupportedOperationException(); } public int getCount() { throw new UnsupportedOperationException(); } }
UserServiceTest 클래스 내에서 static 클래스로 생성합니다. Mock 객체의 주요 기능은 다음과 같습니다.
- List<User> users : DB에 add하는 과정을 생략하기 위해 생성자를 통해서 사용자에 대한 정보를 담는 List입니다.
- List<User> updated : UserService를 통해서 update() 메소드가 실행되면 Level이 업데이트되고 그 결과를 DB처럼 저장하는 변수입니다.
- getAll() : UserService가 upgrdeLevels() 메소드를 통해서 모든 사용자 유저의 Level을 업데이트할 때, 제일 처음 DB에서 정보를 가져올 때 사용한 메소드를 Mock 객체에서 가져오도록한 메소드입니다.
- getUpdated() : update() 메소드를 통해서 업데이트된 사용자의 정보를 반환하는 메소드입니다.
- UserServiceTest, MockUserDao 사용
@Test public void upgradeLevels() throws Exception { UserServiceImpl userServiceImpl = new UserServiceImpl(); MockUserDao mockUserDao = new MockUserDao(this.users); userServiceImpl.setUserDao(mockUserDao); MockMailSender mockMailSender = new MockMailSender(); userServiceImpl.setMailSender(mockMailSender); userServiceImpl.upgradeLevels(); List<User> updated = mockUserDao.getUpdated(); assertEquals(updated.size(), 2); checkUserAndLevel(updated.get(0), "user2", Level.SILVER); checkUserAndLevel(updated.get(1), "user4", Level.GOLD); List<String> request = mockMailSender.getRequests(); assertEquals(request.size(), 2); assertEquals(request.get(0), users.get(1).getEmail()); assertEquals(request.get(1), users.get(3).getEmail()); }
고립된 테스트에서는 Spring IoC Container에서 Bean 객체를 가져오지 않고 직접 생성하여 수동으로 DI를 설정하면 됩니다.
🔹 단위 테스트와 통합 테스트
단위 테스트의 단위는 정하기 나릅입니다. 중요한 것은 하나의 단위에 초점을 맞춘 테스트라는 점입니다. 책에서는 단위테스트와 통합 테스트를 아래와 같이 정의하고 있습니다. 👉는 부가적인 설명입니다.
- 단위 테스트(Unit Test)
upgradeLevels() 테스트처럼 ‘테스트 대상 클래스를 목 오브젝트 등의 테스트 대역 을 이용해 의존 오브젝트나 외부의 리소스를 사용하지 않도록 고립시켜서 테스트하는 것’
👉 개별적인 클래스 또는 메소드와 같은 작은 단위의 코드를 테스트한다. 특정 클래스나 메소드의 동작을 격리되어 테스트하며, 외부 의존성을 목 객체나 스텁으로 대체하여 독립적으로 실행될 수 있다. 단위 테스트에서는 주로 외부 의존성을 목 객체나 스텁으로 대체하여 테스트 대상을 격리한다. 외부 리소스나 네트워크 호출 등을 가짜 구현체로 대체하여 테스트의 안정성과 속도를 향상시킨다.
- 통합 테스트(Integration Test)
두 개 이상의 성격이나 계층이 다른 오브젝트가 연동하도록 만들어 테스트하거나, 또는 외부의 DB나 파일, 서비스 등의 리소스가 참여하는 테스트
👉 실제로 서로 상호작용하는 여러 컴포넌트 또는 레이어를 함께 테스트한다. 여러 컴포넌트, 데이터베이스, 외부 서비스와의 상호작용 등을 포함하여 테스트하며, 실제 환경과 유사한 조건에서 실행될 수 있다. 통합 테스트에서는 실제 의존성을 사용하여 컴포넌트 간의 상호작용을 테스트한다. 데이터베이스, 메시징 시스템, 외부 API 등과의 통합을 검증하며, 실제 의존성을 가지고 실행되는 환경에서 테스트 한다.
✅ Mockito 프레임워크
단위 테스트를 만들기 위해서는 스텁이나 목 오브젝트의 사용이 필수적입니다. 하지만 단위 테스트를 작성할 때마다 매번 목 오브젝트를 작성하는 것이 번거롭다는 문제점을 가지고 있습니다. MockUserDao를 만들 때를 보면 사용하지 않는 메소드들을 모두 구현하여야 했기 때문에, 사용하지 않는다는 throw new UnsupportedOperationException을 던지게 구현을 하였습니다. 이러한 번거로운 작업을 편리하게 작성하도록 도와주는 여러 Mock 프레임워크가 있고, 그 중에 Mockito 프레임워크에 대해 살펴보겠습니다.
Mockito와 같은 목 프레임워크의 특징은 목 클래스를 일일이 준비해둘 필요가 없다는 점입니다. 아래와 같은 간단한 메소드 호출만으로 특정 인터페이스르 룩현한 테스트용 목 오브젝트를 만들 수 있습니다.
UserDao mockUserDao = mock(UserDao.class);
이렇게 만들어진 목 오브젝트는 아무런 기능이 없습니다. 여기에 getAll() 메소드를 호출할 때 사용자 목록을 리턴하도록 스텁 기능을 추가할 수 있습니다.
when(mockUserDao.getAll().thenReturn(this.users);
mock 사용법 정리하기
🔹 Mockito 적용
- pom.xml
<!-- Mockito framework --> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.2.0</version> <!-- <scope>test</scope> --> </dependency>
Mockito framwork를 사용하기 위해 의존성을 추가합니다.
- UserServiceTest
//== Mockito를 사용한 테스트 코드 ==// @Test @DirtiesContext public void mockUpgradeLevels() throws Exception { UserServiceImpl userServiceImple = new UserServiceImpl(); //mockito를 사용한 Mock 객체 생성 및 주입 UserDao mockUserDao = mock(UserDao.class); when(mockUserDao.getAll()).thenReturn(this.users); userServiceImpl.setUserDao(mockUserDao); MailSender mockMailSender = mock(MailSender.class); userServiceImpl.setMailSender(mockMailSender); userServiceImpl.upgradeLevels(); //mockUserDao의 update 메소드가 2번 실행되는지 검증, 인수로 User타입의 객체를 임의로(any)로 전달함 verify(mockUserDao, times(2)).update(any(User.class)); //mockUserDao가 update 메소드를 실행할 때 인자로 users.get(1)를 인스턴스로 전달하는지 확인함 verify(mockUserDao).update(users.get(1)); assertEquals(users.get(1).getLevel(), Level.SILVER); //mockUserDao가 update 메소드를 실행할 때 인자로 users.get(3)를 인스턴스로 전달하는지 확인함 verify(mockUserDao).update(users.get(3)); assertEquals(users.get(3).getLevel(), Level.GOLD); //ArgumentCaptor 객체가 SimpleMailMessage 인스턴스를 저장할 수 있도록 설정함 ArgumentCaptor<SimpleMailMessage> mailMessageArg = ArgumentCaptor.forClass(SimpleMailMessage.class); //mockMailSender의 send 메소드가 2번 실행되었는지, //send() 메소드가 실행될 때 SimpleMailMessage 객체가 전달되었는지 확인 verify(mockMailSender, times(2)).send(mailMessageArg.capture()); List<SimpleMailMessage> mailMessages = mailMessageArg.getAllValues(); assertEquals(mailMessages.get(0).getTo()[0], users.get(1).getEmail()); assertEquals(mailMessages.get(1).getTo()[0], users.get(3).getEmail()); }
- 책에서는 아래와 같은 코드가 두 번 중복되었다. 오류인 것 같다. 굳이 두 번 검증할 필요가 없다.
verify(mockUserDao, times(2)).update(any(User.class)); verify(mockUserDao, times(2)).update(any(User.class));
- when : 행동하기
Mock 객체의 행동을 설정합니다. mockUserDao가 getAll() 메소드를 호출할 때라고 정의했습니다.
- thenReturn
Mock 객체의 행동 후에 어떤 작업을 할지 결정한다. List<User> users를 반환한다.
- thenThrow : 특정 예외를 발생시킨다.
- thenAnswer : 모든 작업이 가능하다.
- thenReturn
- verify : 검증하기
Mock 객체를 인자로 받아 Mock 객체의 상호작용이 제대로 이루어졌는지 검증합니다.
- times : 특정 횟수만큼 호출되었는지 검증한다.
- never : 호출되지 않음을 검증한다.
- atLeast : 최소 몇 번 호출되었는지 검증한다.
- atMost : 최대 몇 번 호출되었는지 검증한다.
- only : 해당 함수만 실행되었는지 검증한다.
- ArgumentMatchers : 인수 설정하기
- anyInt(), anyShort(), anyLong(), anyByte(), anyChar(). anyDouble(), anyFloat(), anyBoolean() : 기본 데이터 타입에 대한 임의 값
- anyString() : 문자열에 대한 임의 값
- any() : 임의 타입에 대한 일치
- anyList(), antSet(), anyMap(), anyCollection() : 임의 콜렉션에 대한 일치
- ArgumentCaptor : 호출할 때 전단한 인자를 보관
Mockito의 ArgumentCaptor를 사용하면 메서드 호출 여부를 검증하는 과정에서 실제 호출할때 전달한 인자를 보관할 수 있습니다.
📖 토비 스프링 3.1 -p413~429
🚩jhcode33의 toby-spring-study.git으로 이동하기
- 책에서는 아래와 같은 코드가 두 번 중복되었다. 오류인 것 같다. 굳이 두 번 검증할 필요가 없다.
Uploaded by N2T