Unit Testing
JavaScript 로 알아보는 Unit Testing 작성 방법
  • JavaScript

부제. 생산성과 품질을 위한 단위 테스트 원칙과 패턴

책은 예제코드를 C# 으로 작성되었다. 따라서 xUnit 과 같은 C# 용 테스트 라이브러리에 관한 소개는 제외하고 예제코드는 JavaScript 로 대체하여 작성하였다.

image

모든 테스트가 똑같이 작성되지는 않는다. 일부 테스트는 아주 중요하고 소프트웨어 품질에 매우 많은 기여를 한다. 그 밖에 다른 테스트는 그렇지 않다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움이 되지 않으며 유지보수가 어렵고 느리다. 프로젝트에 도움이 되는지 여부를 명확하게 파악하지 않고 단위 테스트를 작성하는 데만 빠져들기 쉽다.

코드 커버리지, 분기 커버리지

  • 코드 커버리지

가장 많이 사용되는 지표이다. 테스트로 실행되는 코드 라인 수와 제품 코드 베이스의 전체 라인 수의 비율을 나타낸다.

  • 분기 커버리지

원시 코드 라인수를 사용하는 대신 if 문과 switch 문과 같은 제어 구조에 중점을 둔다. 테스트 스위트 내 하나 이상의 테스트가 통과하는 제어 구조의 수를 나타낸다.

특정 커버리지 숫자를 목표로하는것은 개발자들에게 테스트 대상에 신경 쓰지 못하고, 결국 적절한 단위 테스트를 더욱 달성하기 어려워진다.

단위 테스트 란 무엇을까?

단위 테스트라는것에는 많은 정의가 있지만 가장 중요한 내용은 다음과 같다.

  • 작은 코드 조각을 검증한다.

  • 빠르게 수행된다.

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

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

    • 위 3가지 중 격리된 방식 이라는것에 대해 고전파와 런던파로 갈린다.

    • 고전적 접근법에 가장 유명한 책은 켄트 백이 지은 테스트 주도 개발 일 것이다.

    • 런던 스타일에 가장 유명한 지지자는 스티브 프리먼냇 프라이스 이다.

    • 런던파

      • 테스트 대상 시스템을 협력자와 격리하는것을 일컫는다.

      • 즉, 하나의 클래스가 다른 클래스 또는 여러 클래스에 의존하면 이 모든 의존성을 테스트 대역으로 대체해야 한다. 이런 식으로 동작을 외부 영향과 분리해서 테스트 대상 클래스에만 집중할 수 있다.

        image

    • 고전파

      • 코드를 격리하는 것을 넘어 단위 테스트 간에 격리한다.
      • 단일 클래스 또는 클래스 세트에 집중한다.
    • 런던파의 장점

      • 입자성이 좋다. 테스트가 세밀에서 한 번에 한 클래스만 확인한다.
      • 서로 연결된 클래스의 그래프가 커져도 테스트하기 쉽다.
      • 테스트 실패하면 어떤 기능이 실패했는지 확실히 알 수 있다.
    • 우리집 강아지를 부르면, 바로 나에게로 온다

      → 우리집 강아지를 부르면 왼족 앞다리부터 움직이고, 이어서 오른쪽 앞다리를 움직이고 머리를 돌리고, 꼬리를 흔들기 시작한다.

    • 이 움직임은 이해할 수 없다. 나에게 오고 있는가? 도망가고 있는가? 알 수 없다. 실제 동작 대신 개별 클래스 (움직임) 를 테스트할 때 테스트가 이렇게 보이기 시작한다.

    • 테스트가 실패했을 때, 런던파는 어디서 오류가 발생하는지 즉시 찾을 수 있다. 반면, 고전적인 방식이라면 하나의 버그가 전체 시스템에 걸쳐 테스트를 실패하고 야기하는 파급 효과를 초래한다. 문제를 하악하고 테스트를 디버깅하는 데 시간이 더 걸릴 수 있다.

    • 통합 테스트

      • 런던파는 실제 협력자 객체를 사용하는 모든 테스트를 통합 테스트로 간주한다.
      • 런던파 입장에서는 고전파 스타일로 작성된 대부분의 테스트는 통합테스트로 간주한다.
    • 엔두 투 엔드 테스트

      • 통합 테스트
        • 통합 테스트는 공유 의존성, 프로세스 외부 의존성 뿐 아니라 조직 내 다른 팀이 개발한 코드 등과 통합해 작동하는지도 검증하는 테스트이다.
      • 엔드 투 엔드 테스트
        • 통합 테스트의 일부이다.
        • 프로세스 외부 종석성과 함께 어떻게 작동하는지 검증한다.
        • 차이점은 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다.
        • 유지 보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트를 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다.

AAA 패턴

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

// 준비 구절
const foo = 1;
const bar = '2';

// 실행 구절
const result = calculator.sum(foo, bar);

// 검증 구절
expect(result).not.toBe(3);

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

이러한 일광성이 이 패턴의 가장 큰 장점 중 하나이다.

