Unit Testing
Test Double의 연속성 살펴보기
Mark Seemann
이 기사에서 다루는 내용:
- Test Double을 사용한 단위 테스트
- Dummy, Stub, Spy, Fake 및 Mock
- 수동 Mock와 동적 Mock
- 각 Test Double의 용도와 사용 방법
|
이 기사에서 사용하는 기술:
단위 테스트
|

목차
지난 한두 해 사이에 단위 테스트의 중요성이 크게 대두되었지만 전체적인 개념만 이해하고 있는 정도에 그치고 자세하고 어려운 수준까지는 잘 모르는 개발자들이 많습니다. 특히 테스트용 구성 요소를 효과적으로 교체하는 방법에 대해 잘 모르는 경우가 많습니다. 사람들은 대부분 이러한 대체 구성 요소를 Stub 또는 Mock라고 부르지만 이 기사에서 앞으로 설명하는 바와 같이 대체 구성 요소는 크게 두 가지 연속된 개념으로 나눌 수 있습니다.
Gerard Meszaros(
xunitpatterns.com)는 실제 구성 요소 서버를 대체하기 위한 테스트용 개체를 나타내는 일반적인 이름으로 스턴트맨을 지칭하는 "Stunt Double"에 빗대어 "Test Double"이라는 용어를 사용하자고 제안했습니다. Test Double 자체는 일반적인 용어로, 특정 구현 상태를 지칭하는 데에는
그림 1과 같이 다른 이름이 사용됩니다.

Figure 1 Test Double 정의
| Test Double 유형 |
설명 |
| Dummy |
가장 기본적인 Test Double 유형입니다. 구현이 포함되어 있지 않고 주로 매개 변수 값으로만 필요하며 다른 데에는 이용되지 않는 경우에 주로 사용됩니다. Null도 Dummy로 간주할 수 있지만 진정한 의미의 Dummy는 구현을 제외한 인터페이스 또는 기본 클래스의 파생 개체입니다. |
| Stub |
Dummy보다 한 단계 발전한 Test Double로, 인터페이스 또는 기본 클래스가 최소한으로 구현된 형태입니다. 일반적으로 void를 반환하는 메서드에는 구현이 포함되어 있지 않은 반면 값을 반환하는 메서드는 하드 코드된 값을 반환합니다. |
| Spy |
테스트 Spy는 Stub과 유사하지만 멤버를 호출하기 위한 인스턴스를 클라이언트에 제공한다는 점에서 차이가 있으며, 단위 테스트에서 예상대로 멤버가 호출되었는지 확인할 수 있도록 호출해야 할 멤버도 기록합니다. |
| Fake |
Fake에는 일반적으로 상속하는 형식의 여러 멤버 간에 이루어지는 상호 작용을 처리하기 위한 보다 복잡한 구현이 포함됩니다. 완전한 프로덕션 구현과는 차이가 있지만 Fake는 프로덕션 구현을 단순화된 형태로 모방할 수 있습니다. |
| Mock |
다른 Test Double은 대개 테스트 개발자가 코드를 사용하여 만드는 데 반해 Mock는 Mock 라이브러리에서 동적으로 생성됩니다. 따라서 테스트 개발자는 인터페이스 또는 기본 클래스를 구현하는 실제 코드를 볼 수 없지만 Mock를 구성함으로써 반환 값을 제공하고 호출해야 할 특정 멤버를 설정하는 등의 작업을 수행할 수 있습니다. Mock는 구성에 따라 Dummy, Stub 또는 Spy와 같은 동작을 수행할 수도 있습니다. |
이론상 이러한 각 유형들은 서로 크게 다른 것 같지만 실제로는 큰 차이가 없습니다. 따라서 그림 2와 같이 모든 Test Double을 하나의 연속적인 개념으로 간주해도 무방할 것 같습니다. 극단적인 상황으로는 동작이 전혀 구현되지 않은 Dummy가 사용되는 경우도 있고 완벽한 프로덕션 환경을 구현하여 테스트를 실행하는 경우도 있습니다. Dummy와 프로덕션 구현은 모두 확실하게 정의 내릴 수 있는 반면, Stub, Spy, Fake 등은 구분하기가 다소 모호합니다. 테스트 Spy와 Fake는 어떻게 구분해야 할까요? 또한 Mock는 경우에 따라 매우 복잡할 수도 있고 매우 단순할 수도 있기 때문에 그림에서 보듯이 큰 범위에 이어져 있습니다.
그림 2 Test Double의 분포 범위 (더 크게 보려면 이미지를 클릭하십시오.)
개념이 상당히 추상적으로 들리지만 사실은 매우 직관적일 뿐만 아니라 기사에서 예를 많이 들어 설명할 생각이므로 쉽게 이해할 수 있으리라 생각합니다. 지면의 제한 때문에 설명 과정에서 사소한 구현에 사용되는 몇 가지 클래스는 생략합니다. 설명이 부족한 부분이 있으면 MSDN® Magazine 웹 사이트에서 전체 코드가 포함된 기사의 다운로드를 참조하시기 바랍니다.
Dummy의 단위 테스트
Test Double의 연속 그래프에서 다소 극단적인 범위를 차지하는 Mock와 달리 Dummy는 훨씬 간단하므로 먼저 Dummy와 관련한 예를 소개하도록 하겠습니다.
창조 정신이 부족한 탓인지 필자는 이미 온라인 상점에서 실증적으로 검증된 주문 처리 시나리오를 예로 사용하기로 했습니다. 그림 3에 재현된 간단한 Order 클래스를 살펴보십시오. 여기서 가장 중요한 점은 생성자에 다음과 같이 정의되는 IShopDataAccess의 인스턴스가 사용된다는 점입니다.

