Mocks Aren't Stubs

09 May 2016

이하의 내용은 마틴파울러의 아티클 Mocks Aren’t Stub을 정리한 것이며, 스스로의 이해를 돕기 위해 의역한 부분 및 생략한 부분이 다소 있음을 밝힌다.

Mocks Aren’t Stubs - Martin Fowler

Mock 객체는 특별한 테스트 객체의 한 종류일 뿐 아니라, 다른 스타일의 테스팅을 가능하게 해준다. Mock 객체가 어떻게 작동하는지, 어떻게 behavior verification에 기반한 테스팅을 가능하게 하는지, Mock 객체를 둘러싼 커뮤니티들이 다른 스타일의 테스팅을 개발하는데에 어떻게 Mock 객체를 사용하는지 설명한다.

StubMock은 자주 혼동된다. (Stub: a common helper to testing environment.) 그러나 두 개의 구분되는 차이점이 있다.

  • 어떻게 테스트 결과가 검증되는지의 차이
    • state verification vs behavior verification
  • 테스팅과 설계가 함께 어우러지는 방법에 대한 전혀 다른 철학의 차이
    • 고전적 스타일 TDD vs mockist 스타일 TDD

Regular Tests

간단한 예제를 가지고 두 가지 스타일의 테스트를 살펴본다.

  • warehouse 객체는 여러 product들의 재고를 가지고 있다.
  • order 객체는 하나의 product와 하나의 quantity로 구성되어 있다.
  • order 객체는 warehouse로부터 product 객체를 가져와서 자기 스스로에게 채운다(fill).
  • order 객체가 warehouse로부터 product를 채우려고 할 때, 두 가지 경우가 있다.
  • 만약 warehouse에 order 객체를 채울만한 충분한 재고가 있다면 order 객체는 채워지고, warehouse의 product 갯수는 그만큼 줄어든다.
  • 만약 warehouse에 product의 재고가 부족하다면 order 객체는 채워지지 않고, warehouse에는 아무 일도 일어나지 않는다.
public class OrderStateTester extends TestCase {
  private static String TALISKER = "Talisker";
  private static String HIGHLAND_PARK = "Highland Park";
  private Warehouse warehouse = new WarehouseImpl();

  protected void setUp() throws Exception {
    warehouse.add(TALISKER, 50);
    warehouse.add(HIGHLAND_PARK, 25);
  }

  public void testOrderIsFilledIfEnoughInWarehouse() {
    Order order = new Order(TALISKER, 50);
    order.fill(warehouse);
    assertTrue(order.isFilled());
    assertEquals(0, warehouse.getInventory(TALISKER));
  }

  public void testOrderDoesNotRemoveIfNotEnough() {
    Order order = new Order(TALISKER, 51);
    order.fill(warehouse);
    assertFalse(order.isFilled());
    assertEquals(50, warehouse.getInventory(TALISKER));
  }

xUnit 테스트는 전형적인 4단계 순서를 따른다.

  • setup: 위의 예제에서 setUp() 메소드에 해당 (warehouse 셋팅)
  • exercise: order.fill(..) 호출 부분. 우리가 테스트하길 원하는 것을 객체에게 하라고 시키는 곳.
  • verify: assert statement 부분. 실행된 메소드가 작업을 올바르게 수행했는지 확인.
  • teardown: 위의 예제에서는 명시적인 teardown 단계는 없고, garbage collector가 암묵적으로 teardown 작업을 한다.

위의 예제에서 두 가지 종류의 객체를 만들었다. Order는 테스트하려는 클래스인데, Order.fill이 작동하게 하려면 Warehouse 인스턴스도 필요하다. 여기서 우리가 테스트에 초점을 두는 것은 Order 인데, 이를 object-under-test 혹은 system-under-test 줄여서 SUT 라고 한다.

따라서 이 테스트를 위해서는 SUT - Order 클래스와 하나의 협력자(collaborator) - warehouse 인스턴스가 필요하다. (이 둘은 매우 크게 구분된다.)

협력자 warehouse가 필요한 두 가지 이유는

  • 테스트하려는 behavior가 동작하도록 하기 위해 (Order.fill이 warehouse의 메소드를 호출하므로)
  • verification을 위해 (Order.fill의 한 가지 결과로서 warehouse의 상태에 변화가 일어날 수도 있으므로)

예제에서는 state verification을 사용한 것이다. 메소드가 실행된 후에 SUT와 collaborator의 상태를 검사함으로서 실행된 메소드가 올바르게 작동했는지 아닌지를 판단한다. 앞으로 보게 되겠지만, Mock 객체를 사용하면 다른 접근법으로 verification이 가능하다.

Tests with Mock Objects

앞에서 본 예제에 대하여 Mock 객체를 사용해보자. Java Mock 객체 라이브러리인 jMock을 사용하여 시작해보자.

public class OrderInteractionTester extends MockObjectTestCase {
  private static String TALISKER = "Talisker";

  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);
    Mock warehouseMock = new Mock(Warehouse.class);

