[Onboarding] : Unit Testing

문승현·2022년 8월 4일
0

BeDev_3

목록 보기
2/6
post-thumbnail

일반적으로 Mock이란 제작하기 쉬운 재료를 이용해 제품의 외양을 흉내 낸 모조품을 말한다.
소프트웨어 엔지니어링에서도 Mock이라는 단어를 비슷한 의미로 사용하는데,
예를 들어, 실제 모듈과 비슷하게 보이도록 만든 가짜 객체를 Mock 객체라고한다.
실제 객체를 만들기엔 비용과 시간이 많이 들거나 의존성 때문에 제대로 구현하기 어려울 경우,
이런 가짜 객체, 즉 Mock 객체를 만들어 사용하는 경우가 많다.

Mock 객체를 이해하기 위해 아래와 같이 상황을 가정해보자.
나는 사용자 암호 변경 기능, SavePassword()를 구현하기 위해 테스트 케이스를 작성해야 한다.

[Fact]
public void TestSavePassword()
{
  UserRegister register = new UserRegister();
  
  string userId = "sweet88";
  string password = "potato";
  
  register.SavePassword(userId, password);
  
  Assert.Equal(password, register.GetPassword(userId));
}

SavePassword() 기능을 구현하기 위해 위와 같이 TestSavePassword() 테스트 케이스를 만들었다.
그런데 SavePassword 기능 구현 시, 추가 요구 사항이 필요하다는 것을 발견했다.
사용자 암호를 반드시 암호화한 다음에 저장해야 한다는 것이다.
사용하게 될 암호화 모듈의 스펙은 다음 인터페이스와 같이 정해져 있었다.

public interface Cipher 
{
  public string Encrypt(string source);
  public string Decrypt(string source);
}

그런데 해당 암호화 모듈은 다른 사람이 만들어 제공하기로 했다.
암호화 모듈 부분은 제외하고 테스트 케이스를 다시 작성했다.

[Fact]
public void TestSavePassword()
{
  UserRegister register = new UserRegister();
  
  // Cipher cipher = ???
  
  string userId = "sweet88";
  string password = "potato";
  
  register.SavePassword(userId, cipher.Encrypt(password));
  string decryptedPassword = cipher.decrypt(register.GetPassword(userId));
  
  Assert.Equal(password, decryptedPassword);
}

테스트 코드를 만들었지만, 암호화 모듈 구현이 없어 컴파일이 불가능하다.
암호화 모듈을 직접 구현할 수도, 암호화 기능 적용을 누락할 수도 없는 상황이다.
그런데 사실 암호화 모듈 그 자체는 내 관심 밖 영역이다.
이를 해결하고자 암호화 모듈처럼 보이는 객체를 만들어서 개발에 사용하는 것이다.
Cipher 인터페이스에 맞추어 클래스를 구현해보자.

public class ConcreteCipher : Cipher 
{
  public String Decrypt (String source) 
  {
  	return "potato";
  }
  
  public String Encrypt (String source) 
  {
  	return "8ee2027983915ec78acc45027d874316";
  }
}

해당 클래스는 조잡해보이지만, 구현해야 하는 SavePassword()를 테스트하기에는 충분하다.
즉, Mock 객체는 구현을 위해 필요하지만 실제로 준비하기엔 어려움이 있는 대상을
필요한 부분만(여기서는 Encrpyt와 Decrypt 메소드) 채워넣어 만든 객체를 의미한다.

[Fact]
public void TestSavePassword()
{
  UserRegister register = new UserRegister();
  Cipher cipher = new ConcreteCipher();
  
  string userId = "sweet88";
  string password = "potato";
  
  register.SavePassword(userId, cipher.Encrypt(password));
  string decryptedPassword = cipher.Decrypt(register.GetPassword(userId));
  
  Assert.Equal(password, decryptedPassword);
}

이러한 Mock 객체가 필요한 근본적인 이유는 모듈들이 가진 '의존성'때문이다.
그래서 이러한 의존성을 단절시키기 위해 Mock 객체가 사용되는 것이라 할 수 있다.

Mock에 대한 분류 개념, 테스트 더블

Mock에 대한 분류 개념 중 가장 유명한 개념이 테스트 더블(Test Double)이다.
이는『xUnit Test Patterns』의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 단어이다.
‘대역, 스턴트맨’을 나타나는 스턴트 더블(Stunt Double)이라는 단어에서 차용한 것으로,
원본 객체로 테스트가 어려운 경우, 이를 대신해 테스트가 가능하도록 만드는 객체를 지칭한다.

