테스트 자동화는 앞으로 풀어야 할 과제에서뿐만 아니라 현업에서도 광범위하게 쓰입니다.
테스트는 왜 해야 하는가?
함수를 하나 만들고 있다고 해 봅시다. 대부분 매개변수-결과 관계를 중심으로 어떻게 코드를 작성할지 구상하실 겁니다.
개발 중엔 콘솔 창 등을 이용해 실제 실행 결과가 기대했던 결과와 같은지 계속 비교하면서 원하는 기능이 잘 구현되고 있는지 확인할 겁니다.
실제 실행 결과가 기대했던 결과와 다를 땐, 코드를 수정하고 다시 실행해 그 결과를 기대했던 결과와 다시 비교해 볼 겁니다. 원하는 기능을 완성할 때까지 이 과정을 계속 반복하겠죠.
그런데 이렇게 수동으로 코드를 '재실행’하는 건 상당히 불완전합니다.
코드를 수동으로 ‘재실행’ 하면서 테스트를 하면 무언가를 놓치기 쉽습니다.
구체적인 예를 들어봅시다. 현재 함수 f
를 구현하고 있다고 가정해보겠습니다. 코드를 작성하고 f(1)
이 제대로 동작하는지 확인합니다. 제대로 동작하네요. 그런데 f(2)
를 테스트해 보니 제대로 동작하지 않습니다. 코드를 수정한 후 다시 f(2)
를 확인해 봅니다. 제대로 동작하네요. 여기서 끝일까요? 아닙니다. f(1)
이 제대로 동작하는지 확인하지 않았으니까요. 이렇게 테스트를 수동으로 하면 에러가 발생할 여지를 남깁니다.
이런 일은 아주 흔히 발생합니다. 개발자는 무언가를 만들 때 머릿속에 수많은 유스 케이스를 생각하며 코드를 작성하는데, 코드를 변경해야 할 때마다 모든 유스 케이스를 상기하면서 코드를 수정하는 것은 거의 불가능합니다. 하나를 고치면 또 다른 문제가 튀어나오는 이유가 바로 이 때문입니다.
테스팅 자동화는 테스트 코드가 실제 동작에 관여하는 코드와 별개로 작성되었을 때 가능합니다. 테스트 코드를 이용하면 함수를 다양한 조건에서 실행해 볼 수 있는데, 이때 실행 결과와 기대 결과를 비교할 수 있습니다.
Behavior Driven Development
Behavior Driven Development(BDD)라 불리는 방법론에 대해 알아봅시다.
BDD는 테스트(test), 문서(documentation), 예시(example)를 한데 모아놓은 개념입니다.
실제 개발 사례를 이용해 BDD가 무엇인지 차근차근 설명해 보도록 하겠습니다.
거듭제곱 함수와 명세서
x
를 n
번 곱해주는 함수, pow(x, n)
를 구현하고 있다고 가정해 봅시다.(단, n
은 자연수이고, 조건 n≥0
을 만족해야 합니다.)
사실 자바스크립트엔 거듭제곱 연산자 **
가 있습니다. 그럼에도 불구하고 함수를 직접 구현하는 이유는, 구현 과정에 초점을 두면서 BDD를 직접 적용해 보기 위해서입니다. 기능이 간단한 함수를 구현하면서 BDD를 직접 적용해 보면 큰 문제에 BDD를 적용하는 건 쉬울 테니까요.
본격적으로 코드를 작성하기 전에 먼저 해야 할 것이 있습니다. 코드가 무슨 일을 하는지 상상한 후 이를 자연어로 표현해야 합니다.
이때, 만들어진 산출물을 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
가 예상한 대로 동작하는지 확인해줍니다. 위 예시에선assert.equal
이 사용되었는데, 이 함수는 인수끼리 동등 비교했을 때 다르다고 판단되면 에러를 반환합니다. 예시에선pow(2, 3)
의 결괏값과8
을 비교하겠죠. 비교나 확인에 쓰이는 다른 함수들은 아래에서 다시 소개해 드리겠습니다.
명세서는 실행 가능합니다. 명세서를 실행하면 it
블록 안의 테스트가 실행됩니다. 자세한 내용은 아래에서 다시 설명하겠습니다.
개발 순서
실제 개발에 착수하면 아래와 같은 순서로 개발이 진행됩니다.
- 명세서 초안을 작성합니다. 초안엔 기본적인 테스트도 들어갑니다.
- 명세서 초안을 보고 코드를 작성합니다.
- 코드가 작동하는지 확인하기 위해 Mocha라 불리는 테스트 프레임워크를 사용해 명세서를 실행합니다.(Mocha에 대해선 아래에서 다룰 예정입니다.) 이때, 코드가 잘못 작성되었다면 에러가 출력됩니다. 개발자는 테스트를 모두 통과해 에러가 더는 출력되지 않을 때까지 코드를 수정합니다.
- 모든 테스트를 통과하는 코드 초안이 완성되었습니다.
- 명세서에 지금까진 고려하지 않았던 유스케이스 몇 가지를 추가합니다. 테스트가 실패하기 시작할 겁니다.
- 세 번째 단계로 돌아가 테스트를 모두 통과할 때까지 코드를 수정합니다.
- 기능이 완성될 때까지 3~6단계를 반복합니다.
위와 같은 방법은 반복적인(iterative) 성격을 지닙니다. 명세서를 작성하고 실행한 후 테스트를 모두 통과할 때까지 코드를 작성하고, 또 다른 테스트를 추가해 앞의 과정을 반복하니까요. 이렇게 하다 보면 종래에는 완전히 동작하는 코드와 테스트 둘 다를 확보하게 됩니다.
이제 실제 사례에 위 개발 프로세스를 적용해 보겠습니다.
함수 pow
의 스펙 초안은 이미 위에서 작성했으므로, 첫 번째 단계는 이미 끝난 상황입니다. 코드를 본격적으로 작성하기 전에 잠시 자바스크립트 라이브러리 몇 가지를 사용해 테스트를 실행해 보겠습니다. 지금 상태에선 테스트 모두가 실패할 텐데 그런데도 실행해 보는 이유는 테스트가 실제로 돌아가는지 확인하기 위해서입니다.
스펙 실행하기
본 튜토리얼에선 총 3개의 라이브러리를 사용해 테스트를 진행해보겠습니다. 각 라이브러리에 대한 설명은 아래와 같습니다.
- Mocha – 핵심 테스트 프레임워크로,
describe
,it
과 같은 테스팅 함수와 테스트 실행 관련 주요 함수를 제공합니다. - Chai – 다양한 assertion을 제공해 주는 라이브러리입니다. 우리 예시에선
assert.equal
정도만 사용해 볼 예정입니다. - Sinon – 함수의 정보를 캐내는 데 사용되는 라이브러리로, 내장 함수 등을 모방합니다. 본 챕터에선 사용하지 않고, 다른 챕터에서 실제로 사용해 볼 예정입니다.
세 라이브러리 모두, 브라우저나 서버 사이드 환경을 가리지 않고 사용 가능합니다. 여기선 브라우저 환경을 가정하고 사용해 보겠습니다.
아래 HTML 페이지엔 pow
의 스펙, 라이브러리 모두가 들어있습니다.
<!DOCTYPE html>
<html>
<head>
<!-- 결과 출력에 사용되는 mocha css를 불러옵니다. -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mocha/3.2.0/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
를 하나 더 추가하기describe("pow", function() { it("주어진 숫자의 n 제곱", function() { assert.equal(pow(2, 3), 8); assert.equal(pow(3, 4), 81); }); });
-
테스트를 하나 더 추가하기(
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
에서 에러가 발생하면 it
블록은 즉시 종료됩니다. 따라서 기존 it
블록에 assert
를 하나 더 추가하면 첫 번째 assert
가 실패했을 때 두 번째 assert
의 결과를 알 수 없습니다. 두 방법의 근본적인 차이는 여기에 있습니다.
두 번째 방법처럼 it
블록을 하나 더 추가해 테스트를 분리해서 작성하면 더 많은 정보를 얻을 수 있기 때문에 두 번째 방법을 추천해 드립니다.
여기에 더하여 테스트를 추가할 땐 다음 규칙도 따르는 게 좋습니다.
테스트 하나에선 한 가지만 확인하기
테스트 하나에서 연관이 없는 사항 두 개를 점검하고 있다면, 이 둘을 분리하는 게 좋습니다.
이제 두 번째 방법을 사용해 테스트를 직접 추가해봅시다.
결과:
두 번째 테스트가 실패했네요. 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
를 쓰면 그룹을 만들 수 있습니다.
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와 it을 사용해 이 아래에 더 많은 테스트를 추가할 수 있습니다.
});
중첩 describe
는 새로운 테스트 '하위 그룹(subgroup)'을 정의할 때 사용됩니다. 이렇게 새로 정의된 테스트 하위 그룹은 테스트 결과 보고서에 들여쓰기 된 상태로 출력됩니다.
만약에 미래에 자체 헬퍼 함수를 가진 it
과 describe
를 최상위 레벨에 추가한다면, 이들 헬퍼 함수에선 makeTest
에 접근할 수 없을겁니다.
before/after
와 beforeEach/afterEach
함수 before
는 (전체) 테스트가 실행되기 전에 실행되고, 함수 after
는 (전체) 테스트가 실행된 후에 실행됩니다. 함수 beforeEach
는 매 it
이 실행되기 전에 실행되고, 함수 afterEach
는 매 it
이 실행된 후에 실행됩니다.
예시:
describe("test", function() {
before(() => alert("테스트를 시작합니다 - 테스트가 시작되기 전"));
after(() => alert("테스트를 종료합니다 - 테스트가 종료된 후"));
beforeEach(() => alert("단일 테스트를 시작합니다 - 각 테스트 시작 전"));
afterEach(() => alert("단일 테스트를 종료합니다 - 각 테스트 종료 후"));
it('test 1', () => alert(1));
it('test 2', () => alert(2));
});
실행 순서는 다음과 같습니다.
테스트를 시작합니다 - 테스트가 시작되기 전 (before)
단일 테스트를 시작합니다 - 각 테스트 시작 전 (beforeEach)
1
단일 테스트를 종료합니다 - 각 테스트 종료 후 (afterEach)
단일 테스트를 시작합니다 - 각 테스트 시작 전 (beforeEach)
2
단일 테스트를 종료합니다 - 각 테스트 종료 후 (afterEach)
테스트를 종료합니다 - 테스트가 종료된 후 (after)
beforeEach/afterEach
와 before/after
는 대개 초기화 용도로 사용됩니다. 카운터 변수를 0으로 만들거나 테스트가 바뀔 때(또는 테스트 그룹이 바뀔 때)마다 해줘야 하는 작업이 있으면 이들을 이용할 수 있습니다.
스펙 확장하기
첫 번째 반복(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));
});
});
스펙을 실행하면 다음과 같은 결과가 출력됩니다.
기존엔 n
이 음수이거나 정수가 아닌 경우를 생각하지 않고 구현했기 때문에, 새롭게 추가한 테스트는 실패할 수밖에 없습니다. BDD의 핵심은 여기에 있습니다. 실패할 수밖에 없는 테스트를 추가하고, 테스트를 통과할 수 있게(에러가 발생하지 않게) 코드를 개선하는 것이죠.
위에서 사용한 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
).- 이 외의 다양한 assertion은 docs에서 확인할 수 있습니다.
새롭게 추가한 테스트를 통과할 수 있도록 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;
}
이제 에러 없이 테스트를 모두 통과하네요.
요약
BDD에선 스펙을 먼저 작성하고 난 후에 구현을 시작합니다. 구현이 종료된 시점에는 스펙과 코드 둘 다를 확보할 수 있습니다.
스펙의 용도는 세 가지입니다.
- 테스트 – 함수가 의도하는 동작을 제대로 수행하고 있는지 보장함
- 문서 – 함수가 어떤 동작을 수행하고 있는지 설명해줌.
describe
와it
에 설명이 들어감 - 예시 – 실제 동작하는 예시를 이용해 함수를 어떻게 사용할 수 있는지 알려줌
스펙이 있기 때문에 개발자는 안전하게 함수를 개선하거나 변경할 수 있습니다. 함수를 처음부터 다시 작성해야 하는 경우가 생겨도 스펙이 있으면 기존 코드와 동일하게 동작한다는 것을 보장할 수 있습니다.
코드가 바뀌어도 기존에 구현된 기능에 영향을 주지 않게 하는 건 대규모 프로젝트에서 매우 중요합니다. 프로젝트 규모가 커지면 함수 하나를 이곳저곳에서 사용하는데, 수동으로 변경된 함수가 이 함수를 사용하는 모든 곳에서 제대로 동작하는지 확인하는 건 불가능하기 때문입니다.
테스트를 하지 않고 코드를 작성해왔다면 개발자들은 둘 중 한 갈래의 길로 빠져버리고 맙니다.
- 아무 대책 없이 코드를 변경합니다. 부작용을 생각하지 않고 함수를 수정했기 때문에 어디선가 버그가 발생하고 맙니다.
- 수정이나 개선을 기피하게 됩니다. 버그의 대가가 가혹하기 때문이죠. 코드가 구식이 되어도 그 누구도 코드를 건드리려 하지 않습니다. 좋지 않은 상황이죠.
테스팅 자동화는 이런 문제를 피하게 도와줍니다!
테스팅 자동화를 수행하고 있는 프로젝트라면 이런 문제를 걱정하지 않아도 됩니다. 코드에 변화가 있어도 스펙을 실행해 테스트를 진행하면 몇 초 만에 에러 발생 여부를 확인할 수 있습니다.
장점이 하나 더 있습니다. 잘 테스트 된 코드는 더 나은 아키텍처를 만듭니다.
수정과 개선이 쉬우니까 당연히 좋은 아키텍처를 만들 수 있다고 생각할 수 있습니다. 하지만 또 다른 이유가 있습니다.
테스트를 작성하려면 함수가 어떤 동작을 하는지, 입력값은 무엇이고 출력값은 무엇인지 정의하고 난 후에 구현을 시작합니다. 코드는 정의된 사항을 뒷받침 할 수 있게 작성해야 하죠. 구현을 시작하는 순간부터 이미 좋은 아키텍처가 보장됩니다.
사실, 매번 이런 절차를 따라 구현한다는 게 쉽지만은 않습니다. 함수가 어떻게 동작해야 하는지 확신이 서지 않는 상황에서 코드를 작성하기도 전에 스펙을 작성해야 하므로 익숙하지 않을 수 있습니다. 그렇지만 테스트를 작성하면 일반적으로 개발 속도가 빨라지고 이전보다 코드를 더 안정적으로 작성할 수 있습니다.
튜토리얼 후반부의 과제에서 테스트 기반의 다양한 과제를 만나볼 수 있습니다. 여기서 사용된 예시보다 더 실용적인 예시를 곧 만나보도록 합시다.
명세서를 만들 때는 어느 정도의 자바스크립트 지식이 필요한데, 우리는 이제 막 자바스크립트 학습을 시작한 상황입니다. 지금 당장은 명세서를 작성할 필요가 없지만, 이번 챕터에서 복잡한 명세서를 읽을 수 있는 능력을 함양했기 때문에 뒷 챕터에선 여러분들도 충분히 명세서를 작성할 수 있을것이라 믿습니다.