AAA 패턴은 give, when, then 으로 나누기도 한다.

give - 준비 구절
when - 실행 구절
then - 검증 구절

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

  • 테스트 코드는 AAA 패턴에 따라 작성한다.

  • 여러개의 준비,실행,검증 을 나누는 구절 피하기

    • 여러개의 준비,실행,검증을 한다면 이는 단위테스트가 아니라 통합테스트이다.
  • 테스트내 if 문 피하기

    • 안티패턴이다.
    • 단위, 통합 둘 다 if 문으로 구분하지 말고 두 가지의 테스트코드를 모두 작성하라.
  • 실행 구절은 보통 한 줄로 끝나야 한다.

    • 실행 구절이 두 줄 이상인 경우 api 에 문제가 있을 수 있다.
  • 준비, 실행, 검증 단계마다 주석을 달아주는것이 가독성에 좋다.

    • 주석만큼 좋은것은 각 단계를 빈 줄로 구분하는 것이다.
  • 테스트 대상 구분하기

    • 변수명을 sut 로 사용해서 테스트 대상임을 명확하게 구분하는 것이 좋다.
    sut란? xUnit 에서 사용하는 system uner test 의 약자이다.
    
    그 외에도
    AUT - Application Under Test
    MUT - Method Under Test
    CUT - Class Under Test
    OUT - Object Under Test
    과 같은 약자가 있다.
    
    상세 구분하기 번거롭다면 대표격인 sut 를 사용한다.

test fixture

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

sut로 전달되는 인수이거나 데이터베이스나 하드 디스크의 파일일 수 있다. 테스트가 실행 전에 알려진 고정 상태를 유지하기 때문에 동일한 결과를 생성한다. (고정된 값이기 때문에 fixture라는 단어를 사용한다)

discribe('test', () => {
	const fixtures = [...];

  it('test1', () => {
		setFixtures(fixtures); // 테스트 픽스처 재사용
		...
	}

  it('test2', () => {
		setFixtures(fixtures); // 테스트 픽스처 재사용
		...
	}

});

위와같이 테스트 픽스쳐를 재사용하면 전체 테스트 코드의 양을 줄일 수 있다.

이러한 테스트 픽스쳐 재사용에는 단점이 있다.

  • 테스트 간 결합도가 높아진다.
    • 테스트의 준비 로직을 공유하기때문에 한 테스트에서 테스트 픽스쳐를 수정하면 다른 테스트에 영향을 줄 수 있다.
  • 테스트 가독성이 떨어진다.
    • 테스트 코드만 봐서는 전체 동작을 알 수 없다. 테스트 픽스쳐가 선언된 곳으로 이동해서 확인해야한다.

해결방법으로는 테스트 픽스처 재사용을 위한 팩토리 패턴을 사용한다.

단위 테스트 명명법

[테스트 대상 메서드]_[시나리오]_[예상 결과] 와 같이 사용한다.

  • 엄격한 명명 정책을 따르지 않는다. 복잡한 동작을 짧은 문장으로 만들 수 없다. 표현의 자유를 허용하자.
  • 도메인에 익숙하지 않은 비개발자에게 시나리오를 설명하는 것 처럼 테스트 이름을 짓자.
  • 단어를 밑줄 표시로 구분한다. 그러면 긴 이름에서 가독성을 향상시킬 수 있다.

좋은 단위 테스트의 4대 요소

  • 회귀 방지
    • 실행되는 코드가 많을수록 테스트에서 회귀가 나타날 가능성이 높다.
    • 단순한 코드를 테스트하는것은 가치가 거의 없다.
    • 개발자에게 코드는 자산이 아니라 책임이다. 코드 베이스가 커질수록 잠재적인 버그에 더 많이 노출된다.
  • 리팩터링 내성
    • 테스트를 빨간색으로 바꾸지 않고 기본 애플리케이션 코드를 리팩터링 할 수 있는지에 대한 척도
    • 기존 기능이 고장 났을 때 테스트가 조기 경고를 제공한다.
    • 코드 변경이 회귀로 이어지지 않을 것이라고 확신하게 된다.
  • 빠른 피드백
    • 테스트 속도가 빠를수록 테스트 스위트에서 더 많은 테스트를 수행할 수 있고 더 자주 실행할 수 있다.
    • 코드에 결함이 생기자마다 버그에 대해 경고하기 시작하면 피드백 루프가 대폭 줄어든다.
  • 유지 보수성
    • 테스트가 얼마나 이해하기 쉬운가
    • 테스트 코드라인이 적을수록 더 읽기 쉽다.

이 네 가지 특성을 곱하면 테스트의 가치가 된다. 하나라도 0이면 전체가 0이 된다.

즉, 가치가 있으려면 테스트는 네 가지 범주 모두에서 점수를 내야한다.

이상적인 테스트

좋은 단위 테스트의 세 가지 특성은 상호 배타적이다.

  • 회귀 방지
  • 리팩터링 내성
  • 빠른 피드백

이 세 가지 특성 중 두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야만 가능하다.

image

좋은 테스트를 만드는 특성 간에 균형을 이뤄내는 것은 쉽지 않다. 모둔 항목에서 최대 점수를 낼 수 없고, 유지 보수 관점을 계속 지켜야 테스트가 꽤 짧아지고 간결해진다.

따라서 절충해야한다.

엔드 투 엔드 테스트가 빠르면 가장 좋은 선택이겠지만, 그것이 어렵다면 버그를 얼마나 빠르게 잘 찾아내는지에 집중해야 한다.

즉, 회귀 방지와 빠른 피드백 사이에서 선택하는것이 좋다.

image

목과 테스트 취약성

테스트에서 목을 사용하는것은 논란의 여지가 있다.

목은 훌룡한 도구이며 대부분의 테스트에 적용해야한다고 주장하는 반면 목이 테스트 취약성을 초래하며 사용하지 말아야한다는 주장도 있다.

  • 던런파
    • 테스트 대상 코드 조각을 서로 분리하고 불변 의존성을 제외한 모든 의존성에 테스트 대역을 써서 격리하자고 한다.
  • 고전파
    • 단위 테스트를 분리해서 병렬로 실행할 수 있게 하자고 한다.

테스트 대역

모든 유형의 비운영용 가짜 의존성을 설명하는 포괄적인 용어이다.

    • 외부로 나가는 상호 작용을 모방하고 검사하는 데 도움이 된다.
      • ex) SMTP 서버를 통해 이메일 발송
  • 스텁
    • 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다.
      • ex) 데이터베이스로부터 데이터를 조회한다.

