Mocks Aren't Stubs

appti·2024년 4월 26일
0

기타

목록 보기
2/2

Mocks Aren't Stubs

이번 글은 Mocks Aren't Stubs - Martin Fowler 글을 읽고 정리한 글입니다.

용어 정리

원본 글에서 언급한 용어는 다음과 같습니다.

  • SUT(System Under Test)
    • 주요 객체(Primary Object)
    • 테스트 대상 시스템이나 테스트를 하려는 대상을 의미합니다.
  • 협력 객체(Collaborator)
    • 부차적 객체(Secondary Objects)
    • 테스트 대상이 테스트를 하기 위해 필요한 객체를 의미합니다.
  • Test Double
    • 테스트 목적으로 진짜 객체대신 사용되는 모든 종류의 위장(모조) 객체를 의미합니다.
    • Dummy, Fake, Stub, Spy, Mock
  • Fixture
    • 테스트 실행을 위해 기본 값으로 사용되는 객체들의 고정된 상태를 의미합니다.
    • 결과를 반복 가능할 수 있도록 고정된 환경에서 테스트하기 위함입니다.

내용

일반적인 테스트 (Regular Tests)

다음은 원본 글에서 소개된 전형적인 JUnit 테스트 예시입니다.

Order 객체를 가지고 Warehouse 객체로부터 가져와서 채우려(fill)하는 코드입니다.
Order는 하나의 productquantity로 구성되어 있습니다.
OrderWarehouse로 채우고자 할 때, 재고가 있다면 Order는 채워지고 Warehouse의 재고는 그만큼 줄어듭니다.
재고가 없다면 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));
    }
}

테스트 단계는 다음과 같이 4가지 단계로 나눌 수 있습니다.

  • Setup
    • 테스트 준비를 위한 단계입니다.
    • 위 예제에서 setUp() 메소드에 해당하며, Warehouse에 재고를 추가합니다.
  • Exercise
    • 테스트를 수행하기 위해 테스트 대상을 실행하는 단계입니다.
    • order.fill() 메소드를 호출하는 부분이 Exercise에 해당합니다.
  • Verification
    • 상태를 검증하는 단계입니다.
    • assert*() 메소드를 통해 객체의 상태를 확인합니다.
  • TearDown
    • 리소스를 정리하는 단계입니다.
    • 에제에서는 명시적으로 해당 단계를 수행하는 코드는 없습니다.
    • GC가 암묵적으로 이러한 일을 수행합니다.

위 예제에서 Order.fill() 메소드를 호출하기 위해서는 Warehouse 객체도 필요합니다.
여기서 테스트에 초점을 두는 것은 Order 객체이며, 테스트를 수행하기 위해 필요한 객체는 Warehouse입니다.
OrderSUT, Warehouse는 협력 객체에 해당합니다.

이를 위해 테스트 시 두 개의 객체를 생성하고 원하는 상태로 초기화를 진행한 것을 확인할 수 있습니다.
이런 방식은 상태 검증을 사용한 테스트입니다.

모의 객체를 이용한 테스트(Tests with Mock Objects)

위에서 진행한 테스트를 그대로 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());
    }
}

테스트 단계는 다음과 같이 나눌 수 있습니다.

  • Setup
    • Setup Data
      • 테스트에서 사용할 객체의 초기값을 세팅합니다.
    • Setup Expectations
      • 테스트에서 사용할 협력 객체의 Mock과 어떠한 행위를 할지 정의합니다.
  • Exercise
  • Verification
    • Verify State
      • 상태를 검증합니다.
    • Verify Expectations
      • 지정한 행위가 올바른 순서로 호출되었는지 검증합니다.
  • TearDown

이전 테스트와 몇몇 부분이 다릅니다.

첫 번째는 Setup 부분입니다.
기존의 SUTOrder는 동일하나 협력 객체인 Warehouse가 다릅니다.
이전에는 실제 Warehouse였지만, 지금은 Mock Warehouse 입니다.
Mock Warehouse에 어떤 행위를 수행할지 정의합니다.
원본 글에서는 이를 모의 객체에 대한 예측이라고 표현했습니다.

두 번째는 Verifiaction 부분입니다.
Order는 그대로 상태를 검증합니다.
Mock Warehouse의 경우 이전과는 다르게 verify()를 통해 모의 객체를 검증합니다.
즉 모의 객체의 동작을 예측한 내용과 실제 동작이 동일한지 검증합니다.


여기서 확인할 수 있는 상태 검증 테스트와 행위 검증 테스트의 차이점은 다음과 같습니다.

  • 상태 검증에서는 Warehouse 객체의 상태를 비교해 정상적으로 동작했는지 검증했습니다.
  • 행위 검증에서는 Warehouse 객체가 예측한 대로 내부적으로 변수 / 메소드를 올바르게 호출했는지 검증했습니다.

모의 객체와 스텁의 차이 (The Difference Between Mocks and Stubs)

단위 테스트의 경우 하나의 동작에 집중하게 됩니다.
그런데 단위 테스트 시 예제에서의 Warehouse와 같이 종종 다른 객체가 필요할 경우가 있습니다.

