CTest 가장 쉬운 시작

CTest 가장 쉬운 시작

1. CTest가 하는 일

CTest는 테스트 프로그램을 직접 작성해 주는 도구가 아니다.

CTest가 하는 일은 다음과 같다.

  1. CMake에 등록된 테스트 실행
  2. 각 프로그램의 종료 코드 확인
  3. 성공/실패 결과와 실행 시간 정리

가장 중요한 규칙은 하나다.

테스트 프로그램 종료 코드 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.ctests/add_test.c에는 각각 main()이 있다. 두 파일을 한 실행 파일에
같이 넣으면 linker가 main()이 두 개라고 오류를 낸다.

따라서 다음처럼 나눈다.

  • add_core: main() 없이 테스트할 실제 로직
  • add_app: 실제 main() + add_core
  • add_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