Figure 3 Order 클래스
public class Order
{
private int orderId_;
private IShopDataAccess dataAccess_;
private OrderLineCollection orderLines_;
public Order(int orderId, IShopDataAccess dataAccess)
{
if (dataAccess == null)
{
throw new ArgumentNullException("dataAccess");
}
this.orderId_ = orderId;
this.dataAccess_ = dataAccess;
this.orderLines_ = new OrderLineCollection(this);
}
public OrderLineCollection Lines
{
get { return this.orderLines_; }
}
public void Save()
{
this.dataAccess_.Save(this.orderId_, this);
}
internal IShopDataAccess DataAccess
{
get { return this.dataAccess_; }
}
}
public interface IShopDataAccess
{
decimal GetProductPrice(int productId);
void Save(int orderId, Order o);
}
이 경우 IShopDataAccess에 Null을 전달하면 생성자에서 예외가 throw되므로 Order 클래스에 대해 단위 테스트를 실행하려면 Test Double을 제공해야 합니다. 가장 간단한 Test Double은 Dummy로, Visual Studio® 2005를 사용하면 매우 쉽게 만들 수 있습니다. 먼저 단위 테스트 프로젝트에 DummyShopDataAccess라는 클래스를 새로 만들어 IShopDataAccess를 구현하도록 합니다. 그런 다음 Visual Studio에서 인터페이스 이름의 스마트 태크를 클릭하고 "IShopDataAccess 인터페이스 구현"을 선택하여 인터페이스의 모든 멤버를 만들 수 있습니다. 멤버는 각각의 서명을 제외하고 모두 동일하게 구현되므로 여기서는 Save 메서드만 예로 들겠습니다.
public void Save(int orderId, Order o)
{
throw new Exception("The method or operation is not implemented.");
}
Visual Studio로 Dummy를 만드는 데에는 10초도 안 걸릴 뿐만 아니라, 클래스 선언에 인터페이스 선언이 포함되므로 클래스의 멤버 수에 관계없이 코드를 한 줄만 작성하면 됩니다.
DummyShop DataAccess 클래스가 준비되었으므로 다음과 같이 손쉽게 Order 클래스의 간단한 단위 테스트를 작성할 수 있습니다.
[TestMethod]
public void CreateOrder()
{
DummyShopDataAccess dataAccess = new DummyShopDataAccess();
Order o = new Order(2, dataAccess);
o.Lines.Add(1234, 1);
o.Lines.Add(4321, 3);
Assert.AreEqual<int>(2, o.Lines.Count);
// More asserts could go here...
}
IShopDataAccess 인터페이스는 테스트 대상에 대해 실행되는 메서드에 사용되지 않으므로 여기서는 Dummy만으로도 충분합니다. 따라서 Dummy가 까다로운 생성자의 입력 유효성 검사를 통과하도록 하기만 하면 됩니다. DummyShopDataAccess는 호출되었을 때 예외를 throw하므로 DummyShopDataAccess를 사용한다면 실제로 IShopDataAccess 인스턴스를 사용하는 멤버를 호출하려 할 때 바로 테스트가 중단될 것입니다. 앞으로 설명하겠지만 이러한 테스트의 경우에는 다른 Test Double 유형 중 하나를 사용하면 됩니다.
Stub을 사용해야 하는 경우
단위 테스트에서 테스트 대상의 멤버를 호출하면 여기서 다시 Test Double의 멤버를 호출하게 되므로 최소한 예외를 throw하지 않는 개체가 필요하게 됩니다. 이러한 조건에 맞는 가장 간단한 Test Double은 Stub입니다.
다음 예에서 작성해 볼 Order 클래스의 Save 메서드를 호출하는 단위 테스트의 경우, 여기서 다시 IShopDataAccess 인터페이스의 Save 메서드를 호출하므로 예외를 throw하지 않는 방식으로 Save 메서드를 구현해야 합니다. 이 메서드는 void를 반환하므로 매우 간단하게 구현할 수 있습니다.
public void Save(int orderId, Order o) { }
의미 체계에 관심이 있는 독자라면 이 구현이 실제로 Dummy인지 아니면 Stub인지 궁금할 것입니다. 이 코드 줄에 복잡한 구현이 포함되어 있다고 말하기 어려울 뿐더러 예외를 throw하는 다른 멤버를 그대로 둔다면 이 Test Double은 실질적으로 Dummy에 더 가깝다고 느낄 것입니다. 하지만 Dummy는 필요한 매개 변수를 채우는 데에만 사용되는 반면 이 구현의 경우 클라이언트가 문제 없이 Save 메서드를 호출할 수 있으므로 다른 용도로 사용된다고 할 수 있습니다. 즉 이 구현은 Stub에 가깝다고 할 수도 있습니다.
Dummy나 Stub의 보다 명확한 정의를 제안할 수도 있겠지만 이러한 모호성은 이름 외에도 몇 가지 실질적인 부분에서 나타난다는 사실을 알게 되었습니다. 그러나 여러 Test Double이 유형별로 명확히 구분되는 것이 아니라 연속적인 개념으로 존재한다는 점 역시 잘 보여 줍니다. 이 예에서는 나중에 다른 멤버에도 구현을 추가할 수 있도록 StubShopDataAccess라는 새 클래스를 호출합니다.
이제 StubShopDataAccess를 사용하여 다음 단위 테스트를 작성할 수 있습니다.
[TestMethod]
public void SaveOrder()
{
StubShopDataAccess dataAccess = new StubShopDataAccess();
Order o = new Order(3, dataAccess);
o.Lines.Add(1234, 1);
o.Lines.Add(4321, 3);
o.Save();
}
Order 클래스의 Save 메서드를 호출하면 아무런 동작도 수행하지 않는 StubShopDataAccess의 Save 메서드가 호출됩니다. 따라서 단순히 실패할 수 없기 때문에 테스트에 성공하지만, 결과적으로 테스트 대상에 대해 아무 것도 확인할 수 없습니다.
테스트에 Spy 추가
Test Double을 조사하는 것이 이 기사의 주제이기는 하지만, 테스트 대상(이 예의 경우 Order 클래스)이 의도한 대로 작동하는지를 직접 확인하는 것이 단위 테스트의 목적이라는 점을 항상 기억해야 합니다. Save 메서드의 경우 IShopDataAccess 인터페이스의 Save 메서드를 호출할 목적으로 사용되지만 앞서 정의한 테스트에서는 Order.Save가 빈 구현인 경우에도 성공하게 되므로 IShopDataAccess.Save 호출 여부를 확인할 수 없습니다.
Save 메서드가 호출되었는지 확인하려면 Test Double이 Save 메서드의 호출 여부를 기록해야 합니다. 나중에 확인할 수 있도록 멤버 호출을 기록하는 것은 테스트 Spy의 고유한 특징이므로 여기서는 그림 4와 같이 SpyShopDataAccess라는 새로운 클래스를 만듭니다.