목은 상호 작용을 모방하는 테스트 대역에 해당하고 스텁은 내부로 들어오는 상호작용이므로 부작용을 일으키지 않는다. 해당 테스트 대역은 스텁이다.

 

— 학습 중지로 인해 5장을 건너뛰고 6장부터 작성한다. 5장은 처음부터 다시 작성할 필요성이 있다.—

단위 테스트 스타일

단위 테스트 스타일에는 크게 출력 기반, 상태 기반, 통신 기반 3가지 스타일이 있다.

출력기반 스타일의 테스트가 가장 품질이 좋고, 상태 기반 테스트는 두 번째로 좋은 선택이며 통신 기반 테스트는 간헐적으로 사용되어야만 한다.

  • 단위 테스트의 세 가지 스타일
    • 출력 기반 (output-based testing)
    • 상태 기반 (state-based testing)
    • 통신 기반(communication-based testing)

하나의 테스트에서 하나 또는 둘 심지어 세 가지 스타일을 함께 사용할 수 있다.

출력 기반 테스트

테스트 대상 시스템 (SUT)에 입력을 넣고 출력되는 출력을 검증한다.

이러한 테스트 스타일은 부작용이 없고 SUT 작업 결과는 호출자에게 반환하는 값 뿐이다.

const calculateDiscount(products: string[]) {
  const discount = products.length * 0.01;
	return Math.min(discount, 0.2);
}

const discount = calculateDiscount(['Hand Wash', 'Shampoo']);
assert(discount).toBe(0.02);

내부 컬렉션에 상품을 추가하거나 데이트베이스에 저장하는 등의 로직은 존재하지 않는다. calculateDiscount 메서드의 결과는 반환된 할인, 즉 출력 값 뿐이다.

출력 기반 단위 테스트의 스타일은 함수형 이라고도 한다. 이 이름은 부작용 없는 코드 선호를 강조하는 프로그래밍 방식은 함수형 프로그래밍에 뿌리를 두고 있다.

상태 기반 스타일

상태 기반 스타일은 작업이 완료된 후 시스템 상태를 확인하는 방법이다. 상태라는 의미는 SUT나 협력자 중 하나, 또는 데이터베이스나 파일 시스템 등과 같은 외부 의존성의 상태를 의미할 수 있다.

class Order {
  products: string[];  

  addProduct(productName: string) {
    this.products.push(productName);
  }
}

[Fact]
const order = new Order();
order.addProduct('Hand Wash');

asset(order.products.length).toBe(1);
asset(order.products[0]).toBe('Hand Wash');

테스트는 상품이름을 추가한 후 출력 기반 테스트와 달리 order 클래스의 상태에 대해 검증한다.

통신 기반 스타일

목을 사용해 테스트 대상 시스템과 협력자 간의 통신을 검증한다.

const spy = jest.spyOn(axios, 'get').mockResolved({ status: 200 });

[Fact]
const user = await axios.get('foo.com');
asset(spy).toBeCalledTime(1);

외부 통신에 대한 calledTime일 검증한다.

참고로, 고전파는 통신 기반 스타일보다 상태 기반 스타일을 선호한다. 이와 반대로 런던파는 통신 기반 스타일을 선호한다.

단위 테스트 스타일 비교

세 가지 스타일에 대해 특별한 것은 없지만 좋은 단위 테스트의 4대 요소로 함께 고민해보면 좋다.

  • 회귀 방지
  • 리팩터링 내성
  • 빠른 피드백
  • 유지 보수

