JSSpec에서 setInterval()이나 setTimeout()이 포함된 로직을 테스트하려면 어떻게 하나요?입니다. 메일로 짧게 답하기가 좀 힘들기도 하고, 성의가 부족해보이기도 하고 그래서... 이 주제로 짧은 글을 하나 쓰려고 합니다.
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko">다음은 counter.js 입니다:
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
<title>Counter</title>
<script type="text/javascript" src="counter.js"></script>
<script type="text/javascript">// <![CDATA[
window.onload = main;
// ]]></script>
</head>
<body>
<div id="counter">10</div>
</body>
</html>
var counter = 0;간단한 코드이므로 설명은 생략합니다. 스팩을 먼저 만들고 구현을 하면(즉, BDD 혹은 TDD를 하면) 더 쉽겠지만, 실제 상황과 유사하게 하기 위해서 스팩 없이 구현을 먼저 했습니다. Michael Feather의 명저서 "Working Effectively with Legacy Code(레거시 코드 활용 전략이라는 제목으로 번역되었습니다)"에 의하면, 위 코드는 바로 레거시 코드 입니다.
var handle = null;
function main() { startCountdown(); }
function startCountdown() {
counter = 10;
handle = setInterval(function() { countdown(); }, 100);
}
function countdown() {
counter--;
var element = document.getElementById('counter');
element.innerHTML = counter;
if(counter === 0) clearInterval(handle);
}
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이제, 이 레거시 코드를 좋은 코드로 고쳐봅시다.
저는 테스트 케이스가 없는 코드를 레거시 코드로 봅니다. ... 테스트 없는 코드는 나쁜 코드입니다. 얼마나 잘 짰는지는 중요치 않아요. 아무리 예뻐도, 아무리 객체지향적이어도, 아무리 캡슐화가 잘 되어 있어도 소용 없습니다. 테스트가 있으면 빠르고 검증가능한 방식으로 코드의 행위를 수정할 수 있습니다. 테스트가 없으면 코드가 좋아지고 있는지 나빠지고 있는지 알 방법이 없지요.
<div id="counter">10</div>일단, 전역변수인 counter를 5로 강제로 설정하고, countdown() 함수를 호출합니다. 그 결과로 div#counter의 innerHTML이 4로 변했는지를 확인합니다.
<script type="text/javascript">// <![CDATA[
describe('Counter', {
'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
var element = document.getElementById('counter');
counter = 5;
countdown();
value_of(element.innerHTML).should_be('4');
}
});
// ]]></script>
describe('Counter', {위 스팩이 통과하려면 counter.js를 다음과 같이 수정해서 전역 변수 counter를 모두 제거해야 합니다:
'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
var element = document.getElementById('counter');
element.innerHTML = '5';
countdown();
value_of(element.innerHTML).should_be('4');
}
});
var handle = null;한편, div#counter 엘리먼트도 어디서든 접근할 수 있다는 점에서 전역 변수라고 볼 수 있겠습니다. countdown() 함수가 이 엘리먼트에 의존하고 있기 때문에 아래의 HTML이 테스트 코드와 프로덕션 코드 두 곳에 중복으로 나타나죠:
function main() { startCountdown(); }
function startCountdown() {
handle = setInterval(function() { countdown(); }, 100);
}
function countdown() {
var element = document.getElementById('counter');
var counter = Number(element.innerHTML) - 1;
element.innerHTML = counter;
if(counter === 0) clearInterval(handle);
}
<div id="counter">10</div>스팩을 아래와 같이 수정하면 좋겠습니다:
describe('Counter', {countdown() 함수가 element를 인자로 받도록 수정하고, 가짜 엘리먼트를 하나 만들어서 넘겨주었습니다. 이 방식은 countdown() 함수가 element의 innerHTML 속성을 이용할 것이라는 가정을 하고 있다는 점에서 좋지 않습니다만(테스트가 구현 방식에 종속됨), 기존 방식(전역 변수에 의존)보다는 좋다고 판단하였습니다. "best way"는 아니지만 "better way"인거죠.
'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
var element = {innerHTML: '5'};
countdown(element);
value_of(element.innerHTML).should_be('4');
}
});
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);
}
'countdown()이 0.1초 간격으로 호출되어야 한다': function() {위와 같은 방식이 기술적으로 가능한지 여부를 떠나서, 이는 좋은 단위 테스트라고 볼 수 없습니다. 만약 요구사항이 0.1초가 아니라 한 시간이었다면, 위 테스트를 수행하는데 한 시간이 걸린다는 얘기인데, 이렇게 되면 원하는 때에 피드백(즉, 테스트 성공 여부에 대한 확인)을 받을 수가 없게 됩니다.
// 여기에서 뭔가를 수행하고
wait_for(100); // 0.1초(100msec)를 멈춰서 기다린 후
// 여기에서 결과를 확인한다
}
Date._preset = -1;preset() 함수는 시간을 임의로 설정하여 멈추어놓기 위해 사용됩니다. pass() 함수는 시간을 가상으로 흘려보냅니다. get() 함수는 현재 시간을 얻어옵니다. get() 함수가 반환하는 값은 preset() 혹은 pass()가 사용되지 않았다면 실제 시스템 시간이지만, preset()이나 pass()가 사용되었다면 가짜 시간입니다. 위 코드는 실제 프로덕션에서도 사용될 코드이므로 스팩이 아닌 counter.js에 추가합니다. 이제 아래 스팩을 추가해봅시다:
Date.preset = function(msec) {Date._preset = msec;};
Date.pass = function(msec) {
Date._preset = Date._preset == -1 ? msec : Date._preset + msec;
};
Date.get = function() {
return Date._preset == -1 ? new Date() : new Date(Date._preset);
};
'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);
Date.pass(100); // 0.1초 흘리기
updateIfTimePassed();
value_of(called).should_be(true);
} finally {
// countdown 복원
countdown = backup;
}
}
var updatedAt = null;변화되지 않은 부분들 - main(), countdown() - 은 생략하였습니다. 변화된 부분은 다음과 같습니다.
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;
}
}
제어하기 힘들거나 비용이 많이드는 외부 시스템(시스템 타이머, 디스크, 네트워크, 외부 라이브러리, 프레임워크의 노출된 인터페이스 등등)과 엮인 코드를 어떻게 테스트할 것인가.이 문제에 대한 일반해는 InsertTestableLayer를 참고하시기 바랍니다.
현실에서는 시간을 통제하지 못한다. 프로그램에서는 통제할 수 있지만 하지 않는다. 자바에서 현재 시각은 특별한 관리가 필요하지 않는 자원이기 때문에 Date 객체를 직접 생성해서 사용하거나System.currentTimeMillis를 호출하여 얻는다. 이 경우 외부에서 시간의 흐름이나 정밀도를 통제할 수 없기때문에 테스트하기 어렵다. 이 글에서는 시간을 통제하여 시간을 다루는 객체를 테스트 가능하게 만드는 방법을 소개한다. 1. 짧게 말하면시계...
Comments
Testable Layer를 넣어 놓으면 요런(http://dow.ngra.de/2008/10/27/when-systemcurrenttimemillis-is-too-slow/) 문제가 발생했을 때도 쉽게 해결할 수 있죠.
그야말로 Silver Bullet 일까요? ㅋㅋㅋ 은총알 없다고 한 사람이 누구야!?
비밀댓글 입니다
방문해주셔서 감사합니다 ^^ 알려주신 사이트 잘 살펴보도록 하겠습니다!