Figure 4 Spy 클래스
internal class SpyShopDataAccess : IShopDataAccess
{
private bool saveWasInvoked_;
#region IShopDataAccess Members
// Other IShopDataAccess members ommitted for brevity
public void Save(int orderId, Order o)
{
this.saveWasInvoked_ = true;
}
#endregion
internal bool SaveWasInvoked
{
get { return this.saveWasInvoked_; }
}
}
다시 한번 강조하지만 테스트 Spy 유형은 Test Double 연속 그래프에서 큰 부분을 차지하지 않습니다. 그렇다면 IShopDataAccess 인터페이스의 다른 메서드에서 여전히 예외가 throw되는 경우에는 어떨까요? 이 경우 Dummy인 동시에 Spy라고 해야 할까요? 또한 다른 메서드에서 하드 코드된 반환 값을 제공하지만 호출 여부를 기록하지 않는다면 Stub에 가깝다고 할 수 있을까요?
모든 멤버에서 호출을 기록할 때에도 다른 방법을 사용할 수 있습니다. 그림 4의 코드에서는 플래그를 설정하는 간단한 방법을 사용하여 호출 수를 기록합니다.
private int saveInvocationCount_;
public void Save(int orderId, Order o)
{
this.saveInvocationCount_++;
}
internal int SaveInvocationCount
{
get { return this.saveInvocationCount_; }
}
이 구현에서는 첫 번째 구현 방식보다 분명히 많은 정보가 캡처되지만, 그렇다고 Spy에 가까운 것일까요? 모든 호출의 입력 매개 변수까지 기록하면 이보다 복잡한 Spy도 만들 수 있습니다. 그러나 그렇게 하려면 구현해야 할 코드가 많아질 뿐만 아니라, 이러한 Spy는 Test Double 연속 그래프 범위에서 훨씬 오른쪽까지 차지하게 됩니다. 앞서 말했듯이 Stub과 Spy는 그 경계가 모호하기 때문에 두 Test Double을 하나의 연속적인 개념으로 보아야 합니다.
이제 SpyShopDataAccess 클래스를 사용하여 Order가 실제로 저장되었는지 여부를 확인하는 단위 테스트를 작성할 수 있습니다.
[TestMethod]
public void SaveOrderWithDataAccessVerification()
{
SpyShopDataAccess dataAccess = new SpyShopDataAccess();
Order o = new Order(4, dataAccess);
o.Lines.Add(1234, 1);
o.Lines.Add(4321, 3);
o.Save();
Assert.IsTrue(dataAccess.SaveWasInvoked);
}
이 간단한 테스트에서는 Save 메서드가 호출되었는지만 확인되고 사용된 매개 변수의 수와 종류는 알 수 없습니다. 사용된 매개 변수에 대한 데이터도 확인하는 것이 좋지만, 그렇게 하려면 보다 복잡한 테스트 Spy를 작성하거나 기본적으로 모든 호출을 기록하는 Mock를 이용해야 합니다.
단위 테스트용 Mock 만들기
Mock는 테스트 개발자에 의해 만들어지는 Dummy, Stub, Spy 및 Fake와는 매우 다릅니다. Mock는 런타임에 생성되는 동적 Mock 개체의 메서드를 호출하여 만듭니다.
2004년 10월호
MSDN Magazine 칼럼, "Unit Testing: 구세주 Mock 개체! NMock를 사용한 .NET 코드 테스트"(
msdn.microsoft.com/msdnmag/issues/04/10/NMock)에서 필자는 동적 Mock와 관련한 기본 사항과 NMock라는 Mock 라이브러리의 사용 방법을 설명한 바 있습니다. 여기서는 Rhino Mocks라는 무료 Mock 라이브러리(
ayende.com/projects/rhino-mocks.aspx에서 제공)를 사용해 보겠습니다.
IShopDataAccess 인터페이스를 구현하는 코드 파일을 새로 만드는 대신 그림 5와 같이 런타임에 인터페이스를 구현하는 개체를 만들도록 Rhino Mocks에 지시했습니다. 의도대로 작동하려면 인터페이스에서 호출될 멤버를 예상하도록 Mock를 구성해야 합니다. Mock 라이브러리에서는 보통 다른 방식으로 구현되지만 Rhino Mocks의 경우 예상 동작을 기록하는 것으로 작동이 시작됩니다. 기본적으로 Mock에는 단위 테스트가 예상 동작을 정의하는 기록 모드와 클라이언트가 Mock의 멤버를 호출할 수 있는 재생 모드가 있습니다.