    //setup - expectations
    warehouseMock.expects(once()).method("hasInventory")
      .with(eq(TALISKER),eq(50))
      .will(returnValue(true));
    warehouseMock.expects(once()).method("remove")
      .with(eq(TALISKER), eq(50))
      .after("hasInventory");

    //exercise
    order.fill((Warehouse) warehouseMock.proxy());

    //verify
    warehouseMock.verify();
    assertTrue(order.isFilled());
  }

  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    
    Mock warehouse = mock(Warehouse.class);

    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());

    assertFalse(order.isFilled());
  }
}

먼저 testFillingRemovesInventoryIfInStock에 집중하자. 이 테스트에서는 몇 가지 shortcut을 사용했다.

우선 setup 단계가 매우 다르다. 시작 부분에서 두 파트로 나뉜다. - data, expectations

data

작업하고자 하는 객체들을 setup 한다. 그런 의미에서 전통적인 setup과 유사하다. 차이점은 생성되는 객체이다. SUT는 order 객체로 동일하다. 단 collaborator가 Warehouse 클래스의 인스턴스인 warehouse가 아니라 Mock 클래스의 인스턴스인 mock warehouse 이다.

expectations

setup의 두 번째 파트에서는 mock 객체에 대한 예측(expectations)을 생성한다. 예측이란 SUT가 실행되었을 때 mock 객체의 어떤 메소드가 호출되어야 하는지를 나타내는 것이다.

모든 예측이 끝나면 SUT를 실행한다. 실행한 후에 verification을 하는데, 두 가지 측면이 있다.

  • SUT(order)에 대해 assert를 수행하는 것 : 이전의 예제와 같다.
  • mock 객체(warehouse)를 검증하는 것 : 예측한 대로 메소드들이 호출되었는지 확인하는 것.

핵심 차이점은 order가 warehouse와 상호작용 할 때 올바로 작동했는지 어떻게 검증하냐는 것이다. state verification에서는 warehouse의 상태를 assert 함으로써 검증할 수 있었다. 그러나 Mock은 behavior verification을 사용한다. behavior verification에서는 order가 warehouse에 대해 정확하게 호출했는지 아닌지를 살펴보아야 한다. 우리가 Mock에게 기대하는 것을 setup 단계에서 말해주고, mock이 verification 단계에서 스스로 검증하도록 요청함으로써 확인한다.

오직 order 만이 assert를 이용하여 검증한다. 만약 메소드가 order의 상태를 변경하지 않는다면 assert는 전혀 필요가 없다.

두 번째 테스트 testFillingDoesNotRemoveIfNotEnoughInStock에서는 몇 가지 다른 것들을 한다.

첫째로, mock을 다른 방식으로 생성한다 - 생성자가 아니라 MockObjectTestCase 클래스의 mock 메소드를 이용한다. jMock 라이브러리에 있는 메소드인데, 이렇게 하면 명시적으로 verify를 호출할 필요가 없다. 이 메소드를 이용하여 생성된 mock 객체는 테스트가 끝날 때 자동으로 검증된다.

둘째로, withAnyArguments을 사용함으로써 예측에 대한 제약을 완화시켰다. 첫번째 테스트에서 warehouse에 전달된 숫자를 검증하므로, 두번째 테스트에서 같은 테스트를 반복할 필요가 없다. 만약 나중에 order의 로직이 변경되어야 한다면, 오직 한 개의 테스트만 fail될 것이므로 테스트를 수정하는 수고를 덜 수 있다. withAnyArguments는 default 이므로, withAnyArguments를 아예 제거해도 된다.

Using EasyMock

많은 mock 객체 라이브러리들이 존재하는데, 그 중 자주 사용되는 것 중 하나로 EasyMock이 있다. EasyMock 또한 behavior verification을 가능하게 해준다. 단 jMock과 스타일면에서 몇 가지 차이점이 있다.

public class OrderEasyTester extends TestCase {
  private static String TALISKER = "Talisker";

  private MockControl warehouseControl;
  private Warehouse warehouseMock;

  public void setUp() {
    warehouseControl = MockControl.createControl(Warehouse.class);
    warehouseMock = (Warehouse) warehouseControl.getMock();    
  }

  public void testFillingRemovesInventoryIfInStock() {
    //setup - data
    Order order = new Order(TALISKER, 50);

    //setup - expectations
    warehouseMock.hasInventory(TALISKER, 50);
    warehouseControl.setReturnValue(true);
    warehouseMock.remove(TALISKER, 50);
    warehouseControl.replay();

    //exercise
    order.fill(warehouseMock);

    //verify
    warehouseControl.verify();
    assertTrue(order.isFilled());
  }

