유닛테스트가 해야할 5가지 답변 - 테스트를 잘 작성하는 방법

원문
https://medium.com/javascript-scene/what-every-unit-test-needs-f6cd34d9836d


대부분의 개발자들은 어떻게 테스트 해야하는지 모른다.

모든 개발자들을 프로덕션으로 디플로이할 때 발생할 수 있는 결함을 방지하기 위해 유닛 테스트를 해야 한다고 알고 있지만, 대부분의 개발자들은 유닛 테스트의 필수적인 요소를 모른다.

내가 보아온 유닛 테스트의 실패 사례는 셀 수가 없다. 무엇이 잘못되는 건지 혹은 왜 이게 무슨 문제가 되는 것인지 뿐만 아니라 정확히 개발자가 무엇을 테스트하기 위한 것인지 조차 쉽게 알 수가 없었다.

최근 프로젝트에서 테스트의 목적이 무엇인지에 대해 전혀 설명이 없는 거대한 양의 유닛 테스트를 만들어 내게 됐다. 우린 훌륭한 팀이었기에 나는 경계를 늦추고 있었던 것이다. 그 결과 아직도 그 테스트를 작성한 사람만 이해할 수 있는 거대한 양의 유닛 테스트를 갖고 있다.

다행히 우리는 전체적으로 API를 다시 설계했고 모든 테스트들을 버리고 바닥부터 다시 시작했다. 그렇지 않았다면 테스트 코드를 다시 작성하는 일이 나의 최우선 과제였을 것이다.

이 일이 당신에게도 일어나지 않도록 해야한다.


왜 이런 테스트 규칙이 필요한걸까?

당신의 테스트는 소프트웨어의 결함을 방어하는 처음이자 최선의 라인이다. 테스트는 linting이나 정적 분석보다도 더 중요하다. (정상적인 프로그램 로직에 아무런 문제가 없는 서브클래스의 오류들만 찾아낸다) 테스트는 그 구현만큼 중요하다. (중요한 것은 코드는 요구 사항이 있고, 그것이 제대로 구현되어 있지 않다면 그것이 어떻게 구현되었는지는 중요하지가 않다.)

유닛테스트는 어플리케이션을 성공으로 이끌 당신의 비밀무기가 될 많은 기능들을 가지고 있다.

  1. 디자인을 돕는다: 테스트를 작성한다는 것은 이상적인 API 디자인을 위한 명확한 시각을 제공한다.
  2. 기능을 문서화한다: 테스트 디스크립션은 그 코드에 모든 요구사항에 대한 구현을 표현하고 있다.
  3. 개발자의 이해를 테스트한다: 개발자가 모든 치명적인 구성요소의 요구사항들을 코드로 명확히 작성하기에 충분히 문제를 이해하고 있는가?
  4. 품질을 보증(QA)한다: 수동적인 QA는 오류를 범할 수 있다. 내 경험에 비춰보면 리팩토링을 하거나 새 기능 추가 혹은 제거할 때 여파가 있을 수 있는 모든 기능들을 기억해내서 테스트하는 것은 불가능하다.
  5. 지속적인 배포가 된다: 자동화된 QA는 프로덕션으로 배포되기 전에 잘못된 빌드가 배포 되는 것을 막아준다.

The Science of TDD

  • TDD는 버그의 발생 횟수를 줄여준다.
  • TDD는 더욱 모듈화된 디자인이 되도록 도와준다.(소프트웨어이 민첩도와 팀의 속도를 개선해 준다.)
  • TDD는 코드의 복잡도를 감소시켜 준다.

TDD가 효과가 있다는 의미있고 경험적인 증거들은 충분히 있다.


테스트를 먼저 작성해라.

Microsoft Research, IBM, Springer의 test-first와 test-after의 효율성에 대한 연구들에 의하면 일관적으로 test-first가 테스트를 나중에 추가하는 것보다 더 좋은 결과를 가져온다고 한다. 확실하게 말할 수 있다. 구현하기 전에 테스트 먼저 작성해라.

구현을 작성하기 전에 테스트를 먼저 작성해라.


좋은 유닛테스트는 무엇인가?

자 TDD는 효과가 있다는게 입증되었다. 테스트를 먼저 작성하고 더 훈련하고, 이 과정을 믿는다. 알겠다. 그러나 어떻게 좋은 테스트를 작성하는것인가?