Figure 5 동적 Mock
[TestMethod]
public void SaveOrderAndVerifyExpectations()
{
MockRepository mocks = new MockRepository();
IShopDataAccess dataAccess = mocks.CreateMock<IShopDataAccess>();
Order o = new Order(6, dataAccess);
o.Lines.Add(1234, 1);
o.Lines.Add(4321, 3);
// Record expectations
dataAccess.Save(6, o);
// Start replay of recorded expectations
mocks.ReplayAll();
o.Save();
mocks.VerifyAll();
}
그림 5의 경우 의도한 동작은 Save 메서드에 대한 단일 호출뿐이므로 예상 동작을 간단히 설정할 수 있습니다. 예상 동작을 모두 기록한 후에는 ReplayAll을 호출하여 Mock를 재생 모드로 전환합니다.
Order 개체의 Save 메서드를 호출하면 이 메서드가 Mock의 Save 메서드를 호출합니다. 이때 Mock가 재생 모드 상태이므로 메서드가 예상 매개 변수를 사용하여 호출된 것으로 기록됩니다. Save가 호출되었지만 다른 매개 변수가 사용된 경우에는 Mock에서 예외가 throw됩니다. 마지막에 VerifyAll을 호출하면 Mock에서 Save 메서드가 호출되었는지 확인하여 호출되지 않은 경우에 예외를 throw합니다.
이 Mock는 예상 호출이 이루어졌는지 확인하므로 테스트 Spy와 매우 유사하게 작동합니다. 그러나 런타임에만 존재하기 때문에 Test Double 연속 그래프에서 Mock가 차지하는 범위를 정확히 가려내기가 어렵습니다. 이러한 이유로 Mock를 넓은 범위에 연속된 개념으로 여기는 것입니다.
값 반환
지금까지는 값을 반환하지 않는 단일 메서드(Save)에 대한 Mock만 다루었습니다. 앞서 설명한 것과 같이 void를 반환하는 메서드의 Stub은 매우 간단하게 만들 수 있습니다. 그러나 값을 반환하는 메서드의 경우에는 구현하기가 다소 복잡합니다. 지금부터 Order 예를 확장하여 자세한 구현 방법을 설명하도록 하겠습니다.
Order 클래스에는 OrderLine 개체의 컬렉션이 들어 있습니다. 그리고 각 OrderLine 개체에는 productId, 수량 및 해당 개체를 소유한 Order에 대한 참조가 들어 있습니다. 각 품목 소계를 계산하기 위해 OrderLine 클래스에 Total 속성이 있습니다.
private decimal? total_;
public decimal Total
{
get
{
if (!this.total_.HasValue)
{
decimal unitPrice =
this.owner_.DataAccess.GetProductPrice(
this.productId_);
this.total_ = unitPrice * this.quantity_;
}
return this.total_.Value;
}
}
이 구현에서는 IShopDataAccess의 GetProductPrice를 호출합니다. 따라서 Total 속성을 테스트하려면 사용되는 모든 Test Double에서 GetProductPrice의 값을 반환해야 합니다. 이를 구현하는 가장 간단한 방법은 하드 코드 값만 반환하는 것입니다.
public decimal GetProductPrice(int productId)
{
return 25;
}
StubShopDataAccess에 이 코드가 구현되어 있으므로 이제 다음과 같이 단위 테스트를 작성할 수 있습니다.
[TestMethod]
public void CalculateSingleLineTotal()
{
StubShopDataAccess dataAccess = new StubShopDataAccess();
Order o = new Order(7, dataAccess);
o.Lines.Add(1234, 2);
decimal lineTotal = o.Lines[0].Total;
Assert.AreEqual<decimal>(50, lineTotal);
}
첫 번째 주문 품목은 수량이 2개인 제품 1234이고 StubShopDataAccess에서 반환되는 단가가 25이므로 품목 소계는 50이 됩니다.
이 구현은 매우 간단하지만 제품 단가가 각각 다른 여러 주문 품목이 있는 Order를 테스트할 수 없기 때문에 유연성이 떨어집니다. 이러한 문제는 GetProductPrice의 구현을 다음과 같이 변경하면 해결할 수 있습니다.
public decimal GetProductPrice(int productId)
{
switch (productId)
{
case 1234:
return 25;
case 2345:
return 10;
default:
throw new ArgumentException("Unexpected productId");
}
}
복잡한 구현을 위해 Test Double에 조건 논리가 어떻게 사용되는지 잘 살펴보십시오. 하드 코드된 값을 반환하지만 조건 논리가 포함되어 있기 때문에 이 Test Double이 아직도 Stub에 해당하는지 의아해 하는 독자도 있을 것입니다
원칙적으로, 추가할 수 있는 case 문의 수에는 제한이 없지만 이전 단위 테스트 예에서 이미 살펴보았듯이 테스트 코드만 보아서는 Stub에서 어떤 값이 반환되는지 파악하기가 어렵습니다.
Fake 사용
경우에 따라서는 구성이 보다 용이한 Test Double을 만들어야 합니다. IShopDataAccess 인터페이스의 경우 사실상 데이터베이스를 추상화한 것이므로 기본 메모리 내 데이터베이스를 만드는 방법으로 구현할 수 있습니다. 그림 6에 나와 있는 FakeShopDataAccess는 일반적으로 데이터베이스에 필요한 참조 무결성이나 기타 기능이 없기 때문에 매우 원시적이라 할 수 있습니다. FakeShopDataAccess에는 기본적으로 데이터베이스 테이블을 대신하는 개체의 컬렉션이 들어 있습니다. 제품 ID를 기준으로 제품을 인덱싱할 수 있어야 하므로 KeyedCollection<int, Product>에서 파생되는 ProductCollection 클래스(예에서는 보이지 않음)를 만들었습니다. 본질적으로 Product 클래스는 제품 데이터의 속성 모음이며 이 용도로만 만들어지는 또 하나의 테스트 전용 클래스일 뿐입니다.