  public void testFillingDoesNotRemoveIfNotEnoughInStock() {
    Order order = new Order(TALISKER, 51);    

    warehouseMock.hasInventory(TALISKER, 51);
    warehouseControl.setReturnValue(false);
    warehouseControl.replay();

    order.fill((Warehouse) warehouseMock);

    assertFalse(order.isFilled());
    warehouseControl.verify();
  }
}

EasyMock은 예측(expectations)을 설정하기 위해 record, play 메타포를 사용한다.

warehouseControl = MockControl.createControl(Warehouse.class);
warehouseMock = (Warehouse) warehouseControl.getMock(); 

mock으로 만들고자 하는 각각의 객체에 대하여 control객체과 mock객체를 생성한다. 여기서는 warehouseControl과 warehouseMock을 생성하였다. mock은 collaborator의 인터페이스를 만족하고, control은 추가적인 기능들을 가진다.

warehouseMock.hasInventory(TALISKER, 50);

예측을 나타내기 위해, 호출될 것이라고 기대하는 mock 객체의 메소드를 argument와 함께 호출한다.

warehouseControl.setReturnValue(true);

반환되길 기대하는 return value가 있다면 control 객체를 호출한다.

warehouseControl.replay();

예측에 대한 셋팅이 끝났다면 control 객체의 replay 메소드를 호출한다. 이 지점에서 mock은 recording을 끝내고, SUT에게 응답할 준비가 된다.

warehouseControl.verify();

replay 메소드가 끝나면 control의 verify 메소드를 호출한다.

jMock은 String으로 메소드 이름을 지정하는 반면, easyMock은 직접 mock 인스턴스에 대해 실제로 메소드를 호출한다는 점에서 장점을 가진다. IDE의 자동완성을 사용할 수 있고, 메소드 이름을 리팩토링했을 때 테스트 메소드 또한 자동으로 수정되기 때문이다.

jMock 또한 새로운 버전에서는 EasyMock처럼 실제 메소드를 호출하도록 하려고 작업중이다.

The Difference Between Mocks and Stubs

Mock 객체를 사용하는 방법을 완전히 이해하기 위해서, mock 객체와 여러 종류의 test double들을 이해하는 것이 중요하다.

테스팅을 할 때, 한 순간에는 software의 하나의 요소에만 집중한다. (unit testing 이라는 용어처럼) 문제는 하나의 유닛이 작동하려면 종종 다른 유닛들이 필요하다는 것이다. 위의 예제에서, order 객체를 테스트하려면 warehouse가 필요했던 것처럼 말이다.

위에 보인 두 가지 스타일의 테스팅에서 첫번째 케이스는 실제 warehouse 객체를 사용하고, 두번째 케이스는 가짜인 mock warehouse를 사용한다. mock을 사용하는 것은 테스트에서 실제 warehouse를 사용하지 않는 방법 중 하나인데, 이처럼 테스팅에서 가짜 객체를 사용하는 다른 여러가지 방법들이 있다.

가짜 객체들을 지칭하는 어휘는 매우 많은데 여기에서는 Gerard Meszaros의 책에서 나온 어휘를 따를 것이다. 모든 사람들이 사용하는 것은 아니지만 나는 좋은 어휘라고 생각한다.

Meszaros는 테스팅을 목적으로 실제 객체 대신 사용되는 모든 종류의 위장(pretend) 객체들을 지칭하는 일반적인 용어로써 Test Double을 사용한다. 이는 영화에서의 Stunt Double 이라는 개념에서 온 것이다. Meszaros는 다음과 같이 네 가지 종류의 더블을 정의했다.

  • Dummy : 전달 되었으나 실제로 절대 사용되지 않는 객체. 일반적으로 파라미터 리스트를 채우는 데에만 사용한다.
  • Fake : 실제로 동작을 하도록 구현되어있는 객체. 단 일반적으로 실제 product에는 사용할 수 없는 간소화된 형태로 되어있다. (in-memory database가 좋은 예이다.)
  • Stubs : 테스트 동안 호출이 되면 미리 지정된 답변으로 응답을 한다. 미리 프로그램된 것 외의 것에 대해서는 응답하지 않는다. 또한 stub은 호출에 대한 정보를 기록할 수 있다. 예를 들어 email gateway stub은 ‘보낸’ 메세지 또는 몇 개의 메시지를 ‘보냈’는지를 기억할 수 있다.
  • Mocks : 지금 여기서 이야기하는 것. 예측(수신하길 기대하는 호출의 명세)과 함께 미리 프로그램 된 객체.

이러한 더블들 중 오직 mock 만이 behavior verification을 추구한다. 그 외의 다른 더블들은 state verification, behavior verification 둘 다 사용할 수 있으나 일반적으로 state verification을 한다.

mock은 실제로 exercise 단계에서는 다른 더블들과 같게 동작한다. SUT는 가짜 객체가 아닌 실제 collaborator와 대화하고 있다고 믿는다. 그러나 setup, verification 단계에서는 다르다.

