3. 단위 테스트 구조

이 글부터 단위 테스트는 C#, xUnit이 아닌 제가 보기 쉬운 TypeScript, Jest로 작성됩니다. 그렇기에 3.2 xUnit 테스트 프레임워크 살펴보기 절과 3.6 검증문 라이브러리를 사용한 테스트 가독성 향상 절이 생략되었습니다. Jest는 그 자체로 읽기 쉽습니다.

3장에서는 일반적으로 준비(Arrange), 실행(Act), 검증(Assert) 패턴(AAA패턴)으로 작성된 단위 테스트의 구조를 살펴본다.

그러면서 단위 테스트 명명법을 소개한다. 대부분의 관행이 그렇게 좋지는 않다.

이 장에서는 그다지 유용하지 않은 명명 사례를 소개하고 왜 좋은 선택이 아닌지 살펴본다.

마지막으로는 단위 테스트 프로세스를 간소화하는 데 도움이 되는 몇가지 기능도 얘기해본다.

3.1 단위 테스트를 구성하는 방법

3.1.1 AAA 패턴 사용

AAA 패턴은 각 테스트를 준비, 실행, 검증이라는 세 부분으로 나눌 수 있다.

두 숫자의 합을 계산하는 메서드만 있는 Calculator 클래스를 살펴보자.

class Calculator {
	function sum(first: number, second: number): number {
    	return first + second;
    }
}

다음 예제는 클래스의 동작을 검증하는 테스트다. 이 테스트는 AAA 패턴을 따른다.

describe('Calculator', () => { // 응집도 있는 테스트 세트를 위한 describe
  it('sum of two numbers', () => { // 단위 테스트 이름
    // Arrange
    const first = 10;	// 준비 구절
    const second = 20;
    const calculator = new Calculator();
    
    // Act
    const result = calculator.sum(first, second);	// 실행 구절
    
    // Assert
    expect(result).toBe(30);	// 검증 구절
  });
});

AAA 패턴은 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움이 된다.

일단 익숙해지면 모든 테스트를 쉽게 읽을 수 있고 이해할 수 있다.

결국 전체 테스트 스위트의 유지 보수 비용이 줄어든다.

구조는 다음과 같다.

  • 준비 구절에서는 테스트 대상 시스템(SUT)과 해당 의존성을 원하는 상태로 만든다.
  • 실행 구절에서는 SUT에서 메서드를 호출하고 준비된 의존성을 전달하며 출력 값을 캡처한다.
  • 검증 구절에서는 결과를 검증한다. 결과는 반환 값이나 SUT와 협력자의 최종 상태, SUT가 협력자에 호출한 메서드 등으로 표시될 수 있다.

Given-When-Then 패턴

AAA와 유사한 Given-When-Then 패턴에 대해 들어봤을 것이다. 이 패턴도 테스트를 세 부분으로 나눈다.

  • Given - 준비 구절에 해당
  • When - 실행 구절에 해당
  • Then - 검증 구절에 해당

테스트 구성 측면에서 두 패턴 사이에 차이는 없지만, 유일한 차이점은 비개발자에게는 Given-When-Then 구조가 더 읽기 쉽다는 것이다.

테스트를 작성할 때는 준비 구절로 시작하는 것이 자연스럽지만 TDD를 실천한다면 검증 구절로 시작할 수도 있다.

직관적이지는 않지만 TDD의 사고 과정이 그러하기 때문에 제품 코드 전에 테스트를 작성한다면 적용해볼 수 있다.

그러나 테스트 전에 제품 코드를 작성한다면 테스트를 작성할 시점에서 실행에서 무엇을 예상하는지 이미 알고 있으므로 준비 구절부터 시작하는 것이 좋다.

3.1.2 여러 개의 준비, 실행, 검증 구절 피하기

때로는 준비, 실행 또는 검증 구절이 여러개 있는 테스트를 만날 수 있다.

  1. 테스트 준비
  2. 실행
  3. 검증
  4. 좀 더 실행
  5. 다시 검증

이는 여러 개의 동작 단위를 검증하는 테스트를 뜻한다.

이러한 테스트는 더 이상 단위 테스트가 아니라 통합 테스트다. 이러한 구조는 피하는 것이 좋다.