우리는 그 과정을 알아보기위해 실제 프로젝트의 간단한 예제를 살펴볼 것이다. Stamp 구현의 ‘compose()’ 펑션이다.

그리고 테스트 프레임웍으로는 tape을 이용할 것이다 tape은 굉장히 명확하고 필수적인 기능들만 심플하게 가지고 있다.

어떻게 좋은 유닛테스트를 작성할수 있는가에 대해 대답할 수 있기 전에, 먼저 어떻게 유닛테스트가 쓰이게 되는지 이해 할 필요가 있다.

  • 디자인(API)을 돕는다: 구현보다 먼저 디자인 단계에 작성된다.
  • 기능 문서화 및 개발자의 이해에 대한 테스트: 테스트는 테스트될 기능에 대한 명확한 설명이 제공되야 한다.
  • QA/지속적인 배포: 테스트는 배포 중에 에러시 배포를 중단시키고 좋은 버그 리포트를 제공한다.


유닛 테스트를 버그 리포트로 활용

테스트가 실패하게되면 테스트 리포트는 어떤게 잘못되었는지 단서를 제공한다. 근본적인 원인을 빠르게 찾아내는 비밀은 어디서 살펴보기 시작해야하는지를 아는 것이다. 그 과정은 명확한 버그리포트를 얻게된다면 훨씬 쉬워질 것이다.

실패한 테스트는 고퀄리티의 버그 리포트가 될 수 있다.

좋은 테스트 실패 버그 리포트에는 무엇이 있는가.

  1. 무엇을 테스트 했는가?
  2. 무슨 일을 하는가?
  3. 아웃풋이 무엇인가? (혹은 실질적인 동작)
  4. 아웃풋이 무엇이어야 했는가? (혹은 기대되는 동작)

좋은 실패 리포트의 예 좋은 실패 리포트의 예

“무엇을 테스트하는가?”에 대한 대답으로 시작한다.

  • 컴포넌트의 어떤 측면을 테스트하는가?
  • 기능은 어떤 일을 해야하는가? 어떤 특정한 요구사항을 테스트하는가?

‘compose()’함수는 다수의 스탬프(조합 가능한 팩터리 펑션)를 입력받아 새로운 스탬프를 만든다. 이 테스트를 작성하기 위해서는 단일 테스트의 최종 목표(요구사항에 대한 테스트)에서부터 거꾸로 작업하게 될 것이다. 이 테스트를 패스하기 위해 어떤 코드를 작성해야 할까?

기능이 어떤 일을 하는가?

나는 스트링을 작성하는 것으로 시작하는 것을 좋아한다. 어떤 펑션에도 전달되지 않고 아무 것에도 대입되지 않은 채로 말이다. 단지 명확하게 컴포넌트가 만족해야하는 특정한 요구사항에 집중한다. 지금의 경우는 ‘compose()’ 펑션이 펑션을 리턴해야하는 사실로 부터 시작한다.

단순하고 테스트 가능한 요구사항:

'compose() should return a function'

그리고 몇가지 부분을 스킵하고 테스트의 나머지 부분을 구체화한다. 이 스트링은 우리의 목표가 되어 미리 우리가 얻어 내야할것에 집중하게 해준다.

컴포넌트의 어떤 측면을 테스트 하는가?

“컴포넌트 측면”은 테스트별로 다양할것이고 이것은 컴포넌트를 테스트하는 적합한 커버리지에 따라 필요한 양이 결정될것이다.

지금의 경우는 ‘compose()’ 펑션의 리턴타입을 테스트해 제대로된것을 리턴하는지를 확인할것이고 반대로 아무것도 리턴을 안하거나 undefined를 리턴하는지를 확인할것이다.

이 내용을 질문으로 바로 테스트 코드에 작성해보자. 대답은 테스트의 설명으로 들어간다. 이 단계에서 테스트를 위한 펑션을 실행하고 콜백을 넘겨 테스트러너가 테스트 할때 실행할수 있게한다.

test('<컴포넌트의 어떤 측면을 테스트하지?>'), assert => {
});

지금 우리는 compose 함수의 리턴값을 테스트한다.

test('Compose함수의 리턴 타입'), assert => {
});

그리고 콜백안에서 첫번째 테스트의 설명이 들어가게된다.

test('Compose함수의 리턴 타입'), assert => {
     'compose()는 함수를 리턴한다'
});


아웃풋이 무엇인가?(예측한 아웃풋과 실제 아웃풋)