Figure 6 기본 테스트 데이터베이스
internal class FakeShopDataAccess : IShopDataAccess
{
private ProductCollection products_;
internal FakeShopDataAccess()
{
this.products_ = new ProductCollection();
}
#region IShopDataAccess Members
public decimal GetProductPrice(int productId)
{
if (this.products_.Contains(productId))
{
return this.products_[productId].UnitPrice;
}
throw new ArgumentOutOfRangeException("productId");
}
public void Save(int orderId, Order o) { }
#endregion
internal IList<Product> Products
{
get { return this.products_; }
}
}
FakeShopDataAccess는 Save 메서드로 전달된 주문을 저장하지 않습니다. 이는 IShopDataAccess 인터페이스에 저장된 주문을 반환하는 멤버가 없기 때문에 가능한 편법이라고 할 수 있습니다. 그러나 Save 메서드 확인에 이 클래스를 사용하려는 경우에는 내부 사전에 Order 개체를 추가하고 나중에 해당 사전의 내용을 확인하면 됩니다. 이렇게 하면 Fake에 테스트 Spy의 특성이 추가되므로 또 다시 Test Double 유형 간의 경계가 모호해집니다.
FakeShopDataAccess 클래스를 사용하면 그림 7과 같이 데이터 액세스 계층에서 진행되는 프로세스가 보다 분명해집니다. 테스트 대상(Order 개체)을 만들기 전에 제품 및 단가 목록을 만들어 채웁니다. 테스트가 실행되면 GetProductPrice가 목록에서 요청된 제품의 단가를 반환합니다.

Figure 7 Fake 데이터베이스를 사용한 테스트
[TestMethod]
public void CalculateLineTotalsUsingFake()
{
FakeShopDataAccess dataAccess = new FakeShopDataAccess();
dataAccess.Products.Add(new Product(1234, 45));
dataAccess.Products.Add(new Product(2345, 15));
Order o = new Order(9, dataAccess);
o.Lines.Add(1234, 3);
o.Lines.Add(2345, 2);
Assert.AreEqual<decimal>(135, o.Lines[0].Total);
Assert.AreEqual<decimal>(30, o.Lines[1].Total);
}
이 테스트에서는 반환되는 값을 확인하기 위해 Stub 코드로 전환할 필요가 없으므로 Stub을 사용하는 유사한 테스트에 비해 읽기 편리합니다. 그러나 Fake를 만드는 경우에는 Fake 코드 자체를 작성하기가 좀 더 까다롭다는 단점이 있습니다. FakeShopDataAccess의 경우 FakeShopDataAccess 자체와 ProductCollection 클래스 및 Product 클래스의 세 가지 클래스를 만들었습니다. IShopDataAccess 인터페이스도 상당히 단순하기 때문에 좀 더 복잡한 다른 인터페이스를 사용하면 이러한 단점이 더 현저하게 나타납니다.
수동 Mock
단위 테스트의 기본적인 원칙 중 하나는 간단한 코드를 작성하여 복잡한 코드를 테스트한다는 것입니다. 복잡한 Fake를 만들면 이러한 단위 테스트의 근본적인 목적에 위배될 수 있습니다. 복잡한 Fake 자체를 단위 테스트해야 할 수도 있기 때문에 작업이 너무 복잡해지기 때문입니다.
동적 Mock가 이 문제에 대한 궁극적인 해결 방법이 될 수 있지만 전체 Mock 라이브러리를 사용하는 것이 바람직하지 않은 경우도 있습니다. Mock 라이브러리는 테스트 프로젝트에서 참조해야 하는 추가 어셈블리이며, 팀 기반 프로젝트를 진행하는 경우에는 공용 Mock 라이브러리를 표준화하고, 모든 개발자 컴퓨터에 라이브러리를 설치(또는 소스 제어 아래에 이진 파일을 배치)하고, 응용 프로그램 배포와 관련한 기타 모든 세부 사항을 해결해야 합니다. 이러한 이유로 필자는 수동 Mock라는 개체를 throw하는 방법을 많이 사용합니다.
Test Double의 명확성은 그대로 유지하면서 Fake를 사용하지 않도록 그림 7의 테스트를 다시 작성한다고 가정해 봅시다. 이 테스트에서 문제가 되는 IShopDataAccess의 유일한 멤버는 GetProductPrice 메서드이므로 이 메서드를 처리하기 위해 그림 8과 같은 수동 Mock 클래스를 만들었습니다. 여기서 생성자는 Converter<int, decimal>을 받아 저장한 후 GetProductPrice 메서드를 구현하는 데 사용합니다.