실행이 하나면 테스트가 단위 테스트 범주에 있게끔 보장하고, 간단하고, 빠르며, 이해하기 쉽다.

만약 이런 테스트를 보게 된다면 각 동작을 고유의 테스트로 도출하는 리팩터링을 하자.

3.1.3 테스트 내 if 문 피하기

위의 연장선으로 if 문이 있는 단위 테스트를 만날 수도 있는데, 이것도 안티 패턴이다.

if 문은 테스트가 한 번에 너무 많은 것을 검증한다는 표시다.

그러므로 반드시 여러 테스트로 나눠야 한다.

테스트에 분기가 있어서 얻는 이점은 없다.

if문은 테스트를 읽고 이해하는 것을 더 어렵게 만든다.

3.1.4 각 구절은 얼마나 커야 하는가?

AAA 패턴으로 시작할 때 보통 하는 질문은 다음과 같다.

  • 각 구절의 크기가 얼마나 되는가?
  • 테스트가 끝난 후에 정리하는 종료 구절은 어떻게 하는가?

일반적으로 준비 구절이 세 구절 중 가장 크며, 실행과 검증을 합친 만큼 클 수도 있다.

그러나 이보다 훨씬 크면, 같은 테스트 클래스 내 비공개 메서드 또는 별도의 팩토리 클래스로 도출하는 것이 좋다.

준비 구절에서 코드 재사용에 도움이 되는 두 가지 패턴으로 Object MotherTest Datta Builder가 있다.

실행 구절은 보통 코드 한 줄이다. 두 줄 이상인 경우 SUT의 공개 API에 문제가 있을 수 있다.

예시와 함께 살펴보자.

describe('Customer', () => {
  it('sum of two numbers', () => {
    // Arrange
    const store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    const customer = new Customer();
    
    // Act
    const success = customer.Purchase(store, Product.Shampoo, 5);
    
    // Assert
    expect(success).toBeTruthy();
    expect(store.GetInventory(Product.Shampoo)).toBe(5);
  });
});

위 테스트의 실행 구절은 단일 메서드 호출이며 잘 설계된 클래스 API임을 보여준다.

그럼 아래 코드도 보자.

describe('Customer', () => {
  it('sum of two numbers', () => {
    // Arrange
    const store = new Store();
    store.AddInventory(Product.Shampoo, 10);
    const customer = new Customer();
    
    // Act
    const success = customer.Purchase(store, Product.Shampoo, 5);
    store.RemoveInventory(success, Product.Shampoo, 5);
    
    // Assert
    expect(success).toBeTruthy();
    expect(store.GetInventory(Product.Shampoo)).toBe(5);
  });
});

실행 구절을 살펴보면 다음과 같다.

  • 첫 번째 줄에서는 고객이 상점에서 샴푸 다섯 개를 얻으려고 한다.
  • 두 번째 줄에서는 재고가 감소되는데, Purshace() 호출이 성공을 반환하는 경우에만 수행된다.

테스트 자체에는 문제가 없으나 이는 Customer 클래스의 API에 문제가 있다고 볼 수 있다.

클라이언트에게 메서드 호출을 더 강요해서는 안된다.

해결책은 코드 캡슐화를 항상 지키는 것이다.

이전 예제에서 Purchase 메서드의 한 부분으로 고객이 매입한 재고를 제거하고, 클라이언트 코드에 의존하지 않아야 했다.

불변을 지키는 한, 불변 위반을 초래할 수 있는 잠재적인 행동을 제거해야 한다.

그렇다고 한 줄로 하라고는 단정지을 수는 없다. 각각의 사례에서 캡슐화 위반이 있을 수 있는지 살펴보자.

3.1.5 검증 구절에는 검증문이 얼마나 있어야 하는가

단위 테스트의 단위는 동작의 단위이지 코드의 단위가 아니다.

단위 동작은 여러 결과를 낼 수 있으며, 하나의 테스트로 그 모든 결과를 평가하는 것이 좋다.

그렇다고 해도 검증 구절이 너무 커지는 것은 경계해야 한다.

제품코드에서 추상화가 누락됐을 수 있다.

