Extremely Agile/TDD2008.05.21 17:20

찾아보니까 세상에는 굉장히 많은 단위 테스트 툴들이 있더군요. jUnit은 그 중 가장 유명하다 할만 합니다. 그런데 이런 단위 테스트 툴들이 없었을때에는 도대체 어떻게 단위 테스트를 했을까요? -_-

C를 위한 단위 테스트 프레임워크로 Check라는 것이 있습니다. (세상에 존재하는 모든 단위 테스트 프레임워크의 목록을 보고 싶다면 www.xprogramming.com의 관련 페이지에 가보시는게 좋겠군요.  저도 그 중 몇 가지 단위 테스트 프레임워크는 써 봤구요.) 배워볼까 싶어 잠깐 훑어봤는데, 이런 생각이 들더군요.

단위 테스트를 하기 위해서 꼭 특정한 테스트 프레임워크의 사용법을 배워야 한다면, 좀 귀찮지 않을까요? 제가 배우고 있는 언어만도 Java, C++, C, Ruby, Erlang, Scheme 등등 많은데, 그럼 대체 몇 가지의 테스트 프레임워크를 설치해야 하나요?

요즘은 Eclipse 덕에 이런 저런 프로그래밍 언어들을 동일한 환경에서 사용하기가 편해졌다고는 합니다만, 가끔은 환경 설정하고 프로그램 설치하고 하는 것도 귀찮을 때가 있거든요. 특히 시간이 부족해서 후달릴때는 더더욱.

그래서 이번에는 그냥 assert만 사용해서 단위 테스트를 해 봤습니다. 뭐 CppUnit 써서 테스트 할 때와 엇비슷한 코드가 나오더군요 ㅎㅎ (제가 훌륭한 프로그래머가 아니라서 그럴지도 -_-)

개략적인 코드 얼개만 잠시 보시면...

....    // 열심히 짠 C 모듈 코드.

#ifdef _TEST

/*
 * test DRIVER program. Just to test this module.
 */

HashTableBlock hash_table;
HashTableEntryBlock hash_table_entries;

int main() {
    ...
    HashTableEntry* e1 = ...;
    assert( e1 != 0 );
    ...
    HashTableEntry* e3 = ...;
    assert( e3->src_addr == 0x7f000001 );
    assert( e3->src_port == 3086 );
    ...

    int i = 0;
    for( i = 0; i < 1050000; ++i ) {
        HashTableEntry* ee = alloc_hash_entry_block(&hash_table_entries);
        assert( ee != 0 );
    }
    return EXIT_SUCCESS;
}

#endif

#ifdef와 #endif를 사용했기 때문에, test driver 코드는 _TEST가 명시된 경우에만 생성됩니다.

원래는 Check를 써서 테스트를 할까 했는데, 직접 assert를 사용해서 테스트를 해 봐도 뭐 그렇게 테스트가 어려워진다거나 하지는 않는 것 같아요. 결국 취향 문제인데, 테스트 결과를 일목요연하게 정리해서 보여준다거나 하는 기능이 정말로 아쉬운 사람이라면 assert 대신 다른 테스트 프레임워크를 사용하는 것이 낫겠지만, 그런 리포트에 연연하지 않는 사람이라면 이렇게 해도 별 상관 없을 것 같아요.

어차피 단 1개의 테스트만 실패했더라도, 실패한 것은 실패한 것이니까요.

[여기까지 작성한 다음에 출장을 다녀옴 -_-]

위와 같이 assert를 사용한 단위 테스트 방안을, 커널 모듈을 테스트하는 데 응용해 봤습니다. 원래 커널 모듈을 컴파일하기 위해 제가 사용했던 Makefile은 대략 다음과 같이 생겼습니다.

obj-m := captureapp.o

clean:
    \rm -f *.ko *.o *.mod.c


좀 심하게 단순하죠 -_-; 최상위 단계가 clean인 덕에, make를 때리나 make clean을 때리나 효과는 똑같습니다. 아무튼, 이 Makefile을 다음과 같이 바꾸었습니다. (2008년 5월 26일 수정됨)

obj-m := captureapp.o

UNITTEST_SRCS = hash_table_test.c
UNITTEST_EXES = ${UNITTEST_SRCS:.c=}

unittest : ${UNITTEST_EXES}

${UNITTEST_EXES} : ${UNITTEST_SRCS}
    gcc -D_TEST $< -o $@

clean :
    \rm -f *.ko *.o *.mod.c ${UNITTEST_EXES}


따라서 make unittest를 때리면, 단위 테스트를 실행하는 실행파일들이 테스트 타겟별로 만들어지게 됩니다. 위의 경우에는 현재 테스트 타겟이 hash_table 밖엔 없어서 hash_table_test라는 실행파일만 만들어지게 되죠.

