Crashing Your Way to Great Legacy C Tests
- 원문: http://www.renaissancesoftware.net/blog/?p=27
- 글쓴이: James Grenning (번역: 한주영)
C/C++로 되어있는 레거시 코드에 테스트를 추가하기란 꽤나 도전적인 일이다. 테스트를 고려하지 않고 설계된 코드는 당연히 테스트하기 쉽지 않을것이다. 종속성은 관리도 안되어 있고 보이지도 않을 것이다. 여기에 처음으로 테스트를 작성하기란 고통스러울 것이다. 심하게 말이다. 하지만 절망하지 말자! 첫 테스트가 가장 어려운 것이고 두번째 테스트부터는 훨씬 쉬우니까.
여러분이 레거시 코드에 테스트를 추가하려 할 때, 앞으로 무엇을 해야 할 지, 그리고 어떤 결과가 있을지를 미리 알면 여정이 수월할 것이다. 이 글에서 나는 여러분이 처음으로 C/C++코드의 일부를 테스트 하니스에 넣을 때 어떤 식으로 진행될 것인지를 알려줄 것이다.
Michael Feathers는 레거시 코드에 테스트를 추가하는 알고리즘을 다음처럼 제안한다.
- 수정할 지점을 파악한다.
- 테스트 지점을 찾는다.
- 종속성을 끊는다.
- 테스트를 작성한다.
- 수정하고 리팩터링한다.
Michael의 알고리즘과 함께 적용할 수 있는 또다른 방법이 있다. 우선 시나리오를 살펴보자.
여러분은 레거시 코드의 일부를 테스트하고 싶다. 여러분이 테스트에서 호출하려는 함수는 C의 구조체와 함수들이 서로 뒤엉킨 큰 덩어리의 일부이다. 이러한 Function-call data-structure free-for-all (FCDSFFA) 덩어리를 테스트 하니스에 넣어 컴파일이 되도록 하기란 쉽지 않은 일이다. 이것을 실행되도록 하기란 더욱 큰 일이다. 레거시 코드의 함수가 어떤 데이터를 사용하는지 명확하지 않고, 그래서 무엇을 초기화해야 할지도 명확하지 않다. 여러분은 이때 무엇을 초기화해야 할 지 찾아내기 위해 크래시 를 이용할 수 있다.
크래시 테스트 방법을 시작하려면 빈 테스트 케이스와 여러분이 테스트하고 싶은 레거시 함수가 있으면 된다. 이 알고리즘을 C로 표현한다면 다음과 같을 것이다.
void addNewLegacyCtest()
{
makeItCompile();
makeItLink();
while (runCrashes())
{
findRuntimeDependency();
fixRuntimeDependency();
}
makeTestMoreMeaningful();
}
void 레거시C코드에_테스트추가()
{
컴파일되도록하기();
링크되도록하기();
while (실행하여_크래시확인())
{
실행시간종속성찾기();
실행시간종속성해결하기();
}
의미있는테스트만들기();
}
makeItCompile() 컴파일되도록하기()
makeItCompile()은 보통 해당 함수의 헤더파일을 #include하고 대상 함수를 호출하는 것으로 시작한다. 여러분에게 주어지는 것은 길게 나열된 컴파일 오류이다. 첫번째 오류부터 해결하는 것이 가장 낫다. 그 문제가 이어지는 101개의 오류를 일으킨 원인인 경우가 많기 때문이다. 한번에 하나씩 #include해라. 첫번째 오류를 계속 공략해라. 여러분의 목표는 최소한의 #include 만으로 깔끔하게 컴파일되도록 하는 것이다. 용기를 잃지 마라.
종속성이 서로 꼬여서 너무 심각한 경우에 취할 수 있는 빠른 길은 대상 함수를 호출하는 제품 코드에서 #include 부분을 복사해오는 것이다. 깔끔하게 컴파일되기는 하겠지만 이제는 #include 목록이 지저분할 것이다. 일단 테스트가 돌아가기 시작하면 #include 목록을 가지치기하도록 하자.
테스트 대상 함수를 호출하기위해 적절한 구조체와 파라미터를 함수에 전달해야 할 수도 있다. makeItCompile() 중에는 널포인터와 하드코딩된 데이터 값을 사용하는데 주저하지 마라. 구조체는 memset()을 이용하여 원샷에 0으로 초기화해라. 나중에는 좀더 의미있는 값을 전달해야 할테지만 종속성을 널포인터와 단순한 값으로 채워넣는 것만으로 금방 깔끔한 컴파일에 다다를 수 있다. 나중에, 테스트가 돌아가면, 테스트 실행이 크래시를 일으키면서 문제를 일으키는 데이터가 어떤 것인지 알려줄 것이다. (물론 그렇지 않은 경우도 있다.)
아직 TDD에 확신을 가지지 못한 사람들은 이 과정에서 용기를 잃고 makeItCompile()을 exit(FAILURE)로 조기종료하게 된다. 포기하지 마라. exit(FAILURE)는 최후의 순간까지 미루자.
makeItCompile()이 성공적으로 리턴하게되면 곧바로 makeItLink()가 시작된다.
makeItLink() 링크되도록하기()
복잡한 #include를 갖추고 파라미터와 전역 종속성을 널포인터와 무의미한 데이터로 채우고나면 여러분에게 다음 보상이 주어진다. 바로 링크 오류다. makeItLink()는 꽤 복잡할 수 있다. “unresolved external” 오류는 제품 코드 중 일부를 링크하거나 테스트 더블을 추가하여 해결할 수 있다. 자신이 Unit test를 작성하지 않는 핑계를 찾는 사람들은 makeItLink()에서 exit(FAILURE)를 하기도 한다.
runCrashes() 실행하여_크래시확인()
링크오류없이 테스트 실행파일이 만들어지고 나면 십중팔구는 실행하자마자 크래시를 일으킬 것이다. 크래시는 초기화되지 않았거나 혹은 부적절하게 초기화된 데이터에 의한 것이다. 여러분은 지금 제대로 하고 있는 중이다! 계속 나아가면 된다! 크래시는 여러분을 실행시간 종속성으로 인도할 뿐이다.
runCrashes() 가 TRUE 인 동안은 반복문에 머물러 있어라. 반복문에 머무르는 동안 여러분은 필요한 전역 변수나 파라미터에 적절한 값을 넣어 테스트 대상 코드가 문제없이 돌아가도록 하면서 실행시간 종속성을 찾아 고치게 된다. 테스트 대상 함수가 테스트 하니스 안에서 실행되고 있다! 실행시간 종속성을 찾아 고치는 과정을 좀더 깊이 들여다 보자.
findRuntimeDependency() 실행시간종속성찾기()
If you have a debugger, fire it up and visit the crash site. Inspecting for clues will likely yield the root cause of the illegal access. If you don’t have a debugger, it’s time to get one. You can also inspect the input data to find obvious problem initializations. Single stepping through the code under test can also be revealing.
여러분에게 디버거가 있다면, 디버거를 띄워 크래시 지점을 바로 찾아낼 수 있다. 단서를 들여다보면 잘못된 접근에 대한 근본 원인을 찾을 수 있을 것이다. 디버거가 없다면 지금이 디버거를 구비할 때다. 입력 변수의 값을 살펴볼 수도 있고, 한 스텝 씩 실행시켜볼 수도 있을 것이다.
findRuntimeDependency() 는 크래시의 근본원인이 밝혀지면 끝난다. Michael은 컴파일시간 종속성을 찾기 위해 ‘컴파일러에 기대기’를 제안했다. findRuntimeDependency() 는 ‘실행 환경에 기대기’를 이용한다. 프로그램을 실행시키고 OS가 제공하는 잘못된 메모리 접근을 막아주는 기능을 이용하여 실행시간 종속성을 찾아나간다.
이 방법은 임베디드이거나 임베디드가 아니거나 모두 효과적이다. 하지만 임베디드 소프트웨어의 경우에는 잘못된 메모리 접근을 검출해주는 시스템에서만 효과를 볼 수 있다. 잘못된 메모리 접근을 검출해주지 못하는 시스템에서는 여러분의 코드가 문제있는 상황에서도 아무렇지 않게 실행되었다가 나중에 문제를 일으킬 수도 있다.
크래시 테스트를 통해 테스트 케이스를 만들어갈 수 있다는 것은 임베디드 소프트웨어 테스트를 개발 환경에서 실행시켜야 하는 좋은 이유가 된다. 임베디드 소프트웨어 테스트를 개발 환경에서 실행시켜야 하는 다른 이유들이 궁금하다면 TDD for Embedded Software 를 보기 바란다.
fixRuntimeDependency() 실행시간종속성해결하기()
발견한 근본원인을 보면 빼먹은 초기화가 무엇인지 쉽게 알 수 있을 것이다. 원인은 전역 변수나 함수 포인터를 초기화하지 않았다거나 혹은 엉뚱한 값이 지정되어 있을 수 있다. 무엇을 초기화할 지 알아낸 다음 makeItCompile()과 makeItLink() 과정을 다시 진행하게 된다. 실행시간 종속성을 하나 해결하고 나면 대게는 또다른 크래시와 마주치게 된다. 다행인 것은 이러한 실행시간 종속성이라는 구멍을 모두 막고나면 크래시가 더이상 생기지 않을 때가 온다는 것이다. 그러고 나면 새로운 테스트를 추가하는 것은 금방이다.
여러분이 테스트 대상을 너무 크게 잡았다면 깔끔하게 실행되기까지 더오래 걸리고 더 많은 크래시를 보게 될 것이다. 좀더 수월한 대상을 찾아보고 그것을 선택하는 것이 낫다.
makeTestMoreMeaningful() 의미있는테스트만들기()
깊은 숨을 들이쉬자. 힘든 부분이 다 끝났다! 이제 테스트를 좀더 의미있게 만들면 된다.
보통 addNewLegacyCtest() 다음에 해야할 첫번째 일은 cutPasteRenameTweak() 이다. 이것을 더이상 추가할 테스트가 떠오르지 않을때까지 하게 된다. 부디 레거시 C 테스트를 새로 추가할 때 다음과 같이 하지 말기를 바란다.
void addMoreLegacyCtests()
{
while (!testsAreSuffecientForCurrentNeeds())
{
copyPasteTweakTheLastTest();
}
}
void 레거시C코드에_테스트_더_추가하기()
{
while (!테스트가_충분한가())
{
마지막테스트_복사하여붙이고_살짝고치기();
}
}
copyPasteTweakTheLastTest()
마지막테스트_복사하여붙이고_살짝고치기()
일단 첫번째 레거시 테스트가 돌아가게 되면 보통은 맘이 편안해지고 기쁘기도 할 것이다. 개발자들은 금새 새로운 테스트를 생각해낸다. 입력값이나 CHECK할 내용을 살짝 바꾸는 식으로 말이다. 그러니 copyPasteTweakTheLastTest() 는 자연스러운 결과이다. 이렇게 하는 것은 아무 문제없다. 다만 copyPasteTweakTheLastTest() 다음에는 반드시 리팩터링을 하기만 한다면. 만약 리팩터링을 빼먹는다면 테스트코드가 금방 엉망진창이 될 것이다.
테스트는 읽기 쉽고, 유지보수하기 쉽고, 코드만 보고 이해할 수 있어야 한다. 중복이 많다면 이런 필요성을 만족시킬 수 없다.
대신에 다음과 같은 알고리즘을 제안한다.
void addMoreLegacyCtests()
{
while (!testsAreSufficientForCurrentNeeds())
{
copyPasteTweakTheLastTest();
while (!testDifferenncesAreEvident())
{
inspectTheCopyPastedTests();
if (setupStepsAreSimilar())
{
extractAndParameterizeTheCommonSetup();
extractAndParameterizeTheCommonAssertions();
}
else
;//Maybe a new test case group is needed
}
}
}
void 레거시C코드에_테스트_더_추가하기()
{
while (!테스트가_충분한가())
{
마지막테스트_복사하여붙이고_살짝고치기();
while (!테스트간_차이점이_명확한가())
{
복사하여붙여넣은테스트_들여다보기();
if (셋업이_비슷한가())
{
공통셋업_떼어내어_인자_넘겨받게_만들기();
공통체크_떼어내어_인자_넘겨받게_만들기();
}
else
;//테스트 그룹을 새로 만들어야 할지도 모른다
}
}
}
testsAreSuffecientForCurrentNeeds() 테스트가_충분한가()
이름에서 알수 있듯이 testsAreSufficientForCurrentNeeds() 에서 여러분은 지금까지 만들어진 테스트가 충분한지를 결정하게 된다. (난 설명이 필요없는 코드를 사랑한다.) 이때 다음의 질문을 던져보자.
- 입력값은 어떻게 달라질 수 있는가?
- 테스트 대상 코드의 동작을 검증하려면 어떤 것을 CHECK해야 하는가?
- 어떤 테스트가 더 필요한가?
testDifferenncesAreEvident() 테스트간_차이점이_명확한가()
처음에는 testDifferencesAreEvident()가 항상 FALSE를 리턴할 것이다. copyPasteTweakTheLastTest() 를 하고 나면 여러분이 만족할만한 새로운 테스트가 생기게 되지만, copyPasteTweakTheLastTest()를 몇번 실행하고 나면 테스트케이스마다 중복이 많을 것이다. 추가한 테스트에서 살짝 수정한 내용은 긴 초기화 코드와 여러줄에 걸친 CHECK 사이에 감춰져서 테스트케이스들 간의 차이가 드러나지 않는다. 작성한 코드의 라인 수에 따라 돈을 받는 것이 아니라면 지금이 코드를 청소할 때다. 반복문을 몇번 실행하고 나면 이제 copyPasteTweakTheLastTest()를 실행해도 간결하고 읽기 쉬운 테스트케이스를 얻게 된다.
테스트 코드를 깔끔하게 유지하는 것이 왜 중요할까? 테스트 코드는 며칠(아니 몇시간)만 지나더라도 금방 이해하기 어려워지는 경우가 많기 때문에 반드시 깔끔하게 유지해야만 한다. 여러분이 copyPasteTweakTheLastTest()를 실행하는 중에는 살짝 바꾼 내용이 여러분에게 잘 보일런지 모르지만 다른 사람들한테는 그렇지 않다. 그리고 좀 지나면 여러분이 보더라도 테스트케이스 간의 차이점이 드러나지 않을 것이다. 그래서 차이점이 분명하게 보이는 바로 지금이 중복을 없애고 테스트 코드의 가독성을 높일 수 있는 최고의 순간이다. testDifferencesAreEvident()는 중복을 리팩터링하여 공통 입력 데이터와 헬퍼 함수로 바꾼 다음에야 TRUE를 리턴하게 된다.
extractAndParameterizeTheCommonSetup()
공통셋업_떼어내어_인자_넘겨받게_만들기()
이 과정에서는 테스트 케이스마다 나타나는 공통 셋업 부분을 떼어내어 테스트 케이스들이 공유할 수 있도록 공통 변수와 헬퍼 함수를 만든다. 테스트 코드를 수정하면서 조금씩 수정할 때마다 테스트를 모두 실행키도록 한다. extractAndParameterizeTheCommonSetup()의 결과물로 새로 만들어진 초기화 코드를 실제 제품 코드에 사용할 수 있는 경우도 있다. 쓸만한 유틸리티를 만들었으니 promoteTestSetupCodeToProductionCode() 를 호출하는 것도 고려해보기 바란다.
extractAndParameterizeTheCommonAssertions()
공통체크_떼어내어_인자_넘겨받게_만들기()
이 과정은 extractAndParameterizeTheCommonSetup()과 거의 같다. 두 과정을 다음처럼 리팩터링해야 할지도 모르겠다.
extractAndParameterizeCommon(setup);
extractAndParameterizeCommon(assertions);
여러분이 수고를 들인만큼 보상이 따를 것이다. 이 과정을 한두번 거치고 나면 testDifferencesAreEvident() 는 계속 TRUE를 리턴할 것이다. 이렇게 깔끔하게 리팩터링된 테스트코드 위에서 작업을 한다면 여러분에게는 새로운 테스트 시나리오를 손쉽게 추가할 수 있는 도구가 생긴 셈이다. 다음번 포스트를 통해서 예제를 들어보이겠다.
여러분에게 충분한 테스트가 생겼다면, exit(SUCCESS);
이 전체 과정을 명확하게 파악하고 정리할 수 있도록 도와준 서울에 있는 나의 친구들에게 감사를 말을 전한다! 최근에 난 C를 너무 많이 하고 있는게 아닌가 싶다.