우선 테스트 자동화 도구는 테스트 수행의 어떤 단계를 자동화 하느냐에 따라 크게
테스트의 관리/실행(execution)/생성(generation)/측정(measurement) 자동화 도구로 분류할 수 있습니다.
- 첫번째로, 테스트의 관리(management)를 자동화하는 도구는 테스트 과정에서 산출되는 테스트 케이스/슈트/스크립트 및 각종 문서들을 통합관리하는 기능을 제공합니다.
- 두번째로, 테스트의 실행(execution)을 자동화하는 도구는 테스트 대상 프로그램의 수행 및 제어를 자동화하는 기능을 제공합니다.
- 세번째로, 테스트의 생성(generation)을 자동화하는 도구는 테스트 대상 프로그램에 입력으로 주어지는 테스트 케이스 및 테스트 스텁(stub) 등을 자동으로 생성하는 기능을 제공합니다.
- 네번째로, 테스트의 측정(measurement)을 자동화하는 도구는 테스트 수행에 따른 커버리지(coverage) 측정 등의 과정을 자동화합니다.
위의 네 가지 분류는 테스트 자동화 도구를 상호 배타적으로 분류하는 기준은 아닙니다. 다시말해 하나의 테스트 도구가 위의 네 가지 분류에 모두 속할 수도 있다는 것입니다. 예를 들어, 어떤 GUI Record/Playback 자동화 도구가 테스트 대상 GUI 응용프로그램을 수행하고 사용자로부터 주어지는 모든 이벤트를 기록한 후 저장하였다가 회귀(regression) 시험과정에서 저장된 데이터를 다시 활용하는 기능을 제공하고 테스트 수행결과에 대해 다양한 커버리지 측정결과를 제공한다면, 이 자동화 도구는 위의 네 가지 분류에 모두 속할 수 있는 것이죠.
또한, 위의 분류에서 테스트의 실행(execution) 자동화 도구와 생성(generation) 자동화 도구는 테스트 케이스 및 테스트 하니스(harness) 등의 테스트 수행에 필요한 입력 및 환경 등을 자동으로 생성하느냐에 따라 중요한 차이점을 갖습니다.
function countdown() {
counter--;
var element = document.getElementById('counter');
element.innerHTML = counter;
if(counter === 0) clearInterval(handle);
}
간단한 코드이므로 설명은 생략합니다. 스팩을 먼저 만들고 구현을 하면(즉, BDD 혹은 TDD를 하면) 더 쉽겠지만, 실제 상황과 유사하게 하기 위해서 스팩 없이 구현을 먼저 했습니다. Michael Feather의 명저서 "Working Effectively with Legacy Code(레거시 코드 활용 전략이라는 제목으로 번역되었습니다)"에 의하면, 위 코드는 바로 레거시 코드 입니다.
To me, legacy code is simply code without tests. ... Code without tests is bad code. It doesn't matter how well written it is; it doesn't matter how pretty or object-oriented or well-encapsulated it is. With tests, we can change the behavior of our code quickly and verifiably. Without them, we really don't know if our code is getting better or worse. --pxvi
저는 테스트 케이스가 없는 코드를 레거시 코드로 봅니다. ... 테스트 없는 코드는 나쁜 코드입니다. 얼마나 잘 짰는지는 중요치 않아요. 아무리 예뻐도, 아무리 객체지향적이어도, 아무리 캡슐화가 잘 되어 있어도 소용 없습니다. 테스트가 있으면 빠르고 검증가능한 방식으로 코드의 행위를 수정할 수 있습니다. 테스트가 없으면 코드가 좋아지고 있는지 나빠지고 있는지 알 방법이 없지요.
이제, 이 레거시 코드를 좋은 코드로 고쳐봅시다.
2. 안도감을 느낄 수 있을만큼의 스팩
너무 많지도 않고 너무 적지도 않은 딱 적절한 양의 스팩을 달고 싶습니다. 다른 말로 하자면 "안도감을 느끼며 개발을 할 수 있을만큼의 스팩"입니다.
뭐 정의 자체가 애매하다보니 사람마다 그 기준이 다를 수 있겠습니다만, 저는 아래 두 가지 로직을 커버할 수 있는 정도면 안도감이 느껴질 것 같습니다:
* countdown()이 호출되면 div#counter 의 값이 1 씩 감소하는가
* countdown()이 0.1초 간격으로 호출되는가
function countdown() {
var element = document.getElementById('counter');
var counter = Number(element.innerHTML) - 1;
element.innerHTML = counter;
if(counter === 0) clearInterval(handle);
}
한 편, div#counter 엘리먼트도 어디서든 접근할 수 있다는 점에서 전역 변수라고 볼 수 있겠습니다. countdown() 함수가 이 엘리먼트에 의존하고 있기 때문에 아래의 HTML이 테스트 코드와 프로덕션 코드 두 곳에 중복으로 나타나죠:
<div id="counter">10</div>
스팩을 아래와 같이 수정하면 좋겠습니다:
describe('Counter', {
'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
var element = {innerHTML: '5'};
countdown(element);
value_of(element.innerHTML).should_be('4');
}
});
countdown() 함수가 element를 인자로 받도록 수정하고, 가짜 엘리먼트를 하나 만들어서 넘겨주었습니다. 이 방식은 countdown() 함수가 element의 innerHTML 속성을 이용할 것이라는 가정을 하고 있다는 점에서 좋지 않습니다만(테스트가 구현 방식에 종속됨), 기존 방식(전역 변수에 의존)보다는 좋다고 판단하였습니다. "best way"는 아니지만 "better way"인거죠.
위 스팩이 통과하려면 counter.js는 아래와 같이 바뀌어야 합니다:
function startCountdown() {
handle = setInterval(function() {
var element = document.getElementById('counter');
countdown(element);
}, 100);
}
function countdown(element) {
var counter = Number(element.innerHTML) - 1;
element.innerHTML = counter;
if(counter === 0) clearInterval(handle);
}
이 정도로 하고 다음 스팩을 추가하겠습니다.
4. countdown()이 0.1초 간격으로 호출되는가
"countdown()이 0.1초 간격으로 호출되는가"라는 질문은 달리 표현하자면, "0.1초가 지나면 countdown()이 호출되는가"입니다. 이제 드디어 본론에 해당하는 내용이 나왔습니다.
대체 "0.1초가 지나면"이라는 것을 어떻게 테스트하면 좋을까요? JSSpec에 아래와 같은 가상의 기능이 추가되어서 0.1초를 "기다릴 수 있으면" 될까요?
'countdown()이 0.1초 간격으로 호출되어야 한다': function() {
// 여기에서 뭔가를 수행하고
wait_for(100); // 0.1초(100msec)를 멈춰서 기다린 후
// 여기에서 결과를 확인한다
}
위와 같은 방식이 기술적으로 가능한지 여부를 떠나서, 이는 좋은 단위 테스트라고 볼 수 없습니다. 만약 요구사항이 0.1초가 아니라 한 시간이었다면, 위 테스트를 수행하는데 한 시간이 걸린다는 얘기인데, 이렇게 되면 원하는 때에 피드백(즉, 테스트 성공 여부에 대한 확인)을 받을 수가 없게 됩니다.
요약하자면, "0.1초가 지난 후 어떻게 되는가"를 테스트하기 위해 실제로 0.1초를 기다려야 하는 방식은 적절치 않습니다.
좀 더 적절한 방법은 0.1초를 가짜로 흘려보내는 것입니다. 자바스크립트의 Date 클래스를 다음과 같이 확장하고, 애플리케이션의 모든 코드에서 new Date() 대신에 Date.get()을 쓰도록 수정할 것을 권장합니다:
preset() 함수는 시간을 임의로 설정하여 멈추어놓기 위해 사용됩니다. pass() 함수는 시간을 가상으로 흘려보냅니다. get() 함수는 현재 시간을 얻어옵니다. get() 함수가 반환하는 값은 preset() 혹은 pass()가 사용되지 않았다면 실제 시스템 시간이지만, preset()이나 pass()가 사용되었다면 가짜 시간입니다. 위 코드는 실제 프로덕션에서도 사용될 코드이므로 스팩이 아닌 counter.js에 추가합니다. 이제 아래 스팩을 추가해봅시다:
'0.1초가 지나면 countdown()이 호출되어야 한다': function() {
// countdown 바꿔치기
var backup = countdown;
var called = false;
countdown = function() { called = true; };
try {
Date.preset(0); // 시간 고정
updateIfTimePassed();
value_of(called).should_be(false);
첫째, updateIfTimePassed() 함수가 추가되었습니다. 이 함수가 하는 일은 자신이 마지막으로 호출된 후로 0.1초 이상이 흘렀으면 countdown() 함수를 호출해주는 것입니다.
둘째, 이번에 작성한 스팩은 0.1초 간격으로 countdown() 함수가 호출되는가를 확인하기 위한 것이지 countdown() 함수가 제대로 작동되는가를 확인하는 것이 아닙니다. 다른 말로 하자면 countdown() 함수가 제대로 작동하건 안하건 0.1초 간격으로 호출만 된다면 이 스팩이 깨지지 않아야 합니다. 따라서, countdown() 함수를 가짜로 대체하고 다시 복구하는 코드(변수 backup이 사용되는 부분들)가 들어 있습니다.
셋째, 조금 전에 Date 클래스에 추가한 메서드들을 사용하여 시간을 고정시킨 후 가짜로 흘려보내고 있습니다. 이렇게 하면 이 테스트는 실제로 0.1초를 보내지 않고 순식간에 수행됩니다.
이번에는 이에 따른 프로덕션 코드의 수정입니다:
var updatedAt = null;
function startCountdown() {
handle = setInterval(updateIfTimePassed, 10);
}
function updateIfTimePassed() {
if(!updatedAt) updatedAt = Date.get();
var now = Date.get();
var timePassed = now - updatedAt >= 100;
if(timePassed) {
var element = document.getElementById('counter');
countdown(element);
updatedAt = now;
}
}
변화되지 않은 부분들 - main(), countdown() - 은 생략하였습니다. 변화된 부분은 다음과 같습니다.
첫째, startCountdown 내부의 setInterval이 updateIfTimePassed를 0.1초 간격이 아닌 0.01초 간격으로 호출하 고 있습니다. 0.01초는 대략 "시스템에 무리를 주지 않는 한도 내에서 최대한 빈번한 간격"입니다. 이렇게 수정하자 이 메서드는 말 그대로 카운트다운을 시작하는 일만 하게 되었고, 카운트다운의 간격 등에 대해서는 아무런 간섭도 하지 않게 되었습니다.
둘째, updateIfTimePassed() 함수가 새로 만들어졌습니다. 이 함수는 자신이 마지막으로 호출된 이후로 0.1초 이상이 지나면 countdown() 함수를 호출하도록 되어 있습니다.
셋째, 이 과정에서 전역 변수 updatedAt이 추가되었습니다. 좋지 않지만 잠시 참고 가보도록 하겠습니다.
Date 클래스를 확장하는 방식에 대해서 조금 더 부연설명이 필요하신 분은 유닛테스트에서 시각과 시간이라는 글을 참고해주세요. 자바를 기준으로 설명하고 있지만 결국 같은 방식입니다.
5. 리팩토링
원하는 스팩을 두 개 만들었으니 이제 좀 마음놓고 리팩토링을 해야 합니다. 마음에 안드는 부분들이 많습니다.
첫째, 0.1초 간격으로 무언가를 호출하는 로직(updateIfTimePassed 함수)이 카운트다운을 수행하는 로직(countdown 함수)과 엉켜있습니다. 게다가 countdown 함수에 필요한 인자를 넘겨주기 위해서 DOM에도 의존하고 있습니다.
둘째, 전역 변수가 두 개(handle, updatedAt) 있습니다. 그리고 DOM 이 숨은 전역 변수 역할을 하고 있기도 합니다. 이게 무슨 말이냐하면, 전역 변수라는게 나쁜 이유는 어디에서든 접근할 수 있기 때문에, 코드의 여러 지점이 이 전역 변수를 중심으로 엮인다는 점입니다. 그렇게 본다면 div#counter 라는 엘리먼트가 여기저기에서 쓰이면서 의존성을 만들어내고 있다는 점에서 숨은 전역 변수입니다. 예전에 박응주님이 자바의 ThreadLocal이나 WebWork의 ActionContext 등도 전역 변수이다라고 말씀하신 것과 같은 맥락입니다.
(저는 예전에 위 두 가지 문제를 해결하기 위해 Timer 혹은 Scheduler라는 클래스를 만들었었는데 아주 만족스러웠습니다.)
셋째, 스팩 자체도 깔끔하지가 않습니다. 가짜 객체(element, countdown)를 만들어서 이런저런 의존성을 끊어주어야만 테스트간 격리(test isolation)가 이루어진다는 것은 결국 좋지 못한 설계로 인해 생긴 문제에 다름 아닙니다. 위에서 언급한 두 가지 문제를 해결하면 스팩도 더 깔끔해지겠죠. 원래 테스트하기 쉬운 코드일수록 설계가 좋은 코드라는 말이 있습니다. 그 반대도 성립합니다. 설계가 좋은 코드일수록 테스트하기가 쉽기도 합니다. 역시나 가장 좋은 방법은 테스트(혹은 스팩)를 먼저 만들고나서 설계를 하는 것(즉, TDD 혹은 BDD를 하는 것)입니다.
이제 퇴근을 해야겠으니 이런 부분들은 미해결로 남겨놓겠습니다. ^^;
PS - 요약 및 일반화된 결론
setInterval 을 테스트하는 문제는 사실 더 큰 문제의 구체적 사례일 뿐입니다. 문제를 더 일반화하면 이렇게 됩니다:
제어하기 힘들거나 비용이 많이드는 외부 시스템(시스템 타이머, 디스크, 네트워크, 외부 라이브러리, 프레임워크의 노출된 인터페이스 등등)과 엮인 코드를 어떻게 테스트할 것인가.
이 문제에 대한 일반해는 InsertTestableLayer를 참고하시기 바랍니다.
그런데, TestableLayer라는 것을 넣으면 테스트가 가능해진다는 점 말고 대체 뭐가 좋아지는걸까요. 위에서 설계가 좋아진다고 쓰기는 했는데 구체적으로 뭐가 어떻게 좋아진다는 것일까요. 뭐 여러가지 주절주절 설명할 수 있겠지만... 자바스크립트의 경우는 이렇습니다:
비즈니스 로직(0.1초 간격으로 1씩 카운트다운)에 해당하는 코드를 전혀 수정하지 않고 다른 호스트 환경(host environment - Rhino, jslibs, ASP, JScript.NET, Silverlight, Flash/Flex 등)에서 그대로 사용할 수 있게 됩니다. 이것도 또한 반대로 얘기할 수 있는데, 코드를 전혀 수정하지 않고 다른 호스트 환경에서 사용할 수 있게 된다면 제어하기 힘든 외부 시스템에 대한 의존이 제거되었다고 볼 수 있습니다.