테스트 더블을 좀 더 자세히 살펴보기 위하여 예제를 확장해보자. order.fill(…) 하는 것에 실패했을 때 메일을 보낸다고 해보자. 문제는 테스트 도중에 실제 고객에게 실제 이메일을 보내고 싶지 않다는 것이다. 따라서 이메일 시스템을 위한 테스트 더블을 만든다.

여기에서 mock과 stub 사이의 차이점을 볼 수 있다.

public interface MailService {
  public void send (Message msg);
}

public class MailServiceStub implements MailService {
  private List<Message> messages = new ArrayList<Message>();
  public void send (Message msg) {
    messages.add(msg);
  }
  public int numberSent() {
    return messages.size();
  }
}  
class OrderStateTester {
  ...
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    MailServiceStub mailer = new MailServiceStub(); // stub 사용
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }

물론 이것은 가장 심플한 테스트이다 - 단지 메세지가 보내졌다는 것을 테스트한다. 정확한 수신인에게 보내졌는지, 정확한 내용으로 보내졌는지는 테스트하지 않지만, 핵심을 명확히 보여주고 있다.

mock을 사용하면 테스트는 꽤 달라진다.

class OrderInteractionTester...
  public void testOrderSendsMailIfUnfilled() {
    Order order = new Order(TALISKER, 51);
    Mock warehouse = mock(Warehouse.class); // mock 사용
    Mock mailer = mock(MailService.class);  // mock 사용
    order.setMailer((MailService) mailer.proxy());

    mailer.expects(once()).method("send");
    warehouse.expects(once()).method("hasInventory")
      .withAnyArguments()
      .will(returnValue(false));

    order.fill((Warehouse) warehouse.proxy());
  }
}

두 가지 케이스 모두 실제 MailService 대신 테스트 더블을 사용하고 있다. 차이점은 stubstate verification을 사용하고, 반면 mockbehavior verification을 사용한다는 것이다.

stub에서 state verification을 사용하기 위해, 검증을 도와주는 추가적인 메소드들을 만들어야 한다. MailServiceStub 클래스는 MailService를 implements 했고 send(), numberSent() 메소드를 추가하였다.

Mock 객체는 항상 behavior verification을 사용하고, stub은 두 가지 방법 모두 사용할 수 있다. Meszaros는 behavior verification을 사용하는 stub을 Test Spy라고 칭했다. 차이점은 더블이 얼마나 정확하게 실행하고, 검증하냐는 것이다.

Classical and Mockist Testing

여기서 큰 이슈는, mock 혹은 다른 더블들을 언제 사용하냐는 것이다.

Classical TDD style

고전적 TDD 스타일은 가능한 실제 객체를 사용하고, 실제 객체를 사용하기 어려우면 더블을 사용한다. 따라서 고전적 TDDer는 warehouse에서는 실제 warehouse 인스턴스를 사용하고, 실제 고객에게 실제 이메일이 발송되면 안되므로 mailService에는 더블을 사용할 것이다. 더블의 종류가 무엇인지는 그리 중요하지 않다.

Mockist TDD

mockist TDDer들은 관심있는 behavior를 가진 모든 객체에 대해 항상 mock을 사용한다. 즉 warehouse, mailService 모두에 mock을 사용한다.

다양한 mock 프레임워크들이 mock 테스팅을 염두에 두고 설계되었음에도 불구하고, 많은 고전주의자들은 이것을 더블을 생성하는데에 도움이 되는 것으로 생각하고 있다. mockist 스타일의 중요한 분파 중 하나로서 Behavior Driven Development(BDD)가 있다.

BDD는 나의 동료 Dan North에 의해 개발되었다. 사람들이 TDD를 배우는데에 더욱 도움을 주기 위한 기술이며, TDD가 설계 기술로서 어떻게 작동하는지에 중점을 두고 있다. 이후, 객체가 해야 하는 것이 무엇인지 생각하는데에 TDD가 도움을 주는 곳을 더욱 잘 찾을 수 있도록 testbehavior로 renaming 하는 것으로 이어졌다.