예를 들어 SUT에서 반환된 객체 내에서 모든 검증하는 대신 객체 클래스 내에 적절한 동등 멤버를 정의하는 것이 좋다.

그러면 단일 검증 문으로 객체를 기대값과 비교할 수 있다.

expect(P.A).toBe(a);
expect(P.B).toBe(b);
expect(P.C).toBe(c);
expect(P.D).toBe(d);

처럼 너무 커진다면

expect(P.equal(a, b, c, d)).toBeTruthy();

처럼 바꿔보는 것이다.

3.1.6 종료 단계는 어떤가

테스트에 의해 작성된 파일을 지우거나 데이터베이스 연결을 종료하고자 이 구절을 사용할 수 있다.

종료는 일반적으로 별도의 메서드로 도출돼, 클래스 내 모든 테스트에서 재사용된다.

AAA 패턴에는 이 단계를 포함하지 않는다.

또한 종료는 통합 테스트의 영역이다. 단위 테스트는 프로세스 외부에 종속적이지 않기 때문이다.

3부에서는 통합 테스트 후 올바르게 정리하는 법을 자세히 설명한다.

3.1.7 테스트 대상 시스템 구별하기

SUT를 의존성과 구분하는 것이 중요하다.

특히 SUT가 꽤 많은 경우, 테스트 대상을 찾는 데 시간을 너무 많이 들일 필요가 없다.

그렇게 하기 위해 테스트 내 SUT이름을 sut로 하라.

다음 예제에서 Calculator 인스턴스 이름을 바꾸고 난 후의 테스트를 볼 수 있다.

describe('Calculator', () => {
  it('sum of two numbers', () => {
    // Arrange
    const first = 10;
    const second = 20;
    const sut = new Calculator();
    
    // Act
    const result = sut.sum(first, second);
    
    // Assert
    expect(result).toBe(30);
  });
});

3.1.8 준비, 실행, 검증 주석 제거하기

의존성에서 SUT를 떼어내는 것이 중요하듯이, 테스트 내에서 특정 부분이 어떤 구절에 속해 있는지 파악하는 데 시간을 많이 들이지 않도록 각 구절을 구분하는 것도 중요하다.

한 가지 방법은 각 구절을 시작하기 전에 // Arrange와 같은 주석을 다는 것이다.

다른 방법은 다음 예제와 같이 빈 줄로 분리하는 것이다.

describe('Calculator', () => {
  it('sum of two numbers', () => {
    const first = 10;
    const second = 20;
    const sut = new Calculator();
    
    const result = sut.sum(first, second);
    
    expect(result).toBe(30);
  });
});

그러나 대규모 테스트에서는 준비 단계에 빈 줄을 추가해 설정 단계를 구분할 수도 있다.

그러므로

  • AAA 패턴을 따르고 준비 및 검증 구절에 빈 줄을 추가하지 않아도 되는 테스트라면 구절 주석들을 제거하라
  • 그렇지 않으면 구절 주석을 유지하라

3.2 테스트 간 테스트 픽스처 재사용

테스트에서 언제 어떻게 코드를 재사용하는지 아는 것이 중요하다.

준비 구절에서 코드를 재사용하는 것이 단순화 하기 좋은 방법이다.

테스트 픽스처

테스트 픽스처는 테스트 실행 대상 객체다. 이 객체는 정규 의존성. 즉 SUT로 전달되는 인수다.

데이터베이스에 있는 데이터나 하드 디스크의 파일일 수도 있다.

이러한 객체는 각 테스트 실행 전에 알려진 고정 상태로 유지하기 위해 동일한 결과를 생성한다.

따라서 픽스처(Fixture)라는 단어가 나왔다.

두 가지의 방법이 있는 데, 그 중에서 하나만 유용하다. 나머지 하나는 유지비를 증가시킨다.

먼저 올바르지 않은 방법부터 살펴보자.

첫째는 테스트의 생성자역할을 하는 부분에서 픽스처를 초기화하는 것이다.

const _store = new Store();
_store.AddInventory(Product.Shampoo, 10);
_sut = new Customer();

