이번 글은 Mocks Aren't Stubs - Martin Fowler 글을 읽고 정리한 글입니다.
원본 글에서 언급한 용어는 다음과 같습니다.
SUT(System Under Test)
Primary Object
)Collaborator
)Secondary Objects
)Test Double
Dummy, Fake, Stub, Spy, Mock
Fixture
다음은 원본 글에서 소개된 전형적인 JUnit
테스트 예시입니다.
Order
객체를 가지고 Warehouse
객체로부터 가져와서 채우려(fill
)하는 코드입니다.
Order
는 하나의 product
와 quantity
로 구성되어 있습니다.
Order
를 Warehouse
로 채우고자 할 때, 재고가 있다면 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
입니다.
즉 Order
는 SUT
, Warehouse
는 협력 객체에 해당합니다.
이를 위해 테스트 시 두 개의 객체를 생성하고 원하는 상태로 초기화를 진행한 것을 확인할 수 있습니다.
이런 방식은 상태 검증을 사용한 테스트입니다.
위에서 진행한 테스트를 그대로 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
부분입니다.
기존의 SUT
인 Order
는 동일하나 협력 객체인 Warehouse
가 다릅니다.
이전에는 실제 Warehouse
였지만, 지금은 Mock Warehouse
입니다.
Mock Warehouse
에 어떤 행위를 수행할지 정의합니다.
원본 글에서는 이를 모의 객체에 대한 예측이라고 표현했습니다.
두 번째는 Verifiaction
부분입니다.
Order
는 그대로 상태를 검증합니다.
Mock Warehouse
의 경우 이전과는 다르게 verify()
를 통해 모의 객체를 검증합니다.
즉 모의 객체의 동작을 예측한 내용과 실제 동작이 동일한지 검증합니다.
여기서 확인할 수 있는 상태 검증 테스트와 행위 검증 테스트의 차이점은 다음과 같습니다.
Warehouse
객체의 상태를 비교해 정상적으로 동작했는지 검증했습니다.Warehouse
객체가 예측한 대로 내부적으로 변수 / 메소드를 올바르게 호출했는지 검증했습니다.단위 테스트의 경우 하나의 동작에 집중하게 됩니다.
그런데 단위 테스트 시 예제에서의 Warehouse
와 같이 종종 다른 객체가 필요할 경우가 있습니다.
여기서 상태 검증에서는 진짜 Warehouse
객체를 사용했고, 행위 검증에서는 모의 객체를 사용했습니다.
물론 굳이 모의 객체를 사용하지 않고 Test Double
의 다른 종류를 실행할 수 있을 것입니다.
여기서 Mock
을 제외한 나머지는 모두 상태 검증 테스트에 해당합니다.
모의 객체는 Excercise
단계에서는 다른 종류의 Test Double
과 동일하게 동작합니다.
단 Setup
과 Verification
단계에서는 다르게 동작합니다.
다음은 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();
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()
)를 생성해야 합니다.
결과적으로 Stub
은 MailService
를 구현하지만 테스트(검증)를 위한 추가적인 메소드를 구현해야 합니다.
Mock
은 항상 동작 검증을 사용합니다.
Classical
Collaborator
를 실제 객체로 생성되거나 공통적으로 사용하는 Fixture
를 이용해 작성합니다.Test Double
을 사용합니다.Fixture
의 경우 생성하기 위해 복잡한 과정이 필요하지만 재사용이 가능합니다.Mockist
Collaborator
를 Mock
으로 대체하여 작성합니다.Fixture
를 만들어야 합니다.Fixture
의 경우 Classical
에 비해 생성이 간단하고 비용이 적지만 생성하는 과정이 반복되면 비용이 커질 수 있습니다.Fixture
는 구현 클래스에 의존적입니다.Mock
을 사용하기 때문에 특정 코드에 문제가 발생하더라도 해당 테스트에만 영향을 줍니다.Context
)Order
와 Warehouse
처럼 객체의 협력이 간단한지 아니면 Order
와 MailService
처럼 복잡한지Classical
Test Double
도 사용하지 않고 실제 객체와 상태 검증을 사용합니다.Mockist
Mock
과 행위 검증을 진행합니다.행위 검증의 경우 특정 메소드의 호출을 검증하기 때문에 구현 클래스에 매우 의존적입니다.
그렇기 때문에 변경에 취약합니다.
상태 검증의 경우 Collaborator
와 Fixture
를 모두 생성해야 하기 때문에 테스트 시에도 설계에 대한 고민이 필요합니다.
테스트 코드가 너무 복잡하면, 이 설계가 올바른 설계인지 고민할 수 있다는 의미입니다.
행위 검증의 경우 그 때 그때 Collaborator
와 Fixture
의 동작을 정의하면 되기 때문에 이러한 측면에서는 상태 검증보다 취약하다고 볼 수 있습니다.
상태 검증의 경우 테스트가 올바르게 수행되었는지 확인하기 위해 기존의 객체가 수행해야하는 역할과는 별개의 메소드를 추가해야 합니다.
행위 검증의 경우 즉시 예측한 행동을 정의하면 되므로 상태 검증처럼 추가적인 메소드를 정의할 필요가 없습니다.
일반적으로는 상태 검증을 권장하지만, 상태 검증을 하기 힘들 정도로 복잡한 구조의 애플리케이션이라면 행위 검증을 사용하는 것을 허용합니다.