Figure 8 수동 Mock 클래스
internal class ProductPriceMockShopDataAccess : IShopDataAccess
{
private Converter<int, decimal> implement_;
internal ProductPriceMockShopDataAccess(
Converter<int, decimal> productPriceCallback)
{
this.implement_ = productPriceCallback;
}
#region IShopDataAccess Members
public decimal GetProductPrice(int productId)
{
return this.implement_(productId);
}
public void Save(int orderId, Order o)
{
throw new NotImplementedException();
}
#endregion
}
Converter<int, decimal>은 정수를 입력으로 받아 10진수를 반환하는 대리자입니다. IShopDataAccess의 정의를 살펴보면 GetProductPrice와 동일한 서명이라는 것을 알 수 있습니다. 따라서 Converter<int, decimal>을 사용하여 GetProductPrice를 구현할 수 있습니다. 이 구현 과정은 그림 8에 나와 있습니다.
그림 9에 나와 있듯이 이 수동 Mock 자체에는 구현이 포함되지 않으며 단위 테스트의 기반 역할을 합니다. 이 단위 테스트에서는 GetProductPrice 메서드가 호출될 때 호출되는 익명 메서드로 Mock를 초기화합니다. 기본적으로 이러한 구현 방식은 Mock의 예상 동작을 먼저 정의한 후 테스트 대상을 생성 및 실행하는 동적 Mock의 구성 방식과 비슷합니다.

Figure 9 수동 Mock 사용
[TestMethod]
public void CalculateLineTotalsUsingDelegate()
{
ProductPriceMockShopDataAccess dataAccess =
new ProductPriceMockShopDataAccess(delegate(int productId)
{
switch (productId)
{
case 1234:
return 45;
case 2345:
return 15;
default:
throw new ArgumentOutOfRangeException("productId");
}
});
Order o = new Order(10, dataAccess);
o.Lines.Add(1234, 3);
o.Lines.Add(2345, 2);
Assert.AreEqual<decimal>(135, o.Lines[0].Total);
Assert.AreEqual<decimal>(0, o.Lines[1].Total);
}
그림 9의 단위 테스트는 그림 7에 나와 있는 Fake를 사용한 테스트보다 복잡한 것 같지만 보이는 것만으로 쉽게 판단해서는 안 됩니다. 즉, 테스트 자체는 더 복잡하지만, Fake의 경우 Fake 자체를 포함하여 지원 클래스가 3개 필요하지만 수동 Mock의 경우에는 Mock 자체만 있으면 되므로 테스트를 지원하는 코드의 전체 크기는 오히려 작습니다.
대리자를 사용한 Mock 구현의 이점은 테스트 개발자가 각 단위 테스트마다 구현을 새로 작성한다는 점입니다. 결과적으로 반환 값과 동작을 변경할 수 있을 뿐만 아니라 수행할 조사의 수준도 결정할 수 있습니다. 값만 반환하려는 경우에는 그림 9와 유사한 코드를 사용하면 됩니다. 또한 익명 메서드를 사용하면 외부 변수에 액세스할 수 있으므로 나중에 확인하기 위해 메서드 호출도 쉽게 기록할 수 있습니다.
그림 8에 나와 있는 ProductPriceMockShopDataAccess 클래스의 가장 큰 단점은 GetProductPrice 메서드만 대신하며 다른 멤버에서는 NotImplementedException을 throw한다는 점입니다. 이 방식을 일반화하면 수동 Mock의 개념을 기반으로 단계를 확장할 수 있습니다. IShopDataAccess의 범용 수동 Mock에서는 인터페이스의 모든 멤버에 대해 Converter<TInput, TOutput>을 정의할 수 있지만 인터페이스에 멤버가 너무 많으면 너무 복잡해질 수도 있습니다.
다음과 같이 모든 멤버를 구현하는 데 사용할 수 있는 대리자를 정의하는 편이 더 바람직합니다.
public delegate void ImplementationCallback(MemberData member);
이 대리자는 void를 반환하고 MemberData 개체를 입력으로 받는 메서드를 정의합니다. MemberData 클래스는 비교적 간단하므로 여기서 따로 설명하지는 않겠지만 이 기사의 코드 다운로드에 포함되어 있습니다. 기본적으로 MemberData 클래스는 호출된 멤버의 이름, 매개 변수 값의 목록, 그리고 구현에서 제공해야 하는 반환 값을 설정하는 데 사용할 수 있는 속성이 들어 있는 속성 모음입니다. 반환 값은 모든 메서드에 필요한 것은 아니므로 선택적으로 사용할 수 있습니다. 대표적인 예로 Save 메서드의 경우 void를 반환합니다.
ImplementationCallback 대리자를 사용하면 그림 10과 같이 보다 일반적인 수동 Mock를 만들 수 있습니다. 앞서와 마찬가지로 Mock에 구현이 포함된 대리자를 채우는 데 생성자가 사용됩니다. 각 멤버 구현은 호출된 메서드의 이름과 매개 변수의 이름 및 값을 포함하는 MemberData 개체가 생성되는 일반적인 패턴을 따릅니다. 그리고 대리자를 호출할 때 멤버 변수가 매개 변수로 사용됩니다. 메서드에서 값을 반환해야 하는 경우에는 멤버 변수에서 추출된 값이 반환됩니다. 이때 ReturnValue 속성은 구현 대리자가 설정합니다.

