이 글은 Test Doubles을 읽고 내용 정리 및 개인 의견에 대해 작성하였습니다.
단위 테스트는 개발자의 생산성을 유지하고 코드의 결함을 줄이는 데 중요한 도구입니다. 간단한 코드에 대해서는 작성하기 쉬울 수 있지만 코드가 복잡해지면 작성하기가 어려워집니다.
Test Double은 이러한 경우에 유용합니다. Test Double이란 테스트에서 실제 구현을 대신할 수 있는 개체 또는 메소드를 말합니다. Test Double을 통해 실제로 실행하지 않고 무거운 메소드가 호출되도록 하는 등 시스템의 특정 세부 정보를 검증할 수 있습니다.
소위 잘 알고있는 Mokito나 mock이 Test Double을 더 쉽게 생성할 수 있게 해주는 소프트웨어 라이브러리입니다. 이 라이브러리에서는 바로 아래서 설명드릴 Stubbing과 Interaction testing을 지원합니다.
AuthorizationService fakeAuthorizationService =
new FakeAuthorizationService();
AccessManager accessManager = new AccessManager( fakeAuthorizationService ):
when(...).thenReturn(...)
verify(...)
Test Double은 복잡한 시스템에서 귀중한 테스트 도구로서의 수단이 될 수 있습니다. 수많은 개발자들이 mocking framework를 통한 개발을 진행중에 있으며 손쉽게 테스트 코드를 작성할 수 있다는 점에서 많은 사랑을 받고 있습니다.
하지만 mocking framework의 남용은 실제 구현과 동기화되지 않을뿐더러 리팩토링을 어렵게 만드는 반복 코드가 생성되는 문제점을 야기했습니다.
단위 테스트가 Test Double에 너무 많이 의존하는 경우, 엔지니어는 통합 테스트를 실행하거나 기능이 예상대로 작동하는지 수동으로 확인해봐야 동일한 수준의 신뢰를 얻을 수 있습니다.
이러한 추가 작업을 수행할 경우 개발자가 실제 구현된 객체를 사용한 테스트 수행 시간에 비해 시간이 너무 많이 소요되는 문제가 발생하며, 수동으로 확인하는 작업을 건너뛸 경우 버그가 발생할 수 있는 딜레마가 발생합니다.
Google에서는 이러한 스타일의 테스트가 확장하기 어렵다는 것을 알게되었고 테스트 중인 시스템을 설계할 때 엄격한 지침을 따르도록 정책을 변경했습니다.
테스트에서 실제 구현을 선호하는 것을 클래식 테스트라 부르는데 Google 엔지니어들은 클래식 테스트 스타일에 더 적합한 방식으로 코드를 작성하게 되었습니다.
모의 프레임워크에 과도하게 의존하는 테스트를 방지하고자 Google은 다음과 같은 어노테이션을 만들었습니다.
@DoNotMock("Use SimpleQuery.create() instead of mocking.")
public abstract class Query {
public abstract String getQueryValue();
}
다음 어노테이션을 사용할 경우 "해당 클래스는 무조건 클래식 테스트로 구현해!"라는 의미를 API 소유자가 코드에 의미를 담았다고 생각하시면 좋을 것 같습니다.
그렇다면 Google은 도대체 어느 경우에 Test Double을 사용하는 걸까요?
Google은 테스트에서 실제 구현된 객체를 사용
하는 방식을 선호한다고 했으나 일부 상황에서는 Test Double 사용을 고려해볼 필요가 존재합니다.
테스트 코드 작성시 클래식 테스트와 Test Double 두 가지 중 하나를 선택한다고 할 때 상호간의 trade-off가 존재하기 때문에 만약 Test Double을 사용할 경우 다음과 같은 사항들을 고려해야 합니다.
단위 테스트의 가장 중요한 특성 중 하나는 빨라야 한다는 것입니다. Test Double은 실제 구현된 코드가 느릴 경우 유용하게 사용할 수 있습니다.
ex) 실제 구현된 메소드 테스트시 호출당 1초가 소요된다 했을 때 5개의 tc를 생성할 경우 5초 소요
이렇게 실제 구현된 내용을 사용할 경우 빌드 및 테스트 시간에 오랜 시간을 소요해야 된다는 단점이 존재합니다.
'어떤 것이 옳다' 하는 확실한 답이 존재하지 않기 때문에 개발자의 가치관에 따라 클래식 테스트 방식을 선택할지 Test Double을 선택할지가 결정됩니다.
테스트를 실행하면 항상 동일한 결과가 나오는 경우 테스트는 결정적 입니다 . 반대로 테스트가 시스템이 변경되지 않은 경우에도 결과가 변경될 수 있는 경우가 있는데 이를 비결정적이라 부릅니다. (ex) 외부 시스템 api 호출)
테스트는 항상 통과하거나 항상 실패하기에 테스트의 비결정성으로 취약성이 발생할 수 있습니다. 이러한 취약성이 자주 발생하는 경우 Test Double 사용을 고려합니다.
실제 구현을 사용할 때 모든 종속성을 구성해야 하는 문제가 존재합니다. Test Double에는 종속성이 없는 경우가 많으므로 실제 구현된 내용을 이용하는 것보다 Test Double을 이용하는 것이 테스트 코드 작성간 훨씬 간단할 수 있습니다.
실제 구현을 사용하는 것이 테스트 내에서 가능하지 않은 경우 가장 좋은 옵션은 Faking 기법을 대신 사용하는 것입니다. Faking은 실제 구현과 유사하게 동작하여 다른 Test Double 기술보다 선호되는 방식입니다.
// This fake implements the FileSystem interface. This interface is also
// used by the real implementation.
public class FakeFileSystem implements FileSystem {
// Stores a map of file name to file contents. The files are stored in
// memory instead of on disk since tests shouldn’t need to do disk I/O.
private Map<String, String> files = new HashMap<>();
@Override
public void writeFile(String fileName, String contents) {
// Add the file name and contents to the map.
files.add(fileName, contents);
}
@Override
public String readFile(String fileName) {
String contents = files.get(fileName);
// The real implementation will throw this exception if the
// file isn’t found, so the fake must throw it too.
if (contents == null) { throw new FileNotFoundException(fileName); }
return contents;
}
}
Google의 일부 팀은 Faking을 이용한 API를 제공해 다른 팀에서 이용할 수 있도록 제공하여 전체적인 개발 속도를 향상시켰습니다.
이러한 장단점들을 고려하며 Faking 기법을 이용하는 것이 과연 실제 구현된 로직을 이용하는 것보다 생산성 향상을 가져다 줄 것인지 한 번 더 고민해봐야 합니다.
Stubbing은 테스트에 적용하기가 매우 쉽기 때문에 실제 구현을 사용하는 것이 쉽지 않을 때마다 이 기술을 사용하고 싶을 수 있습니다.
그러나 Stubbing을 과도하게 사용하면 이러한 테스트를 유지해야 하는 개발자의 생산성이 크게 저하될 수 있습니다.
@Test public void creditCardIsCharged() {
// Pass in test doubles that were created by a mocking framework.
paymentProcessor = new PaymentProcessor(mockCreditCardServer, mockTransactionProcessor);
// Set up stubbing for these test doubles.
when(mockCreditCardServer.isServerAvailable()).thenReturn(true);
when(mockTransactionProcessor.beginTransaction()).thenReturn(transaction);
when(mockCreditCardServer.initTransaction(transaction)).thenReturn(true);
when(mockCreditCardServer.pay(transaction, creditCard, 500)).thenReturn(false);
when(mockTransactionProcessor.endTransaction()).thenReturn(true);
// Call the system under test.
paymentProcessor.processPayment(creditCard, Money.dollars(500));
// There is no way to tell if the pay() method actually carried out the
// transaction, so the only thing the test can do is verify that the
// pay() method was called.
verify(mockCreditCardServer).pay(transaction, creditCard, 500);
}
과도한 Stubbing을 피하기 위해 mockCreditCardServer
가 아닌 faking 객체인 creditCardServer
혹은 실제 구현된 creditCardServer
를 사용할 수 있습니다.
@Test public void creditCardIsCharged() {
paymentProcessor = new PaymentProcessor(creditCardServer, transactionProcessor);
// Call the system under test.
paymentProcessor.processPayment(creditCard, Money.dollars(500));
// Query the credit card server state to see if the payment went through.
assertThat(creditCardServer.getMostRecentCharge(creditCard)).isEqualTo(500);
}
테스트 목적을 명확히 하기 위해 Stub된 각 메소드는 테스트의 검증 내용과 직접적인 관계가 있어야 합니다. 그렇기에 각 테스트는 일반적으로 적은 Stub을 가져야만 합니다.
많은 메소드를 Stubbing 해야 하는 테스트는 Stubbing이 과도하게 사용되고 있거나 테스트 중인 시스템이 너무 복잡하여 리팩터링해야 한다는 시그널일 수 있습니다.
따라서 일반적으로는 Faking을 통한 구현 또는 실제 구현된 객체를 이용하는 테스트가 가장 적합하지만 테스트가 지나치게 복잡해지지 않도록 하기 위해 일부를 Stubbing하는 방식으로 사용할 경우 가장 합리적인 사용이 될 수 있습니다.
Interaction testing (mocking)의 주요 문제는 테스트 중인 시스템이 제대로 작동하는지 알 수 없다는 것입니다. 또한, 테스트 중인 시스템의 구현 세부 정보를 활용한다는 것입니다.
@Test
public void displayGreeting_renderUserName() {
when(mockUserService.getUserName()).thenReturn("Fake User");
userGreeter.displayGreeting(); // Call the system under test.
// The test will fail if any of the arguments to setText() are changed.
verify(userPrompt).setText("Fake User", "Good morning!", "Version 2.1");
// The test will fail if setIcon() is not called, even though this
// behavior is incidental to the test since it is not related to
// validating the user name.
verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}
@Test
public void displayGreeting_renderUserName() {
when(mockUserService.getUserName()).thenReturn("Fake User");
userGreeter.displayGreeting(); // Call the system under test.
verify(userPrompter).setText(eq("Fake User"), any(), any());
}
@Test
public void displayGreeting_timeIsMorning_useMorningSettings() {
setTimeOfDay(TIME_MORNING);
userGreeter.displayGreeting(); // Call the system under test.
verify(userPrompt).setText(any(), eq("Good morning!"), any());
verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}
test double이 코드를 종합적으로 테스트하고 테스트가 빠르게 실행되도록 보장할 수 있기 때문에 엔지니어링 속도에 매우 중요하다는 것을 알 수 있습니다.
하지만 이를 오용하면 불분명하고 취약하며 덜 효과적인 테스트로 이어질 수 있기 때문에 개발 생산성이 크게 저하될 수 있으며, 테스트를 안하니만 못하다는 문제점을 가지고 있습니다.
따라서 실제 구현된 로직를 사용할 것인지 또는 Test Double 방식을 이용할 것인지에 대한 선택은 팀 또는 개발자가 적합하다고 생각하는 기준을 몇가지 정의하고 사용해야만 합니다.
테스트 빌드 및 실행시간 단축은 개발 생산성에 매우 큰 영향을 준다고 생각합니다.
하지만, 실제 구현된 로직들을 가지고 테스트 코드를 작성해야 한다!
라는 글의 방향과 Google의 선택이 올바른지에 대해서는 한 번 더 고민해볼 필요가 있다고 생각합니다.
저는 '적절한 Stubbing과 Faking 객체만 잘 이용한다면 실제 구현된 로직을 테스트 코드로 사용하지 않아도 되지 않을까..?' 라는 입장이어서 굳이 클래식 테스트를 지향하지 않아도 된다는 의견을 가지고 있습니다.
Stubbing과 Mocking을 최소화하고 Faking 객체를 만들어 TC들을 리팩토링한다면 실제 구현된 로직을 테스트 코드에 사용하지 않더라도 신뢰도가 높은 TC를 제공할 수 있다고 생각합니다.
mock을 최소화하고 각 로직에 Faking 객체를 좀 더 많이 만들어보는 노력을 해야되지 않을까하는 의견을 제안합니다. 이럴 경우 개발 생산 속도는 떨어지지만 개발자 테스트를 최소화 할 수 있지 않을까 하는 생각을 가지고 있습니다.
Faking이 활성화 될 경우, 일부 TC에 대해 @DoNotMock annotation을 활용해 Faking 객체 또는 실제 구현된 객체를 이용하라는 메세지를 공유하면 더 좋은 TC를 만들 수 있습니다.
테스트 코드 작성시 Stubbing은 검증하는 로직에 한해서 사용되는게 좋지 않을까 합니다.
다음 글에서는 테스트 프레임워크인 junit을 활용한 테스트 코드 작성에 대해 공유하는 시간을 가져보겠습니다.
읽어주셔서 감사합니다.