여기서 상태 검증에서는 진짜 Warehouse 객체를 사용했고, 행위 검증에서는 모의 객체를 사용했습니다.
물론 굳이 모의 객체를 사용하지 않고 Test Double의 다른 종류를 실행할 수 있을 것입니다.
여기서 Mock을 제외한 나머지는 모두 상태 검증 테스트에 해당합니다.

모의 객체는 Excercise 단계에서는 다른 종류의 Test Double과 동일하게 동작합니다.
SetupVerification 단계에서는 다르게 동작합니다.

다음은 MockStub의 차이점을 조금 더 쉽게 확인하기 위한 예제입니다.

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();
    order.setMailer(mailer);
    order.fill(warehouse);
    assertEquals(1, mailer.numberSent());
  }
}

Stub을 사용한 테스트입니다.

실제 프로덕션에 적합하지는 않지만 MailService에서 의도한 대로 동작할 수 있는 MailServiceStub을 생성하고 이를 테스트에 사용했습니다.

이후 검증은 Stub에 대한 상태를 검증하고 있습니다.

class OrderInteractionTester {

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

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

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

Mock을 사용한 테스트입니다.

Stub과 같이 객체를 따로 정의하는 것이 아닌 Mock 객체를 생성하고, 상황에 맞는 동작을 하는지 예측하고, Mock 객체가 스스로 이를 검증하도록 합니다.

그렇기 때문에 위 예제에서는 상태를 검증하는 부분이 없습니다.


두 가지의 예제 모두 실제 서비스 대신 Test Double을 사용하고 있습니다.
Stub은 상태 검증을, Mock은 행동 검증을 사용한다는 차이점이 있습니다.

Stub에서 상태 확인을 사용하려면 확인을 돕기 위해 Stub에서 몇 가지 추가 메소드(예제에서의 numberSent())를 생성해야 합니다.
결과적으로 StubMailService를 구현하지만 테스트(검증)를 위한 추가적인 메소드를 구현해야 합니다.

Mock은 항상 동작 검증을 사용합니다.

고전적 테스팅과 모의 객체 테스팅 (Classical and Mockist Testing)

  • Classical
    • 테스트 작성 시 테스트 대상 이외의 모든 필요한 Collaborator를 실제 객체로 생성되거나 공통적으로 사용하는 Fixture를 이용해 작성합니다.
    • 기본적으로는 실제 객체를 테스트 시 사용하지만, 그럴 수 없다면 Test Double을 사용합니다.
    • Fixture의 경우 생성하기 위해 복잡한 과정이 필요하지만 재사용이 가능합니다.
    • 공통적으로 사용되는 코드에 문제가 발생하면 전체 테스트에 영향을 미칩니다.
  • Mockist
    • 테스트 작성 시 테스트 대상 이외의 모든 필요한 CollaboratorMock으로 대체하여 작성합니다.
    • 테스트마다 Fixture를 만들어야 합니다.
    • Fixture의 경우 Classical에 비해 생성이 간단하고 비용이 적지만 생성하는 과정이 반복되면 비용이 커질 수 있습니다.
    • Fixture는 구현 클래스에 의존적입니다.
      • 변경에 취약합니다.
    • Mock을 사용하기 때문에 특정 코드에 문제가 발생하더라도 해당 테스트에만 영향을 줍니다.

차이점들 중에서 선택하기 (Choosing Between the Differences)

  • 문맥(Context)
    • OrderWarehouse처럼 객체의 협력이 간단한지 아니면 OrderMailService처럼 복잡한지
    • Classical
      • 간단한 경우 어떠한 Test Double도 사용하지 않고 실제 객체와 상태 검증을 사용합니다.
      • 복잡한 경우 상황에 따라 알맞는 방식(상태 검증 / 행위 검증)을 선택합니다.
    • Mockist
      • 간단한 경우, 복잡한 경우 모두 Mock과 행위 검증을 진행합니다.

언제 상태 검증을 사용하고, 언제 행위 검증을 사용할 것인가?

행위 검증의 경우 특정 메소드의 호출을 검증하기 때문에 구현 클래스에 매우 의존적입니다.
그렇기 때문에 변경에 취약합니다.

상태 검증의 경우 CollaboratorFixture를 모두 생성해야 하기 때문에 테스트 시에도 설계에 대한 고민이 필요합니다.
테스트 코드가 너무 복잡하면, 이 설계가 올바른 설계인지 고민할 수 있다는 의미입니다.

행위 검증의 경우 그 때 그때 CollaboratorFixture의 동작을 정의하면 되기 때문에 이러한 측면에서는 상태 검증보다 취약하다고 볼 수 있습니다.

상태 검증의 경우 테스트가 올바르게 수행되었는지 확인하기 위해 기존의 객체가 수행해야하는 역할과는 별개의 메소드를 추가해야 합니다.

행위 검증의 경우 즉시 예측한 행동을 정의하면 되므로 상태 검증처럼 추가적인 메소드를 정의할 필요가 없습니다.

일반적으로는 상태 검증을 권장하지만, 상태 검증을 하기 힘들 정도로 복잡한 구조의 애플리케이션이라면 행위 검증을 사용하는 것을 허용합니다.

profile
안녕하세요

0개의 댓글