describe("Customer", () => {
  it("Purchase succeeds when enough inventory", () => {
    const success = _sut.Purchase(_store, Product.Shampoo, 5);
    
    expect(success).toBeTruthy();
    expect(_store.GetInventory(Product.Shampoo)).toBe(5);
  });
  
  it("Purchase fails when not enough inventory", () => {
    const success = _sut.Purchase(_store, Product.Shampoo, 15);
    
    expect(success).toBeFalsy();
    expect(_store.GetInventory(Product.Shampoo)).toBe(10);
  });
});

이 방법은 테스트 코드의 양을 크게 줄일 수 있으며 대부분의 테스트 픽스처 구성을 제거할 수 있다.

하지만 두 가지 중요한 단점이 있다.

  • 테스트 간 결합도가 높아진다.
  • 테스트 가독성이 떨어진다.

3.2.1 테스트 간의 높은 결합도는 안티 패턴이다.

위 코드에서는 모든 테스트가 서로 결합돼 있다. 즉, 테스트의 준비 로직을 수정하면 모든 테스트에 영향을 미친다.

예를 들어,

_store.AddInventory(Product.Shampoo, 10);

위 코드를 다음과 같이 수정하면

_store.AddInventory(Product.Shampoo, 15);

상점의 초기 상태에 대한 가정을 무효화하므로 쓸데없이 테스트가 실패하게 된다.

이는 중요한 지침을 위반한다.

테스트를 수정해도 다른 테스트에 영향을 주어서는 안된다.

3.2.2 테스트 가독성을 떨어뜨린다.

테스트만 보고는 더 이상 전체 그림을 볼 수 없다.

테스트를 이해하기 위해서는 클래스의 다른 부분도 봐야한다.

준비 로직이 별로 없더라도 테스트 메서드로 바로 옮기는 것이 좋다.

3.2.3 더 나은 테스트 픽스처 재사용법

테스트 픽스처를 재사용할 때 생성자 사용이 최선의 방법은 아니다.

두 번째 방법은 다음처럼 비공개 팩토리 메서드를 두는 것이다.

describe("Customer", () => {
  it("Purchase succeeds when enough inventory", () => {
    const store = CreateStoreWithInventory(Product.Shampoo, 10);
    const sut = CreateCustomer();
    
    const success = sut.Purchase(store, Product.Shampoo, 5);
    
    expect(success).toBeTruthy();
    expect(store.GetInventory(Product.Shampoo)).toBe(5);
  });
  
  it("Purchase fails when not enough inventory", () => {
    const store = CreateStoreWithInventory(Product.Shampoo, 10);
    const sut = CreateCustomer();
    
    const success = sut.Purchase(store, Product.Shampoo, 15);
    
    expect(success).toBeFalsy();
    expect(store.GetInventory(Product.Shampoo)).toBe(10);
  });
});

function CreateStoreWithInventory(product: Product, quantity: number): Store {
  const store = new Store();
  store.AddInventory(product, quantity);
  return store;
}

function CreateCustomer(): Customer {
  return new Customer();
}

이렇게 하면 테스트 코드를 짧게 하면서, 동시에 테스트 진행 상황에 대한 전체 맥락을 유지할 수 있다.

테스트 픽스처 재사용 규칙에 한 가지 예외가 있다. 모든 테스트에, 또는 거의 대부분의 테스트에 사용되는 경우 생성자에 픽스처를 인스턴스화할 수도 있다.

데이터베이스와 작동하는 통합 테스트가 종종 여기에 해당한다.

3.3 단위 테스트 명명법

테스트에 표현력이 있는 이름을 붙이는 것이 중요하다.

그렇다면, 단위 테스트 이름을 어떻게 정해야 하는가?

가장 유명하지만 가장 도움이 되지 않는 방법 중 하나가 다음과 같은 관습이다.

[테스트 대상 메서드]_[시나리오]_[예상 결과]

동작 대신 구현 세부 사항에 집중하게끔 부추기기 떄문에 분명히 도움이 되지 않는다.

간단하고 쉬운 영어 구문이 훨씬 더 효과적이다.

describe('Calculator', () => {
  it('sum of two numbers', () => {
    const first = 10;
    const second = 20;
    const sut = new Calculator();
    
    const result = sut.sum(first, second);
    
    expect(result).toBe(30);
  });
});

