단일 책임 원칙은 하나의 모듈은 한 가지 책임을 가져야 한다는 의미이다.
하나의 모듈이 바뀌는 이유는 한 가지여야 한다고 설명할 수도 있다.
고객으로부터 레벨 업그레이드 안내 메일 발송해달라는 요청사항이 들어왔다. 메일 발송을 위해 해야하는 할 일 두 가지가 있다.
1. 사용자 이메일 관리
2. 메일 발송 기능 추가
private void sendUpgradeEmial(User user) {
Properties props = new Properties();
props.put("mail.smtp.host", "mail.ksug.org");
Session s = Session.getInstance(props, null);
MimeMessage message = new MimeMessage(s);
try {
message.setFrom(new InternetAddress("useradmin@ksug.org"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress(user.getEmail()));
message.setSubject("Upgrade 안내");
message.setText("사용자님의 등급이 " + user.getLevel().name());
Transport.send(message);
} catch (AddressException e) {
throw new RuntimeException(e);
} catch (MessagingException e) {
throw new RuntimeException(e);
}
}
만약 메일 서버가 준비되어 있지 않다면 어떻게 될 것인가? 운영중에야 메일 서버가 있을테지만, 개발중이라면 어떨까?
다음과 같은 예외가 발생할 것이다.
java.lang.RuntimeException: javax.mail.MessagingException: Could not connect to SMTP host: mail.ksug.org, port: 25;
테스트가 수행될 때 실제로 사용할 메일 서버를 제대로 준비해두고 실행하면 아무런 문제가 없을 것이다.
그런데 과연 테스트를 하면서 매번 메일을 발송하는 것이 바람직한가? 대개는 바람직하지 못하다.
이와 마찬가지로 UserService와 JavaMail사이에도 똑같은 원리를 적용할 수 있다. JavaMail은 자바의 표준 기술이고 안정적인 모듈이다. JavaMail API를 통해 요청이 들어간다는 보장만 있으면 굳이 테스트를 할 때마다 JavaMail을 구동하지 않아도 된다.
운영시에는 JavaMail을 직접 이용해서 동작하도록 해야겠지만, 개발 중이거나 테스트를 수행할 때는 JavaMail을 대신할 수 있는, 그러나 JavaMail을 사용할 떄와 동일한 인터페이스를 갖는 코드가 동작하도록 만들어도 될것이다. 이렇게 하면 굳이 매번 검증이 필요 없는 불필요한 메일 전송 요청을 보내지 않아도 되고, 테스트도 빠르고 안전하게 수행될 수 있다.
JavaMail 대신에 테스트에서 사용할, JavaMail 같은 인터페이스를 갖는 오브젝트를 만들어서 사용하면 문제는 모두 해결된다.
JavaMail의 API는 이 방법을 적용할 수 없다.
트랜잭션을 적용하면서 살펴봤던 서비스 추상화를 이용하면 적용하면 된다.
메일을 전송하는 메소드로만 구성된 인터페이스
public interface MailSender {
void send(SimpleMailMessage simpleMessage) throws MailException;
void send(SimpleMailMessage[] simpleMessages) throws MailException;
}
기본적으로 메일 발송 기능을 제공하는 JavaMailSenderImpl 클래스를 이용하면 된다.
private void sendUpgradeEmial(User user) {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost("mail.server.com");
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(user.getEmail());
mailMessage.setFrom("useradmin@ksug.org");
mailMessage.setSubject("Upgrade 안내");
mailMessage.setText("사용자님의 등급이 " + user.getLevel().name());
mailSender.send(mailMessage);
}
아직은 JavaMailSimpleImple 클래스의 오브젝트를 직접사용하기에 JavaMail API를 사용하지 않는 테스트용 오브젝트로 대체할 수는 없다. 이제 스프링의 DI를 적용해보자.
public class UserService {
// ...
private MailSender mailSender;
public void setMailSender(MailSender mailSender) {
this.mailSender = mailSender;
}
private void sendUpgradeEMail(User user) {
SimpleMailMessage mailMessage = new SimpleMailMessage();
mailMessage.setTo(user.getEmail());
mailMessage.setFrom("useradmin@ksug.org");
mailMessage.setSubject("Upgrade 안내");
mailMessage.setText("사용자님의 등급이 " + user.getLevel().name());
this.mailSender.send(mailMessage);
}
}
<bean id="userService" class="springbook.user.service.UserService">
<property name="userDao" ref="userDao" />
<property name="transactionManager" ref="transactionManager" />
<property name="mailSender" ref="mailSender" />
</bean>
<bean id="mailSender" class="org.springframework.mail.javamail.JavaMailSenderImpl">
<property name="host" value="mail.server.com" />
</bean>
테스트가 수행될 때 JavaMail을 사용해 메일을 전송할 필요가 없다. 그냥 아무것도 하지 않는, 빈 클래스르 만들어 보자.
public class UserServiceTest {
static class DummyMailSender implements MailSender {
@Override
public void send(SimpleMailMessage simpleMessage) throws MailException {
}
@Override
public void send(SimpleMailMessage... simpleMessages) throws MailException {
}
}
}
테스트 설정파일의 JavaMailSenderImpl -> DummyMailSender로 변경한다.
<bean id="mailSender" class="springbook.user.service.UserServiceTest$DummyMailSender" />
테스트가 성공적인걸 볼 수 있다.
1. 실제로 메일이 발송될 일이 없다.
2. 메일 발송 기능 자체는 MailServer에 대한 학습 테스트 또는 메일 서버 설정 점검용 테스트를 만들어서 확인해보면 된다.
3. 메일 전송 메소드가 호출됐는지 간단히 확인해보려면 DummyMailSender의 메소드에 간단한 콘솔을 출력하도록 만들고 한 번쯤 찍힌 내용을 확인해보는 방법도 사용 할 수 있다.
public class UserServiceTest {
@Autowired MailSender mailSender;
@Test
public void upgradeAllOrNothing() throws Exception {
// ...
testUserService.setMailSender(mailSender);
// ...
}
}
서비스 추상화
두 가지 방법에 쓴 전략이 비슷하긴 하다. 하지만 전자가 사용자 관리 비즈니스 로직과 메일 발송 트랜잭션 개념을 적용하는 기술적인 부분이 한데 섞이게 한다면, MailSender의 구현 클래스를 이용하는 방법은 서로 다른 종류의 작업을 분리해 처리한다는 면에서 장점이 있다.
서비스 추상화란 이렇게 원할한 테스트만을 위해서도 충분히 가치가 있다. 기술이나 환경이 변할 수 있음에도 JavaMail처럼 확장이 불가능하게 설계해놓은 API를 사용해야 한다면 추상화 계층을 도입을 적극 고려해볼 필요가 있다.
원래 UserDao는 운영 시스템에서 사용하는 DB와 연결돼서 동작한다.
UserService는 메일 전송 기능
테스트 대상이 되는 오브젝트가 또 다른 오브젝트에 의존하는 일은 매우 흔하다.
해결책
UserDao : 환경 자체를 간단한게 만듬
UserService : 아무런 일도 하지 않는 빈 오브젝트로 대체
테스트 대역
테스트 스텁
테스트 대상의 오브젝트의 메소드가 돌려주는 리턴 값 뿐만 아니라 의존 오브젝트에 넘기는 값과 그 행위 자체를 검증하고 싶다면 어떻게 해야 할까?
UserServiceTest에 목 오브젝트 개념을 적용해보자.
public class UserServiceTest {
static class MockMailSender implements MailSender {
// UserService로부터 전송 요청을 받은 메일 주소를 저장해두고 이를 읽을 수 있게 한다.
private List<String> requests = new ArrayList<String>();
public List<String> getRequests() {
return requests;
}
@Override
public void send(SimpleMailMessage mailMessage) throws MailException {
// 전송 요청을 받은 이메일 주소를 저장해둔다.
// 간단하게 첫 번째 수신자 메일 주소만 저장했다.
requests.add(mailMessage.getTo()[0]);
}
@Override
public void send(SimpleMailMessage... mailMessage) throws MailException {
}
}
}
public class UserServiceTest {
@Test
// 컨텍스트의 DI 설정을 변경하는 테스트라는 것을 알려준다.
@DirtiesContext
public void upgradeLevels() throws Exception {
userDao.deleteAll();
for (User user : users) {
userDao.add(user);
}
// 메일 발송 결과를 테스트할 수 있도록 목 오브젝트를 만들어 userService 의존 오브젝트를 주입해준다.
MockMailSender mockMailSender = new MockMailSender();
userService.setMailSender(mockMailSender);
// 업그레이드 테스트 메일 발송이 일어나면 MockMailSender 오브젝트의 리스트에 그 결과가 저장된다.
userService.upgradeLvls();
checkLvlUpgraded(users.get(0), false);
checkLvlUpgraded(users.get(1), true);
checkLvlUpgraded(users.get(2), false);
checkLvlUpgraded(users.get(3), true);
checkLvlUpgraded(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(1), is(users.get(3).getEmail()));
}
}