제라드는 앞서 설명한 Mock 객체를 테스트 더블의 하위로 분류해놓았다.
각각을 설명하기 위해 인터넷 쇼핑몰에서 유저에게 쿠폰을 발급하는 상황을 가정해보자.
그런데 쿠폰은 종류가 매우 다양하고 복잡하여 다음과 같이 인터페이스로 정의되어 있다.

public interface ICoupon 
{
  public string getName(); // 쿠폰 이름
  public bool isValid(); // 쿠폰 유효 여부 확인
  public int getDiscountPercent(); // 할인율
  public bool isAppliable(Item item); // 해당 아이템에 적용 가능 여부
  public void doExpire(); // 사용할 수 없는 쿠폰으로 만듦
}

유저에게 쿠폰을 발급하는 AddCoupon() 기능에 대한 테스트 케이스는 아래와 같다.
하지만 쿠폰 인터페이스의 구현 객체가 없어 정상적으로 컴파일 할 수 없다.
테스트 더블의 분류에 따라 어떻게 해당 문제를 해결하는지 살펴보자.

[Fact]
public void TestAddCoupon() 
{
  User user = new User("area88");
  Assert.Equals(0, user.GetTotalCouponCount());
  
  // ICoupon coupon = ??????
  
  user.AddCoupon(coupon);
  
  Assert.Equals(1, user.GetTotalCouponCount());
}

더미 객체(Dummy Object)

더미 객체는 말 그대로 단순한 껍데기를 의미한다.
아래 DummyCoupon은 인스턴스화될 수 있는 수준으로만 쿠폰 인터페이스를 구현한 객체다.

public class DummyCoupon : ICoupon 
{

	public int GetDiscountPercent() 
    {
		return 0;
	}

	public String GetName() 
    {
		return null;
	}
    
    public bool IsAppliable(Item item) 
    {
		return false;
	}

	public bool IsValid() 
    {
		return false;
	}

	public void DoExpire() 
    {
	}
}

이를 이용해 TestAddCoupon() 코드를 마무리 지어보면 아래와 같다.

[Fact]
public void TestAddCoupon() 
{
  User user = new User("area88");
  Assert.Equals(0, user.GetTotalCouponCount());
  
  ICoupon coupon = new DummyCoupon();
  
  user.AddCoupon(coupon);
  
  Assert.Equals(1, user.GetTotalCouponCount());
}

이처럼 원본 객체의 기능까지는 필요하지 않고 인스턴스화된 객체만 필요한 경우 사용한다.
그런데 테스트 과정에서 더미 객체의 메소드 호출이 필요한 경우가 있다.
예를 들어, 구체화된 쿠폰 이름이나 쿠폰 할인율이 필요한 경우가 있다고 가정해보자.
해당 경우에는 더미 객체보다 조금 더 발전된 객체가 있어야 한다.

테스트 스텁(Test Stub)

테스트 스텁은 더미 객체가 마치 실제로 동작하는 것처럼 보이게 만들어놓은 객체다.
더미 객체로 만들어진 DummyCoupon의 경우에도 메소드가 호출되면 동작을 하긴한다.
다만, 타입 별 기본값이 리턴되거나 아무일도 일어나지 않도록 동작한다.

반면에 아래 테스트 스텁은 객체의 특정 상태를 가정해서 만들어놓은 구현체다.
특정한 값을 리턴해주거나 특정한 메시지를 출력하는 등의 작업을 한다.
앞에서 Mock 객체를 설명할 때 만들었던 ConcreteCipher 클래스가 이에 해당한다.

public class StubCoupon : ICoupon 
{

	public int GetDiscountPercent() 
    {
		return 7;  // 특정한 값
	}

	public String GetName() 
    {
		return "VIP 할인 쿠폰";  // 특정한 메시지
	}
    
    public bool IsAppliable(Item item) 
    {
		return true;
	}

	public bool IsValid() 
    {
		return true;
	}

	public void DoExpire() 
    {
	}
}

위의 StubCoupon으로 인스턴스화된 객체의 경우,
메소드 호출 시 특정한 값(7)과 메시지("VIP 할인 쿠폰")를 리턴한다.
이처럼 테스트 스텁을 사용할 때는 테스트에 필요한 메소드 부분만 하드코딩 하면된다.

이를 바탕으로 고객이 가장 최근 받은 쿠폰을 반환하는 기능을 구현하면 아래와 같다.
쿠폰을 정상적으로 반환한다면, 해당 쿠폰 정보가 StubCoupon에 정의된 것과 같아야한다.

[Fact]
public void TestGetLastOccupiedCoupon() 
{
  User user = new User("area88");
  ICoupon eventCoupon = new StubCoupon();
  
  user.AddCoupon(eventCoupon);
  ICoupon lastCoupon = user.GetLastOccupiedCoupon();
  
  Assert.Equals(7, lastCoupon.GetDiscountPercent());
  Assert.Equals("VIP 할인 쿠폰", userCoupon.GetName());
}

