Google Test로 C++ 단위 테스트 시작하기: EXPECT vs ASSERT, Fixture, Mock, Catch2 비교
이 글의 핵심
C++ 단위 테스트를 Google Test와 Catch2로 실습합니다. EXPECT/ASSERT 차이, Fixture, 매개변수화 테스트, Mock 객체, 테스트 격리·속도 함정, TDD 사이클까지 실전 예제로 정리합니다.
단위 테스트란?
개별 함수나 클래스를 독립적으로 테스트
C++는 컴파일러가 타입 오류를 잡아주지만, 로직 오류(경계값을 잘못 처리하거나, 부호 있는 정수 오버플로를 놓치거나, 예외 안전성이 깨지는 경우)는 컴파일러가 절대 잡아주지 못합니다. 단위 테스트가 잡으려는 것이 바로 이 영역입니다 — add(2, 3)이 5를 반환하는지 눈으로 매번 확인하는 대신, 그 검증 자체를 코드로 만들어 “함수의 동작을 코드로 명세한 것”으로 남깁니다. 이렇게 하면 나중에 누군가(미래의 자신을 포함해) add 함수를 리팩토링하다 실수로 로직을 깨뜨려도, 테스트가 실패하며 즉시 알려줍니다 — 사람이 수동으로 매번 재확인하는 대신, 기계가 반복적으로 확인해 주는 셈입니다.
// 테스트할 코드
int add(int a, int b) {
return a + b;
}
// 테스트 코드
TEST(AddTest, BasicTest) {
EXPECT_EQ(add(2, 3), 5);
EXPECT_EQ(add(-1, 1), 0);
}
Google Test 기본
EXPECT_EQ와 ASSERT_TRUE가 겉보기엔 비슷해 보이지만 실패 시 동작이 다릅니다 — EXPECT_* 계열은 검증이 실패해도 실패를 기록만 하고 같은 TEST 블록의 나머지 줄을 계속 실행하는 반면, ASSERT_* 계열은 실패하는 즉시 return으로 그 테스트 함수를 중단합니다. 이 차이가 실무에서 중요한 이유는, 한 검증의 실패가 이후 코드의 전제 자체를 무너뜨리는 경우(예: 포인터가 nullptr이 아님을 확인한 뒤에야 안전하게 역참조할 수 있는 경우) EXPECT_TRUE(ptr != nullptr)로 검사했다가 실패해도 다음 줄이 실행되어 *ptr이 진짜 널 포인터 역참조로 크래시를 일으킬 수 있기 때문입니다. 이런 자리에는 ASSERT_TRUE를 써서 실패 즉시 안전하게 멈추고, 서로 독립적인 여러 조건을 한 테스트에서 모두 확인하고 싶을 때는 EXPECT_*를 써서 한 번 실행에 가능한 한 많은 실패 정보를 얻는 것이 일반적인 관례입니다.
#include <gtest/gtest.h>
// 테스트 작성
TEST(TestSuiteName, TestName) {
EXPECT_EQ(1 + 1, 2);
ASSERT_TRUE(true);
}
int main(int argc, char** argv) {
testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
# 컴파일 (main을 직접 작성했으므로 gtest_main은 링크하지 않음)
g++ -std=c++17 test.cpp -lgtest -pthread
# main을 생략하고 gtest_main이 제공하는 main을 쓰려면
# g++ -std=c++17 test.cpp -lgtest_main -lgtest -pthread
# 실행
./a.out
gtest_main은 위와 똑같은 main 함수 하나만 담은 라이브러리입니다. 직접 main을 쓰는 이유는 테스트 전에 로깅이나 전역 환경을 설정하고 싶을 때이고, 그럴 필요가 없다면 main을 지우고 gtest_main을 링크하는 편이 간단합니다. 정적 라이브러리를 링크할 때는 순서가 중요해서, -lgtest_main이 -lgtest보다 앞에 와야 undefined reference to testing::InitGoogleTest 같은 링크 에러를 피할 수 있습니다. 실무에서는 손으로 링크 옵션을 쓰기보다 CMake의 FetchContent로 GoogleTest를 가져와 target_link_libraries(tests GTest::gtest_main)과 gtest_discover_tests(tests)로 CTest에 등록하는 방식이 가장 흔합니다. GoogleTest는 프로젝트와 같은 컴파일러·같은 옵션으로 빌드하라고 권장하는데, 시스템에 미리 설치된 바이너리를 다른 표준 라이브러리 설정으로 링크하면 ABI 불일치로 이해하기 어려운 링크 에러나 크래시가 나기 때문입니다.
ASSERT_*에는 알아 둘 제약이 하나 있습니다. 실패 시 현재 함수에서 return;하는 방식으로 구현되어 있어서, 반환값이 있는 함수 안에서는 쓸 수 없습니다. 테스트 도우미 함수 int loadFixture() 안에 ASSERT_TRUE를 넣으면 return-statement with no value, in function returning 'int' 같은 컴파일 에러가 납니다. 도우미 함수는 void로 만들고 결과를 출력 인자로 돌려주거나, 호출한 쪽에서 ASSERT_NO_FATAL_FAILURE(helper())로 감싸 치명적 실패를 전파하는 방법을 씁니다. 또 ASSERT가 멈추는 것은 현재 함수뿐이라, 도우미 함수 안에서 실패해도 호출한 테스트 본문은 계속 실행된다는 점도 자주 헷갈리는 부분입니다.
실전 예시
예시 1: 기본 테스트
DivideTest가 EXPECT_THROW(calc.divide(10, 0), std::invalid_argument)로 예외 발생을 검증하는 부분을 눈여겨볼 만합니다 — 정상 경로(값이 맞게 계산되는가)뿐 아니라 실패 경로(잘못된 입력이 명세된 방식으로 실패하는가)까지 테스트에 포함시킨 것입니다. 실무에서 흔한 실수는 정상 케이스만 테스트하고 예외·에러 경로는 건너뛰는 것인데, 실제 버그는 대부분 경계 조건(0으로 나누기, 빈 컨테이너, 널 입력)에서 발생하므로 예외 경로 테스트가 오히려 더 높은 버그 발견율을 갖는 경우가 많습니다.
#include <gtest/gtest.h>
class Calculator {
public:
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) {
if (b == 0) throw std::invalid_argument("0으로 나눌 수 없음");
return a / b;
}
};
TEST(CalculatorTest, AddTest) {
Calculator calc;
EXPECT_EQ(calc.add(2, 3), 5);
EXPECT_EQ(calc.add(-1, 1), 0);
}
TEST(CalculatorTest, DivideTest) {
Calculator calc;
EXPECT_EQ(calc.divide(10, 2), 5);
EXPECT_THROW(calc.divide(10, 0), std::invalid_argument);
}
예시 2: Fixture 사용
여러 테스트가 같은 준비 상태(여기서는 {1, 2, 3}이 채워진 벡터)를 필요로 할 때마다 각 TEST 안에서 반복해 초기화하면 코드 중복이 쌓이고, 초기화 로직을 바꿀 때 모든 테스트를 하나씩 고쳐야 합니다. ::testing::Test를 상속한 Fixture 클래스는 이 문제를 해결합니다 — SetUp()은 각 TEST_F 실행 직전마다 새로 호출되므로, SizeTest와 ElementTest는 서로의 실행 순서나 부작용에 전혀 영향받지 않고 각자 깨끗한 vec 상태에서 시작합니다. 이것이 중요한 이유는 테스트 격리(isolation) 때문입니다 — 한 테스트가 다른 테스트의 상태에 의존하면 실행 순서를 바꾸는 것만으로 테스트가 실패하거나 통과하는 불안정한(flaky) 테스트 스위트가 되는데, SetUp/TearDown이 매 테스트마다 상태를 리셋해 이런 우연한 의존을 원천적으로 차단합니다.
class VectorTest : public ::testing::Test {
protected:
void SetUp() override {
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
}
void TearDown() override {
vec.clear();
}
std::vector<int> vec;
};
TEST_F(VectorTest, SizeTest) {
EXPECT_EQ(vec.size(), 3);
}
TEST_F(VectorTest, ElementTest) {
EXPECT_EQ(vec[0], 1);
EXPECT_EQ(vec[1], 2);
}
예시 3: 매개변수화 테스트
같은 로직을 서로 다른 입력값으로 반복 테스트해야 할 때, (1,2,3), (0,0,0), (-1,1,0) 각각에 대해 TEST(AddTest, Case1), TEST(AddTest, Case2)처럼 개별 테스트 함수를 복사-붙여넣기로 늘리면 케이스가 늘어날수록 유지보수가 급격히 힘들어집니다. TestWithParam과 INSTANTIATE_TEST_SUITE_P는 테스트 로직은 TEST_P 안에 한 번만 작성하고, 입력값 목록만 ::testing::Values(...)로 따로 관리하게 해 줍니다 — 새 경계값 케이스를 추가하고 싶으면 Values 목록에 튜플 하나만 추가하면 되고, Google Test가 각 값 조합에 대해 독립된 테스트 인스턴스를 자동으로 생성해 실행하며 실패 시 어떤 입력값 조합에서 실패했는지까지 리포트에 표시해 줍니다.
class AddTest : public ::testing::TestWithParam<std::tuple<int, int, int>> {};
TEST_P(AddTest, ParameterizedTest) {
auto [a, b, expected] = GetParam();
EXPECT_EQ(add(a, b), expected);
}
INSTANTIATE_TEST_SUITE_P(
AddTests,
AddTest,
::testing::Values(
std::make_tuple(1, 2, 3),
std::make_tuple(0, 0, 0),
std::make_tuple(-1, 1, 0)
)
);
예시 4: Mock 객체
Database가 실제 DB 서버에 연결하는 클래스라면, 이를 사용하는 서비스 코드를 테스트할 때마다 진짜 데이터베이스가 실행 중이어야 하고, 네트워크 지연·데이터 상태 초기화 같은 부담이 테스트에 그대로 따라붙습니다. MockDatabase는 Database의 순수 가상 인터페이스만 구현하되 실제 동작 대신 EXPECT_CALL로 미리 정한 값을 반환하도록 만든 가짜 객체입니다 — connect()가 항상 true를 반환하고 query("SELECT *")가 항상 "result"를 반환하도록 고정해 두면, 테스트는 “서비스 코드가 Database 인터페이스를 올바르게 사용하는가”만 검증할 뿐 실제 DB의 존재 여부와 무관해집니다. 이렇게 테스트 대상(SUT)을 그 의존성으로부터 격리하는 것이 Mock의 핵심 목적이며, 동시에 EXPECT_CALL은 그 함수가 정확히 기대한 인자로 호출되었는지도 함께 검증하므로 반환값 확인을 넘어 “올바른 쿼리를 보냈는가” 같은 상호작용 자체를 테스트할 수 있습니다.
#include <gmock/gmock.h>
class Database {
public:
virtual ~Database() = default;
virtual bool connect() = 0;
virtual std::string query(const std::string& sql) = 0;
};
class MockDatabase : public Database {
public:
MOCK_METHOD(bool, connect, (), (override));
MOCK_METHOD(std::string, query, (const std::string&), (override));
};
TEST(ServiceTest, MockTest) {
MockDatabase mockDb;
EXPECT_CALL(mockDb, connect())
.WillOnce(::testing::Return(true));
EXPECT_CALL(mockDb, query("SELECT *"))
.WillOnce(::testing::Return("result"));
EXPECT_TRUE(mockDb.connect());
EXPECT_EQ(mockDb.query("SELECT *"), "result");
}
이 예제는 문법을 보여 주기 위한 것이지만, 테스트 본문이 Mock을 직접 호출하고 그 반환값을 확인하고 있다는 점에서 실제로는 아무것도 검증하지 않습니다. Mock이 설정한 대로 동작하는지 확인하는 것은 gMock 자체를 테스트하는 셈입니다. 실제 테스트에서는 Mock을 테스트 대상 객체에 주입하고, 대상 객체의 동작을 검증해야 합니다.
class UserService {
Database& db_;
public:
explicit UserService(Database& db) : db_(db) {}
std::string loadAll() {
if (!db_.connect()) return "";
return db_.query("SELECT *");
}
};
TEST(UserServiceTest, ReturnsEmptyWhenConnectFails) {
MockDatabase mockDb;
EXPECT_CALL(mockDb, connect()).WillOnce(::testing::Return(false));
EXPECT_CALL(mockDb, query(::testing::_)).Times(0); // 연결 실패 시 쿼리하면 안 됨
UserService svc(mockDb);
EXPECT_EQ(svc.loadAll(), "");
}
EXPECT_CALL은 반드시 호출이 일어나기 전에 설정해야 합니다. 호출 뒤에 설정하면 그 기대는 이후의 호출에만 적용되어, 이미 일어난 호출은 “기대하지 않은 호출”로 처리됩니다. 테스트가 끝날 때 Mock 객체가 소멸하면서 충족되지 않은 기대가 있는지 검사하므로, 실패 메시지는 Actual function call count doesn't match EXPECT_CALL(mockDb, connect())... Expected: to be called once, Actual: never called - unsatisfied and active 형태로 테스트 끝에 나옵니다. 기대를 설정하지 않은 메서드가 호출되면 기본 Mock은 Uninteresting mock function call 경고만 출력하는데, 이 경고를 없애려면 NiceMock<MockDatabase>를, 반대로 기대 밖의 호출을 모두 실패로 만들려면 StrictMock<MockDatabase>를 씁니다. 저는 Mock을 처음 도입할 때 모든 호출에 EXPECT_CALL을 거는 실수를 했는데, 그러면 구현을 조금만 바꿔도(쿼리 순서를 바꾸거나 캐시를 추가하면) 동작은 같은데 테스트가 줄줄이 깨집니다. 검증이 필요한 상호작용에만 EXPECT_CALL을 쓰고, 단순히 값을 돌려주기만 하면 되는 호출은 ON_CALL로 기본 동작만 정해 두는 편이 테스트를 구현 변경에 덜 민감하게 만듭니다.
Catch2 사용
Catch2와 Google Test의 차이로 흔히 설치 방법이 꼽힙니다. Catch2 v2는 단일 헤더(catch.hpp) 하나만 include하면 되는 헤더 온리 라이브러리였고, 아래 예제도 v2 문법입니다. 다만 현재 주력 버전인 Catch2 v3는 헤더 온리가 아니라 빌드해서 링크하는 라이브러리로 바뀌었습니다. v3에서는 #include <catch2/catch_test_macros.hpp>처럼 필요한 헤더만 포함하고 CMake에서 Catch2::Catch2WithMain을 링크하며, CATCH_CONFIG_MAIN 매크로 대신 이 라이브러리가 main을 제공합니다. v2 예제를 v3에 그대로 쓰면 catch2/catch.hpp: No such file or directory가 나는 이유가 이것입니다. 헤더 온리였던 v2는 테스트 파일마다 거대한 헤더를 컴파일해 빌드가 느렸는데, v3의 구조 변경은 이 컴파일 시간을 줄이기 위한 것이었습니다. 문법 면에서는 SECTION이 독특한데, 하나의 TEST_CASE 안에 여러 SECTION을 두면 각 섹션이 매번 TEST_CASE의 처음부터 새로 실행되면서 그 섹션까지만 도달하는 방식으로 동작합니다 — 이는 Google Test의 Fixture(SetUp/TearDown)가 하는 일을 함수 하나 안에서 표현하는 대안적 스타일이며, 공유 준비 코드가 TEST_CASE 본문에 자연스럽게 녹아들어 별도 클래스 선언 없이도 테스트 간 상태 격리를 얻을 수 있습니다.
#define CATCH_CONFIG_MAIN
#include <catch2/catch.hpp>
TEST_CASE("Calculator tests", "[calculator]") {
Calculator calc;
SECTION("Addition") {
REQUIRE(calc.add(2, 3) == 5);
}
SECTION("Division") {
REQUIRE(calc.divide(10, 2) == 5);
REQUIRE_THROWS(calc.divide(10, 0));
}
}
자주 발생하는 문제
문제 1: 테스트 의존성
Google Test는 기본적으로 테스트를 정의된 순서대로 실행하지만, 이 순서는 명세된 계약이 아니며 --gtest_shuffle로 무작위화하거나 --gtest_filter로 일부만 실행하거나 CTest가 테스트를 병렬로 나눠 실행하면 쉽게 달라집니다. 전역 변수처럼 테스트 간에 공유되는 상태가 있으면 Test2가 Test1이 먼저 실행되었다는 암묵적 전제에 의존하게 됩니다. 이런 의존성은 두 테스트를 각각 단독 실행하면 발견되지 않고, 특정 순서로 묶어 실행하거나 병렬 실행기를 도입할 때 갑자기 실패하기 시작해 원인을 찾기 매우 어려운 “flaky test”의 전형적인 원인이 됩니다. 각 테스트가 지역 변수만으로 자신의 상태를 완결짓게 하면, 테스트 실행 순서나 병렬화 방식이 바뀌어도 결과가 항상 동일하게 재현됩니다. 숨은 순서 의존성을 찾고 싶다면 CI에서 가끔 --gtest_shuffle --gtest_repeat=10으로 돌려 보세요. 실패하면 출력에 찍힌 --gtest_random_seed 값으로 같은 순서를 재현할 수 있습니다. 전역 상태는 코드에 드러난 전역 변수만이 아니라 싱글턴, 정적 캐시, 환경 변수, 현재 작업 디렉터리, 로케일 설정까지 포함한다는 점도 기억해 둘 만합니다.
// ❌ 테스트 간 의존성
TEST(BadTest, Test1) {
globalVar = 10;
}
TEST(BadTest, Test2) {
EXPECT_EQ(globalVar, 10); // Test1에 의존
}
// ✅ 독립적 테스트
TEST(GoodTest, Test1) {
int var = 10;
EXPECT_EQ(var, 10);
}
TEST(GoodTest, Test2) {
int var = 10;
EXPECT_EQ(var, 10);
}
문제 2: 너무 큰 테스트
한 TEST 안에 여러 함수를 몰아 검증하면, 테스트가 실패했을 때 리포트는 “EverythingTest가 실패했다”고만 알려줄 뿐 열 개 함수 중 어느 것이 실패의 원인인지는 로그를 하나하나 읽어야 알 수 있습니다. 함수별로 테스트를 나누면 실패한 테스트의 이름 자체가 “무엇이 깨졌는지”를 즉시 알려주는 문서 역할을 하게 되며, CI 실패 알림만 보고도 어떤 기능에 문제가 생겼는지 바로 파악할 수 있습니다 — 테스트를 잘게 쪼개는 것은 코드량을 늘리기 위함이 아니라, 실패했을 때의 진단 속도를 높이기 위한 것입니다.
// ❌ 여러 것을 한 번에 테스트
TEST(BadTest, EverythingTest) {
// 10개 함수 테스트
}
// ✅ 하나씩 테스트
TEST(GoodTest, AddTest) {
// add 함수만
}
TEST(GoodTest, SubtractTest) {
// subtract 함수만
}
문제 3: 외부 의존성
/tmp/test.txt를 직접 여는 테스트는 실행 환경(로컬 개발 머신, CI 서버, 다른 개발자의 컴퓨터)마다 그 파일이 존재하는지가 달라지므로, 코드는 전혀 바뀌지 않았는데도 환경에 따라 통과하거나 실패하는 비결정적인 테스트가 됩니다. 이런 문제는 파일 시스템뿐 아니라 네트워크, 시간(현재 날짜에 의존하는 로직), 환경 변수 등 테스트 스위트가 통제할 수 없는 모든 외부 자원에서 동일하게 발생합니다. Mock이나 의존성 주입으로 이런 외부 자원을 “테스트가 통제 가능한 가짜”로 바꿔 두면, 단위 테스트는 오직 자신이 검증하려는 로직에만 좌우되는 결정적(deterministic)인 결과를 내게 됩니다.
// ❌ 파일 시스템 의존
TEST(BadTest, FileTest) {
std::ifstream file("/tmp/test.txt");
// 파일 없으면 실패
}
// ✅ Mock 사용
TEST(GoodTest, FileTest) {
MockFileSystem fs;
// Mock으로 제어
}
모든 외부 의존성을 Mock으로 바꿔야 하는 것은 아닙니다. 파일 파서라면 파일 시스템 인터페이스를 Mock으로 만드는 것보다, 파서가 std::istream&을 받도록 설계하고 테스트에서는 std::istringstream에 내용을 넣어 넘기는 편이 훨씬 단순하고 튼튼합니다. 실제 파일이 꼭 필요하면 std::filesystem::temp_directory_path() 아래에 테스트마다 고유한 경로로 만들고 TearDown에서 지우면, 고정 경로 /tmp/test.txt처럼 병렬 실행 중인 다른 테스트와 충돌하거나 Windows에서 경로가 없어 실패하는 문제를 피할 수 있습니다.
문제 4: 느린 테스트
단위 테스트의 실질적 가치는 개발자가 코드를 조금 바꿀 때마다 부담 없이 자주 돌려볼 수 있다는 데 있습니다. 테스트 하나가 실제로 10초씩 잠들어 있다면, 수백 개의 테스트가 쌓인 스위트 전체를 실행하는 데 몇 분이 걸릴 수 있고, 그 시점부터 개발자는 테스트를 자주 돌리지 않고 커밋 직전에만 몰아서 실행하게 되어 테스트가 애초에 제공하려던 “즉각적인 피드백”이라는 가치를 잃습니다. sleep_for 같은 실제 시간 대기는 거의 항상 시간을 흉내 낼 수 있는 가짜 시계나 콜백 트리거로 대체 가능하며, 단위 테스트는 밀리초 단위로 끝나는 것을 목표로 설계하고, 실제로 시간이 걸리는 통합/E2E 테스트는 별도의 느린 테스트 스위트로 분리해 실행 빈도를 다르게 가져가는 것이 일반적인 관례입니다.
// ❌ 느린 작업
TEST(BadTest, SlowTest) {
std::this_thread::sleep_for(std::chrono::seconds(10));
}
// ✅ 빠른 테스트
TEST(GoodTest, FastTest) {
// 즉시 완료
}
TDD (Test-Driven Development)
TDD의 순서(테스트 → 최소 구현 → 리팩토링)가 반대(구현 → 테스트)보다 나은 이유는, 테스트를 먼저 작성하면 “이 함수가 무엇을 해야 하는가”라는 인터페이스 설계 질문에 구현 세부사항에 매몰되기 전에 먼저 답해야 하기 때문입니다. PushTest를 먼저 작성한 시점에는 Stack<T>가 아직 존재하지 않지만, 그 테스트는 이미 “push로 값을 넣으면 top으로 그 값을 꺼낼 수 있어야 한다”는 API 계약을 코드로 확정짓습니다. 이후 “최소 구현”만 만들어 테스트를 통과시키고(과설계를 피하는 장치), 테스트가 초록불로 유지되는 상태에서만 리팩토링을 진행하는 것이 TDD의 안전망 역할입니다 — 리팩토링 중 실수로 동작을 바꿔버려도 테스트가 즉시 알려주므로, 겉보기 구조를 개선하면서도 동작 보존을 지속적으로 검증할 수 있습니다.
C++에서 TDD의 첫 단계(“실패하는 테스트”)는 대개 컴파일 에러라는 형태로 나타납니다. Stack<int>가 아직 없으니 테스트 파일이 빌드되지 않는 것이 첫 번째 빨간불이고, 선언을 만들어 컴파일을 통과시킨 뒤 값이 틀려 실패하는 것이 두 번째 빨간불입니다. 이 두 단계를 구분해 두면, 인터페이스(컴파일) 설계와 동작(런타임) 구현을 각각 작은 단계로 나눠 진행할 수 있습니다.
// 1. 실패하는 테스트 작성
TEST(StackTest, PushTest) {
Stack<int> stack;
stack.push(10);
EXPECT_EQ(stack.top(), 10);
}
// 2. 최소 구현
template<typename T>
class Stack {
std::vector<T> data;
public:
void push(const T& value) {
data.push_back(value);
}
T top() const {
return data.back();
}
};
// 3. 리팩토링
최소 구현의 top()은 빈 스택에서 호출하면 data.back()이 정의되지 않은 동작을 일으킵니다. 다음 TDD 사이클은 바로 이 경계 조건을 테스트로 먼저 정하는 것입니다. 예를 들어 “빈 스택의 top()은 std::out_of_range를 던진다”는 명세를 EXPECT_THROW로 작성하고, 그 테스트를 통과시키도록 구현을 고칩니다. 이렇게 경계 조건 하나하나를 테스트로 고정해 가는 것이 TDD가 “설계 도구”라고 불리는 이유입니다. 정의되지 않은 동작 자체는 테스트로 안정적으로 잡기 어려우므로, 테스트를 AddressSanitizer·UndefinedBehaviorSanitizer 빌드(-fsanitize=address,undefined)로도 돌려 두면 이런 구멍을 테스트 실행 중에 발견할 수 있습니다.
FAQ
Q1: 단위 테스트는 언제 작성하나요?
A: 새 기능을 추가할 때, 그리고 특히 버그를 고칠 때 가장 효과가 큽니다. 버그를 재현하는 테스트를 먼저 작성해 실패하는 것을 확인한 뒤 고치면, 수정이 실제로 그 버그를 잡았다는 증거가 남고 같은 버그가 다시 들어오는 것도 막을 수 있습니다. 리팩토링 전에는 기존 동작을 고정하는 테스트를 먼저 깔아 두는 것이 안전합니다.
Q2: Google Test와 Catch2 중 무엇을 고르나요?
A: Google Test는 gMock과 함께 쓰는 Mock 지원, 사망 테스트(EXPECT_DEATH), 매개변수화·타입 매개변수화 테스트, IDE·CI 통합이 풍부합니다. Catch2는 REQUIRE(a == b)처럼 일반 표현식으로 쓰는 단언과 SECTION 기반 구조가 간결하고, BDD 스타일(SCENARIO/GIVEN/WHEN)도 지원합니다. Mock을 많이 쓸 계획이면 Google Test, 가볍고 읽기 쉬운 테스트가 우선이면 Catch2가 무난합니다.
Q3: 테스트 커버리지는 몇 %를 목표로 해야 하나요?
A: 보편적으로 맞는 숫자는 없습니다. 커버리지는 “테스트가 실행한 줄”을 셀 뿐 그 줄의 결과를 검증했는지는 알려 주지 않으므로, 단언 없는 테스트로도 100%를 만들 수 있습니다. 숫자 목표보다는 버그가 나면 비용이 큰 핵심 로직과 경계 조건이 테스트되어 있는지, 커버리지 보고서에서 실행되지 않은 분기가 어디인지를 확인하는 용도로 쓰는 편이 유익합니다.
Q4: Mock은 언제 쓰나요?
A: 네트워크, 데이터베이스, 시계, 하드웨어처럼 테스트가 통제할 수 없거나 느린 의존성을 대체할 때 씁니다. 순수 계산 로직이나 값 객체까지 Mock으로 감싸면 테스트가 구현 세부 사항에 묶여 리팩토링을 방해하므로, 가능하면 실제 구현이나 간단한 Fake(메모리 기반 저장소 등)를 먼저 고려합니다.