BDD는 mockist 접근법을 취한다. 그러나 여기에 더해 naming style, 분석들의 통합이라는 양쪽으로 확장되었다. BDD가 mockist 테스팅을 사용하는 TDD의 또 다른 변형이라는 것이 이 아티클과의 유일한 관련성이므로 여기서 더 설명하지는 않겠다. (BDD 더보기: https://dannorth.net/introducing-bdd/)

Choosing Between the Differences

이 아티클에서 나는 다음의 차이점을 설명했다.

  • state verification or behavior verification의 차이
  • classic TDD or mockist TDD의 차이

state or behavior

첫번째로 고려해야 하는 것은 컨텍스트이다. order & warehouse 처럼 객체들간의 협력이 쉬운가? 아니면 order & mailService 처럼 어려운가?

간단한 협력이라면 선택은 간단하다. 만약 내가 classic TDDer라면 mock, stub 등 어떠한 종류의 더블도 쓰지 않는다. 실제 객체를 사용하고 state verification을 한다. 만약 내가 mockist TDDer라면 mock을 사용하고 behavior verification을 한다.

협력이 어려운 경우, mockist TDDer는 선택이 필요 없다. mock을 사용하고 behavior verification을 한다. classic TDDer라면 선택이 필요한데 그리 대단한 것은 아니고, 보통 그때 그때 각각의 상황에 따라 가장 쉬운 길을 선택할 것이다.

우리가 본 것 처럼, state vs behavior verification은 대개 큰 결정사항이 아니다. 진짜 이슈는 classic vs mockist TDD 이다.

특수한 케이스를 살펴보자. 때때로 협력이 어려운 경우가 아닌데도 불구하고 state verification을 사용하기 어려울 때가 있다. cache가 좋은 예이다. cache의 문제점은 cache가 hit인지 missed인지를 state만 봐서는 알 수 없다는 것이다. 이 경우는 하드코어 classical TDDer 일지라도 behavior verification이 현명한 선택이다. 그 반대의 경우의 예외도 있을 것이다.

classic vs mockist 사이의 선택에 대해 깊이 파고 들어갈수록 고려해야 할 요소들이 많다. rough한 그룹들로 나눠서 살펴보자.

Driving TDD

Mock 객체는 XP 커뮤니티에서 왔고, 주요 특징 중 하나가 TDD를 강조한다는 것이다. - 여기서 시스템의 설계는 테스트 작성에 의해 drive되는 iteration을 통하여 진화한다.

따라서 mockist들이 설계시의 mockist testing의 효과에 대해 특히 이야기하는 것은 놀랍지 않다. 특히 그들은 need-driven development 스타일을 옹호한다. 이 스타일은 시스템 외부에서 SUT를 위한 인터페이스를 만들며 첫번째 테스트를 작성함으로써 스토리 개발을 시작한다. 협력 객체에 대한 예측을 생각함으로써 SUT와 이웃들 사이의 인터랙션을 개척한다. - SUT의 외부 인터페이스를 효과적으로 디자인하는 것이다.

첫번째 케이스가 작동하면 mock에 대한 예측은 다음 단계를 위한 명세(specification)이 되고, 테스트들의 시작점이 된다. 각각의 예측을 협력객체에 대한 테스트로 변경하고, 한 번에 하나의 SUT를 작업하면서 프로세스를 반복한다. 이 스타일은 outside-in 으로도 알려져 있는데, 성격을 아주 잘 묘사하는 이름이다. 이것은 계층적(layered) 시스템에서도 잘 작동한다. mock 레이어를 바닥에 두고 UI 프로그래밍을 시작한다. 그리고 하위 레이어에 대해 테스트를 작성하고, 단계적으로 한 번에 한 개의 레이어를 밟아나간다. 이것은 매우 구조화되고 잘 컨트롤되는 접근인데, 신규 투입 개발자에게 OO와 TDD를 가이드할 때 도움이 된다고 많은 사람들이 생각하고 있다.

Classical TDD는 어떨까. mockist와 유사한 단계를 밟아 접근할 수도 있다. mock 대신 stub 메소드를 사용하고, 협력 객체로부터 어떤 것이 필요하다면 단순히 원하는 응답을 하드코딩한다. 그리고 green 막대를 확인한 후, 하드코딩 된 응답을 적당한 코드로 변경한다.

다르게 할 수도 있다. 일반적인 스타일은 middle-out이다. 이 스타일에서는 기능을 택하고 기능이 동작하기 위해서 도메인에서 무엇이 필요한지 결정한다. 필요한 것을 도메인 객체들이 수행하도록 하고, 동작이 시작되면 그 위에 UI를 올린다. 이렇게 하면 가짜의 무언가가 필요 없다. 많은 사람들은 이를 좋아한다. 처음부터 도메인 모델에 집중하여 도메인 로직이 UI로 새지 않도록 유지하는 것을 도와주기 때문이다.

나는 mockist나 classicst나 모두 한 번에 하나의 스토리로 작업한다는 것을 강조한다. 그 들 모두 애자일 백그라운드를 가지고 있고, fine-grained된 iteration을 선호하며, 레이어 단위 보다는 기능 단위로 작업한다.

Fixture Setup

classic TDD에서는 SUT 뿐 아니라 SUT가 필요로 하는 모든 협력객체들 또한 만들어야 한다. 예제에서는 단지 몇 개의 객체 뿐이었지만, 실제 테스트에서는 종종 많은 양의 보조 객체들이 필요하다. 보통 이 객체들은 각각의 테스트 수행시마다 생성되고 사라진다.

그러나 mockist 테스트에서는 오직 SUT와, SUT와 인접한 mock들만을 작성하면 된다. 이것은 복잡한 픽스쳐를 구축하는 작업을 피할 수 있다. (최소한 이론적으로는 그렇다.)

실전에서 classicist들은 복잡한 픽스쳐들을 가능한 재사용하려는 경향이 있다. 가장 심플한 방법은 픽스쳐 셋업 코드를 xUnit setUp() 메소드에 넣는 것이다. 좀 더 복잡한 픽스쳐는 여러개의 테스트 클래스에서 사용될 필요가 있는데, 이러한 경우는 특별한 픽스쳐를 만든다. 나는 보통 이 것을 Object Mothers라고 하는데, 예전의 ThoughtWorks XP 프로젝트에서 사용된 네이밍 컨벤션을 따른 것이다. 규모가 있는 classic 테스팅에서 Object Mother의 사용은 필수적이다. 그러나 mother는 추가된 코드이고, 관리되어야 하고, mother에 대한 어떤 변경이 테스트에 영향을 미칠 수 있다. 픽스쳐를 준비하기 위한 성능적 cost 또한 있을 수 있다. - 적절히 실행되었을 때 심각한 문제가 생겼다는 이야기는 들은 적 없다. 대부분의 픽스쳐 객체들은 생성 비용이 저렴하고, 만약 비싸다면 보통 더블이 사용된다.

결과적으로 두 스타일 모두 다른 스타일에게 ‘작업이 너무 많다’고 주장한다. mockist들은 픽스쳐를 만드는 노력이 크다고 하고, classicist들은 픽스쳐는 재사용되지만 mock은 모든 테스트마다 생성해야 한다고 말한다.

Test Isolation

mockist 테스팅을 하는 시스템에서 버그가 생기면, 보통 버그를 포함하는 SUT의 테스트만 실패하게 된다. 하지만 classic 접근법에서는 클라이언트 객체의 모든 테스트가 실패한다. 또한 다른 객체들을 테스트할 때, 버그를 가진 객체가 협력객체로 사용되는 모든 곳에서도 실패하게 된다. 결과적으로 매우 자주 사용되는 객체에서 실패가 발생하면 시스템 전체에 걸친 테스트 실패의 잔물결이 일어난다.

mockist 테스터들은 이것을 큰 이슈로 여긴다. 에러의 근원을 찾고 이를 고치기 위해서 많은 디버깅을 유발하게 된다는 것이다. 그러나 classical 테스트들은 이것을 문제의 근원이라고 보지 않는다. 일반적으로 어떤 테스트가 실패하는지를 살펴보면 테스트가 실패하는 원인을 상대적으로 쉽게 찾을 수 있고, 개발자들은 그 실패의 근원으로부터 다른 실패들도 찾아낼 수 있다는 것이다. 나아가 테스트를 규칙적으로 한다면, 마지막으로 수정한 것에 의해 테스트가 깨진 것을 알 수 있으므로 실패를 찾는 것은 어렵지 않다.

의미심장할 수도 있는 한 가지 요소는 테스트의 정밀도이다. classic 테스트는 여러개의 실제 객체들로 테스트를 수행하기 때문에, 종종 하나의 객체가 아닌 객체들의 클러스터에 대한 주요 테스트로서의 역할을 하게 된다. 만약 클러스터가 많은 객체들에 걸쳐져있다면 버그의 진짜 근원을 찾는 것은 더욱 어려워질 수 있다. 여기서 일어나고 있는 것은 테스트가 너무 거칠게(coarse grained) 작성되었다는 것이다.

확실히 mockist 테스트에서는 이런 문제로 고민할 가능성이 더욱 적을것이다. 주요 객체가 아닌 모든 객체들을 mock으로 하는 것이 관습이기 때문에, 협력객체에 대해 정교한(finer grained) 테스트가 필요하다는 것이 명확하기 때문이다.

과도하게 거친(coarse) 테스트가, 반드시 classic 테스트의 실패는 아니다. 그보다는 classic 테스팅을 적절히 하는 것에 대한 실패이다.

경험에 의한 좋은 법칙은, 모든 클래스에 대해 정교한 테스트를 확실하게 분리해내는 것이다. 클러스터가 때로는 합리적일지라도 매우 적은 객체 - 6개 이하로 제한되어야 한다. 추가적으로, 과도하게 거친 테스트 때문에 디버깅 문제를 겪고 있다면, 정교한 테스트를 만들어 나가면서 test driven 방식으로 디버깅을 해야 한다.

본질적으로 고전적 xunit 테스트는 단지 유닛 테스트일 뿐 아니라 mini-integration 테스트이다. 그 결과로 많은 사람들은 객체를 위한 메인 테스트에서 놓친 에러를 클라이언트 테스트가 잡을 수 있다는 사실을 좋아한다. 특히 클래스들이 상호작용하는 영역을 조사하면서 말이다.

mockist 테스트는 이러한 퀄리티를 잃어버린다. 게다가 mockist 테스트에서의 예측이 잘못되었는데도 유닛 테스트의 결과는 성공(green)이라서, 가지고 있는 에러를 가려버리게 되는 결과를 초래할 위험을 가지게 된다.

강조해야 할 포인트는, 어떤 스타일의 테스트를 사용하든 전체 시스템에 걸쳐 수행되는 거친 정밀도의 인수 테스트와 결합해야 한다는 것이다. 인수 테스트가 늦어져서 후회하는 프로젝트를 종종 보아왔다.

Coupling Tests to Implementations

mockist 테스트를 작성하는 것은 SUT가 그것의 공급자들과 올바르게 대화하는지를 확인하기 위해 SUT의 outbound 호출을 테스트하는 것이다. classic 테스트는 오직 최종 state에 대해서만 신경을 쓴다. 그 상태가 어떻게 유도되었는지가 아니다. 따라서 mockist 테스트는 메소드의 구현에 대해 결합도가 높다. 협력객체 호출에 대한 특성을 변경하면 일반적으로 mockist 테스트는 실패하게 된다.

이러한 결합(coupling)은 몇 가지 염려를 낳는다. 가장 중요한 것은 TDD에 대한 영향이다. mockist 테스트에서는, 테스트를 작성하는 것은 behavior의 구현에 대하여 생각을 하게 해준다. - mockist 테스터들은 이 것을 장점으로 본다. 그러나 classic 테스터들은 오직 외부 인터페이스로부터 무슨 일이 일어나는지에 대해서만 생각하고, 구현에 대한 모든 고려사항들은 테스트 작성이 끝난 이후로 남기는 것이 중요하다고 생각한다.

구현에 결합되는 것은 리펙토링에도 또한 간섭을 일으킨다. 구현이 변경되면 테스트가 깨질 가능성이 classic 테스팅보다 더욱 크기 때문이다.

이것은 mock 툴킷들의 본성에 의해 더욱 나빠진다. 종종 mock 툴들은 매우 구체적으로 메소드 호출을 지정하고 파라미터를 매칭시킬 것을 요구한다. 심지어 지금의 테스트와 관련이 없는 때에도 말이다. jMock 툴킷의 목표 중 하나는, 크게 상관 없는 곳에서는 좀 더 느슨하게 예측하는 것을 허용하여, 예측을 명세할 때 좀 더 flexible 해지자는 것인데, 문자열을 사용함으로서 리팩토링이 더욱 까다로워졌다.

Design Style

이러한 테스팅 스타일들의 매력적인 측면들 중 하나는 그들이 설계 결정에 어떻게 영향을 미치냐는 것이다. 각 스타일이 장려하는 설계간의 몇 가지 차이점들에 대해 살펴보자.

레이어를 다루는 것에 대한 차이점은 이미 언급했다. mockist 테스팅은 외부에서 내부로(outside-in) 접근하는 방식이다. 반면 도메인 모델로부터 나아가는 스타일을 선호하는 개발자들은 classic 테스팅을 선호하는 경향이 있다.

더 작은 레벨에서 보면, mockist 테스터들은 값을 리턴하는 메소드들 보다는 collecting object에 따라 행동하는 메소드들을 선호하는 것을 발견했다…?

예제를 살펴보자. 문자열을 만들기 위해 객체들의 그룹으로부터 정보를 추출하는 행위를 하는 예제이다. 일반적인 방법은, 메소드가 각 객체들에 대해 문자열을 리턴하는 메소드를 호출하고, 반환받은 문자열을 임시 변수에 취합하는 것이다. mockist 테스터들은 아마도 각 객체들에게 문자열 버퍼를 전달하여 각 객체들이 버퍼에 각각의 문자열들을 직접 추가하도록 할 것이다. - 문자열 버퍼를 파라미터를 취합하는데에 사용하는 것이다.

mockist 테스터들은 train wrecks - getThis().getThat().getTheOther() 과 같은 메소드 체이닝을 피하는 것에 대해 더 많이 이야기한다. 메소드 체인을 피하는 것은 디미터 법칙 (Law of Demeter)을 따르는 것으로도 알려져 있다. 메소드 체인은 smell 이기도 하지만, problem of middle men objects with forwarding methods ..? 또한 smell 이다. (나는 Law of Demeter 보다는 Suggestion of Demeter 라고 불리는 것이 더욱 comfortable 할 것이라고 항상 생각해왔다.)

사람들이 OO design에서 가장 이해하기 힘든 것 중 하나는 “Tell Don’t Ask” 원칙이다. 이 원칙은 클라이언트 코드에서 무언가를 하기 위해 객체에서 데이터를 꺼내는 것 보다는, 그 객체에게 무언가를 하라고 지시하라고 장려하는 것이다. Mockists들은 mockist 테스팅이 이것을 장려하고 ‘getter confetti’를 피하는데에 도움이 된다고 말한다. (getter confetti..? = 오늘날의 코드에 너무 많이 전파되어 있는…). classicist들은 이 방법 말고도 많은 다른 방법이 있다고 말한다.

state-based verification에서 알려진 하나의 이슈는, 오직 verification을 서포트하기 위해서 쿼리 메소드들을 생성해야 한다는 것이다. 순수하게 테스팅만을 위해서 어떤 객체의 API에 메소드들을 추가하는 것은 never comfortable하다. behavior verification을 사용하면 이 문제를 피할 수 있다. 이에 대한 반대 주장은, 실전에서 그러한 변경은 보통 마이너하다는 것이다.

mockist들은 role interfaces를 좋아하고, mockist 테스팅 스타일을 사용하는 것이 role interfaces를 더욱 장려한다고 단언한다. 각각의 협력객체는 개별적으로 mock되고, 따라서 role interface로 변경될 가능성이 크기 때문이다. 문자열을 만들기 위해 string buffer를 사용하는 위의 예제에서 mockist들은 도메인에 적합한 특정 role을 고안할 가능성이 더 클 것이고, 아마도 그것은 string buffer에 의해 구현될 것이다.

설계 스타일에서 이러한 차이는 대부분의 mockist들에게 중요한 motivator가 된다는 점을 기억하자. TDD의 기원은 진화적인 설계를 지원하기 위한 강력한 자동 회귀 테스팅을 얻고자 하는 욕구였다. 그 길을 따라 TDD의 수련자들은 테스트를 먼저 작성하는 것이 설계 프로세스에서 중대한 향상을 가져왔음을 발견했다. mockist들은 어떤 종류의 설계가 좋은 설계인지에 대해 강한 아이디어를 가지고 있고, 사람들이 이러한 설계 스타일을 develop하는데 도움을 주기 위해 mock 라이브러리들을 개발했다.

So should I be a classicist or a mockist?

어려운 질문이다. 개인적으로 나는 항상 올드한 유행을 따르는 classic TDDer 였고 지금도 바뀔 이유가 없다. 나는 mockist TDD의 강렬한 이득을 찾지 못하고, 테스트와 구현의 결합(coupling)에 따른 결과에 대해 걱정하고 있다.

나는 테스트를 작성할 때, 이것이 어떻게 수행되는지가 아니라 행위의 결과에 초점을 맞춘다는 사실을 정말로 좋아한다. 그러나 mockist는 예측을 작성하기 위해 SUT가 어떻게 구현될지를 지속적으로 생각한다. 이것이 나에게는 매우 부자연스럽게 느껴진다.

mockist 테스팅이 끌린다면 시도해보라. 특히, 다음과 같은 영역(mockist TDD가 개선하고자 하는 영역)에서 어려움을 겪고 있다면 시도할만한 가치가 있다.

  • 테스트가 실패할 때 디버깅 하는 데에 많은 시간을 소비하고 있는데, 그 실패가 깔끔하게 깨지지 않고 문제가 어느 곳인지 알려주지 않는 경우
  • 객체가 behavior를 충분히 가지고 있지 않아서 mockist 테스팅이 개발팀에게 behavior가 더 풍부한 객체들을 만들도록 장려할 수 있을 때.

Final Thoughts

unit testing, xunit 프레임워크 그리고 TDD가 발전함에 따라 점점 더 많은 사람들이 mock 객체에 뛰어들고 있다. 사람들은 많은 시간을 들여 mock 객체 프레임워크를 배우지만, mock 객체 프레임워크의 토대인 mockist/classical의 구분을 완전히 이해하지 못하고 있다. mockist/classical 중 어느 쪽으로 치우치든지 상관 없이, 관점의 차이를 이해하는 것은 유용하다. mock 프레임워크가 편리하다는 것을 알게 되었다고 해서 mockist가 될 필요는 없지만, 소프트웨어의 많은 설계 결정들을 가이드하는 생각을 이해하는 것은 유용하다.

이 artical의 목적은 이러한 차이점을 보여주고 그들 사이의 trade-off를 제시하는 것이었다. mockist의 생각에는 내가 생각한 것 이상의 것들이 있다. 특히 설계 스타일의 결과 측면에서 그렇다. 앞으로 몇 년 동안 더 많은 글을 보고, 코딩 전에 테스트를 작성하는 것의 매혹적인 결과에 대한 이해가 더 깊어지기를 바란다.

Further Reading

  • xUnit testing: http://xunitpatterns.com
  • TDD: TDD by example 책 - 켄트백
  • mockist style testing: http://www.mockobjects.com/files/mockrolesnotobjects.pdf
  • BDD: http://dannorth.net/introducing-bdd/

Reference: http://martinfowler.com/articles/mocksArentStubs.html

Shell - awk 명령어 기본 Git HEAD~ / HEAD^ 차이