hash_table.h에는 테스트 타겟인 hash table관련 코드들이 들어가있게 되구요. hash_table_test.c에는 그 코드들에 대한 단위 테스트 루틴들이 들어가게 됩니다. 우선 hash_table.h를 보시면, 코드 윗부분에 다음과 같은 매크로 디렉티브들이 들어가 있습니다.

#ifdef _TEST
#include <stdint.h>
#include <assert.h>
#include <string.h>
#define u32 uint32_t
#define u16 uint16_t
#define u8 uint8_t
#else
#include <linux/module.h>
#define assert(x)
#endif

hash_table.h의 코드가 커널 모듈에 들어갈 코드이긴 하지만, 그 코드가 커널 루틴을 심하게 건드리는 코드가 아닌 단순히 커널 모듈 안에서 사용될 라이브러리 코드라면, 사용자 공간에서 테스트를 하는 것이 낫거든요. 그래서, 위와 같은 매크로 디렉티브들을 사용하여 커널 코드에서 사용되는 타입과 사용자 공간에서 사용되는 타입들을 일치시킵니다. assert는 커널 모듈안에서는 사용할 수 없으니까 제거해주는 부분도 넣었구요.

이제 hash_table_test.c를 보시면 되겠군요. 만일의 경우를 대비해 hash_table_test.c의 코드 대부분은 #ifdef _TEST ... #endif 사이에 정의됩니다. 앞서 보셨던 코드와 별반 다를 것은 없습니다.

#include <stdlib.h>
#include "hash_table.h"

#ifdef _TEST

/*
 * test DRIVER program. Just to test this module.
 */

HashTableBlock hash_table;
HashTableEntryBlock hash_table_entries;

int main() {
    ...
    HashTableEntry* e1 = ...;
    assert( e1 != 0 );
    ...
    HashTableEntry* e3 = ...;
    assert( e3->src_addr == 0x7f000001 );
    assert( e3->src_port == 3086 );
    ...

    int i = 0;
    for( i = 0; i < 1050000; ++i ) {
        HashTableEntry* ee = alloc_hash_entry_block(&hash_table_entries);
        assert( ee != 0 );
    }
    return EXIT_SUCCESS;
}

#endif


적고보니 단위 테스트를 '프레임워크 없이'도 좀 체계적으로 진행하려면 준비해야 할 것이 꽤나 많은데요. 아마 단위 테스트 프레임워크를 사용하는 이유 중 하나는 이런 체계를 어떻게 만들어야 할 지 감이 잘 오지 않기 때문이겠죠. 만들고 나면 사실 별거 아닐 수도 있습니다만...

신고
Posted by 이병준

소중한 의견, 감사합니다. ^^

Extremely Agile/TDD2008.01.11 17:01

사실 이번 글은 독립적인 글이라기 보다는 이전 글의 연장선상에 있는 글이라고 보는 것이 좋겠네요. C에서 assert 관련 기능은 assert.h에 정의되어 있습니다. assert는 흔히 '프로그램 수행 중에 반드시 충족되어야 하는 조건을 디버깅 단계에서 검증하기 위해 사용하는' 루틴입니다. Java라면 VM을 실행할 때 -ea를 옵션으로 주고 실행시키면 assert 기능이 활성화되도록 만들 수 있고, java -ea:com.wombat.fruitbat와 같이 하면 특정한 package에 대해서만 assert 기능이 활성화되도록 만들 수 있습니다.

예전에 assert 기능을 release 모드에서 꼭 제거할 필요가 있느냐, 라는 논쟁이 있었던 것으로 기억합니다. 제거해야한다는 입장을 취하는 사람들은 "이미 디버깅이 끝난 소프트웨어에 assert 코드를 남겨두는 것이 무슨 의미가 있는가?"라는 주장을 했구요, 제거하지 말아야 한다는 주장을 하는 사람들은 "정말로 디버깅이 100% 완료되었는지 확신할 수 있는가?  그리고 assert 문을 남겨두는 것이 성능에 그렇게 해가 된다고는 보지 않는다"는 주장을 했었죠.

사실 assert를 통해 검사하는 조건들은 "프로그램 수행 중에 응당 발생할 수 있는 예외적 조건"들이 아닙니다. 예외적 조건들에 대한 처리 루틴은 프로그램 안에 반드시 들어가 있어야 합니다만, assert를 통해 검사하는 것은 "절대로 발생하면 안되는 조건이 실제로 발생하였는지"를 검사하는 것입니다. "절대로 발생해서는 안되는 조건이 발생했다는 것"은 프로그램에 있어서는 안되는 버그가 있다는 이야기이고, 그런 버그는 반드시 제거되어야 합니다.

