DEV Community

besfir1356
besfir1356

Posted on • Originally published at besfir.xyz

Spy, Stub, Fake 그리고 Mock에 대해서 알아보자.

블로그에 작성한 글입니다. 코드에 라인 하이라이팅이 되지 않아서 글 만으로는 어색한 부분이 있을 수 있는데, 블로그에 오시면 확인이 쉽습니다.

Test double

Test double(테스트 대역)은 영화 속 배우를 대신하는 Stunt double(스턴트 대역)에서 유래되었으며, 테스트를 진행하기 어려운 경우 이를 대신해 진행해 주는 객체를 뜻한다. 다른 방식으로 설명하자면 테스트 대상에 집중할 수 있도록, 의존하는 구성요소를 테스트에 용이하도록 단순화한 객체를 뜻한다.

여기서 테스트 대상을 System Under Test 줄여서 SUT라고 부르고, SUT가 의존하는 구성요소를 Depended-On Component 줄여서 DOC라고 부른다.

Image description

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));
}
Enter fullscreen mode Exit fullscreen mode
class ConsolePrinter {
    public void print(String text){
        System.out.println(text);
    }
}

class DummyConsolePrinter extends ConsolePrinter {
    @Override
    public void print(String text) {}
}
Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

    }
}
Enter fullscreen mode Exit fullscreen mode

테스트 대역의 한계

  • 리팩토리 내성이 낮다.

리팩토리 내성이 낮다는 말이 와닿지 않을 수 있는데, 관리 비용이 증가한다는 것과 같은 말이다. 예제를 통해 확인해보자. 테스트 대역을 이용하면 아래처럼 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();
}
Enter fullscreen mode Exit fullscreen mode

이때 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);
}
Enter fullscreen mode Exit fullscreen mode

직접 만드는 테스트 대역의 경우에는 테스트 외부에서 Stubbing을 하기 때문에 중복이 줄어 그 정도가 덜하다. 그러나 Mock 라이브러리를 이용하는 경우, 테스트 내부에서 Stubbing을 하기 때문에 모든 테스트를 수정해야 하는 고통을 겪게 된다.

  • 신뢰도가 떨어진다.

Stubbing을 작성할 때, production에서 DOC가 실제 그렇게 동작하길 기대하면서 작성한다. 하지만 막상 실제 객체는 예상한대로 동작하지 않을 수 있다.

  • 코드량이 지나치게 많아질 수 있다.

"리팩토리 내성이 낮다." 의 끝에서 언급한 이유로 코드량이 지나치게 많아질 수 있다.

결론

각 테스트 대역을 구분하기 쉽게 최대한 간단하게 정리했지만, 실제 프로젝트를 진행하다보면 구분히 명확히 되지 않을 수 있다. 이는 사실 그리 이상한 일이 아닌데, MSDN 메거진에서는 아래의 그림을 예시로 들며 이론과 달리 실제로는 각 테스트 대역의 차이점이 모호하다고 얘기한다.

테스트 대역의 스펙트럼

용어보다는 객체의 역할에 집중하는 것이 더 생산적일 것 같다.

Reference

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more