‘equal()’은 내가 좋아하는 assertion이다. 만약에 ‘equal()’만이 모든 테스트에서 유일한 assertion이라면 거이 대부분의 이세상의 테스트는 더 나아질것이다. 왜냐면?

‘equal()’는 유닛테스트가 답변해야할 제일 중요한 2가지 질문에 답변하기 때문이다.

  • 실제 아웃풋이 무엇인가?
  • 아웃풋이 무엇이어야 했는가?

만약 이 두가지 질문에 대답을 하지못한다면 제대로 테스트를 하지 못한것이다. 다른것은 몰라도 이것한가지만은 꼭 기억하라

 Equal은 당신의 새로운 기본 assertion이고 이것은 좋은 테스트의 열쇠가 될것이다.

강력한 기능을 가진 대부분의 assertion 라이브러리들의 다양한 assertion들은 테스트의 질을 떨어뜨린다.

도전

유닛테스트를 더 잘 작성하고 싶은가? 앞으로는 모든 테스트의 assertion 으로 ‘equal()’이나 ‘deepEqual()’을 사용해봐라 (아니면 당신 선택한 라이브러리중 가장 비슷한것) 테스트에 해가 되지 않을까 걱정하지 않아도 된다. 장담컨데 이런 연습이 극적으로 도움이 될것이다.

코드에서는 어떻게 보일까?

const actual = '<아웃풋이 무엇인가?>';
const expected = '<아웃풋이 무엇이어야 했는가?>';

테스트 실패시 첫번째 질문은 사실 두가지 의무를 가지고있다. 질문에 대답을 코드로 완성하는것으로 두번째에 대한 답변이 될 수 있다.

const actual = '<어떻게 테스트를 재현할까?>'; //how is the test reproduced?

여기서 중요한점은 ‘actual’ 값이 어떤 컴포넌트의 public api에 의해 만들어진 값이라는것이다. 그렇지 않다면 이 테스트는 값어치가 없다. 나는 각종 목업과 스텁으로 도배되어 정작 테스트해야할 코드는 거쳐지지 않은 테스트들을 많이 봐왔다.

리턴값을 확인해보자:

const actual = typeof compose();
const expected = 'function';

assertion을 작성할때 ‘atucal’ 이나 ‘expected’라는 로컬 변수를 꼭 사용하지 않아도 된다. 하지만 나는 최근에 나의 모든 테스트에 이런 로 컬변수들을 사용하기 시작했다. 이런 로컬변수를 이용하면 테스트를 읽기 수훨해진다는것을 알아냈기때문이다.

assertion이 얼마나 명확해지는지 확인해볼까?

assert.equal(actual, expected, 'compose() should return a funtion');

이렇게 하면 “어떻게”와 “무엇”을 테스트 코드에서 분리해 낼 수 있다.

  • 어떻게 결과를 얻어냈는지 알고 싶을 때는 변수 대입문을 살펴보면 된다.
  • 무엇을 테스트했는지를 알고싶을 때는 assertion의 설명을 보면 된다.

이제 결과적으로 테스트 자체가 고퀄리티의 버그 리포트가 되었다.

전체 테스트 코드는 아래와 같다.

import test from 'tape';
import compose from '../source/compose';

test('Compose function output type', assert => {
  const actual = typeof compose();
  const expected = 'function';

  assert.equal(actual, expected,
    'compose() should return a function.');

  assert.end();
});

이제부터 테스트를 작성할 때는 테스트가 다음 질문에 모두 답변을 해야 한다는 것을 명심했으면 한다.

  1. 무엇을 테스트하는가?
  2. 테스트할 대상이 무엇을 해야 하는가?
  3. 아웃풋이 무엇인가?
  4. 아웃풋이 무엇이어야 했는가?
  5. 어떻게 테스트가 재현될 수 있을까?(How can the test be reproduced?)

마지막 질문에 대한 대답은 ‘actual’ 값을 얻어오는 부분에서 확인할 수 있다.

유닛테스트 템플릿

import test from 'tape';

// For each unit test you write,
// answer these questions:
test('What component aspect are you testing?', assert => {
  const actual = 'What is the actual output?';
  const expected = 'What is the expected output?';

  assert.equal(actual, expected,
    'What should the feature do?');

  assert.end();
});

유닛 테스트를 잘 사용하는 방법은 많이 있다. 하지만 테스트를 잘 작성하는 방법을 아는 것이 더 중요하다.