Figure 10 일반화된 수동 Mock
internal class MockShopDataAccess : IShopDataAccess
{
private ImplementationCallback implement_;
internal MockShopDataAccess(ImplementationCallback callback)
{
this.implement_ = callback;
}
#region IShopDataAccess Members
public decimal GetProductPrice(int productId)
{
MemberData member = new MemberData("GetProductPrice");
member.Parameters.Add(new ParameterData("productId", productId));
this.implement_(member);
return (decimal)member.ReturnValue;
}
public void Save(int orderId, Order o)
{
MemberData member = new MemberData("Save");
member.Parameters.Add(new ParameterData("orderId", orderId));
member.Parameters.Add(new ParameterData("o", o));
this.implement_(member);
}
#endregion
}
이러한 방식을 사용한 테스트는 그림 11에 나와 있는 것과 같이 앞서 소개한 테스트와 유사합니다. MemberData에는 호출된 멤버에 대한 정보가 들어 있으므로 예상된 메서드가 호출되는지 여부를 초기에 검사하여 호출되지 않으면 예외를 throw하는 것이 바람직합니다. 호출된 메서드가 GetProductPrice 메서드인 경우에는 테스트 코드에서 MemberData 입력 매개 변수에 관련 반환 값이 설정됩니다. 그리고 MockShopDataAccess가 호출자에게 이 값을 반환합니다.

Figure 11 일반화된 Mock 사용
[TestMethod]
public void CalculateLineTotalsUsingManualMock()
{
MockShopDataAccess dataAccess =
new MockShopDataAccess(delegate(MemberData member)
{
if (member.Name == "GetProductPrice")
{
int productId = (int)member.Parameters["productId"].Value;
switch (productId)
{
case 1234:
member.ReturnValue = 45m;
break;
case 2345:
member.ReturnValue = 15m;
break;
default:
throw new ArgumentOutOfRangeException(
"productId");
}
}
else
{
throw new InvalidOperationException("Unexpected member");
}
});
Order o = new Order(11, dataAccess);
o.Lines.Add(1234, 3);
o.Lines.Add(2345, 2);
Assert.AreEqual<decimal>(135, o.Lines[0].Total);
Assert.AreEqual<decimal>(30, o.Lines[1].Total);
}
이러한 범용 수동 Mock를 만드는 과정은 반복적인 패턴을 따릅니다. 먼저 형식과 멤버를 만든 후, 각 멤버에 MemberData 개체를 만들고, 해당하는 경우 대리자를 호출하고 ReturnValue 속성을 반환합니다. 이 구현 패턴은 자동화가 가능하므로 Reflection 및 CodeDOM 논리를 사용하여 이러한 프로세스를 수행하는 런타임 개체를 내보낼 수 있습니다. 이에 대한 자세한 내용은 기사에서 다루는 범위를 벗어나지만 코드 다운로드에 이러한 방식의 기본 개념을 보여 주는 예가 포함되어 있습니다. 이 방식에서는 DelegateMock 클래스는 대리자 기반 Mock를 즉석에서 만들 수 있습니다. 이 단위 테스트는 Mock가 생성되는 줄만 제외하면 그림 11의 테스트와 거의 동일합니다. Mock가 생성되는 줄은 다음과 같이 됩니다.
IShopDataAccess dataAccess =
DelegateMock.Create<IShopDataAccess>(
delegate(MemberData member){...});
일반 Mock와 마찬가지로 이 수동 Mock도 동적으로 생성되고 런타임에만 존재합니다. 사실 DelegateMock와 동적 Mock는 해당 Mock가 대신하는 인터페이스나 기본 클래스가 구현되는 방법만 다릅니다.
대리자 기반 Mock의 장점은 자유롭게 사용할 수 있고 시작하기가 비교적 쉽다는 데 있습니다. 동적 Mock는 이해하고 배우는 데 어려움을 느끼는 개발자가 많은 반면, 대리자는 개발자들이 대체로 쉽게 이해하고 코드를 작성하는 데 크게 어려움을 느끼지 않습니다. 예상 동작, 반환 값, 호출 기록 등은 모두 코드에 명시적으로 작성되어야 하기 때문에 새 프로젝트 모델을 별도로 배우지 않아도 대부분의 개발자들은 해당 작업을 수행하는 방법을 이미 알고 있습니다.
현실적인 Mock
수동 Mock는 몇 가지 장점을 가지고 있지만 분명히 몇 가지 단점도 있습니다. 그 중 하나로, 모든 단위 테스트에서 대리자 기반 구현을 제공하다 보면 너무 복잡해진다는 점을 들 수 있습니다. 특히 테스트 Spy의 경우와 같이 호출도 기록해야 하는 경우에는 그 정도가 더 심합니다. 그러나 무엇보다 큰 단점은 대리자를 구현으로 작성하는 작업은 지루하고 오류가 발생하기 쉽다는 데 있습니다.
수동 Mock도 단순한 Mock만큼 유용하지만 필자는 개인적으로 동적 Mock를 더 선호합니다. 뛰어난 Mock 라이브러리라는 구성하기 쉽고 동시에 테스트 Spy 역할도 하는 Mock를 생성하기 때문입니다.
이 기사에서 여러 가지 형태로 소개한 품목 소계 예는 Rhino Mocks를 사용하면 쉽게 작성할 수 있습니다. Mock 자체는 다음과 같이 지원 클래스를 사용하지 않고 단 두 줄의 코드로 간단히 만들 수 있습니다.
MockRepository mocks = new MockRepository();
IShopDataAccess dataAccess = mocks.CreateMock<IShopDataAccess>();
그리고 앞서 소개한 예에서와 마찬가지로 dataAccess 변수를 사용하여 새 Order 인스턴스를 만들 수 있습니다. 여기서 Mock는 기록 모드로 만들어지므로 다음 두 줄의 코드로 예상 동작과 반환 값을 정의할 수 있습니다.
Expect.Call(dataAccess.GetProductPrice(1234)).Return(45m);
Expect.Call(dataAccess.GetProductPrice(2345)).Return(15m);
첫째 줄은 1234라는 값을 사용하여 GetProducPrice 메서드를 호출하고 45라는 값을 반환 받는 Mock의 예상 동작을 지정합니다. 둘째 줄은 제품 ID 2345에 대해 유사한 예상 동작을 설정합니다.
Mock는 기본적으로 테스트 Spy 역할을 하므로 코드를 추가하지 않아도 테스트의 마지막에 mocks.VerifyAll이 호출되어 모든 예상 동작이 수행되었는지 여부가 확인됩니다.
Test Double 유형 비교
그림 12에는 여러 Test Double과 각각의 장단점이 나와 있습니다. 표에서는 각 Test Double 유형이 서로 확실히 구분되고 많이 다른 것처럼 보이지만 이 기사를 읽은 독자라면 실질적으로는 각 유형의 경계가 모호하고 모든 Test Double은 서로 연장선상에 있다는 점을 이해하리라 믿습니다. 이러한 구분은 대체로 의미상으로만 의의가 있는 듯하지만, 우리가 사용하는 용어에 따라 추상적인 개념에 대한 우리의 사고도 지배된다는 점을 감안할 때, 이에 대한 정확한 설명이 매우 중요하다고 할 수 있습니다.

