테스트 주도 개발
Overview
TDD(Test-driven Development)는 코드를 작성하기 전에 테스트를 쓰는 방법론입니다.
Pros
대부분의 사람은 TDD를 버그 잡는 방법이라고 생각합니다. 그런 면도 있지만 생각해보면 버그를 잡기 위해 테스트를 짠다는 생각은 비직관적입니다. 테스트를 실행하고 그 후에 버그를 잡는다고 생각하는 것이 더 논리적일 것입니다.
실제로 TDD를 통해 정교한 테스트를 짜기 위해서 코드를 어떻게 구성할지 고민하게 되며, 그 과정에서 버그가 더 적은 코드를 짜게 됩니다. 테스트가 쉽도록 코드를 디자인하는 것도 같은 효과를 내게 됩니다.
TDD는 무턱대고 코드를 바로 작성하지 않고 코드를 면밀하게 살펴보도록 합니다. 자신이 작성할 코드가 어떤 역할을 하게 되며, 전체와 어떤 관계가 있는지 생각하게 합니다.
Cons
TDD는 프로그래머들이 자연스럽게 생각하고 일하는 방식과 일치하지 않습니다. 소프트웨어는 집을 건축하는 것과는 달리 더 유동적입니다. 아마 대부분의 프로그래머는 테스트를 작성하는 것보다 바로 뭔가 만들어내고 싶어 할 것입니다.
같은 맥락에서 또 살펴보면, 코드를 디자인한다는 것은 초반에 명확하지 않을 수 있고 대신 빠르게 프로토타입을 만들어 전반적인 아웃라인을 그리는 방법이 나을 수 있습니다. 이런 경우, 디자인이 지속적으로 바뀌는 과정에서 초반에 테스트를 짜다가 이후에 다시 짜야 하거나 지워야 할 경우가 높기 때문에 시간 낭비라고 느껴질 수도 있습니다.
지금까지 살펴본 단점들을 요약한 가장 큰 단점이라고 하면 바로 속도입니다. 장기적으로 피할 수 있는 문제들을 피할 수 있다는 점에서 반론의 여부가 있을 수 있겠지만 속도가 느리다는 생각이 기본 정설입니다.
Popularity
위에 살펴본 단점들 때문에 완전한 TDD를 따르는 사람들은 적습니다. 그럼에도 자동화된 테스트를 작성하는 것은 기본이며, 코드를 작성하는 과정 중 가까운 시일 내에 작성하게 됩니다.
예를 들어, 탄탄한 프로그래밍 팀은 테스트를 포함하지 않은 코드에 대한 pull request 요청이 들어왔을 때, 정말 사소한 부분이 아니고서는 merge하지 않습니다.
Should I use it?
당연히 시도해봐야 합니다! 완벽하게 TDD를 짜려고 하면 골머리를 앓을 것입니다. 대부분의 사람처럼 코드를 작성하는 과정 중 "가까운 시일 내에" 작성하게 되더라도, 작성하려는 코드에 대해 특정한 규칙을 설정하고 차근차근 생각함으로, 코드가 큰 틀에서 어떤 의미를 갖게 되는지 살피는 것은 분명 특별한 경험이 될 것입니다.
Test Framework
Testing, and the value thereof
엔지니어링에서 수학은 가장 엄격한 잣대를 요구합니다. 구조 공학, 기계 공학, 우주 공학 모두 미분, 적분, 미분 방정식, 선형 수학 등을 주로 사용하며, 무엇이든지 그냥 만드는 경우는 없습니다.
컴퓨터 공학도 수학에 깊은 뿌리를 두고 있지만, 공학도들처럼 직접적으로 사용하는 경우는 드뭅니다.
그렇다면, 프로그래머들은 자신이 구축한 시스템이 바르게 작동하는지 어떻게 알 수 있을까요? 일반적으로 프로그래머들이 하부 시스템들을 구축해 나가는 과정에는 계산과정이 그다지 많지 않습니다.
대부분의 프로그래머들에게 해당되는 실제적인 답변은...
테스트 작성이 프로그래머 세계에서의 엄격한 공학입니다
여기서 언급하는 "테스트"라는 말은 특정한 매뉴얼을 가지고 프로그램의 작동을 확인한다는 말이 아닙니다.
Automated Test를 말하는 것이며, 구체적으로 Unit Test를 말하는 것입니다.
Automated Test는 말 그대로 자동으로 실행되는 테스트를 말합니다.
Automated Test를 작성하는 이유는, 사람이 테스트하는 모든 과정을 테스트 코드로 구성함으로 매번 완벽하고 빠르게 작동되게 하기 위해서입니다.
오늘날 전문적으로 구축된 소프트웨어 시스템들은, 누군가 코드를 작성해서 코드 관리 시스템(주로 GitHub)에 올리는 과정 중간에, 약 100개에서 많게는 1000개의 테스트가 일상적으로 작동되게끔 합니다.
What's a "unit test"?
신뢰할 수 있는 시스템은 신뢰할 수 있는 컴포넌트로 구성되어 있습니다. 레고 블록이 갑자기 양을 바꾸지 않습니다. 갑자기 변하지 않고 자신의 역할을 분명히 합니다. 이 신뢰를 바탕으로 다른 레고 블록을 연결할 수 있으며 복잡한 블록 구조를 만들어낼 수 있게 되는 것입니다.
프로그램의 컴포넌트는 레고 블록처럼 작동되어야 합니다. 서로가 약속한 대로 작동하게 된다면 아주 복잡한 프로그램도 만들어낼 수 있게 됩니다. 그러지 못한다면 모든 것들은 관리가 불가능할 것입니다.
그렇다면 어떻게 (자신이 작성한) 각각의 컴포넌트들이 서로 의도대로 작동하는지 알 수 있을까요?
유닛 테스트가 바로 그 역할을 합니다. 각 "유닛"은 여러분이 작성한 컴포넌트, 레고 블록입니다.
쉽게 이해해보면, 프로그래밍에서 가장 기본적인 유닛은 바로 함수입니다.
When should I unit test?
사소한 함수를 제외한 모든 함수에 유닛 테스트를 해야 합니다. 프로그래밍 인터뷰에서는 시간 제약을 감당하기 위해 원칙을 벗어날 수밖에 없을 때도 있을 것입니다. 그러나 유닛 테스트를 안 하는 것은 아주 안 좋은 습관입니다. 마치 가드레일이 없는 도로에서 고속으로 달리는 차가 낭떠러지에서 떨어지기 쉬운 것처럼, 오히려 테스트를 안 해서 버리게 되는 시간이 더 많게 될 것입니다.
복잡하게 코드를 작성해서 스스로 헷갈리게 되는 경우는 아주 빈번합니다. 반면 각각의 컴포넌트들을 테스트하면 무엇을 만들고 있는지를 바로 이해할 수 있습니다.
A warning sign that you should be testing
코드를 작성하는 과정에서 여기저기 console.log
를 찍어 지금 도대체 무슨 일이 일어나고 있는지 고민하고 있다면, 그것보다 작은 테스트들을 통해 현재의 코드를 확인하는 방법이 더 나을 수 있습니다.
Assertions
유닛 테스팅은 Assertions(해결하고자 하는 '주장')이라는 개념으로 이루어져 있습니다.
프로그래머들은 자신이 주장한 특정한 환경에서 주어진 값이 옳은지 여부를 해결하기 위한 Assertion을 내세우게 됩니다.
간단한 예로:
function square(x) {
return x * x;
}
위 함수에 대한 아주 단순한 유닛 테스트는 다음과 같이 적을 수 있습니다:
console.log(square(5) === 25);
- 만약 square라는 함수에 5라는 값을 input 값으로 넣었을 때 output 값이 25가 나오면 true를 반환합니다.
- 그러나 sqaure라는 함수에 5라는 값을 input 값으로 넣었을 때 output 값이 125가 나오면 false를 반환합니다.
Test frameworks
위에 잠시 살펴본 바와 같이 테스트를 처음 구성할 때 console.log
를 사용해서 유닛 테스트를 하는 방식은 문제가 되지 않습니다. 분명 테스트를 하지 않고 완성된 코드를 실행하려는 습관보다 훨씬 더 좋은 습관입니다.
하지만 보다 더 전문적인 프로그래머들이라면 Assertion을 기반으로 한 프레임워크를 사용하여, 잘 구성된 테스트를 통해 발생한 문제들을 빠르게 대처할 수 있어야 합니다.
Assertion의 한가지 예를 들면 다음과 같습니다:
var output = square(5);
expect(output).to.equal(25);
당장은 테스트 프레임워크에 대해 전부 이해할 필요는 없습니다.
비록 전문적인 프로그래머가 되는 첫걸음을 내딛는 과정일지라도, console.log
를 찍는 것보다 assertion을 통해 테스트를 작성하는 것은 중요한 훈련이 될 것입니다. 다음과 같이 아주 쉬운 assertion을 구성해보세요:
function assertEqual(actual, expected, testName) {
if (actual === expected) {
console.log('passed');
} else {
console.log('FAILED ' + testName + ': Expected ' + expected + ', but got ' + actual);
}
}
위와 같이 console.log
를 직접 함수 내에 사용하지 않고, assertion 내에 사용할 수도 있습니다.
Terminology note: Assertions are not unit tests!
Assertion은 주어진 프로그램의 역할이 true 또는 false인지 판단하는 주장으로 이루어진 문장입니다.
먼저 Assertion을 만들기 위해서는 기본적인 구성을 해야합니다.
테스트를 위한 input 값을 준비하고 예상되는 expected 결과값을 준비합니다. Class를 포함하는 경우, 그 class에 대한 예시를 명시해 놓아야 합니다. 또는 assertion 이전에 실행해야 할 다른 상황이 있을 수도 있습니다. 예를 들어, 4가지 순서로 이루어진 코드에서 3번째 순서를 실행시키기 위해서 1번과 2번의 결과값을 준비해야 하는 상황이 있을 수 있습니다.
Assertion을 진행하기 전에 하는 모든 세팅도 테스트의 일부라고 볼 수 있습니다.
Unit test와 assertion을 일부러 구분해서 소개하는 이유도 이 때문입니다.
How do I decide what to test?
아래 square 함수에 대한 더 구체적인 assertion을 한번 살펴봅시다:
-5
가 input 값일 때,25
가 output 값으로 반환한다.0
이 input 값일 때,0
이 output 값으로 반환한다.5
가 input 값일 때,25
가 output 값으로 반환한다.0.25
가 input 값일 때,0.0625
가 output 값으로 반환한다.
위 assertion들이 잘 작성되었다고 느껴졌을 것입니다. 왜 그렇게 느껴졌을까요? 물론, 위 assertion들은 그냥 작성된 것이 아닙니다. 잘 만들어졌는지 여부를 확인하기 위해 square(제곱)의 특징을 한번 살펴봅시다:
- 음수를 제곱하면 양수가 된다.
- 0을 제곱하면 0이된다.
- 숫자의 제곱은 그 숫자 자신을 곱하는 것이다.
- 분수를 곱하면 그 숫자는 더 작은 분수를 반환한다.
어느 때든지 함수에 대한 테스트를 작성할 때에는 "범주적 추론(Categorical Reasoning)"을 통해 작성하고자 하는 코드가 그 기능을 충실히 이행할 수 있도록 모든 범주를 찾아야 합니다.
Note: Your tests go outside the code that you're testing
테스트할 코드는 절대 테스트를 진행하는 코드 안에 넣으시면 안 됩니다.
각 테스트는 각 함수를 "블랙 박스"처럼 다뤄야 합니다.
각 테스트는 단순히 input 값을 제공하고 assertion과 expectation만을 output 값으로 반환하는 역할을 하는 것입니다.
아래는 좋지 않은 예입니다:
function decorateClassListWithAges(classList) {
var classListWithAges = classList.map(function(student) {
return {'name': student, 'age': getRandomIntInclusive(10, 15)}
});
var checkAge = assertRange(classListWithAges[0].age, 10, 15,
'check age is between 10 and 15');
return classListWithAges;
};
A note on testing object equality
우리는 다음과 같이 두 배열, [1, 2, 3] === [1, 2, 3]
을 비교할 수 없다는 것을 압니다. 각 배열에 해당하는 내용을 비교하는 것이 아니라 각 배열에 할당된 메모리 주소를 비교하기 때문입니다.
아래와 같은 방식으로, 간단한 값인 숫자와 문자열로만 이루어진 배열이라는 가정하에 두 배열을 비교해주는 함수를 작성할 수 있습니다 (예를 들어, object가 들어있거나 또 다른 배열이 들어있는 배열은 아니라는 말입니다). 이러한 간단한 값을 Primitive type은 scalar type 이라고 부른다는 것 기억하고 있지요?
function assertArraysEqual(actual, expected, testName) {
var areEqualValues = actual.every(function(item, i) {
return item === expected[i];
});
var areEqualLength = (actual.length === expected.length);
if (areEqualLength && areEqualValues) {
console.log('passed');
} else {
console.log('FAILED [' + testName + '] Expected "' + expected + '", but got "' + actual + '"');
}
}
간단한 방법으로 배열 안에 값을 다 문자열로 만든 후 비교하는 방법이 있습니다.
JSON.stringify()
와 같은 helper 함수를 사용하여 해결할 수 있습니다. JSON에 관해서는 JSON format 을 통해 더 자세히 살펴볼 수 있지만, 이 함수만 사용한다면 굳이 볼 필요는 없을 것입니다.
function assertObjectsEqual(actual, expected, testName) {
actual = JSON.stringify(actual);
expected = JSON.stringify(expected);
if (actual === expected) {
console.log('passed');
} else {
console.log('FAILED [' + testName + '] Expected ' + expected + ', but got ' + actual);
}
}
그런데도 이 테스트 방식은 두 object를 비교하는 모든 경우의 수를 감지하기에 완벽한 테스트 방식은 아닙니다. object의 경우, key 값은 같을 수 있지만 순서가 다르게 될 수 있기 때문입니다. 키의 순서는 동일한지 여부를 파악할 때 비교의 대상이 되지 않아야 하지만 stringify()
을 하면 그 순서에 의해 둘이 다른 값으로 인지되기 때문입니다.
JSON.stringify({foo: 1, bar: 2})
// "{"foo":1,"bar":2}"
JSON.stringify({bar: 1, foo: 2})
// "{"bar":1,"foo":2}"
이 말은즉슨, 완전한 "deep equality"를 확인하는 방법을 살펴보는 것은 이 코스에서 다루기에 너무 먼 이야기 입니다.
관심이 있으신 분들은 다음 사이트에 작성된 코드를 확인해보실 수 있습니다
기술 면접 중에 유닛 테스트를 할 때 여러분(당사자)만 "expected" 값에 대해 관리를 하게 됩니다. 그렇기 때문에, JSON.stringify
를 사용하여 빠르게 비교를 한다고 하더라도 큰 문제가 되지 않을 것입니다. 단지, 인터뷰를 진행하는 사람에게 이러한 방법이 production 코드에서는 안전하지 않은 방법임에도 불구하고 왜 그렇게 작성하게 되었는지를 설명할 준비는 하셔야 할 것입니다.
'창고(2021년 이전)' 카테고리의 다른 글
[JS] 매개변수 길이가 유동적일때 (0) | 2019.10.01 |
---|---|
[JS] 클로저(예시 위주) (0) | 2019.10.01 |
[Git] Command Line (0) | 2019.09.30 |
[JS] array Method(forEach, map, filter,reduce) (0) | 2019.09.29 |
[JS] truthy, falsy (0) | 2019.09.29 |