[본문 읽기]

저작자 표시
신고
JSSpec 관련 메일을 일주일에 몇 통 정도 받습니다. 가장 많이 받는 질문 중 하나는(국내외를 막론하고) 바로
JSSpec에서 setInterval()이나 setTimeout()이 포함된 로직을 테스트하려면 어떻게 하나요?
입니다. 메일로 짧게 답하기가 좀 힘들기도 하고, 성의가 부족해보이기도 하고 그래서... 이 주제로 짧은 글을 하나 쓰려고 합니다.


1. 초간단 요구사항 및 초기구현

화면에 숫자 10 이 나타나고, 이게 0.1초1 씩 감소하다가 0 이 되면 멈추는 그런 애플리케이션을 만든다고 칩시다. 다음은 counter.html 입니다 (doctype 생략):
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko">
<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>
다음은 counter.js 입니다:
var counter = 0;
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);
}
간단한 코드이므로 설명은 생략합니다. 스팩을 먼저 만들고 구현을 하면(즉, 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초 간격으로 호출되는가

이제 하나씩 공략해 봅시다.


3. countdown()이 호출되면 div#counter 의 값이 1 씩 감소하는가

다음은 스팩입니다. body 태그 안쪽만 옮겼습니다.
<div id="counter">10</div>

<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>
일단, 전역변수인 counter5로 강제로 설정하고, countdown() 함수를 호출합니다. 그 결과로 div#counterinnerHTML4로 변했는지를 확인합니다.

스팩을 짜놓고 보니까 좀 지저분합니다. 전역 변수(counter)를  하나 제거하면 좀 나아질 것 같군요.
describe('Counter', {
    'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
        var element = document.getElementById('counter');
        element.innerHTML = '5';
        countdown();
        value_of(element.innerHTML).should_be('4');
    }
});
위 스팩이 통과하려면 counter.js를 다음과 같이 수정해서 전역 변수 counter를 모두 제거해야 합니다:
var handle = null;

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#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()을 쓰도록 수정할 것을 권장합니다:
Date._preset = -1;
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);
};
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);
           
            Date.pass(100); // 0.1초 흘리기
            updateIfTimePassed();
            value_of(called).should_be(true);
        } finally {
            // countdown 복원
            countdown = backup;
        }
    }
좀 길군요. 하나씩 설명을 하겠습니다.

첫째, 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 등)에서 그대로 사용할 수 있게 됩니다. 이것도 또한 반대로 얘기할 수 있는데, 코드를 전혀 수정하지 않고 다른 호스트 환경에서 사용할 수 있게 된다면 제어하기 힘든 외부 시스템에 대한 의존이 제거되었다고 볼 수 있습니다.

신고

구구단을 UML로..

나쁜 코딩 - 약한 결합을 위해 추상클래스를 선언했지만...를 읽다가 흥미가 동하여 끄적여봅니다.

참고로 UML을 학습하시는 분들이 계신데, 위에 작성한 클래스들을 UML로 작성해보면 정말 약한 결합을 실현한 것처럼 보입니다. 제가 UML을 별로 선호하지 않는 이유인데요. 책에 나와 있는 그림이랑 똑같은데 뭐가 문제라는 겁니까라고 얼굴이 벌게져서 항의하는 후배 앞에서 정말 뭐라 할 말을 잃었습니다. 여전히 후배랑 화해를 못하고 있어요... 나쁜 UML (엉뚱한데 탓한다) UML을 신봉하는 사람에게 코드를 좀 보란 말이야 라고 얘기해도 자꾸 UML 다이어그램만 보더라구요.

저도 UML을 딱히 좋아하는 것은 아니지만, 어느 정도 유용한 측면이 분명 있다고 생각합니다. 사실, 이 경우엔 UML 자체가 문제라기 보다 UML을 제대로 못 그린 것이 문제인거죠.

위에 작성한 클래스라는 것은 복잡한 것 빼고 간단히 요약하면 이런 코드입니다:
abstract class Computer {
   static int arr[][];
   abstract void print();
}

class Windows extends Computer {
   void print() {
       // depends on "arr"
   }
}

class Linux extends Computer {
   void print() {
      // depends on "arr"
  }
}

이 코드를 UML로 나타내라고 하면 보통은 이렇게 하겠죠:

사용자 삽입 이미지

따라서, 위 다이어그램을 보면 마치 약한 결합(generalization)인 것으로 보입니다. 하지만, 좋은 모델이라는 것은 "적절한 요소를 드러내야"하는데, 이 맥락에서 "적절한 요소"란 Windows 클래스와 Linux 클래스가 "arr"에 의존하고 있다는 점입니다. 한편, arr은 엄밀히 쓰자면 Computer.arr 이죠. 따라서 아래와 같은 선이 추가되어야 합니다:

사용자 삽입 이미지

이제 다이어그램만 봐도 잘못된 설계가 눈에 보이게 됩니다.

많은 사람들이 다양한 측면에서 UML을 비판합니다만(시각화가 적절치 못하다, Executable UML은 비용이 너무 비싸다 등등), 그렇다고 해서 아주 못 쓸 물건은 아니라는 얘기가 하고 싶었어요.

관련글:

신고
< Newer     Older >

티스토리 툴바