Testing/Unit Testing

단위 테스트란 무엇인가

Seung-o 2024. 2. 7. 00:11

단위 테스트의 정의

 

단위 테스트의 가장 중요한 세 가지 속성은 아래와 같다.

 

1. 작은 코드 조각을 검증하고, 

2. 빠르게 수행하고,

3. 격리된 방식으로 처리하는 자동화된 테스트

 

처음 두 가지 속성은 대체로 논란의 여지가 없지만, 마지막 속성인 '격리 문제'는 단위 테스트의 고전파와 런던파를 구분하는 근원이 된다. 

 

격리 문제에 대한 런던파의 접근

 

런던파에서는 '테스트의 격리 방식'을 '테스트 대상 시스템을 협력자 ( collaborator )에게서 격리하는 것'으로 규정한다. 즉, 하나의 클래스가 다른 클래스들에 의존하면 모든 의존성을 테스트 대역 ( test double )로 대체해야한다. 

 

이 방법의 이점 중 하나는 테스트가 실패하면 코드베이스의 어느 부분이 고장 났는지 확실히 알 수 있다는 것이다. 클래스의 모든 의존성은 테스트 대역으로 대체됐기 때문에 의심의 여지가 없다.

또 다른 이점은 객체 그래프( Object graph )를 분할할 수 있다는 것이다. 클래스 사이의 의존 관계가 복잡해질수록 테스트 대역 없이 테스트를 진행하는 것은 어렵다. 이 때, 테스트 대역을 사용하면 객체 그래프를 다시 만들지 않아도 된다. 

아주 사소한 이점으로는 "한 번에 한 클래스만 테스트하라"라는 지침을 간단한 테스트 스위트만으로 지켜낼 수 있다는 것이다. 

 

런던 스타일에서는 테스트 대상 클래스를 의존성에서 분리하여 단순한 테스트 스위트 구조를 형성한다.

 

격리 문제에 대한 고전파의 접근

 

단위 테스트의 정의를 다시 살펴보면, 첫 번째 항목에 "작은 코드 조각을 검증하고"라고 적혀있다. 작은 코드 조각은 어느 정도의 범위를 의미하는 것일까? 런던 스타일에서 각각의 클래스를 분리하고, 하나의 테스트 스위트가 하나의 클래스만을 검증해야한다면 고전 스타일에서는 코드를 꼭 격리하는 방식으로 테스트해야하는 것은 아니다. 대신 단위 테스트는 서로 격리해서 실행할 수 있어야 한다. 각 테스트가 서로의 결과에 영향을 주지 않는 것을 '격리 방식'으로 규정한 것이다.

 

그럼 여기서 의문이든다. 고전 스타일에서는 어떤 의존성과의 상호작용을 허용하는 것이고, 어떤 의존성은 테스트 더블을 사용해야하는 것일까? 이에 대한 답변으로 저자는 "공유 의존성"에 대해서만 테스트 더블을 사용하는 것을 권한다.

 

공유 의존성은 테스트 대상 클래스 간이 아니라 단위 테스트 간에 공유한다. 그런 의미에서 Spring이나 NestJS에서 사용되는 싱글톤 의존성은 각 테스트에서 새 인스턴스를 만들 수 있기만 하면 공유되지 않는다. 즉, 실제 운영 환경에서는 인스턴스가 단 하나만 있지만, 테스트는 이 패턴을 따르지 않고 인스턴스를 재사용하지도 않는다. 이러한 의존성을 비공개 의존성이라고 지칭한다. 

 

공유 의존성을 대체하는 이유 중 하나는 테슽 실행 속도를 높이는 데 있다. 공유 의존성은 거의 항상 실행 프로세스 외부에 있는 데 반해, 비공개 의존성은 보통 그 경계를 넘지 않는다. 그래서 데이터베이스나 파일 시스템 등의 공유 의존성에 대한 호출은 비공개 의존성에 대한 호출보다 지연된다. 

 

고전 스타일에서는 공유 의존성을 격리하는 것이 곧 단위 테스트의 격리를 의미한다.

 

단위 테스트의 런던파와 고전파

 

