블로그에 작성한 글입니다. 코드에 라인 하이라이팅이 되지 않아서 글 만으로는 어색한 부분이 있을 수 있는데, 블로그에 오시면 확인이 쉽습니다.
Test double
Test double(테스트 대역)은 영화 속 배우를 대신하는 Stunt double(스턴트 대역)에서 유래되었으며, 테스트를 진행하기 어려운 경우 이를 대신해 진행해 주는 객체를 뜻한다. 다른 방식으로 설명하자면 테스트 대상에 집중할 수 있도록, 의존하는 구성요소를 테스트에 용이하도록 단순화한 객체를 뜻한다.
여기서 테스트 대상을 System Under Test 줄여서 SUT라고 부르고, SUT가 의존하는 구성요소를 Depended-On Component 줄여서 DOC라고 부른다.
SUT와 DOC 간의 협력이 어색하지 않다면, 해당 기능(행동)을 하나의 단위로 보고 함께 테스트 하는 것을 Sociable test라 한다. Sociable test의 경우, 현재 시간과 같이 SUT가 제어할 수 없는 상황이나 Email처럼 비용이 많이 드는 코드를 테스트 하기 어렵다.
이와 반대로, SUT와 DOC의 의존성을 끊고 하나의 모듈 단위로 테스트하는 것을 Solitary test라 한다. Solitary test의 경우 DOC를 단순하고 통제할 수 있는 객체로 만들어 SUT가 외부 어떠한 요인에도 영향을 받지 않도록 고립시켜 테스트 할 수 있다. 이때 만드는 DOC가 바로 테스트 대역이다.
💡 테스트 대역의 필요성에 대해서 서술하다보니 sociable test가 부정적으로 표현됐는데, 실제로는 그렇지 않다. 추후에 Solitary test와 Sociable test의 장단점을 정리해 보도록 하겠다.
언제 사용하는가?
- 제어할 수 없는 상황을 통제할 때.
랜덤 숫자, 현재 시간, DB에서 특정 레코드 읽기는 항상 같은 결과를 보장하지 않는다. 또한 네트워크 오류나 외부 서비스의 상태가 항상 정상이 아닐 수도 있다. 이처럼 SUT가 제어할 수 없는 상황을 일관된 상황으로 통제할 때 사용할 수 있다.
- 비용이 많이 드는 테스트를 할 때.
이메일, SMS 또는 메신저 등으로 보내는 템플릿이나 메시지큐에 보내는 메시지 내용 등은 테스트 비용이 많이 들어 테스트하기 어려운 기능이다. 이 경우, 검증에 필요한 내용을 테스트 대역에 저장한 뒤 검증할 수 있다.
- 테스트 작성을 위한 환경 구축할 때.
특정 기능이 아직 구현되지 않아 테스트를 할 수 없는 경우 사용할 수 있다.
Test double의 종류
Dummy
가장 기본적인 테스트 대역으로, 파라미터 값으로 넘길때만 사용되며 실제로 쓰이지는 않는 객체이다. 필드에 기본값만 채워져있는 객체나 메소드 구현부가 비어있는 객체가 이에 해당된다.
@Test
void dummyTest() {
Article dummyArticle = new Article("", "", new Date(), 0);
assertThrows(Exception.class, () -> articleService.register(dummyArticle));
}
class ConsolePrinter {
public void print(String text){
System.out.println(text);
}
}
class DummyConsolePrinter extends ConsolePrinter {
@Override
public void print(String text) {}
}
Stub
Dummy에서 한 단계 업그레이드 된 객체이며 하드 코딩된 값을 반환한다.
테스트 중 발생하는 호출에 대해서 미리 결과를 준비해놓는 것은 Stubbing이라 한다.
class StubArticleRepository implements ArticleRepository {
@Override
public List<Article> getAll() {
return List.of(
new Article("title_1", "content_1", LocalDateTime.of(2020, 5, 19, 10, 50), 1),
new Article("title_2", "content_1", LocalDateTime.of(2021, 8, 23, 10, 50), 0)
);
}
}
Spy
실제 객체와 동일한 동작을 할 수도 있고 Stub으로써 동작할 수도 있다. 호출이 되었을 때, 테스트 검증에 필요한 데이터를 저장한다.
class TestDoubleTest {
@Test
void spyTest() {
SpyArticleRepository spy = new SpyArticleRepository();
ArticleService service = new ArticleService(spy);
Article stub1 = new Article("title_1", "content_1", LocalDateTime.of(2020, 5, 19, 10, 50), 1);
Article stub2 = new Article("title_2", "content_2", LocalDateTime.of(2021, 6, 29, 11, 15), 2);
Long id1 = service.register(stub1);
Long id2 = service.register(stub2);
assertEquals(1, id1);
assertEquals(2, id2);
assertEquals(2, spy.getArticles().size()); // Spy's save behavior happened 2 times
}
}
class SpyArticleRepository implements ArticleRepository {
private List<Article> articles = new ArrayList<>();
@Override
public Long save(Article article) {
articles.add(article);
return 1L;
}
public List<Article> getArticles() {
return articles;
}
}
class ArticleService {
private ArticleRepository repository;
public ArticleService(ArticleRepository repository) {
this.repository = repository;
}
public Long register(Article article) {
return repository.save(article);
}
}
Fake
실제 객체의 동작을 단순화해서 구현한 객체이며, 동적으로 동작은 하지만 프로덕션 환경에서 사용하기는 적합하지 않다. HashMap을 이용해서 인메모리 DB를 구현한 것을 예로 들 수 있다.
class ArticleRepositoryFake implements ArticleRepository {
private Map<Long, Article> articles = new HashMap<>();
@Override
public Long save(Article article) {
Long existId = article.getId();
if (existId == null) {
return createNewArticle(article);
}
updateOldArticle(existId, article);
return existId;
}
private void updateOldArticle(Long existId, Article article) {
articles.put(existId, article);
}
private Long createNewArticle(Article article) {
Long id = (long) (getAll().size() + 1);
this.articles.put(id, article);
return id;
}
}
Mock
Mock 라이브러리를 통해 동적으로 생성한 객체이다. 구성하기에 따라 Dummy, Stub 또는 Spy처럼 동작한다.
💡 Mock 라이브러리 또는 테스트 프레임워크를 이용해서 Spy 객체 생성하면, 보통은 실제 객체처럼 동작하며 원하는 메소드를 선택적으로 Stubbing 해서 사용할 수 있다. 필자가 관심을 두고 있는 Mockito, Mockk, Spock 그리고 Jest에서 확인했으나, 혹시 다른 경우가 있다면 제보 바란다.
@ExtendWith(MockitoExtension.class)
class TestDoubleTest {
@Test
@DisplayName("A mock behave like a dummy")
void mockTest1(@Mock Member dummyMember) {
Article article = new Article("Title 1", "Content 1", now(), 10, dummyMember);
ArticleRepository repository = new ArticleRepository();
assertDoesNotThrow(() -> repository.save(article));
}
@Test
@DisplayName("A mock behave like a stub")
void mockTest2(@Mock ArticleRepository stubRepository) {
when(stubRepository.findById(anyLong())).thenReturn(new Article("Title 1", "Content 1", now(), 10, new Member("","")));
ArticleService service = new ArticleService(stubRepository);
Article article = service.fetch(1L);
assertEquals("Title 1", article.getTitle());
}
@Spy
ArticleRepository spyRepository;
@Test
@DisplayName("A mock behave like a spy")
void mockTest3() {
ArticleService service = new ArticleService(spyRepository);
service.fetch(1L);
service.fetch(2L);
verify(spyRepository, times(2)).findById(anyLong());
}
}
테스트 대역의 한계
- 리팩토리 내성이 낮다.
리팩토리 내성이 낮다는 말이 와닿지 않을 수 있는데, 관리 비용이 증가한다는 것과 같은 말이다. 예제를 통해 확인해보자. 테스트 대역을 이용하면 아래처럼 SUT의 내부 구현이 외부에 드러나게 된다.
class SutServiceTest {
@Test
public void someMethodTest() {
// given
FooService mockFooService = mock(FooService.class);
SutService sut = new SutService(mockFooService);
// when
when(mockFooService.bar()).thenReturn(1); // Internal implementation was exposed
sut.someMethod();
...
// then
verify(mockFooService).bar(); // Internal implementation was exposed
...
}
}
class SutService {
private FooService fooService;
public SutService(FooService fooService) {
this.fooService = fooService;
}
public void someMethod() {
fooService.bar();
}
}
interface FooService{
int bar();
}
이때 DOC의 메소드 이름이나 파라미터가 변경될 경우, 다시 말해 메소드 시그니처가 변경될 경우 테스트 코드도 같이 수정되어야 한다.
class RefactoringTest {
@Test
public void someMethodTest() {
// given
FooService mockFooService = mock(FooService.class);
SutService sut = new SutService(mockFooService);
// when
when(mockFooService.bar()).thenReturn(1); // Break
sut.someMethod();
...
// then
verify(mockFooService).bar(); // Break
...
}
}
interface FooService{
Bar bar(Factor factor);
}
직접 만드는 테스트 대역의 경우에는 테스트 외부에서 Stubbing을 하기 때문에 중복이 줄어 그 정도가 덜하다. 그러나 Mock 라이브러리를 이용하는 경우, 테스트 내부에서 Stubbing을 하기 때문에 모든 테스트를 수정해야 하는 고통을 겪게 된다.
- 신뢰도가 떨어진다.
Stubbing을 작성할 때, production에서 DOC가 실제 그렇게 동작하길 기대하면서 작성한다. 하지만 막상 실제 객체는 예상한대로 동작하지 않을 수 있다.
- 코드량이 지나치게 많아질 수 있다.
"리팩토리 내성이 낮다." 의 끝에서 언급한 이유로 코드량이 지나치게 많아질 수 있다.
결론
각 테스트 대역을 구분하기 쉽게 최대한 간단하게 정리했지만, 실제 프로젝트를 진행하다보면 구분히 명확히 되지 않을 수 있다. 이는 사실 그리 이상한 일이 아닌데, MSDN 메거진에서는 아래의 그림을 예시로 들며 이론과 달리 실제로는 각 테스트 대역의 차이점이 모호하다고 얘기한다.
용어보다는 객체의 역할에 집중하는 것이 더 생산적일 것 같다.
Top comments (0)