회귀 방지 지표는 특정 스타일에 따라 달라지지 않지만 세 가지 특성으로 결정된다.

  • 테스트 중에 실행되는 코드의 양
  • 코드 복잡도
  • 도메인 유의성

실행하는 코드가 적든 많든 원하는 대로 테스트를 작성할 수 있다. 어떤 스타일도 회귀 방지 지표의 특성이라는 이러한 부분에서는 도움이 되지 않을 수 있다.

반면, 통신 기반 스타일을 남용하면 모든 것에 목을 사용하는 등의 피상적인 테스트가 될 수 있다. 하지만 이는 통신 기반 테스트의 결정적인 특징이 아니라 기술을 남용하는 극단적인 사례이다.

즉, 테스트 수가 수만개가 아니라면 테스트 스타일과 테스트 피드백 속도 사이에는 상관관계가 거의 없다. 짧던 길던 이런 지표들에 대해 고민하며 정확한 테스트 코드를 작성하는것이 중요하다.

리팩터링 내성 지표로 스타일 비교하기

리팩터링 내성은 리팩터링 중에 발생하는 거짓 양성 수에 대한 척도이다. 결국 거짓 양성은 식별할 수 있는 동작이 아니라 코드의 구현 세부 사항에 결합된 테스트이 결과이다.

출력 기반 테스트는 테스트 대상 메서드에만 집중하기 때문에 이러한 거짓 양성 방지가 가장 우수하다.

상태 기반 테스트는 일반적으로 거짓 양성이 되기 쉽다. 상태 기반 테스트는 큰 API 노출 영역에 의존하므로, 구현 세부 사항과 결합할 가능성도 더 높다.

통신 기반 테스트가 허위 경보에 가장 취약하다. 테스트 대역으로 상호 작용을 검증하는 방법은 대부분 깨지기 쉽다. 이는 항상 스텁과 상호 작용 하는 경우이고 이러한 상호 작용을 확인해서는 안 된다. 통신 기반 테스트를 작성할 때에는 더 신중해야 한다.

(리팩터링 내성 지표를 위해서는 통신 기반 스타일은 큰 의미가 없다는 내용인듯)

유지 보수성 지표로 스타일 비교하기

유지 보수성은 단위 테스트의 유지비를 측정한다.

  • 테스트를 이해하기 얼마나 어려운가? (테스트 크기에 대한 함수)
  • 테스트를 실행하기 얼마나 어려운가? (테스트에 직접적으로 관련 있는 프로세스 외부 의존성 개수에 대한 함수)

테스트가 크면, 필요할 때 파악하기도 변경하기도 어려우므로 유지 보수가 쉽지 않다. 마찬가지로 하나 이상으 ㅣ프로세스 외부 의존성(데이터베이스 등)과 직접 작동하는 테스트는 데이터베이스 서버 재부팅, 네트워크 연결 문제 해결 등과 같이 운영하는 데 시간이 필요하므로 유지 보수가 어렵다.

상태 기반 테스트는 일반적으로 출력 기반 테스트보다 유지 보수가 쉽지 않다. 상태 검증은 종종 출력 검증보다 더 많은 공간을 차지한다.

예제를 통해 살펴보자.

const sut = new Article();

const text = 'Commect Text';
const author = 'John Doe';
const now = new Date('2019-04-01');

