CodeGym /행동 /C++ SELF /CTest — 빌드의 일부로서 테스트 실행

CTest — 빌드의 일부로서 테스트 실행

C++ SELF
레벨 37, 레슨 3
사용 가능

1. doctest/Catch2가 이미 있는데 CTest가 왜 필요한가?

테스트 프레임워크를 막 배우신 분은 자연스럽게 묻습니다: "테스트는 이미 실행되는데, 굳이 CTest가 필요할까?" 개발자의 삶에는 이미 충분히 많은 도구가 있고, 특히 데드라인이 다가올 때는 더 그렇습니다. 여기서 역할을 깔끔하게 구분해 보고 CTest가 실제로 무엇을 더해 주는지 살펴봅시다.

테스트 프레임워크(doctest/Catch2)는 테스트 실행용 바이너리 내부의 라이브러리입니다. 테스트 케이스를 찾고 실행하며, "기대값/실제값"을 보기 좋게 출력하고 프로세스 종료 코드를 0(성공) 또는 != 0(실패)로 반환합니다.

CTest는 외부 도구입니다. 그것은 여러분의 TEST_CASE나 CHECK를 "이해"하지 않습니다. 대신 다른 일을 합니다: 테스트를 빌드 프로세스의 일부로 조직합니다. 간단히 말해, CTest는 디스패처입니다: "프로젝트에 등록된 이런저런 테스트들을 모두 실행하고 통계를 수집해 결과를 반환하자"는 역할을 합니다.

다음 표를 머릿속에 두는 것이 유용합니다:

엔티티 어디에 존재하는가 무엇을 하는가 무엇을 하지 않는가
테스트 프레임워크 (doctest/Catch2) 테스트 .exe 내부 테스트 케이스 실행, 리포트 출력 프로젝트/빌드 구조를 알지 못함
CMake 빌드 "레시피" 타겟 컴파일/링크 스스로는 "테스트 러너"가 아님
CTest CMake 옆(에코시스템) 등록된 테스트를 실행하고 결과를 집계 CHECK를 분석하지 않음, exit‑code만 중요

2. CTest의 핵심 아이디어: 테스트는 코드 0을 반환하는 명령이다

"CTest"라는 말을 들으면 초보자는 마치 컴파일러의 마법 모드처럼, 테스트를 빌드에 "내장"해서 저절로 실행되는 것처럼 오해할 수 있습니다. 실제 모델은 매우 현실적입니다: 테스트는 단순히 실행 가능한 프로세스(명령)이며, 프로세스에는 종료 코드가 있습니다.

개발 자동화의 거의 모든 부분은 다음 단순한 합의에 기반합니다: 0은 성공을 의미하고, 다른 모든 숫자는 오류를 의미합니다. CTest도 예외가 아닙니다. CTest는 테스트 명령을 실행하고 반환값이 0인지 아닌지를 확인합니다. 0이 아니면 테스트는 실패로 간주됩니다. 테스트가 "모두 정상"이라고 출력했더라도 종료 코드가 그렇지 않다면 실패입니다.

이 동작을 직접 느껴보려면 테스트 프레임워크 없이 만든 단순한 러너를 보는 것이 좋습니다. 보기에는 세련되진 않지만 매우 솔직합니다(서식 없는 cout처럼):

#include <iostream>

bool test_add() {
    return (2 + 3) == 5;
}

int main() {
    if (!test_add()) {
        std::cerr << "test_add FAILED\n";
        return 1;                   // <-- 0이 아님: 실패
    }

    std::cout << "test_add OK\n";    // test_add OK
    return 0;                        // <<-- 0: 성공
}

그래서 "프레임워크 + CTest" 조합이 잘 작동합니다: 프레임워크는 프로세스 내부에서 통과/실패를 판정하고 적절한 exit‑code를 설정하고, CTest는 외부에서 그 프로세스들을 실행해 결과를 집계합니다.

CTest가 CMake 프로젝트에 연결되는 방법

CMake는 빌드 파일을 생성할 수 있을 뿐만 아니라 "테스트 레지스트리"도 생성할 수 있습니다 — 프로젝트의 테스트로 간주되는 명령들의 목록입니다. CTest는 이 목록을 읽습니다.

CTest를 CMake에 연결하는 최소 단계는 두 가지입니다. 첫째, "프로젝트에 테스트가 있다"라고 알려서 테스트 지원을 켭니다. 둘째, 특정 명령들을 테스트로 등록합니다.