그런데 테스트 스텁은 객체의 상태를 대신해주고 있긴 하지만 하드코딩된 형태다.
따라서 로직(동작)이 들어가는 부분은 테스트할 수 없다.
예를 들어, 특정 쿠폰 적용 여부에 따라 결제 금액이 달라지는 경우라고 가정하자.

[Fact]
public void TestGetOrderPrice()
{
  PriceCalculator calculator = new PriceCalculator();
  
  Item item = new Item("LightSavor","칼",100000); // new Item(이름, 카테고리, 가격)
  
  ICoupon coupon = new StubCoupon();
  
  Assert.Equals(93000, calculator.GetOrderPrice(item, coupon)); // 쿠폰으로 인해 할인된 가격
}

테스트 할 GetOrderPrice() 메소드를 아래와 같이 구현했다고 하자.

public class PriceCalculator 
{
  public int getOrderPrice(Item item, ICoupon coupon) 
  {
  	if (coupon.IsValid() && coupon.IsAppliable(item)) // 쿠폰이 유효하고 쿠폰 적용 가능힌 아이템이면
    {
  		return (int)(item.GetPrice()*GetDiscountRate(coupon.GetDiscountPercent()));
  	}
  	return item.GetPrice();
  }
  
  private double GetDiscountRate(int percent)
  {
  	return (100 - percent) / 100d; 
  }
}

현재는 IsValid와 IsAppliable이 항상 true를 반환하기에 쿠폰이 적용된다.
쿠폰이 적용되지 않는 상황에 대해서는 테스트하지 못한다.
따라서 대입되는 item에 따라 true 혹은 false가 나오도록 만들고자 한다.

이에 StubCoupon의 isAppliable 메소드를 다음과 같이 수정하였다.
칼이면 쿠폰 적용이 가능하고 시계면 쿠폰 적용이 불가하다.

public bool IsAppliable(Item item) 
{
  if (item.GetCategory().Equals("칼") ) 
  {
  	return true;
  } 
  else if (item.GetCategory().Equals("시계")) 
  {
  	return false;
  }
  return false;
}

객체가 이정도 수준까지 발전하면 실제 로직이 구현된 것처럼 보이는데,
이는 테스트 스텁이라기보다는 페이크 객체에 가깝다고 할 수 있다.

페이크 객체(Fake Object)

테스트 스텁은 하나의 인스턴스 상태를 대표하는 반면,
페이크 객체는 여러 개의 인스턴스 상태를 대표하거나, 복잡한 로직을 포함한다.
아래의 FakeCoupon은 데이터베이스를 대신할 List와 복잡한 로직이 구현되어있다.

public class FakeCoupon : ICoupon 
{
	List<string> categoryList = new List<string>(); 
	
  	public FakeCoupon ()
  	{
		categoryList.Add("칼");
		categoryList.Add("장난감");
		categoryList.Add("시계");
	}
	
	public bool IsAppliable(Item item) 
  	{
		if( this.categoryList.Contains(item.GetCategory())) 
        {
			return true;
		}
		return false;
	}
}

이처럼 객체 내부에서 필요로 하는 다른 외부 객체와 복잡한 로직들을
비교적 단순화하여 구현한 객체를 페이크 객체라고 한다.
결과적으로 테스트 케이스 작성을 위해 필요한 의존성을 제거하기 위해 사용되는 것이다.

테스트 스파이(Test Spy)

테스트 케이스를 작성하는 과정에서 특정 객체가 사용되었는지,
그 객체의 예상된 메소드가 정상적으로 호출되었는지 확인해야 하는 상황이 발생한다.
해당 경우 호출 여부를 몰래 감시해서 기록했다가, 나중에 해당 기록 정보를 확인하여 해결한다.
이와 같은 목적으로 사용되는 테스트 더블을 테스트 스파이라고 한다.
보통 스파이들이 다른 일도 하면서 스파이 일을 겸업으로 하듯이,
테스트 스파이 객체도 다른 동작을 하면서 스파이 기능까지 하는 경우가 많다.

[Fact]
public void TestGetOrderPrice()
{
  PriceCalculator calculator = new PriceCalculator();
  
  Item item = new Item("LightSavor","칼",100000); // new Item(이름, 카테고리, 가격)
  
  ICoupon coupon = new StubCoupon();
  
  Assert.Equals(93000, calculator.GetOrderPrice(item, coupon)); // 쿠폰으로 인해 할인된 가격
}

