CTest 가장 쉬운 시작
1. CTest가 하는 일
CTest는 테스트 프로그램을 직접 작성해 주는 도구가 아니다.
CTest가 하는 일은 다음과 같다.
- CMake에 등록된 테스트 실행
- 각 프로그램의 종료 코드 확인
- 성공/실패 결과와 실행 시간 정리
가장 중요한 규칙은 하나다.
테스트 프로그램 종료 코드 0 → PASS
테스트 프로그램 종료 코드 0 이외 → FAIL
전체 흐름은 다음과 같다.
테스트할 로직을 library로 분리
↓
실제 main과 테스트 main이 같은 library를 사용
↓
add_test()로 테스트 실행 파일을 CTest에 등록
↓
cmake --build로 프로그램과 테스트 exe 빌드
↓
ctest로 테스트 exe 실행 및 결과 수집
2. 실제 프로그램과 테스트가 분리된 가장 작은 예제
현재 서버처럼 실제 프로그램에도 main()이 있고 테스트 프로그램에도 별도의main()이 있는 구조를 사용해 보자.
simple-add/
├── CMakeLists.txt
├── src/
│ ├── add.h
│ ├── add.c
│ └── main.c
└── tests/
└── add_test.c
핵심은 add()를 main.c 안에 넣지 않고 add.c로 분리하는 것이다.
2.1 테스트할 함수 선언: src/add.h
#ifndef ADD_H
#define ADD_H
int add(int left, int right);
#endif
2.2 실제 로직: src/add.c
#include "add.h"
int add(int left, int right) {
return left + right;
}
이 파일에는 main()이 없다. 실제 프로그램과 테스트가 함께 사용할 로직만 있다.
2.3 실제 프로그램: src/main.c
#include "add.h"
#include <stdio.h>
int main(void) {
int result = add(2, 3);
printf("2 + 3 = %d\n", result);
return 0;
}
이것이 사용자가 직접 실행하는 실제 프로그램이다.
.\add_app.exe
예상 출력:
2 + 3 = 5
2.4 분리된 테스트 프로그램: tests/add_test.c
#include "add.h"
#include <stdio.h>
int main(void) {
int actual = add(2, 3);
int expected = 5;
printf("CHECK add: actual=%d, expected=%d\n", actual, expected);
if (actual != expected) {
fprintf(stderr, "FAIL add\n");
return 1;
}
puts("PASS add");
return 0;
}
테스트 프로그램은 실제 main.c를 실행하지 않는다. add()를 직접 호출해서 결과를
검사한다.
- 검사 성공:
return 0 - 검사 실패:
return 1 printf: 실행 중 확인하고 싶은 체크포인트
3. 실제 프로그램과 테스트를 CMake에 등록
최상위 CMakeLists.txt는 다음과 같다.
cmake_minimum_required(VERSION 3.22)
project(simple_add LANGUAGES C)
# main()이 없는 공통 로직
add_library(add_core STATIC
src/add.c)
target_include_directories(add_core PUBLIC
src)
# 사용자가 실행할 실제 프로그램
add_executable(add_app
src/main.c)
target_link_libraries(add_app PRIVATE
add_core)
# 테스트 기능 활성화
include(CTest)
if(BUILD_TESTING)
# 테스트 전용 main()이 있는 실행 파일
add_executable(add_test
tests/add_test.c)
target_link_libraries(add_test PRIVATE
add_core)
# add_test.exe를 CTest에 등록
add_test(NAME add COMMAND add_test)
endif()
빌드 결과의 관계는 다음과 같다.
┌──────────────┐
src/main.c ──────▶│ add_app │
│ 실제 프로그램 │
└──────┬───────┘
│ link
┌────▼────┐
│ add_core │ ◀── src/add.c
└────▲────┘
│ link
┌──────┴───────┐
tests/add_test.c ─▶│ add_test │
│ 테스트 프로그램│
└──────────────┘
왜 library로 분리하는가?
src/main.c와 tests/add_test.c에는 각각 main()이 있다. 두 파일을 한 실행 파일에
같이 넣으면 linker가 main()이 두 개라고 오류를 낸다.
따라서 다음처럼 나눈다.
add_core:main()없이 테스트할 실제 로직add_app: 실제main()+add_coreadd_test: 테스트main()+add_core
이 방식이면 실제 프로그램이 사용하는 것과 동일한 add() 구현을 테스트하게 된다.
library가 반드시 필요한가?
아니다. 작은 프로젝트라면 같은 add.c를 실제 프로그램과 테스트 실행 파일에서
각각 컴파일해도 된다.
# 실제 프로그램: main.c + add.c
add_executable(add_app
src/main.c
src/add.c)
target_include_directories(add_app PRIVATE src)
# 테스트 프로그램: 테스트 main + 같은 add.c
add_executable(add_test
tests/add_test.c
src/add.c)
target_include_directories(add_test PRIVATE src)
add_test(NAME add COMMAND add_test)
이 구조도 정상적으로 테스트할 수 있다.
src/main.c ───────┐
src/add.c ────────┴──▶ add_app.exe
tests/add_test.c ─┐
src/add.c ────────┴──▶ add_test.exe
차이는 src/add.c가 두 target에서 각각 컴파일된다는 점이다. 소스가 작다면 큰
문제가 아니다. 여러 실행 파일과 테스트가 같은 소스를 사용하기 시작하면add_library()로 한 번 정리하는 편이 편리하다.
함수가 정말 main.c 안에 같이 있다면?
main.c 안의 일반 함수도 기술적으로 테스트할 수 있다. 다만 테스트 프로그램도
자체 main()을 가져야 하므로 production main()만 제외할 장치가 필요하다.
/* src/main.c */
#include "add.h"
int add(int left, int right) {
return left + right;
}
#ifndef ADD_UNIT_TEST
#include <stdio.h>
int main(void) {
printf("2 + 3 = %d\n", add(2, 3));
return 0;
}
#endif
테스트 target에서만 ADD_UNIT_TEST를 정의한다.
add_executable(add_test
tests/add_test.c
src/main.c)
target_compile_definitions(add_test PRIVATE ADD_UNIT_TEST)
target_include_directories(add_test PRIVATE src)
add_test(NAME add COMMAND add_test)
이 방법도 동작하지만 단점이 있다.
- production 파일에 테스트용
#ifdef가 들어간다. main.c가 커지면 테스트할 함수와 process 시작 코드가 섞인다.- 다른 실행 파일에서도 함수를 재사용하기 어렵다.
그래서 library 분리는 “테스트하려면 반드시 해야 하는 규칙”이 아니라, 코드가
커졌을 때 테스트와 재사용을 편하게 만드는 설계 선택이다.
함수가 static이면 다른 C 파일에서 직접 호출할 수 없다. 이때는 public 함수를 통해
간접 검증하거나, 테스트할 로직을 별도 C 파일로 옮기는 방법이 일반적이다.
현재 저장소도 같은 구조다.
src/main.c
└── doip_uds_server
└── diag_core + doip_core
tests/uds_smoke_test.c
└── uds_smoke_test
└── diag_core + doip_core
tests/doip_gcs_loopback_test.c
└── doip_gcs_loopback_test
└── diag_core + doip_core
4. 실행 순서
이 저장소에서는 반드시 프리셋을 사용한다.
# 1. CMake 프로젝트 생성
cmake --preset windows-msvc
# 2. 서버와 테스트 프로그램 컴파일
cmake --build --preset windows-debug
# 3. 등록된 모든 테스트 실행
ctest --preset windows-debug
여기서 C 테스트 프로그램은 당연히 컴파일되어야 한다. 역할이 다음처럼 분리되어
있다는 뜻이다.
cmake --preset ... CMake: 어떤 파일을 어떻게 빌드할지 구성
↓
cmake --build ... MSBuild와 C compiler: C 코드를 exe로 컴파일/link
↓
ctest ... CTest: 이미 만들어진 테스트 exe를 실행하고 결과 수집
즉 CTest가 컴파일러를 직접 실행하는 담당이 아니다. 빌드되지 않은add_test.exe를 CTest가 실행하려 하면 executable을 찾지 못해 실패한다.
CTest는 Python 같은 일반적인 스크립트 언어가 아니라 CMake와 함께 제공되는
테스트 실행기(test runner)다. 참고로 CTest에는 CMake 문법을 이용하는 별도의
dashboard script 모드(ctest -S)도 있지만, 이 프로젝트에서 사용하는ctest --preset windows-debug는 등록된 native C 테스트 exe를 실행하는 용도다.
정상 결과 예:
Start 1: add
1/1 Test #1: add ........................ Passed
100% tests passed, 0 tests failed
5. 실패했을 때 출력 보기
기본 CTest 출력은 간단하다. 실패 원인을 함께 보려면 다음 명령을 사용한다.
ctest --preset windows-debug --output-on-failure
성공한 테스트의 체크포인트까지 전부 보고 싶다면 verbose 모드를 사용한다.
ctest --test-dir build\vs2026 -C Debug -V
특정 테스트 하나만 자세히 실행하려면 -R 정규식을 사용한다.
ctest --test-dir build\vs2026 -C Debug `
-R doip_listen_address_unit -V
테스트 목록만 보고 실행하지 않으려면:
ctest --preset windows-debug -N