두 분파의 차이를 코드로 살펴보자. 

 

아래는 고전 스타일의 테스트 코드 예시이다.

 

test('purchase_succeeds_when_enough_inventory', () => {
	// Given (Arrange)
	const store = new Store();
	store.AddInventory(Product.Shampoo, 10);
	const customer = new Customer();

	// When (Act)
	// customer가 삼푸 5개를 구매한 결과
	const success = customer.purchase(store, Product.shampoo, 5);

	// Then (Assert)
	expect(success).toBe(true); // success는 true가 되어야 함.
	// 5개를 구매했으므로 store에 샴푸가 5개 남아있어야 함
	expect(store.getInventory(Product.Shampoo)).toBe(5); 
})

test('purchase_fails_when_not_enough_inventory', () => {
	// Given
	const store = new Store();
	store.AddInventory(Product.Shampoo, 10);
	const customer = new Customer();

	// When
	// customer가 샴푸 15개를 구매한 결과
	const success = customer.purchase(store, Product.shampoo, 15);

	// Then
	expect(success).toBe(false);
	expect(store.getInventory(Product.Shampoo)).toBe(10);
})

 

이 테스트의 검증 대상 ( SUT, System Under Test )은 Customer 클래스이다. 이 테스트에는 Store 클래스가 협력자 ( Collaborator )로 참여하고 있다. 이 방식에서는 자연스럽게 Customer와 Store가 모두 검증이 되는 동시에, Store에서 문제가 생겼을 시 Customer 까지 테스트까지 오류가 발생하게 된다.

더보기

Q. MUT ( Method under test )는 SUT 와 무엇이 다른가요?

A. MUT는 보통 함수를 이야기하고 SUT는 클래스 전체를 이야기 합니다.

위 코드를 런던 스타일로 구성하면 아래와 같다.

 

jest.mock('./store.js');

// purchase.test.js
test('purchase_succeeds_when_enough_inventory', () => {
	// Given
	const store = mocked(Store, true);
	// mock을 만드는 방식은 다양합니다. 
    store.mockImplementation(() => ({
      hasEnoughInventory: jest.fn((product: Product, amount: number) => {
        return true;
      }),
    }));
	const spy = jest.spyOn(store, 'removeInventory');
	const customer = new Customer();

	// When
	// customer가 삼푸 5개를 구매한 결과
	const success = customer.purchase(store, Product.shampoo, 5);

	// Then
	expect(success).toBe(true); // success는 true가 되어야 함.
	// removeInventory가 1번 실행되었어야 함.
	expect(spy).toHaveBeenCalledTimes(1);
})

test('purchase_fails_when_not_enough_inventory', () => {
	// Given
	const store = mocked(Store, true);
    store.mockImplementation(() => ({
      hasEnoughInventory: jest.fn((product: Product, amount: number) => {
        return false;
      }),
    }));
	const spy = jest.spyOn(store, 'removeInventory');
	const customer = new Customer();

	// When
	// customer가 삼푸 5개를 구매한 결과
	const success = customer.purchase(store, Product.shampoo, 5);

	// Then
	expect(success).toBe(false); // success는 true가 되어야 함.
	// removeInventory가 실행되지 않아야 함.
	expect(spy).toHaveBeenCalledTimes(0);
})

 

런던 스타일에서는 더 이상 Store 객체가 실제 인스턴스가 아니다. Store는 mocking 된 객체로 약속된대로 행동하게 된다.

 

더보기

Q. Mock과 테스트 대역 ( test double )은 어떻게 다른가요? 

A. 테스트 대역은 실행과 관련 없이 모든 종류의 가짜 의존성을 의미하는 포괄적 용어인 반면, Mock은 테스트 대역의 한 종류이다.

 

결국 고전 스타일과 런던 스타일의 코드 차이는 어떤 의존성을 테스트 대역으로 치환할 것인지에 대한 문제로 귀결된다. Store 객체는 비공개 의존성이지만 변경이 가능한 의존성이다. 고전 스타일에서는 이 객체를 그대로 사용하지만, 고전 스타일에서는 목으로 치환한다.

 

의존성 계층