Figure 12 각 Test Double의 장단점
| Test Double 유형 |
장점 |
단점 |
| Dummy |
만들기가 매우 쉽습니다. |
용도가 제한적입니다. |
| Stub |
만들기가 쉽습니다. |
유연성에 제약이 있습니다. 단위 테스트에서 결과를 명확하게 제공하지 않습니다. 멤버가 올바르게 호출되었는지 여부를 확인하는 기능이 없습니다. |
| Spy |
멤버가 올바르게 호출되었는지 확인할 수 있습니다. |
유연성에 제약이 있습니다. 단위 테스트에서 결과를 명확하게 제공하지 않습니다. |
| Fake |
다양한 시나리오에 사용할 수 있는 거의 완전한 구현을 제공합니다. |
만들기가 어렵습니다. 그 자체에 대한 단위 테스트가 필요할 정도로 복잡할 수 있습니다. |
| Mock |
Test Double을 효율적으로 만들 수 있습니다. 멤버가 올바르게 호출되었는지 확인할 수 있습니다. 단위 테스트에서 결과를 명확하게 제공합니다. |
익히기가 어렵습니다. |
개인적인 견해로는 대부분의 상황에서 뛰어난 동적 Mock 라이브러리가 Dummy, Stub 및 Spy를 모두 대신할 수 있다고 생각합니다. 그러나 매우 복잡한 인터페이스 또는 기본 클래스를 대신할 Test Double을 만들어야 한다면 Mock보다 Fake가 더 효과적일 수도 있습니다.
Mock의 경우 테스트마다 모든 예상 동작이 명시적으로 정의되어야 합니다. 테스트 대상과 Test Double 간의 상호 작용이 복잡해지면 적절한 Fake를 작성하는 경우에 비해 수많은 예상 동작을 정의하는 과정이 너무 장황하고 고된 작업이 될 수 있습니다. 안타깝게도 Mock를 사용했을 때 이러한 결과가 발생할지를 파악하는 명확한 기준은 없기 때문에 개인의 판단에 의존하는 수밖에 없습니다.
Mark Seemann은 덴마크 코펜하겐의 Microsoft Services에서 Microsoft 고객과 파트너가 엔터프라이즈급 응용 프로그램을 설계, 디자인, 개발할 수 있도록 지원하고 있습니다. 문의 사항이 있으면 저자의 블로그(
blogs.msdn.com/ploeh)를 통해 연락하시기 바랍니다.