sut.addComment(text, author, now();

assert(sut.comments.count).toBe(1);
assert(sut.comments[0].text).toBe(text);
assert(sut.comments[0].author).toBe(author);
assert(sut.comments[0].now).toBe(now);

이 테스트는 글에 댓글을 추가한 후 댓글 목록에 댓글이 나타나는지 검증한다. 이 테스트는 단순하고 댓글이 하나 있지만, 검증부는 네 줄에 걸쳐 있다. 상태 기반 테스트는 종종 훨씬 많은 데이터를 확인해야 하므로 크기가 대폭 커질 수 있다.

대부분의 코드를 숨기고 테스트를 단축하는 헬퍼 메서드로 문제를 완화할 수 있지만 이러한 메서드를 작성하고 유지보수하는 데 상당한 노력이 필요하다.

결론

출력 기반 상태 기반 통신 기반
리팩터링 내성을 지키기 위해 필요한 노력 낮음 중간 중간
유지비 낮음 중간 높음

여러가지 스타일을 비교해봤지만 여러가지 측면에서 출력 기반 테스트가 가장 결과가 좋다. 이 스타일은 구현 세부 사항과 거의 결합되지 않으므로 내성을 적절히 유지하고자 주의를 많이 기울일 필요가 없다. 이러한 테스트는 간결하고 프로세스 외부 의존성이 없기 때문에 유지 보수도 쉽다.

반면, 상태 기반 프로세스는 두 지표 모두 좋지 않다. 유출된 구현 세부 사항에 결합할 가능성이 높고, 크기도 커서 유지비가 많이 든다.

그러므로 항상 출력 기반 테스트를 선호하는게 좋다.

대부분의 테스트를 출력 기반 테스트로 만들기 위해 상태 기반 테스트와 통신 기반 테스트를 어떻게 출력 기반 테스트로 바꿀 수 있는지 살펴보자.

함수형 아키텍쳐

함수형 프로그래밍은 수학적 함수를 사용한 프로그래밍이다. 수학적 함수는 숨은 입출력이 없는 함수이다.

const discount = products.length * 0.02;
return Math.min(discount, 0.2);

위에서 살펴봤던 함수를 다시 표현했다.

이 함수를 살펴보면 하나의 입력과 하나의 출력이 모두 시그니쳐에 명시될 수 있으므로 수학적 함수라 할 수 있다.

숨은 입출력이 없는 메서드는 수학에서 말하는 함수의 정의를 준수하기 때문에 수학적 함수라고 한다.

image

입출력을 명시한 수학적 함수는 이에 따르는 테스트가 짧고 간결하며 이해하고 유지 보수하기 쉬우므로 테스트하기가 매우 쉽다. 출력 기반 테스트를 적용할 수 있는 메서드 유형은 수학적 함수뿐이다. 이는 유지 보수성이 뛰어나고 거짓 양성 빈도가 낮다.

반면에, 숨은 입출력은 코드를 테스트하기 힘들게 한다. (가독성도 떨어진다)

부작용을 일으킬 수 있는 예제를 살펴보자.

const addComment(text: string) {
  const comment = new Comment(text);
  _comment.push(comment); // <-- 부작용
  return comment;
}

addComment 메서드는 글을 입력하고 댓글을 출력하는 두 가지 내용은 시그니처에 표현되어 있다. 하지만 _comment.push(comment) 하는 동작은 시그니쳐에 표현되어있지 않으므로 숨어있는 출력 부작용이다.

물론 어떤 부작용도 일으키지 않는 애플리케이션은 작성할 수 없다. 이러한 애플리케이션은 비현실적이다.

함수형 프로그래밍의 목표는 부작용을 완전히 제거하는 것이 아니라 비즈니스 로직을 처리하는 코드와 부작용을 일으킬 수 있는 코드를 분리하는 것이다.

image

가변 셸은 함수형 코어에 입력 데이터를 제공하고 데이터베이스와 같은 프로세스 외부 의존성에 부작용을 적용해 그 결정을 해석한다.

기 두 계층을 계속 잘 분리하려면, 가별 셸이 의사 결정을 추가하지 않게끔 결정을 나타내는 클래스에 정보가 충분히 있는지 확인해야 한다.

다시말해 가변 셸은 가능한 한 아무 말도 하지 않아야 한다.

출력 기반 테스트로의 전환

조직의 모든 방문자를 추적하는 샘플 프로젝트를 살펴보자. 이 시스템은 가장 최근 파일의 마지막 줄에 방문자의 이름과 방문 시간을 추가한다. 파일당 최대 항목 수에 도달하면 인덱스를 증가시켜 새 파일을 작성한다.

class AuditManager {
  maxEntrisePerFile;
  directoryName;

  constroctor(maxEntriesPerFile, directoryName) {
    this.maxEntrisePerFile = maxEntrisePerFile;
    this.directoryName = directoryName;
  }

  addRecode(visitorName, timeOfVisit) {
    const filePaths = Dictory.getFiles(this.directoryName);
    const sorted = SortByIndex(filePaths);
    
    const newRecode = `${visitorName};${timeOfVisit}`;
    
    if (sorted.length === 0) {
      const newFile = Path.Combile(this.directoryName, 'audit_1.txt');
      File.WirteAllText(newFile, newRecode);
      return;
    }

    const [currentFileIndex, currentFilePath] = sorted.Last();
    const lines = File.ReadAllLines(currentFilePath).ToList();

    if (line.count < this.maxEntriesPerFile) {
      lines.add(newRecode);
      const newContent = lines.join('\r\n');
      File.WriteAllText(currentFilePath, newContent);
    } else {
      const newIndex = currentFileIndex + 1;
      const newName = `audit_${newIndex}.txt`;
      const newFile = Path.Combile(this.directoryName, newName);
      File.WriteAllText(newFile, newRecode);
    }
  }
}

코드에 대해 간단히 설명하자면 AuditManager는 애플리케이션의 주요 클래스이다. 생성자는 파일당 최대 항목 수와 작업 디렉토리를 설정 매개변수로 받는다. 이 클래스에서 공개 메서드는 addRecode 뿐이며, 감사 시스템의 모든 작업을 수행한다.

  • 작업 디렉터리에서 전체 파일 목록을 검사한다.
  • 인덱스별로 정렬한다. (모든 파일 이름은 audit_${index}.txt 의 패턴을 따른다.
  • 아직 감사 파일이 없으면 단일 레코들로 첫 번째 파일을 생성한다.
  • 감사 파일이 있으면 최대 파일을 가져와서 파일의 항목 수가 파일의 항목 수가 한계에 도달했는지에 따라 새 레코드를 추가하거나 새 파일을 생성한다.

AuditManager 클래스는 파일 시스템과 밀접하게 연결돼 있어 그대로 테스트하기 어렵다. 테스트 전에 파일을 올바른 위치에 배치하고, 테스트가 끝나면 해당 내용을 확인한 후 삭제해야 한다.

현재 코드의 병목 지점은 파일 시스템이다. 이는 테스트가 실행 흐름을 방해할 수 있는 공유 의존성이다.

또 파일 시스템은 테스트를 느리게 한다.

초기
회귀 방지 좋음
리팩터링 내성 좋음
빠른 피드백 나쁨
유지 보수성 나쁨

단위 테스트의 특성을 다시 살펴보자.

  • 단위 테스트는 단위 동작 단위를 검증해야 한다.
  • 빠르게 수행되어야 한다.
  • 다른 테스트와 별도로 처리해야 한다.

이러한 수준을 더 만족하기위해 코드를 리팩토링 해보자.

위에서 문제가 되었던 파일 시스템 읽는 부분을 목처리 해보려고 한다.

class AuditManager {
  constroctor (maxEntriesPerFile, directoryName, fileSystem) {
    this.maxEntriesPerFile = maxEntriesPerFile;
    this.directoryName = directoryName;
    this.fileSystem = fileSystem;
  }

  addRecode (visitorName, timeOfVisit) {
    const filePaths = this.fileSystem.getFiles(this.directoryName);
    const sorted = SortByIndex(filePaths);

    const newRecode = `${visitorName};${timeOfVisit}`;
    
    if (sorted.length === 0) {
      const newFile = Path.Combine(this.directoryName, 'audit_1.txt');
      this.fileSystem.wirteAllText(newFIle, newRecode);
      return;
    }

    const [currentFileIndex, currentFilePath] = sorted.last();
    const lines = this.fileSystem.readAllLines(currentFilePath);
    
    if (lines.count < this.maxEntriesPerFile) {
      lines.add(newRecode);
      const newContent = lines.join('\r\n');
      this.fileSystem.writeAllText(currentFilePath, newContent);
    } else {
      const newIndex = currentFIleIndex + 1;
      const newName = `audit_${newIndex}.txt`;
      this.fileSystem.writeAllText(newFile, newRecode);   
    }
  }
}

이렇게 리팩토링함으로써 AuditManager는 파일 시스템으로부터 분리되었으므로 공유 의존성이 사라지고 독립적으로 실행할 수 있다.

[Fact]
a_new_file_is_created_when_the_current_file_overflows() {
  const filesystemMock = jest.spyOn(FileSystem, writeAllText);
  const sut = new AuditManager(3, 'audits');
  sut.addRecode('alice', new Date());

  assert(fileSystemMock).toBeCalledTime(1);
}

가치 있는 단위 테스터를 위한 리팩터링

코드 복잡도는 코드 내 의사 결정 (분기) 지점 수로 정의한다. 이 숫자가 클수록 복잡도는 더 높아진다.

4가지로 구분된 코드 유형은 다음과 같다.

  • 도메인 모델 및 알고리즘
  • 간단한 코드
  • 컨트롤러
  • 지나치게 복잡한 코드

image

가장 문제가 되는 코든 유형은 지나치게 복잡한 코드이다. 단위 테스트가 어렵겠지만, 테스트 커버리지 없이 내버려두는 것은 너무 위험하다.

지나치게 복잡한 코드 유형은 다른 모든 테스트를 리팩터링 하거나 제거해야 한다. 테스트 스위트의 크기를 부풀려서는 안된다.

좋지 않은 테스트를 작성하는 것보다는 테스트를 전혀 작성하지 않는 편이 낫다

단일 책임 원칙을 지키는 구조로 프로젝트를 리팩터링 할 수 있다. 이는 각 클래스가 단일한 책임만 가져야 한다는 원칙이다. 그러한 책임 중 하나로 늘 비즈니스 로직이 있는데, 이 패턴을 적용하면 거의 모든 것과 분리할 수 있다.

단일 책임 원칙을 잘 지켜 코드를 리팩터링하는 방법 외에도 험블 객체 패턴, MVP, MVC 패턴 등을 사용해볼 수 도 있다.

이 모든 방법들의 공통점은 코드를 더 작게 나누고 본인의 관심사에만 집중하게 만듦으로써 테스트를 더 잘 수행할 수 있도록 한다.

통합테스트

통합 테스트를 하는 이유

단위 테스트에만 전적으로 의존하면 시스템이 전체적으로 잘 동작하는지 확신할 수 없다. 단위 테스트가 비즈니스 로직을 확인하는 데 좋지만, 비즈니스 로직을 외부와 단절된 상태로 확인하는 것만으로는 충분하지 않다. 각 부분이 데이터베이스나 메시지 버스 등의 외부 시스템과 어떻게 통합되는지 확인해야 한다.

통합 테스트의 역할

  • 단위 동작 단위를 검증한다.
  • 빠르게 수행한다.
  • 다른 테스트와 별도로 처리한다.

image

컨트롤러를 다루는 테스트가 단위 테스트일 수도 있다. 외부 의존성을 목으로 대체하면 대게 테스트 속도가 빨라지고 서로 격리될 수 있다. 하지만 대부분의 애플리케이션은 목으로 대체할 수 없는 프로세스가 있다. 예를들면 데이터베이스와 같은 것이 있다.

지나치게 간단한 코드와 지나치게 복잡한 코드는 테스트가 되지 않는다. 노력을 들일만한 가치가 없거나 간단하게 리팩터링 해야한다.

단위 테스트와 통합 테스트 간에 균형을 유지하는 것이 중요하다. 통합 테스트가 프로세스 외부 의존성에 직접 작동하면 느려지며, 이러한 테스트는 유지비가 많이 든다. 통합 테스트는 코드를 많이 거치므로 회귀 방지가 단위 테스트보다 우수하다. 또한 제품 코드와의 결합도가 낮아서 리팩터링 내성도 우수하다.

프로젝트 특성에 따라 다를 수 있지만, 단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황을 확인하고, 통합 테스트는 주요 흐름과 단위 테스트가 다루지 못하는 기타 예외 상황을 다룬다.

주요 흐름은 시나리오의 성공적인 실행이다. 예외 상황은 비즈니스 시나리오 수행 중 오류가 발생하는 경우이다.

image

테스트 개수는 단위테스트가 가장 많음으로써 빠른 피드백과 유지보수성을 확보하고,

엔드 투 엔드 테스트를 통해 회귀방지,리팩터링 내성에 신경써야 한다.

외부 의존성을 테스트하는 방법

통합 테스트는 외부 의존성에 대해 검증하는 방법에는 크게 두 가지가 있다.

  • 외부 의존성을 사용하여 함께 테스트한다.
  • 외부 의존성을 목으로 대체한다.

이 두 가지 방법은 각각 어떤 경우에 사용하는게 적절할까.

  • 관리 의존성
    • 전체를 제어할 수 있는 외부 의존성
    • 애플리케이션을 통해서만 접근할 수 있는 경우에 사용한다.
    • 대표적인 예로는 데이터베이스와 같은것이 있다. 직접 접근하지 않고 애플리케이션에서 제공하는 API를 통해 접근한다.
  • 비관리 의존성
    • 전체를 제어할 수 없는 프로세스 외부 의존성
    • 상호작용을 외부에서 볼 수 있는 경우
    • SMTP 서버나 메시지 버스등이 여기에 해당한다.

image

통합 테스트에서 실제 데이터베이스를 사용할 수 없다면 어떻게 할까

결론을 말하자면 데이터베이스. 즉 유일한 외부 의존성에 대해 테스트가 불가능한 상황이라면 통합 테스트가 무의미하다. 데이터베이스를 목한다는것은 단지 어떤 레파지토리 코드에 접근했는가만 테스트하는것으므로 의미가 없다. 이런 경우에는 과감히 통합테스트를 작성하지말고 단위 테스트에 집중하라.

가치가 충분하지 않는 테스트는 테스트 스위트에 있어서는 안된다.

엔드 투 엔드 테스트

대부분의 경우 통합 테스트 범주에 관리 의존성을 포함시키고 비관리 의존성만 목으로 대체하면 통합 테스트의 보호 수전이 엔드 투 엔드 테스트와 비슷해지므로 엔드 투 엔드 테스트를 생략할 수 있다.

API 프로젝트만으로는 통합 테스트와 엔드 투 엔드 테스트의 경계가 모호하다. 실제 프론트엔드 부터 데이터베이스까지 테스트할 때 엔드 투 엔드를 고려하는것이 좋다.

로깅 테스트

로깅은 회색 지대로 테스트에 관해서는 어떻게 해야 할지 분명하지 않다. 그것은 다음과 같은 질문으로 나누어 생각해볼 수 있다.

  • 로깅을 조금이라도 테스트해야할까?
  • 만약 그렇다면 어떻게 테스트해야할까?
  • 로깅이 얼마나 많으면 충분할까?
  • 로거 인스턴스를 어떻게 전달할까?

로깅은 횡단 기능으로 코드베이스 어느 부분에서나 필요로 할 수 있다. 그런면에서 다른 기능들과 다르지 않다. 결국 로깅은 텍스트 파일이나 데이터베이스와 같은 프로세스 외부 의존성에 부작용을 초래한다.

로깅 구분에 따라 테스트를 해야한다. 진단 로깅의 경우에는 필요하지 않은 테스트이다.

  • 지원 로깅
    • 지원 담당자나 시스템 관리자가 추적할 수 있는 메세지를 생성한다.
  • 진단 로깅
    • 개발자가 애플리케이션 내부 상황을 파악할 수 있도록 돕는다.

데이터베이스 테스트

데이터베이스를 테스트하는 방법의 첫 번째 단계는 데이터베이스 스키마를 일반 코드로 취급하는 것이다. 일반 코드와 마찬가지로 데이터베이스 스키마는 Git과 같은 형상 관리 시스템에 저장하는것이 최선이다.

모델 데이터베이스를 사용하는 것은 데이터베이스 스키마를 유지하는 데 상당히 좋지 못한 방법이다. 그 이유는 다음과 같다.

  • 변경 내역 부재
    • 데이터베이스 스키마를 과거의 특정 시점으로 되돌릴 수 없다. 이는 운영 환경에서 버그를 재현할 때 중요하다.
  • 복수의 원천 정보
    • 모델 데이터베이스는 개발 상태에 대한 원천 정보를 둘러싸고 경합하게 된다. 이렇게 기준을 두 가지로 두면 부담이 가중된다.

참조 데이터도 데이터베이스 스키마다

데이터베이스 스키마라면 자주 거론되는 대상은 테이블, 뷰, 인덱스, 저장 프로시저 그리고 데이터베이스가 어떻게 구성되는지에 대한 청사진을 형성하는 나머지 모든 것이다.

그러나 참조 데이터는 데이터베이스 스키마로 여기지 않는다.

참조 데이터는 애플리케이션이 제대로 동작하도록 미리 채워야 하는 데이터이다.

모든 개발자를 위한 별도의 데이터베이스 인스턴스

실제 데이터베이스로 테스트하는 것은 충분히 어렵다. 다른 개발자들과 데이터베이스를 공유해야 한다면 훨씬 더 어려워진다.

  • 서로 다른 개발자가 실행한 테스트는 서로 간섭되기 때문이다.
  • 하위 호환성이 없는 변경으로 다른 개발자의 작업을 막을 수 있기 때문이다.

통합 테스트를 병렬로 실행하려면 상당한 노력이 필요하다. 성능 향상을 위해 시간을 허비하지 말고 순차적으로 통합 테스트를 실행하는것이 더 실용적일 수 있다.

대안으로 컨테이너를 사용해 테스트를 병렬처리할 수 있다. 하지만 이러한 방식은 실제로 유지 보수 부담이 너무 커디게 된다.

  • 도커 이미지를 유지보수 해야한다.
  • 각 테스트마다 컨테이너 인스턴스가 있는지 확인해야 한다.
  • 통합 테스트를 일괄 처리해야 한다.
  • 다 사용한 컨테이너는 폐기해야 한다.

통합 테스트 실행 시간을 최소화해야 하는 경우가 아니라면 컨테이너를 사용하지 않는 것이 좋다.

단위 테스트 안티 패턴

비공개 메서드 단위 테스트

비공개 메서드는 테스트를 어떻게 해야할까? 결론은 전혀 하지 말아야 한다 이다.

단위 테스트를 하기위해 비공개 메서드를 노출하는 경우에는 기본 원칙 중 하나인 식별할 수 있는 동작만 테스트한다 를 위반하는 것이다. 비공개 메서드를 노출하면 테스트가 구현 세부 사항과 결합하게 되고 결과적으로 리팩터링 내성이 떨어진다.

때로는 비공개 메서드가 너무 복잡해서 테스트해야겠다고 생각이 드는 경우가 있다. 하지만 비공개 메서드가 테스트가 필요하다고 생각이 들 정도로 커졌다는것은 추상화가 누락되지 않았는지 의심해봐야 한다. 비공개 메서드가 너무 복잡하면 별도의 클래스로 도출해야 하는 추상화가 누락됐다는 징후이다.

class Order {
   generateDescription() {
			// 복잡한 비공개 메서드를 간단한 공개 메서드에서 사용하고 있다.
			return `totalPrice = ${this.getPrice}`;
   }

   private getPrice(): number {
			let basePrice = ..; // _products 에 기반한 계산
			let discounts = ..; // _products 에 기반한 계산
			let taxes =  ..; // _products 에 기반한 계산
			return basePrice - discounts + taxes;
   }
}

복잡한 getPrice 비공개 메서드를 사용한다. 중요한 비즈니스 로직이 있기 때문에 테스트를 철저히 해야한다. 이 로직은 추상화가 누락됐다.

class Order {
   generateDescription() {
			// 복잡한 비공개 메서드를 간단한 공개 메서드에서 사용하고 있다.
			return `totalPrice = ${this.getPrice}`;
   }
}

class PriceCalculator {
	calculate(_products: Product[]): number {
			let basePrice = ..; // _products 에 기반한 계산
			let discounts = ..; // _products 에 기반한 계산
			let taxes =  ..; // _products 에 기반한 계산
			return basePrice - discounts + taxes;
   }
}

Order와 PriceCalculator 로 분리하고 이제 비공개 메서드가 아니므로 테스트가 가능하다.

비공개 메서드 테스트가 타당한 경우

절대 테스트 하지 말라는 것은 아니다. 비공개 메서드를 테스트하는 것 자체는 나쁘지 않다. 비공개 메서드는 구현 세부사항이고, 구현 세부사항을 테스트하면 궁극적으로 테스트가 깨지기 쉽다. 그렇기는 해도 메서드가 비공개이면서 식별할 수 있는 동작인 경우는 매우 드물다.