CMake에서는 이렇게 씁니다:

enable_testing()

add_test(NAME core_tests COMMAND core_tests)

의미가 거의 자연어처럼 읽힙니다(프로그래머에게 드문 경우로 덜 고통스럽게 읽힘): 테스트를 활성화했고, 이름이 core_tests인 테스트를 추가했으며, 이 테스트는 core_tests라는 명령으로 실행됩니다(대개 테스트 실행 파일 이름).

중요: add_test는 명령을 등록합니다, "테스트 파일"을 등록하는 것이 아닙니다. 필요하면 애플리케이션 실행이나 스크립트, 포맷 검사기 등 무엇이든 테스트로 등록할 수 있습니다. 여기서는 유닛 테스트 바이너리에 초점을 맞춥니다.

3. 실전 예: 테스트 바이너리와 CTest 등록

이제 작지만 완전한 "프로젝트 골격"을 만들겠습니다. 이 프로젝트에는 로직(테스트 대상), 애플리케이션(사용자가 실행), 그리고 테스트(CTest가 실행)가 있습니다. 교육적으로 중요합니다: 모든 것을 하나의 main.cpp에 섞으면 테스트를 "실행"한 것처럼 보이지만 자동화 및 유지보수가 어려워집니다.

학습용 미니 애플리케이션 TinyCalc를 가정합시다. 현재 두 가지 기능이 있습니다: 숫자를 범위에 눌러주는 clamp와, 나눗셈이 불가능할 때 std::optional을 반환하는 safe_div. 마법은 없고 테스트하기 좋은 함수들입니다.

테스트할 "코어" 코드

파일 src/core.hpp:

#pragma once

#include <optional>

int clamp(int x, int lo, int hi);
std::optional<int> safe_div(int a, int b);

파일 src/core.cpp:

#include "core.hpp"

int clamp(int x, int lo, int hi) {
    if (x < lo) return lo;
    if (x > hi) return hi;
    return x;
}

std::optional<int> safe_div(int a, int b) {
    if (b == 0) return std::nullopt;
    return a / b;
}

"테스트 가능성"에 주목하세요: std::cin이 없고, 시간이나 난수에 의존하지 않습니다. 파라미터를 주면 결과를 돌려주는 함수들은 우주가 거의 저항 없이 테스트됩니다.

코어를 사용하는 애플리케이션

파일 src/main.cpp:

#include <iostream>
#include "core.hpp"

int main() {
    std::cout << clamp(15, 0, 10) << '\n';   // 10
    const auto r = safe_div(10, 2);

    if (r) {
        std::cout << *r << '\n';             // 5
    }
}

이는 단지 라이브러리가 연결되어 작동함을 보여주는 예시입니다. main을 유닛 테스트로 검증할 수도 있지만 별도의 주제입니다: 오늘은 "코어"를 테스트하면 충분합니다.

doctest/Catch2 기반 테스트 바이너리

파일 tests/core_tests.cpp:

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

#include "core.hpp"
#include <optional>

TEST_CASE("clamp clamps into [lo, hi]") {
    CHECK(clamp(5, 0, 10) == 5);
    CHECK(clamp(-1, 0, 10) == 0);
    CHECK(clamp(999, 0, 10) == 10);
}

TEST_CASE("safe_div returns value or nullopt") {
    CHECK(safe_div(10, 2) == std::optional<int>{5});
    CHECK(safe_div(10, 0) == std::nullopt);
}

이 파일은 core_tests와 같은 별도의 실행 파일로 빌드됩니다. 직접 실행하면 프레임워크가 테스트를 찾아 실행하고 올바른 종료 코드를 반환합니다.

CMakeLists.txt: 모든 것을 연결하기

프로젝트 루트의 CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(TinyCalc LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_library(core src/core.cpp)
target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)

add_executable(tinycalc src/main.cpp)
target_link_libraries(tinycalc PRIVATE core)

add_executable(core_tests tests/core_tests.cpp)
target_link_libraries(core_tests PRIVATE core)

enable_testing()
add_test(NAME core_tests COMMAND core_tests)

여기에는 두 가지 핵심 아이디어가 있습니다.

  • 첫째: 테스트는 애플리케이션과 마찬가지로 별도의 실행 파일(타겟)입니다. 테스트를 tinycalc에 섞지 않는 것이 좋습니다. 그렇지 않으면 나중에 무엇을 언제 실행하는지 헷갈리게 됩니다.
  • 둘째: add_test는 core_tests 실행을 CTest의 테스트로 등록합니다. CTest는 이 바이너리를 실행하고 exit‑code를 검사합니다.

