모던 JavaScript 튜토리얼을 읽고 정리하였습니다.
테스트 자동화와 Mocha
테스트 자동화는 현업에서도 광범위하게 쓰인다.
테스트는 왜 해야 하는가?
함수를 하나 만들었다고 가정했을 때, 대부분 매개변수-결과 관계를 중심으로 어떻게 코드를 작성할지 구상할 것이다. 개발 중에는 콘솔 창 등을 이용해서 실제 실행 결과가 기대했던 결과와 같은지 비교해가며 원하는 기능이 잘 구현되는지 확인하는 과정을 원하는 기능을 완성할 때까지 반복할 것이다.
그런데 이렇게 수동으로 코드를 재실행하는 것은 상당히 번거롭고, 불완전하다.
코드를 수동으로 재실행하면서 테스트를 하면 무언가를 놓치기가 쉽다.
예를 들자면 현재 함수 f를 구현한다고 가정할 때, f(1)이 제대로 동작하는 것을 확인했으나, f(2)는 잘 동작하지 않았다. 그래서 코드를 수정하고 f(2)가 동작하는 지 확인했지만, 여전히 f(1)도 동작하는지 확인하지 않으면 다시 f(1)에서 에러가 발생할 가능성을 남기게 된다.
이처럼 코드를 변경할 때마다 모든 유스케이스를 상기하면서 코드를 수정하는 것은 거의 불가능하다. 하나를 고치면 또 다른 문제가 튀어나오는 이유가 바로 이 때문이다.
테스팅 자동화는 테스트 코드가 실제 동작에 관여하는 코드와 별개로 작성되었을 때 가능하다. 테스트 코드를 사용하면 함수를 다양한 조건에서 실행해 볼 수 있으며 이때 실행 결과와 기대 결과를 비교할 수가 있다.
Behavior Driven Development
Behavior Driven Developent(BDD)라 불리는 방법론은 테스트(test), 문서(documentation), 예시(example)를 한데 모아놓은 개념이다. 실제 개발 사례를 이용해 BDD가 무엇인지 알아보자.
거듭제곱 함수와 명세서
x를 n번 곱해주는 함수, pow(x, n)를 구현하고 있다고 가정해보자. (n은 자연수, n >= 0)
본격적으로 코드를 작성하기 전에 먼저 해야할 일은, 코드가 무슨 일을 하는지 상상한 후 이를 자연어로 표현하는 것이다. 이때 만들어진 산출물을 BDD에선 명세서(specification), 또는 짧게 줄여 스펙(spec)이라고 부른다. 명세서엔 아래와 같이 유스케이스에 대한 설명과 테스트가 담겨있다.
describe("pow", function() {
it("주어진 숫자의 n 제곱", function() {
assert.equal(pow(2, 3), 8);
});
});
스펙은 세가지 중요 구성 요소로 이뤄진다.
- describe(“title”, function() {…}) 구현하고자 하는 기능에 대한 설명, 위의 예시에서는 함수 pow가 어떤 동작을 하는지에 대한 설명이 들어간다. it 블록을 한데 모아주는 역할도 한다.
- it(“유스케이스 설명”, function() {…}) it의 첫번쨰 인수엔 특정 유스 케이스에 대한 설명이 들어간다. 이 설명은 누구나 읽을 수 있고 이해할 수 있는 자연어로 적어준다. 두 번째 인수엔 유스 케이스 테스트 함수가 들어간다.
- assert.equal(value1, value2) 기능을 제대로 구현했다면 it 블록 내의 코드 assert(equal(value1, value2))가 에러없이 돌아가야 한다. 함수 assert.*는 pow가 예상한 대로 동작하는지 확인하는 역할을 수행한다. 위 예시에서는 equal이 사용되었는데, 이 함수는 인수끼리 동등 비교했을 때 다르다고 판단되면 에러를 반환한다. 예시에선 pow(2, 3)의 결괏값과 8을 비교한다.
명세서를 실행하면 it 블록 안의 테스트가 실행된다. 자세한 내용은 아래에서 다시 설명한다.
개발 순서
실제 개발에 착수할 때 아래와 같은 순서로 개발이 진행된다.
- 명세서 초안을 작성한다. 기본적인 테스트를 추가한다.
- 명세서 초안을 보며 코드를 작성한다.
- 코드가 작동하는지 확인하기 위해 Mocha라 불리는 테스트 프레임워크를 사용해 명세서를 실행한다. 이 떄 코드가 잘못 작성되었다면 에러가 출력된다. 개발자는 테스트를 모두 통과해 에러가 더는 출력되지 않을 때까지 코드를 수정한다.
- 모든 테스트를 통과하나느 코드 초안이 완성된다.
- 유스케이스 몇 가지를 더 추가하여 실패하는 테스트가 있는지 엄밀히 확인한다.
- 다시 3단계로 돌아가 테스트가 모두 통과될 때까지 코드를 수정한다.
- 기능이 완성될 때까지 3-6단계를 반복한다.
위와 같은 방법은 반복적인(iterative) 성격을 지닌다.
이제 실제 사례에서 위 개발 프로세스를 적용해볼 것이다.
함수 pow의 스펙 초안은 이미 위에서 작성했으므로 1단계는 완료헀다. 코드를 본격적으로 작성하기 전에 잠시 자바스크립트 라이브러리 몇 가지를 사용해 테스트를 실행해볼 것이다. 지금 상태에서는 테스트가 모두 실패할 것이다.
스펙 실행하기
본 튜토리얼에서 총 3개의 라이브러리를 사용해서 테스트를 진행한다. 각 라이브러리는 다음과 같다.
- Mocha - 핵심 테스트 프레임워크로 describe, it와 같은 테스팅 함수와 테스트 실행 관련 주요 함수를 제공한다.
- Chai - 다양한 assertion을 제공해주는 라이브러리이다. 여기서 제공해주는 함수 중, 우리는 assert.equal 정도만 사용해볼 것이다.
- Sinon - 함수의 정보를 캐내는 데 사용되는 라이브러리로, 내장 함수 등을 모방한다.
세 라이브러리 모두, 브라우저나 서버 사이드 환경을 가리지 않고 사용 가능하다. 여기서 브라우저 환경을 가정하고 사용해보자.
<!DOCTYPE html>
<html>
<head>
<!-- 결과 출력에 사용되는 mocha css -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha.css">
<!-- Mocha 프레임워크 코드 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/mocha.js"></script>
<script>
mocha.setup('bdd'); // 기본 셋업
</script>
<!-- chai -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/chai/3.5.0/chai.js"></script>
<script>
// chai의 다양한 기능 중, assert를 전역에 선언한다.
let assert = chai.assert;
</script>
</head>
<body>
<script>
function pow(x, n) {
/* 코드 작성 */
}
</script>
<!-- 테스트(describe, it...)가 있는 스크립트 불러오기 -->
<script src="test.js"></script>
<!-- 테스트 결과를 id가 "mocha"인 요소에 출력 -->
<div id="mocha"></div>
<!-- 테스트 실행 -->
<script>
mocha.run();
</script>
</body>
</html>
위 페이지는 다음과 같이 설명할 수 있다.
<head>
- 테스트에 필요한 서드파티 라이브러리와 스타일을 불러온다.script
- 테스트할 함수(pow)의 코드가 들어간다.- 테스트 - describe(“pow”, …)를 외부스크립트(test.js)에서 불러온다.
- HTML 요소
<div id="mocha">
- Mocha 실행 결과가 출력된다. mocha.run()
- 테스트를 실행시켜주는 명령어
지금은 함수 pow 본문에 아무런 코드도 넣지 않았으므로 테스트가 실패할 수 밖에 없다. 실행해보면 결과는 다음과 같다. pow(2, 3)가 8이 아닌 undefined를 반환하기 때문에 에러가 발생하는 것이다.
참고로, karma 같은 고수준의 테스트 러너(test-runner)를 사용하면 다양한 종류의 테스트를 자동으로 실행할 수 있다.
코드 초안
오로지 테스트 통과만을 목적으로 한다면 코드를 다음과 같이 작성할 수도 있다.
function pow(x, n) {
return 8;
}
이제 스펙을 실행해도 에러가 발생하지 않는다.
스펙 개선하기
그러나 이 코드로는 pow(3,4)를 실행하면 틀린 결과를 도출할 것이다. 이렇게 기존의 테스트에는 문제가 없지만 함수가 제 열할을 하지 못할 경우가 종종 발생할 수 있다. 스펙이 불완전해서 그런 것이니 더 많은 유스 케이스가 필요하다는 신호이다.
Pow(3, 4) = 81을 만족하는 지 여부를 확인하는 테스트를 추가해보자.
스펙에 테스트를 추가하는 방법은 아래와 같이 두 가지가 있다.
-
기존 it 블록에 assert를 하나 더 추가하기 assert에서 에러가 발생하면 it 블록은 즉시 종료되므로 기존 It 블록에서 assert를 하나 더 추가하면 첫 번째 assert가 실패했을 때 두 번째 assert의 결과를 알 수 없다.
describe("pow", function() { if("주어진 숫자의 n 제곱", function() { assert.equal(pow(2, 3), 8); assert.equal(pow(3, 4), 81); }); });
-
it 블록 하나 더 추가하기 별도의 it 블록으로 분리해서 작성하면 더 많은 정보를 얻을 수 있으므로 두 번쨰 방법을 추천한다.
describe("pow", function() { it("2를 세번 곱하면 8이다.", function() { assert.equal(pow(2, 3), 8); }); it("3을 네번 곱하면 81이다.", function() { assert.equal(pow(3, 4), 81); }); });
테스트를 추가할 때에는 테스트 하나 당 한가지만 확인하는 것이 좋으며, 테스트 하나에서 연관이 없는 사항 두 개를 점검하지 않는 것은 피해야 한다.
이제 두 번째 방법을 사용해 테스트를 직접 추가하고 실행하면 다음과 같이 실행 결과가 나타난다.
두 번째 테스트가 실패했다. assert에선 함수 리턴값이 81이 될것이라 기대했는데, 함수는 항상 8을 반환하기 때문이다.
코드 개선하기
두 번째 테스트도 통과할 수 있도록 코드를 개선해야 한다. 이번에는 실제 우리가 구현하고자 했던 기능을 생각하면서 코드를 제대로 작성해보자.
function pow(x, n) {
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
함수가 제대로 작동하는 지 확인하기 위해 더 많은 값을 테스트해보자. 수동으로 여러 개의 it 블록을 만드는 대신 for 문을 사용해서 자동으로 it 블록을 만들 수가 있다.
describe("pow", function() {
function makeTest(x) {
let expected = x * x * x;
it(`${x}을/를 세 번 곱하면 ${expected}입니다.`, function() {
assert.equal(pow(x, 3), expected);
});
}
for (let x = 1; x <= 5; x++) {
makeTest(x);
}
});
이 스펙을 돌려보면 총 다섯 번의 테스트가 만들어지며 모두 에러 없이 정상 실행됨을 알 수 있다.
중첩 describe
테스트를 몇 개 더 추가해볼 것이다. 아래 예시에서 헬퍼 함수 makeTest와 for문이 중첩 describe 안에 함께 묶여있다는 것을 눈여겨보자. makeTest는 오직 for문 안에서만 사용되고, 다른 데선 사용되지 않기 때문에 이렇게 묶어놓았다. 아래 스펙에선 makeTest와 for문은 함께 어우러져 pow가 제대로 동작하는지 확인해준다.
describe("pow", function() {
describe("x를 세 번 곱합니다.", function() {
function makeTest(x) {
let expected = x * x * x;
it(`${x}를/을 세 번 곱하면 ${expected}입니다.`, function() {
assert.equal(pow(x, 3), expected);
});
}
for (let x = 1; x <= 5; x++) {
makeTest(x);
}
});
});
중첩 describe는 새로운 테스트 ‘하위 그룹(subgroup)’을 정의할 때 사용된다. 이렇게 정의된 테스트 하위 그룹은 테스트 결과 보고서에 들여쓰기 된 상태로 출력된다.
만약에 미래에 자체 헬퍼 함수를 가진 it와 describe를 최상위 레벨에 추가한다면, 이들 헬퍼 함수에선 makeTest에 접근할 수 없다.
before/after와 beforEach/afterEach
- before : 전체 테스트가 실행되기 전에 실행
- after : 전체 테스트가 실행된 후에 실행
- beforeEach : 매 it가 실행되기 전에 실행
- afterEach : 매 it가 실행된 후 실행
describe("test", function() { before(() => alert("테스트를 시작합니다 - 테스트가 시작되기 전")); after(() => alert("테스트를 종료합니다 - 테스트가 종료된 후")) beforeEach(() => alert("단일 테스트를 시작합니다 - 각 테스트 시작 전")); afterEach(() => alert("단일 테스트를 종료합니다 - 각 테스트 종료 후")); it('test1', () => alert(1)); it('test2', () => alert(2)); })
위의 테스트의 실행 순서는 다음과 같다.
테스트를 시작합니다 - 테스트가 시작되기 전 (before) 단일 테스트를 시작합니다 - 각 테스트 시작 전 (beforeEach) 1 단일 테스트를 종료합니다 - 각 테스트 종료 후 (afterEach) 단일 테스트를 시작합니다 - 각 테스트 시작 전 (beforeEach) 2 단일 테스트를 종료합니다 - 각 테스트 종료 후 (afterEach) 테스트를 종료합니다 - 테스트가 종료된 후 (after)
스펙 확장하기
첫 번째 반복(iteration)에선 함수 pow의 기본적인 기능을 구현해보았다. 또 다른 반복을 돌면서 기능을 개선해보자. 함수 pow(x, n)의 매개변수 n은 양의 정수여야 하지만 자바스크립트에서는 수학 관련 연산을 수행하다가 에러가 발생하면 NaN을 반환한다. 함수 pow도 n이 조건에 맞지 않으면 NaN을 반환하게 만들어보자.
n이 조건에 맞지 않을 때 함수가 NaN을 반환하는지 아닌지를 검사해주는 테스트틀 먼저 추가하자.
describe("pow", function() {
// ...
it("n이 음수일 때 결과는 NaN입니다.", function() {
assert.isNaN(pow(2, -1));
});
it("n이 정수가 아닐 때 결과는 NaN입니다.", function() {
assert.isNaN(pow(2, 1.5));
});
});
이 스펙을 실행하면 새롭게 추가된 테스트는 실패하게 된다. BDD의 핵심은 실패할 수 밖에 없는 테스트를 추가하고, 테스트를 통과할 수 있도록 코드를 개선하는 것에 있다.
다양한 assertion
위에서 사용한 assert.isNaN은 기대하는 결과가 NaN인지 아닌지를 확인해준다. Chai는 이외에도 다양한 assertion을 지원한다.
- assert.equal(value1, value2) - value1과 value2의 동등성을 확인한다.(value1 == value2)
- assert.strictEqual(value1, value2) - value1과 value2의 일치성을 확인한다. (value1 === value2)
- assert.notEqual, assert.notStrictEqual - 비동등성, 비일치성을 확인한다.
- assert.isTrue(value) - value가 true인지 확인한다. (value === true)
- assert.isFalse(value) - value가 false인지 확인한다. (value === false)
새롭게 추가한 테스트를 통과할 수 있도록 pow에 코드를 몇 줄 추가해볼 것이다.
function pow(x, n) {
if (n < 0) return NaN;
if (Math.round(n) != n) return NaN;
let result = 1;
for (let i = 0; i < n; i++) {
result *= x;
}
return result;
}
이제 테스트는 에러 없이 모두 통과된다.
Comments