calculator.GetOrderPrice()이 호출되면 결과값이 93000이 나와야 Assert 문장이 참이 된다.
그런데 GetOrderPrice() 메소드를 이용해 쿠폰 할인 가격을 구하기 위해서는
내부적으로 쿠폰 인터페이스에서 정의된 IsAppliablie() 메소드가 호출되어야 한다.
호출 여부를 확인하기 위해 아래와 같이 SpyCoupon을 구현하였다.

public class SpyCoupon implements ICoupon 
{
	List<string> categoryList = new List<string>();
	private int isAppliableCallCount;

	public boolean IsAppliable(Item item) 
	{
		isAppliableCallCount++; // 호출되면 증가
		if(this.categoryList.Contains(item.GetCategory())) 
        {
			return true;
		}
		return false;
	}
	
	public int GetIsAppliableCallCount()
	{
		return this.isAppliableCallCount;
	}
}

IsAppliable() 메소드 호출 횟수를 저장할 내부 변수를 만들었다.
스파이 기능을 넣은 결과, 테스트 케이스는 다음과 같은 식으로 테스트할 수 있다.

public void TestGetOrderPrice_discountableItem()
{
  PriceCalculator calculator = new PriceCalculator();
  Item item = new Item("LightSavor","Kitchen knife",100000);
  ICoupon coupon = new SpyCoupon();
  
  Assert.Equals(93000, calculator.GetOrderPrice(item, coupon));
  
  int methodCallCount = ((SpyCoupon)coupon).GetIsAppliableCallCount();
  
  Assert.Equals(1, methodCallCount);
}

Mock 객체(Mock Object)

일반적인 테스트 더블은 상태(state)를 기반으로 테스트 케이스를 작성한다.
Mock 객체는 행위(behavior)를 기반으로 테스트 케이스를 작성한다.
그렇다면 상태 기반 테스트와 행위 기반 테스트란 무엇인가?

1. 상태 기반 테스트(state base test)
객체 지향 프로그램의 특징 중 하나는 객체가 특정 시점에 자신만의 상태를 갖는다는 점이다.
상태 기반 테스트는 이런 특징에 기반한 테스트 방식이다.
테스트 대상 클래스의 메소드를 호출하고, 그 결과 값과 예상값을 비교하는 식이다.
물론, 메소드는 ‘두 값의 합 구하기’ 같은 ‘기능’으로만 동작할 수도 있다.
하지만 많은 경우에 있어 메소드 호출은 객체의 상태를 변경한다.
SetName(“홍길동”) 같은 메소드만 보더라도, 객체의 이름 속성 값을 바꿔버린다.
특정한 메소드 호출 후, 객체의 상태에 대해 예상값과 비교하는 방식이 상태 기반 테스트이다.
SetName() 메소드를 호출했으면, GetName() 메소드로 확인해보는 식이다.

2. 행위 기반 테스트(behavior base test)
행위 기반 테스트는 올바른 로직 수행의 판단 근거로 특정한 동작의 수행 여부를 이용한다.
예를 들어, 메소드의 리턴 값이 없거나 리턴 값을 확인하는 것만으로는 부족한 경우,
즉, 예상대로 동작했음을 보증하기 어려운 경우에 사용한다.

테스트 하고 싶은 상황이 아래와 같다고 하자.
methodA에 입력 값으로 A가 주어지는 경우, methodB가 호출되면 안된다.
그런데 입력 값으로 B가 주어지면, methodB가 호출되어야 한다.


하지만 methodA만 놓고 봤을 때는 입력 값에 따른 차이를 알아낼 수가 없다.
즉, methodB의 호출 여부를 조사하지 않으면 정상 동작 여부를 판단할 수 없다.
만일 methodA가 정상 동작했을 경우 methodB가 반드시 호출되는 구성이라면,
반대로 methodB의 호출 여부로 methodA의 정상 여부를 판단할 수 있다고 보는 것이다.
따라서 methodB의 호출 여부를 확인하는 것이 테스트 시나리오의 종료 조건이 된다.

하지만 전통적인 테스트 케이스 작성 방식, 즉 상태 기반 테스트에선,
사실상 이런 상황에 대한 테스트 케이스를 작성하기가 매우 어렵거나 불편하다.
이럴 때 찾아낸 방법이, 테스트 스파이 객체를 사용하거나
자체적으로 검증 기능을 제공하는 Mock 객체를 만들어서 테스트 케이스를 작성하는 것이다.
즉, 행위를 점검하는 방향으로 테스트 케이스를 만드는 방식인 것이다.
따라서 행위 기반 테스트를 수행할 때는 예상하는 행위들을 시나리오로 만들어놓고
해당 시나리오대로 동작이 발생 여부를 확인하는 것이 핵심이 된다.

참고 자료 1) - Mocks Aren't Stubs

0개의 댓글