4. 실제로 CTest를 실행하는 방법

여기서 초보자가 흔히 겪는 첫 번째 문제점이 나옵니다: 사용자가 단순히 ctest를 실행하고 이상한 오류를 보거나 "0 tests"를 본 뒤에 "CTest가 망가졌다"고 선언하는 경우입니다. 대부분의 경우 CTest 자체가 문제가 아니라, 어떤 디렉터리에서 실행했는지와 무엇이 이미 빌드되었는지에 대한 이해가 부족한 탓입니다.

CTest는 빌드 디렉터리에서 동작합니다. CMake가 테스트 목록을 생성하는 곳이 바로 빌드 디렉터리이기 때문입니다. 그래서 일반적인 순서는: 먼저 프로젝트를 구성(configure)하고, 빌드해서(테스트 바이너리를 생성) 그 다음에 ctest를 실행하는 것입니다.

명령어 관점에서 전형적인 시나리오는 다음과 같습니다:

cmake -S . -B build
cmake --build build
ctest --test-dir build

만약 ctest를 build 폴더에서 실행한다면 보통은 다음으로 충분합니다:

cd build
ctest

출력에는 보통 CTest가 발견한 테스트 수, 실행한 항목, 실패한 항목이 표시됩니다. 중요한 점: 테스트가 실패하면 CTest는 != 0인 종료 코드로 종료됩니다. 이는 자동화 시스템 입장에서 테스트 스텝이 실패했음을 의미합니다.

"0 tests"가 뜨는 이유

CTest가 테스트 개수를 0으로 표시하면 거의 항상 두 가지 중 하나입니다: enable_testing() 또는 add_test(...)를 깜빡했거나, ctest를 CMake가 테스트 구성을 생성한 올바른 디렉터리에서 실행하지 않은 경우입니다.

실패한 테스트의 출력을 확인하는 방법

또 다른 자주 있는 놀라움: 테스트가 실패했는데도 기대값/실제값 같은 자세한 메시지를 못 보는 경우입니다. 이는 버그가 아니라 출력 정책입니다. 보통은 "실패 시 출력 보기" 모드를 켜는 것이 편리합니다:

ctest --test-dir build --output-on-failure

또는 "매우 자세한" 모드:

ctest --test-dir build -V

요지: CTest는 "조용한 디스패처"로 동작할 수도 있고, 요청 시 "수다스러운 디스패처"가 될 수도 있습니다. 개발 중에는 자세한 출력이 유용하고, 자동화에서는 대개 간단한 요약이 선호됩니다.

여러 테스트: 이름과 필터링

프로젝트에 테스트 바이너리가 하나뿐이면 상황은 단순합니다. 하지만 프로젝트가 커지면 파싱 테스트, 모델 컨테이너 테스트, 출력 포맷 테스트 등 테스트가 여러 개가 됩니다. 이때 CTest의 가치는 더욱 커집니다: 전체 집합을 관리하고, 부분 집합만 선택해 실행할 수 있습니다.

CMake에서는 단순히 add_test를 여러 번 호출하면 됩니다:

add_executable(parser_tests tests/parser_tests.cpp)
target_link_libraries(parser_tests PRIVATE core)

add_test(NAME parser_tests COMMAND parser_tests)

좋은 습관: 테스트 이름(NAME ...)은 리포트에서 사람이 읽기 쉽도록 정하세요. test1, test2 같은 이름은 장부에 있는 변수 a, b 같은 느낌으로, 기술적으로는 가능하지만 나중에 본인에게 골칫거리를 안겨줍니다.

테스트가 많을 때 CTest는 전체를 실행하는 대신 이름 기반의 필터로 일부만 실행할 수 있습니다. 예를 들어 이름에 "core"가 들어가는 것만 실행하려면:

ctest --test-dir build -R core

즉, 이름에 core가 포함된 테스트만 실행합니다. 모든 것을 매번 돌릴 필요 없이 특정 서브시스템을 수정할 때 유용합니다.

5. 테스트가 빌드 프로세스의 일부가 되는 방법