그러니 "release 모드로 컴파일 할 때에는 assert 관련 코드는 반드시 제거되어야 한다"는 주장은 나름대로 타당성이 있죠. 하지만 "프로그램에서 버그를 100% 완벽하게 제거한다"는 것이 달성하기 힘든 목표이고 보면, 릴리즈 되는 최종 소프트웨어에 assert 문을 남겨두는 것이 크게 나쁘다고는 볼 수 없을 것 같아요. 릴리즈 된 소프트웨어를 설치하고 운영하는 중에 예기치 못한 문제가 발생한 경우, assert가 있으면 그런 문제를 해결하는 것이 분명 편해지거든요. 그래서 저는 릴리즈 모드로 컴파일된 프로그램에도 '굳이 필요하다면' assert를 남겨둘 수 있도록 하려고 해요.

자. 그러면 지난 시간에 살펴본 코드들로 돌아가서, 실제 코드를 보도록 하죠. 지난 시간에는 _DEBUG를 사용해서, 디버그 모드에서 사용할 디버그 매크로들이 활성화되도록 했었습니다. _DEBUG가 정의되어 있지 않으면, 이런 매크로들은 실제 코드에서는 사라지도록 했었죠.

그런데 assert에는 이미 이런 기능이 내재되어 있습니다. 컴파일시에 NDEBUG라는 상수가 정의되어 있으면 assert는 코드에서 사라집니다. (완전히 사라지지는 않습니다. assert 함수의 body 부분만 사라지죠. 그래서 NDEBUG를 정의하더라도 assert를 호출하는 오버헤드를 100% 제거할 수는 없습니다. 컴파일러가 최적화를 나이스하게 해 주지 않는 한.) NDEBUG가 정의되어 있지 않으면, assert는 자신이 하도록 되어 있는 일을 합니다. 자신이 검사하는 expression의 값이 0이 되면 파일명과 라인수를 출력하고 프로그램을 강제 종료시켜버리는 거죠.

그런데 기왕에 _DEBUG를 쓰는 마당에 NDEBUG라는 엇비슷하게 생겨먹은 상수를 또 쓴다는 게 어쩐지 좀 맘에 안드는 군요. 그러니 NDEBUG대신 DISABLE_ASSERT라는 상수를 사용하도록 한번 고쳐보겠습니다. 다른 디버깅 함수들은 대문자인데 assert는 소문자라는 것도 좀 맘에 들지 않는 부분이니, 그 부분도 고쳐보겠습니다. assert가 소문자이기 때문에, 자칫 실수하면 Heisenbug라는 버그가 발생할 수도 있거든요. http://ideathinking.com/blog-v2/?p=56 에 실제 그런 문제를 겪으신 분의 포스팅이 있습니다. http://sunsite.ualberta.ca/jargon/html/H/heisenbug.html 에 가면 Heisenbug의 정의가 있으니 참고하시기 바랍니다.

그럼 실제 코드를 볼까요?

#ifndef DISABLE_ASSERT
#define ASSERT(X) assert(X)
#else
#define ASSERT(X)
#endif

이런 식으로 해 놓고 assert 대신 ASSERT를 쓰면, "assert의 인자로 '반드시 수행되어야만 하는 코드'를 넣어서 발생하는 문제"(즉, Heisenbug)를 어느 정도는 방지할 수 있겠습니다. 설사 생기더라도 교정하기는 좀 쉽겠죠. 대문자로 되어 있으니, 찾아내기도 좀 더 쉬워질 테구요.

ASSERT 매크로를 _DEBUG와는 무관하게 구현하였기 때문에, 컴파일 시에 _DEBUG를 정의하지 않고 컴파일하더라도 (즉, 릴리즈 모드로 컴파일하더라도) ASSERT를 코드 안에 그대로 남겨둘 수 있습니다. ASSERT를 전부 제거하고 싶으면 DISABLE_ASSERT까지 정의해서 (-DDISABLE_ASSERT) 컴파일을 해야만 하죠.

자. 그러면 '반드시 실행되어야 하는 코드의 실행 결과를 검사하려면 어떻게 해야 하죠? 그건 ASSERT로는 좀 곤란해요. DISABLE_ASSERT를 정의하고 컴파일 하는 순간, 그 코드는 오브젝트 파일 안에서는 사라져버릴 것이거든요. 그런 경우에는 VERIFY같은 매크로를 추가로 정의해서 사용하는 편이 더 낫습니다. VERIFY의 구현에 대해서는 http://www.developerfusion.co.uk/show/1719/7/ 를 참고하시는 것이 좋을 것 같습니다. :-)


[4부로 이어집니다]

신고
Posted by 이병준

소중한 의견, 감사합니다. ^^