만약 이걸 위 규칙처럼 쓴다면 어떻게 될까? 아마 다음과 같을 것이다.

  it('Sum_TwoNumbers_ReturnsSum', () => {});

이 이름은 프로그래머의 눈에는 논리적으로 보이지만, 테스트 가독성에 전혀 도움이 되지 않는다.

3.3.1 단위 테스트 명명 지침

표현력 있고 읽기 쉬운 테스트 이름을 지으려면 다음 지침을 따르자.

  • 엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 이러한 정책의 좁은 상자 안에 넣을 수 없다. 표현의 자유를 허용하자.
  • 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자. 도메인 전문가나 비즈니스 분석가가 좋은 예다.
  • 단어를 밑줄 또는 공백(테스트 이름이 문자열인 경우)로 구분한다.

3.3.2 예제: 지침에 따른 테스트 이름 변경

다음과 같은 이름이 있다고 생각해보자.

"IsDeliveryValid_InvalidDate_ReturnsFalse"

이 테스트는 DeliveryService가 잘못된 날짜의 배송을 올바르게 식별하는지 검증한다.

이걸 우선 쉬운 영어로 바꿔보자.

"Delivery with invalid date should be considered invalid"

좀 더 개선해보자. 배송 날짜는 미래에서만 선택할 수 있어야 하므로 과거가 무효한 날짜가 될 것이다.

이를 테스트 이름에 반영해보자.

"Delivery with past date should be considered invalid"

너무 장황하다. considered를 제외해도 의미가 퇴색되지 않는다.

"Delivery with past date should be invalid"

should be문구는 안티 패턴이다. 사실을 서술할 때는 소망이나 욕구가 들어가지 않는다.

"Delivery with past date is invalid"

마지막으로 기초 영문법을 지켜야 한다. 관사를 붙여 테스트 이름을 읽기 쉽게 만들자.

"Delivery with a past date is invalid"

3.4 매개변수화된 테스트 리팩터링하기

보통 테스트 하나로는 동작 단위를 완전하게 설명하기에 충분하지 않다.

이 단위는 일반적으로 여러 구성요소를 포함하며, 각 구성 요소는 자체 테스트로 캡처해야 한다.

다행히 대부분의 단위 테스트 프레임워크는 매개변수화된 테스트를 사용해 테스트를 묶을 수 있는 기능을 제공한다.

Jest에서는 test.each 문법을 사용해 쉽게 사용할 수 있다. 심지어 Jest에서는 테스트 이름마저 바꿀 수 있다. (여기에서는 책에 나온대로 고정적이게 네이밍을 했다.)

describe("Delivery Service", () => {
  it.each([
  	[-1, false],
    [0, false],
    [1, false],
    [2, true]
  ])("Can detect an invalid delivery data", (daysFromNow: number, expected: boolean) => {
  	const sut = new DeliveryService();
    const deliveryDate = new Date();
    deliveryDate.setDate(deliveryDate.getDate() + daysFromNow);
    const delivery = new Delivery(deliveryDate);
    
    const isValid = sut.IsDeliveryValid(delivery);
    
    expect(isValid).toBe(expected);
  });
});

뭔가 코드는 아름다워졌긴 하지만, 테스트 메서드가 나타내는 사실을 파악하기가 어려워졌다.

절충안으로 긍정적인 테스트 케이스와 부정적인 테스트를 분리하자.

describe("Delivery Service", () => {
  it.each([
  	[-1],[0],[1]
  ])("Detects an invalid delivery data", (daysFromNow: number) => {
  	/* ... */
  });
  
  it("The soonest delivery date is two days from now", () => {
  	/* ... */
  });
});

이런식으로 리팩터링하면 가독성을 크게 해치지 않을 수 있다.

보다시피, 테스트 코드의 양과 그 코드의 가독성은 서로 상충된다.

그리고 동작이 너무 복잡하면 매개변수화된 테스트를 조금도 사용하지 말자.

긍정적인 테스트 케이스와 부정적인 테스트 케이스 모두 각각 고유의 테스트 메서드로 나타내라.

좋은 웹페이지 즐겨찾기