"빌드의 일부"라는 표현은 마치 컴파일 중(예: constexpr처럼) 테스트가 실행되는 것처럼 들릴 수 있습니다. 실제로는 "프로젝트 검증 과정의 일부"를 의미합니다. 먼저 빌드는 코드가 컴파일되고 링킹되는지 확인합니다. 그 다음 테스트는 동작이 의도대로인지 검증합니다.

이것은 책임의 중요한 분리입니다. 컴파일러는 형태를 검사합니다: 타입, 식별자, 문법, 링킹. 테스트는 의미를 검사합니다: "safe_div(10, 0)이 실제로 nullopt를 반환하는가", "clamp가 범위를 벗어나지 않는가", "파서가 잘못된 입력을 거부하는가" 등입니다.

여기서 CTest의 역할이 드러납니다: "수동으로 테스트 exe를 실행하는 행위"를 표준화된 프로젝트 테스트 명령으로 바꿉니다. 더 이상 테스트 바이너리 이름이나 위치를 기억할 필요가 없습니다. "CTest, 프로젝트 테스트를 실행해"라고 명령하면 CMake가 등록해둔 명령을 기반으로 실행합니다.

멀티 컨피규레이션 생성기(예: 일부 IDE)에서는 Debug/Release 같은 빌드 구성이 있습니다. 이런 경우 CTest에 명시적으로 구성 정보를 알려줘야 할 수 있습니다. 핵심은 "테스트는 빌드된 구성과 동일한 구성에서 실행되어야 한다"는 점입니다. Debug로 빌드했다면 Debug에서, Release로 빌드했다면 Release에서 실행하세요.

6. CTest를 사용할 때 흔한 실수

실수 #1: 테스트를 등록했지만 바이너리가 빌드되지 않는다.
add_test(NAME core_tests COMMAND core_tests)처럼 작성하고 core_tests 실행 파일이 실제로 존재해야 합니다. add_executable(core_tests ...)가 없거나 컴파일 오류로 빌드에 실패하면 CTest는 실행할 파일을 찾지 못합니다. 해결책은 빌드를 먼저 고친 뒤 테스트를 실행하는 것입니다.

실수 #2: ctest를 잘못된 폴더에서 실행해서 테스트를 찾지 못한다.
CTest는 전지전능하지 않습니다: 디스크 전체를 뒤져서 build를 찾지 않습니다. 리포지터리 루트에서 ctest를 실행하고 테스트 구성은 build/에 있는 경우, CTest는 종종 "0 tests"를 보이거나 아무 것도 못합니다. 규칙을 지키세요: 테스트는 빌드 폴더에서 실행하거나 테스트 디렉터리를 명시적으로 지정하세요.

실수 #3: 테스트가 std::cin에 의존해 인터랙티브하게 기다린다.
CTest는 테스트를 자동으로 실행하는 명령입니다. 테스트가 사용자 입력을 기다리면 CTest도 같이 기다립니다 — 그래서 "ctest가 멈췄다"고 보일 수 있습니다. 유닛 테스트 수준에서는 로직을 파라미터화하고 입출력은 main에 남겨두는 설계로 이 문제를 해결하세요.

실수 #4: 테스트가 실패했지만 이유를 볼 수 없다.
초보자는 CTest가 항상 모든 출력을 보여야 한다고 생각하지만 그렇지 않습니다. 기본적으로 조용할 수 있으며 실패 시에만 간단한 정보만 제공합니다. 자세한 출력을 원하면 -V 또는 --output-on-failure를 사용하세요. 또한 테스트 프레임워크가 진단을 stderr/stdout에 출력하는지 확인하세요.

실수 #5: 테스트가 "FAILED"를 출력하지만 0을 반환한다.
자작 러너나 잘못된 검사 로직에서 흔히 발생합니다. CTest는 출력 텍스트가 아니라 프로세스의 종료 코드를 신뢰합니다. "문제가 있다"를 출력했더라도 return 0;을 반환하면 CTest는 해당 테스트를 통과된 것으로 간주합니다. 자동화 세계의 공약수는 간단합니다: 성공은 0, 실패는 != 0입니다. 이 계약과 싸우지 마세요.

1
과제
C++ SELF, 레벨 37, 레슨 3
잠금
테스트 러너
테스트 러너
1
과제
C++ SELF, 레벨 37, 레슨 3
잠금
두 세트
두 세트
1
과제
C++ SELF, 레벨 37, 레슨 3
잠금
도구 모드
도구 모드
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION