본문 바로가기
Spring&SpringBoot/<토비의 스프링 3.1 Vol1.1>, 이일민

테스트에 서비스 추상화 적용하기 (feat. 테스트 대역, 테스트 스텁, 목 오브젝트)

by 민휘 2023. 4. 4.

사용자 레벨이 업그레이드되는 경우, 해당 사용자에게 이메일을 보내 알려주는 기능을 추가하기로 했다. 이메일 전송 메소드는 UserService의 upgradeLevels에서 호출된다.

 

메일 서비스 추상화의 목적

 

메일 서비스에 추상화 계층을 추가하면 테스트하기 어려운 JavaMail을 테스트하기 편리하게 만들어준다.

 

메일 서비스 테스트의 관심사

자바 프로그램에서 메일 보내기 기능을 구현하려면 JavaMail API를 사용해서 실제 메일 서버에 전송할 메일을 전달한다. 운영 환경에서는 직접 메일을 전송해야하지만, UserService의 upgradeLevels을 테스트하는 테스트 환경에서는 메일 전송 테스트가 레벨 업그레이드라는 관심사를 벗어날 뿐만 아니라 메일 서버에 부담을 줄 수 있다.

 

JavaMail은 수많은 시스템에서 사용돼서 검증된 안정적인 모듈이다. 따라서 JavaMail API를 통해 요청이 들어간다는 보장만 있으면 굳이 테스트할 때마다 JavaMail을 구동시킬 필요가 없다. 그러니 JavaMail과 동일한 인터페이스를 갖는 오브젝트를 만들어서 테스트에 사용하면 되겠다.

 

 

 

JavaMail은 확장하기 어려운 API이다

 

그런데 문제는 JavaMail은 이렇게 테스트 용으로 DI하기 어렵다. 인터페이스가 아닌 클래스에 의존하고, final 생성자라 직접 생성도 불가능하고, final 클래스라 상속까지 불가능하다.

 

스프링은 JavaMail을 사용해 만든 코드는 테스트용으로 바꿔치기가 어렵다는 문제를 해결하기 위해 메일 서비스 추상화의 인터페이스를 제공한다. 바로 MailSender이다.

package org.springframework.mail;

public interface MailSender { // 스프링 제공!

	void send(SimpleMailMessage simpleMessage) throws MailException;
	void send(SimpleMailMessage[] simpleMessages) throws MailException;

}

 

MailSender를 구현하는 테스트용 MailSender를 사용해서, 테스트 환경에서는 메일 서버 구동 없이 JavaMail API에 요청이 전달되는지 테스트 가능하다. 이렇게 테스트 대상(UserService)이 사용하는 의존 오브젝트(JavaMailSenderImpl)를 대체하도록 만든 오브젝트를 테스트 대역(DummyMailSender)이라고 한다.

public class DummyMailSender implements MailSender {

    @Override
    public void send(SimpleMailMessage simpleMessage) throws MailException {

    }

    @Override
    public void send(SimpleMailMessage[] simpleMessages) throws MailException {

    }
}
<!-- 테스트 환경 -->
<bean id="mailSender" class="springbook.user.service.DummyMailSender"></bean>
public class UserServiceTest {
	@Autowired MailSender mailSender;

	@Test
	public void upgradeAllOrNothing() {
		UserService testUserService = new TestUserService(users.get(3).getId());
		testUserService.setUserDao(this.userDao);
		testUserService.setDataSource(this.dataSource);
		testUserService.setTransactionManager(this.transactionManager);
		testUserService.setMailSender(this.mailSender);

		userDao.deleteAll();
		for(User user: users) userDao.add(user);

		try {
			testUserService.upgradeLevels();
			fail("TestUserServiceException expected");
		} catch (TestUserServiceException e) {}

		checkLevelUpgraded(users.get(1), false);
	}

}

 

 

서비스 추상화로 얻는 장점

서비스 추상화는 DI를 사용하여 인터페이스를 두고 여러 구현체를 쉽게 바꿀 수 있다. 그래서 DI의 장점인 변경에 유연한 시스템을 만드는 것과 같은 맥락의 장점을 가진다.

 

서비스 추상화를 한다면 서비스의 인터페이스에서 해당 서비스가 무엇을 해야만 하는지에 집중할 수 있다. 구현체는 서비스의 관심사를 어떻게 구현하는지 집중한다. 예를 들어 메일 서비스에서 MailSender는 메일 전송에만 관심을 둔다. 메일 서비스 구현체는 어떤 메시징 서버를 사용할지, 메시징 서버를 사용할지 말지, 메일 발송 큐를 둘지, 트랜잭션을 적용할지 등을 결정하여 메일 전송을 한다. 결국 무엇과 어떻게를 분리하여 확장성 있는 설계가 가능하다.

 

운영 환경에서 확장이 필요하지 않다는 것이 결정되더라도, 테스트 환경에서는 운영 환경과 다른 구현체가 필요할 수 있다. 예를 들어 Mail Service에서 JavaMail을 사용한다고 결정하더라도, 테스트 환경에서는 메시징 서버를 사용하지 않게 하기 위해 JavaMail API의 인터페이스를 필요로 했던 것 처럼 말이다. 따라서 서비스 추상화는 테스트를 쉽게 만드는 것만으로도 가치가 있다.

 

테스트 대역

서비스 추상화가 테스트 환경에서 유용하게 사용되는 것을 위에서 알아보았다. 테스트할 대상이 의존하고 있는 오브젝트를 DI로 바꿔치기 하는 방법이었다. 이렇게 테스트 환경에서 바꿔치기하는 오브젝트를 테스트 대역이라고 한다.

 

운영 환경에서 테스트할 대상이 의존하는 오브젝트를 생성하거나 실행하는데 비용이 많이 드는 경우, 테스트 환경에서는 비싼 오브젝트를 비용이 더 적게 드는 오브젝트로 바꿔치기해서 사용하는 경우가 많다. 이렇게 비용이 덜 드는 테스트용 오브젝트로 활용할 수 있는 것이 테스트 스텁과 목 오브젝트이다.

 

  • 테스트 스텁 : 테스트 대상이 의존하고 있는 오브젝트를 단순히 대체하여 테스트 오브젝트가 정상적으로 실행되도록 도와준다. 위에서 살펴본 DummyMailSender가 그 예시이다.
  • 목 오브젝트 : 테스트 대상 오브젝트와 테스트 대상이 의존하고 있는 오브젝트 사이(자기 자신)에서, 테스트 대상 오브젝트가 넘겨주는 출력값을 보관한다. 대상과 의존 오브젝트 사이에 커뮤니케이션을 검증하기 위해 사용한다.

 

메일 서비스 추상화에 적용한 목 오브젝트의 예시를 살펴보자. MockMailSender는 UserService가 메일 전송을 요청한 유저가 누구인지를 리스트로 보관한다. MockMailSender는 UserService와 의존 대상인 MailSender 사이에서 테스트가 원활하게 돌아가도록 MailSender API를 구현하고, 이 둘 사이의 메시지 요청 내용을 저장하는 역할을 한다.

 

public class MockMailSender implements MailSender {

    private List<String> requests = new ArrayList<>();

    public List<String> getRequests() {
        return requests;
    }

    @Override
    public void send(SimpleMailMessage mailMessage) throws MailException {
        requests.add(mailMessage.getTo()[0]);
    }

    @Override
    public void send(SimpleMailMessage[] mailMessages) throws MailException {

    }
}
<bean id="mailSender" class="springbook.user.service.MockMailSender"></bean>
public class UserServiceTest {
		@Autowired MailSender mailSender;

		@Test
    @DirtiesContext // 컨텍스트의 DI 설정을 변경하는 테스트이다
    public void upgradeLevels() {
        userDao.deleteAll();
        for(User user:users) userDao.add(user);

        MockMailSender mockMailSender = new MockMailSender();
        userService.setMailSender(mockMailSender);

        userService.upgradeLevels();

        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(1), is(users.get(3).getEmail()));
    }
}