Mocks Aren't Stubs 번역
원문
Mock과 Stub의 차이를 살펴보다가 과거 마틴 파울러의 글을 발견하게 되었다.
이런 논쟁, 차이점도 있었구나를 보는 정도로 이 글을 읽으면서 번역한 내용을 기록한다
https://martinfowler.com/articles/mocksArentStubs.html
참고로 마틴파울러는 번역에 대한 별다른 제재를 두지 않았다. https://martinfowler.com/faq.html
Regular Test (Example)
보편적인 JUnit 테스트의 예시로 Mock과 Stub을 설명한다.
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));
}
xUnit 테스트는 설정, 실행, 검증, 분해의 일반적인 4단계 순서를 따른다. (setup, exercise, verify, teardown)
테스트 대상은 주문(Order) 이지만, order.fill()이 동작하려면 창고(Warehouse) 도 필요하다.
여기서 테스트 대상이 되는 개체, 대상 시스템을 SUT(System Unfer Test)라고도 한다.
즉 SUT(Order)와 협력자(Warehouse)가 필요하다.
협력자는 테스트 동작이 작동하기 위해서, 확인을 위해 필요하다.
위 테스트 스타일은 상태 검증을 사용한다.
즉 메서드가 실행된 후 SUT와 협력자의 상태를 검증하여 메서드가 올바르게 작동했는지 여부를 확인한다.
Tests with Mock Objects
jMock을 사용해 Mock 객체를 사용한 테스트 예제를 살펴본다.
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 부분은 data와 expectations으로 나뉜다.
data는 대상이 되는 개체를 설정한다.
여기서 차이점은 SUT은 동일하지만, 협력자 창고 객체는 Mock(모의) 개체이다.
expectations 부분에서는 mock 객체에 대해 기대치를 설정한다.
SUT이 실행될 때 Mock에서 호출되어야 하는 메서드를 나타낸다.
여기서 중요한 차이점은 주문이 창고와의 상호 작용 확인하는 방법이다.
Mcok은 행동 검증을 사용하여 대신 주문이 창고에서 올바른 호출를 했는지 확인한다.
setUp에 예상하는 사항을 Mock에게 정의하고, 검증 중에 Mock이 자신을 검증하도록 함으로써 검증을 한다.
두 번째 테스트에서는, Mock 생성자가 아닌 MockObjectTestCase의 Mock 메서드를 사용하여 Mock을 다른 방법으로 만든다.
이것은 jMock 라이브러리의 편의 방법으로 나중에 명시적으로 verify를 호출할 필요가 없다는 것을 의미하며,
편의방식으로 만들어진 모든 Mock은 테스트가 끝날 때 자동으로 검증된다.
또 다른 점은 withAnyArguments를 사용함으로써 expectations에 대한 제약을 완화했다.
그 이유는 첫 번째 테스트에서 번호가 창고로 전달되는지 확인하기 때문에 두 번째 테스트에서 해당 요소를 반복할 필요가 없기 때문이다.
나중에 주문의 논리를 변경해야 할 경우 하나의 테스트만 실패하므로 테스트를 마이그레이션하는 작업이 쉬워진다.
The Difference Between Mocks and Stubs
위에서 보여준 두 가지 테스트 스타일에서 첫 번째 케이스는 실제 창고 객체를 사용하고 두 번째 케이스는 Mock 창고 객체를 사용했다.
Mock 사용은 실제 창고를 테스트에서 사용하지 않는 한 가지 방법이지만, 이와 같은 테스트에서 사용되는 객체들이 있다.
메사로스는 테스트 더블(Test Double)이라는 용어를 테스트 목적으로 실제 물체 대신 사용되는 모든 종류의 가짜 물체를 총칭하는 용어로 사용한다. 이 이름은 영화 속 스턴트 더블의 개념에서 유래되었다. 메사로스는 다섯 가지 특정한 종류를 정의했다.
Dummy
객체들은 전달되지만, 실제로는 사용되지 않는다. 일반적으로 매개변수의 목록을 채우는데 사용된다.
Fake
개체는 실제로 동작하는 구현을 가지고 있지만, 일반적으로 프로덕션 환경과 비슷하면서 다른 환경을 만드는 데 사용한다. (ex. 인메모리 디비)
Stubs
테스트 중에 만들어진 호출에 미리 준비된 답을 제공하며 일반적으로 테스트를 위해 프로그래밍된 것 외에는 전혀 응답하지 않다.
Spies
어떻게 호출 되었는지에 따라 일부 정보를 기록하는 스텁이다. (ex. 전송된 메시지 수를 기록하는 이메일 서비스)
Mocks
이 글에서 말하는 것다. 즉, 수신할 것으로 예상되는 호출의 스펙을 따르는 기대값으로 미리 프로그래밍된 객체이다.
많은 사람들은 실제 개체가 작업하기 불편한 경우에만 테스트 더블을 사용한다.
테스트 더블에 대한 더 일반적인 예시를 들기 위해, 주문을 채우지 못한 경우 이메일 메시지를 보내고 싶다는 요구사항이 있다고 가정한다.
문제는 테스트 중에 고객에게 실제 이메일 메시지를 보내고 싶지 않다는 것이다. 그래서 대신 우리가 제어하고 조작할 수 있는 이메일 시스템의 테스트 더블을 만든다.
여기서 우리는 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());
}
이것은 메시지가 전송되었다는 매우 간단한 테스트다.
올바른 사람에게 또는 올바른 내용으로 전송되었는지 테스트하지는 않았지만 메시지가 전달 된 사실을 테스트할 것이다.
만약 Mock을 사용하면 이 테스트가 상당히 다르게 보일 것이다.
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());
}
}
두 경우 모두 실제 메일 서비스 대신 테스트 더블을 사용하고 있다. Stub은 상태 확인을 사용하고 Mock은 동작 확인을 사용한다는 차이점이 있다.
스텁에서 상태 확인을 사용하려면 검증을 돕기 위해 스텁에서 몇 가지 추가 메서드를 만들어야 한다.
결과적으로 MailService를 구현한 스텁은 테스트 메서드를 추가해야한다.
모의 객체는 항상 동작 검증을 사용하며 스텁은 어느 검증이든 수 있다.
Meszaros는 테스트 spy로 동작 검증을 사용하는 스텁을 나타낸다.
차이점은 테스트 더블이 정확히 실행되고 확인하는 방법에 있으며, 이는 사용자가 직접 탐색하도록 둔다.
Classical and Mockist Testing
이번에는 전통적인 TDD와 Mock TDD의 차이점을 살펴본다.
Classic TDD 스타일 은 가능하면 실제를 사용하고, 실제를 사용하기 불편하면 테스트 더블을 사용하는 것이다.
따라서 Classic TDD를 사용하는 사람은 실제 창고를 사용하고 메일 서비스에는 테스트 더블을 사용한다. 여기서 더블의 종류는 그다지 중요하지 않다.
그러나 mockist TDD는 복잡한 동작을 가진 모든 객체에 대해 항상 mock을 사용한다. 이 경우 창고 및 메일 서비스 모두에 적용된다.
모의 객체 스타일의 중요한 점은 행동 주도 개발 (BDD)이다.
BDD는 원래 TDD가 설계 기술로 작동하는 방식에 초점을 맞춰 사람들이 테스트 주도 개발을 더 잘 배울 수 있도록 돕는 기술로 Daniel Terhorst-North가 개발했다. 이로 인해 TDD가 개체가 수행해야 하는 작업에 대해 생각하는 데 도움이 되는 부분을 더 잘 탐색하기 위해 테스트 이름을 동작으로 변경했다.
BDD가 모의객체 테스트를 사용하는 경향이 있는 TDD의 또 다른 변형이라 할 수 있다.
자세한 내용은 해당 글을 참고 : https://dannorth.net/introducing-bdd/
Choosing Between the Differences
Classic TDD, mockist TDD 중 어떤 것을 사용할지 결정하는데 가장 먼저 고려해야 할 것은 맥락이다.
주문과 창고 같은 쉬운 협업을 생각하고 있는가, 아니면 주문과 우편 서비스 같은 어색한 협업을 생각하고 있는가?
쉬운 협업이라면 선택은 간단하다. 만약 Classic TDDer라면 나는 모의, 스텁 또는 어떤 종류의 테스트 더블도 사용하지 않고, 실제 물체와 상태 검증을 사용한다. 만약 Mockist TDDer라면 모의와 행동 검증을 사용한다.
어색한 협업인 상황에서는, 만약 Classic TDDer라면 선택의 여지가 있지만, 어떤 것을 사용할지는 큰 문제가 아니다. 일반적으로 Classic TDDer는 각각의 상황에 가장 쉬운 방법을 사용하여 사례별로 사용 예를 결정할 것이다.
그래서 우리가 보듯이, 상태 대 행동 검증은 대부분 큰 결정이 아니다. 때문에 이를 결정할 시, 더 고려해야 할 사항들을 설명한다.
Driving TDD
모의 객체는 XP 커뮤니티에서 나왔으며 XP의 주요 특징 중 하나는 TDD에 중점을 둔다는 것이다.
이 스타일을 사용하면 시스템 외부에 대한 첫 번째 테스트를 작성에서 일부 인터페이스를 SUT로 만들어 사용자 스토리를 개발하기 시작한다. 협력자에 대한 expect를 통해 SUT와 협력자 간의 상호 작용을 확인하여 SUT의 our bound 인터페이스를 효과적으로 설계할 수 있다.
첫 번째 테스트를 실행하면 다음 단계에 대한 스펙과 테스트의 시작점이 잡힌다.
각 기대치를 협력자에 대한 테스트로 전환하고 한 번에 한 SUT씩 시스템으로 개발하는 과정을 반복한다.
이 스타일은 outside-in이라고도 한다.
먼저 아래에 있는 모의 레이어를 사용하여 UI를 프로그래밍하는 것으로 시작하고, 하위 계층에 대한 테스트를 작성하고, 한 번에 한 계층씩 시스템을 단계적으로 통과합니다.
이는 매우 체계적이고 통제된 접근 방식이며, 많은 사람들이 OO와 TDD 입문하는데 좋은 방식이다.
Classic TDD는 Mock 대신 Stub 메소드를 사용하여 단계적 접근 방식을 수행한다.
이렇게 하려면 협력자로부터 필요한 것이 있을 때마다 SUT가 작동하도록 테스트에 필요한 응답을 정확히 하드 코딩하면 된다.
검증이 되면 하드 코딩된 응답을 적절한 코드로 대체한다.
하지만 Classic TDD는 다른 것도 할 수 있다. 이 스타일에서는 기능을 사용하고 이 기능이 작동하기 위해 도메인에서 필요한 기능을 결정한다. 도메인 개체가 필요한 작업을 수행할 수 있도록 하고 작업이 완료되면 UI를 맨 위에 계층화 한다. 이렇게 하면 아무것도 Fake할 필요가 없을지도 모른다. 도메인 모델에 먼저 관심을 집중시켜 도메인 로직이 UI로 유출되지 않도록 하는 장점이 있다.
Fixture Setup
기존 TDD에서는 SUT뿐만 아니라 SUT에 필요한 모든 협력자를 생성해야 한다.
이 예에서는 몇 개의 객체만 있었지만, 실제 테스트에서는 많은 양의 협력자가 수반되는 경우가 많다. 일반적으로 이러한 개체는 테스트를 실행할 때마다 생성되고 소멸된다.
그러나 Mockist test는 SUT와 인접한 Mock만 만들면 된다. 이렇게 하면 복잡한 Fixture를 만드는데 관련된 작업을 단순화 할 수 있다.
실제로 Classic 테스트는 복잡한 Fixture를 가능한 많이 재사용하는 경향이 있다. 가장 간단한 방법으로 Fixture 설정 코드를 xUnit 설정 방법에 넣음으로써 이 작업을 수행한다. 여러 테스트 클래스에서 더 복잡한 Fixture를 사용해야 하는 경우, Fixture를 생성하는 클래스를 따로 만든다.
초기 Thinkworks XP 프로젝트에서 사용된 명명 규칙에 따라 이러한 Object Mothers 부른다. 더 큰 Classic 테스트에서 Mothers를 사용하는 것은 필수적이지만, Mothers은 유지되어야 하는 추가적인 코드이고, 만약 Mothers에 대한 어떠한 변경이 테스트를 통해 상당한 파급 효과를 가질 수 있다. 또한 Fixture를 설정하는 데 성능 비용이 발생할 수 있습니다. 하지만 제대로 설정할 경우 문제가 되지 않으며, 대부분의 Fixture는 만드는 데 비용이 적게 든다.
Test Isolation
Mock 테스트를 사용하는 시스템에 버그를 도입하면 일반적으로 SUT에 버그가 포함된 테스트만 실패한다. 그러나 고전적인 접근 방식에서는 클라이언트 개체의 모든 테스트도 실패할 수 있으며, 이로 인해 buggy 개체가 다른 개체의 테스트에서 협력자로 사용되는 오류가 발생할 수 있다. 결과적으로, 많이 사용되는 객체의 실패는 시스템 전체에 걸쳐 실패한 테스트의 파문을 일으킨다.
Mock 테스터는 이 문제를 주요 문제로 간주한다, 오류의 근원을 찾아내고 오류를 수정하기 위해 많은 디버깅을 초래한다.
그러나 Classic 테스터들은 이것을 문제의 근원으로 표현하지 않는다. 일반적으로 범인은 어떤 테스트가 실패하는지 살펴봄으로써 비교적 쉽게 발견할 수 있으며 개발자는 다른 실패가 근본 결함에서 파생된 것임을 알 수 있다. 또한 정기적으로 테스트하는 경우, 실패의 원인이 마지막으로 편집한 내용으로 인해 발생한 것임을 알 수 있으므로 결함을 찾는 것은 어렵지 않.
Classic 테스트는 여러 실제 개체를 사용하기 때문에 하나의 테스트가 아닌 개체의 클러스터에 대한 기본 테스트로 사용되는 경우가 많다. 클러스터가 여러 개체에 걸쳐 있는 경우 버그의 실제 출처를 찾는 것이 훨씬 더 어려울 수 있다. 하지만, Mock 이 문제로 고통받을 가능성은 상당히 낮다.
본질적으로 Classic xunit 테스트는 단위 테스트뿐만 아니라 미니 통합 테스트이기도 하다. 때문에 많은 사람들은 클라이언트 테스트가 객체에 대한 주요 테스트에서 누락되었을 수 있는 오류, 특히 클래스가 상호 작용하는 것을 확인할 수 있다는 점을 좋아한다. 하지만 Mock은, Mock에 대한 예상이 잘못되어 테스트는 성공하지만, 오류를 내는 단위 테스트가 발생할 수 있는 위험이 있다.
어떤 스타일의 테스트를 사용하든 시스템 전체에서 작동하는 거친 인수 테스트와 결합해야 한다는 점을 강조한다.
Coupling Tests to Implementations
목 테스트를 작성할 때 SUT가 협력자와 제대로 상호작용하는지 확인하기 위해 SUT의 아웃바운드 호출을 테스트한다. Classic 테스트는 그 상태가 어떻게 파생되었는지가 아니라 최종 상태에만 관심이 있다. 따라서 Mock 방법의 구현과 더 결합된다. 협력자를 호출하는 방식을 바꾸면 대개 Mock이 깨진다.
이러한 결합은 몇 가지 우려로 이어진다. 가장 중요한 것은 TDD에 미치는 영향이다. Mock 테스트에서, 테스트를 작성하는 것은 여러분이 행동의 구현에 대해 생각하게 한다. 실제로 Mock 테스터는 이것을 장점으로 보지만, Classic 테스터들은 외부 인터페이스에서만 일어나는 일을 생각하고 테스트 작성을 마칠 때까지 구현에 대한 모든 고려를 남겨두는 것이 중요하다고 생각한다.
구현에 대한 결합은 또한 리팩터링을 방해하는데, 이는 구현 변경이 기존 테스트보다 훨씬 더 시험을 망칠 가능성이 높기 때문이다.
이는 Mock toolkits의 특성상 악화될 수 있다. Mock toolkits은 특정 테스트와 관련이 없는 경우에도 매우 구체적인 메서드 호출 및 매개 변수 일치를 지정해야한다.
Final Thoughts
유닛 테스트, xunit 프레임워크, 테스트 주도 개발에 대한 관심이 커지면서 Mock 객체에 부딪히는 사람이 늘고 있다. 대부분의 경우 사람들은 모의 객체 프레임워크를 뒷받침하는 Mockist/Classic의 분열을 완전히 이해하지 못한 채 모의 객체 프레임워크에 대해 조금 배운다. 그 분열의 어느 쪽에 기울던지 간에, 저는 이 견해 차이를 이해하는 것이 유용하다고 생각한다. Mock 프레임워크가 유용한 것을 찾기 위해 모의 실험자가 될 필요는 없지만, 소프트웨어의 많은 설계 결정을 안내하는 생각을 이해하는 것은 유용하다.
이 기사의 목적은 이러한 차이점들을 지적하고 그들 사이의 절충점을 제시하는 것이었다.