C++ 테스트 전략 세우기: 테스트 피라미드, GTest 단위·통합·E2E, GMock 모킹
들어가며
플러그인 시스템, 동적 라이브러리, 규모가 큰 C++ 프로젝트에서는 작은 변경의 부작용이 치명적일 수 있습니다. 공개 클래스에 private 멤버 하나를 추가했을 뿐인데 객체 크기가 바뀌어 ABI가 깨지고 기존 호스트 앱이 크래시하거나, DB 연결 로직만 바꿨는데 결제가 실패하는 식입니다. 이런 일이 몇 번 일어나면 팀은 리팩터링 자체를 피하게 됩니다.
테스트 전략은 함수·클래스 단위의 단위 테스트, 모듈·DB·API 연동을 보는 통합 테스트, 전체 시나리오를 보는 E2E 테스트, 그리고 외부 의존성을 가짜로 대체하는 모킹을 계층적으로 조합하는 일입니다. 한 계층만으로는 부족합니다. 단위 테스트만 있으면 각 부분은 맞는데 합치면 안 되는 통합 버그를 놓칩니다. 통합 테스트만 있으면 느리고, 실패했을 때 어디가 문제인지 좁히기 어렵습니다. E2E에만 의존하면 느린 테스트에 개발 속도가 묶입니다. 그리고 외부 DB·API·파일 없이 로직을 검증하려면 모킹이 필요합니다.
이 글에서는 각 계층을 실제로 동작하는 코드로 구성하고, 어느 계층에서 무엇을 검증할지 정하는 기준을 다룹니다. 예제는 C++17 이상, GoogleTest/GoogleMock 1.12 이상을 기준으로 합니다.
테스트가 없어서 막히는 상황들
플러그인 업데이트 후 호스트 크래시
이미지 필터 플러그인에 캐시 로직을 추가했습니다. 플러그인 단위 테스트는 모두 통과하는데, 호스트 앱과 함께 실행하면 세그멘테이션 폴트가 납니다. 단위 테스트는 플러그인 내부만 검증했고, 호스트와 플러그인 사이의 경계(구조체 레이아웃, 메모리를 누가 해제하는지, 호출 규약)는 아무도 검증하지 않았기 때문입니다. 이런 문제는 호스트가 실제로 플러그인을 로드해서 API를 호출하는 통합 테스트로만 잡을 수 있습니다.
DB 없이는 결제 로직을 검증할 수 없음
결제 서비스의 “잔액 부족 시 거부” 로직을 테스트하려는데, 실제 DB를 띄우면 느리고 테스트 DB가 내려가면 CI 전체가 실패합니다. 외부 의존성이 테스트를 지배하고 있는 상태입니다. DB 접근을 인터페이스 뒤로 숨기고 Mock으로 findUser()가 잔액이 부족한 사용자를 반환하도록 설정하면, 결제 로직만 밀리초 단위로 검증할 수 있습니다. DB 연결 자체는 통합 테스트에서 따로 검증합니다.
로컬에서는 통과하고 CI에서만 실패
로컬에서는 통과하던 테스트가 GitHub Actions에서 “cannot open shared object file”로 실패합니다. 로컬 셸에는 LD_LIBRARY_PATH가 설정되어 있었지만 CI에는 없었던 것입니다. 이런 환경 차이는 테스트 신뢰도를 크게 떨어뜨립니다. 필요한 경로는 RPATH나 CTest의 ENVIRONMENT 속성처럼 빌드 시스템 안에 명시하고, CI는 가능하면 Docker 이미지로 고정합니다.
테스트가 너무 느려서 PR마다 기다림
테스트 대부분이 실제 DB·네트워크·파일 시스템을 사용하면 PR 하나 검증에 수십 분이 걸리게 됩니다. 테스트 피라미드가 뒤집힌 상태입니다. 로직 검증은 모킹한 단위 테스트로 옮기고, 통합·E2E 테스트는 병렬화하거나 PR에서는 핵심 경로만 실행하고 전체는 메인 브랜치나 야간 빌드에서 돌립니다.
테스트가 통과해도 불안함
테스트가 많은데도 리팩터링 후 “실제로는 뭔가 깨졌을 수 있다”는 불안이 남는다면, 테스트가 구현 세부 사항에 묶여 있거나 경계 조건을 검증하지 않는 경우가 많습니다. 특히 Mock이 “호출되었는지”만 확인하고 “결과가 맞는지”는 확인하지 않는 테스트가 흔합니다. 호출 검증(행위 검증)과 함께 반환값과 객체 상태를 확인하는 상태 검증을 하고, 0·음수·빈 문자열·nullptr·최댓값 같은 경계값을 넣어야 합니다.
테스트 피라미드와 계층별 역할
flowchart TB
subgraph pyramid[테스트 피라미드]
E2E["E2E (소수)\n전체 시나리오\n느림"]
INT["통합 (중간)\n모듈 연동\n중간 속도"]
UNIT["단위 (다수)\n함수/클래스\n빠름"]
end
UNIT --> INT
INT --> E2E
| 계층 | 목적 | 실행 시간 | 의존성 | 도구 |
|---|---|---|---|---|
| 단위 | 함수·클래스 로직 검증 | 밀리초 | Mock/Fake | GTest, GMock |
| 통합 | 모듈·DB·파일·플러그인 경계 검증 | 초 단위 | 실제 구성요소 또는 테스트용 인스턴스 | GTest + 실제 리소스 |
| E2E | 사용자 시나리오 전체 검증 | 초~분 | 전체 실행 환경 | 서브프로세스 실행 |
피라미드 형태가 의미하는 것은 “아래로 갈수록 많이”라는 방향입니다. 흔히 70/20/10 같은 비율이 인용되지만 이는 경험칙일 뿐이고, 경계가 많은 플러그인 시스템이라면 통합 테스트 비중이 더 커지는 것이 자연스럽습니다. 중요한 것은 각 버그를 잡을 수 있는 가장 낮은(가장 빠른) 계층에서 잡는 것입니다.
GTest 단위 테스트: 파라미터화와 픽스처
기본 단위 테스트
외부 의존성이 없는 순수 함수와 유틸리티 클래스가 가장 쉬운 대상입니다.
// calculator.hpp
#pragma once
int add(int a, int b);
int divide(int a, int b); // b==0이면 std::invalid_argument
// calculator.cpp
#include "calculator.hpp"
#include <stdexcept>
int add(int a, int b) { return a + b; }
int divide(int a, int b) {
if (b == 0) throw std::invalid_argument("divide by zero");
return a / b;
}
// test_calculator.cpp
#include <gtest/gtest.h>
#include <stdexcept>
#include "calculator.hpp"
TEST(CalculatorTest, AddPositiveNumbers) {
EXPECT_EQ(add(2, 3), 5);
}
TEST(CalculatorTest, AddNegativeNumbers) {
EXPECT_EQ(add(-1, -2), -3);
}
TEST(CalculatorTest, DivideNormal) {
EXPECT_EQ(divide(10, 2), 5);
}
TEST(CalculatorTest, DivideTruncatesTowardZero) {
EXPECT_EQ(divide(-7, 2), -3); // C++11부터 정수 나눗셈은 0 방향으로 절사
}
TEST(CalculatorTest, DivideByZeroThrows) {
EXPECT_THROW(divide(10, 0), std::invalid_argument);
}
파라미터화 테스트로 경계값 모으기
같은 검증을 입력만 바꿔 반복할 때는 TEST_P와 INSTANTIATE_TEST_SUITE_P를 씁니다. 실패하면 어떤 파라미터에서 실패했는지 테스트 이름에 인덱스가 붙어 나옵니다.
#include <gtest/gtest.h>
#include <climits>
#include "calculator.hpp"
struct AddParams {
int a, b, expected;
};
class CalculatorParamTest : public ::testing::TestWithParam<AddParams> {};
TEST_P(CalculatorParamTest, Add) {
const auto& p = GetParam();
EXPECT_EQ(add(p.a, p.b), p.expected);
}
INSTANTIATE_TEST_SUITE_P(
AddCases,
CalculatorParamTest,
::testing::Values(
AddParams{0, 0, 0},
AddParams{1, 1, 2},
AddParams{-1, 1, 0},
AddParams{INT_MAX, 0, INT_MAX},
AddParams{INT_MIN, 0, INT_MIN}
)
);
AddParams{INT_MAX, 1, ...} 같은 케이스를 넣고 싶어진다면, 그것은 테스트 문제가 아니라 설계 문제라는 신호입니다. 부호 있는 정수 오버플로는 정의되지 않은 동작이므로 기대값을 적을 수 없습니다. 이런 경우에는 add가 오버플로를 어떻게 다룰지(예외, 포화, std::optional 반환)부터 정하고 그 동작을 테스트해야 합니다. 테스트에 UndefinedBehaviorSanitizer를 함께 켜 두면 이런 경계를 놓쳤을 때 바로 드러납니다.
픽스처를 사용한 단위 테스트
여러 테스트가 같은 준비 코드를 공유하면 ::testing::Test를 상속한 픽스처로 묶습니다. GTest는 테스트마다 픽스처 객체를 새로 만들기 때문에 테스트 간에 상태가 공유되지 않습니다.
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include <vector>
class StringProcessor {
public:
std::string toUpper(const std::string& s) const;
std::vector<std::string> split(const std::string& s, char delim) const;
};
class StringProcessorTest : public ::testing::Test {
protected:
void SetUp() override {
processor = std::make_unique<StringProcessor>();
}
std::unique_ptr<StringProcessor> processor;
};
TEST_F(StringProcessorTest, ToUpperEmpty) {
EXPECT_EQ(processor->toUpper(""), "");
}
TEST_F(StringProcessorTest, ToUpperNormal) {
EXPECT_EQ(processor->toUpper("hello"), "HELLO");
}
TEST_F(StringProcessorTest, SplitByComma) {
auto result = processor->split("a,b,c", ',');
ASSERT_EQ(result.size(), 3u); // 크기가 틀리면 아래 인덱싱이 위험하므로 ASSERT
EXPECT_EQ(result[0], "a");
EXPECT_EQ(result[1], "b");
EXPECT_EQ(result[2], "c");
}
ASSERT_*는 실패 시 현재 테스트 함수를 즉시 빠져나가고, EXPECT_*는 실패를 기록한 뒤 계속 진행합니다. 뒤의 검증이 앞의 조건에 의존할 때(위의 크기 확인처럼)만 ASSERT를 쓰고, 나머지는 EXPECT로 두면 한 번의 실행에서 실패를 여러 개 볼 수 있습니다.
플러그인 로드와 파일 시스템 통합 테스트
플러그인 로드 통합 테스트
호스트가 동적 라이브러리를 로드하고 C API를 호출하는 경계를 검증합니다.
// plugin_host.hpp
#pragma once
#include <cstddef>
#include <memory>
#include <string>
class PluginHost {
public:
PluginHost();
~PluginHost();
bool loadPlugin(const std::string& path);
int callPluginProcess(const void* input, std::size_t inLen, void* output, std::size_t outLen);
void unloadPlugin();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// test_plugin_integration.cpp
#include <gtest/gtest.h>
#include <filesystem>
#include "plugin_host.hpp"
namespace fs = std::filesystem;
class PluginIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
host = std::make_unique<PluginHost>();
pluginPath = findTestPlugin();
}
static std::string findTestPlugin() {
const fs::path base = fs::path(BINARY_DIR) / "plugins";
std::error_code ec;
if (!fs::is_directory(base, ec)) return "";
for (const auto& e : fs::directory_iterator(base, ec)) {
const auto ext = e.path().extension();
if (ext == ".so" || ext == ".dll" || ext == ".dylib")
return e.path().string();
}
return "";
}
std::unique_ptr<PluginHost> host;
std::string pluginPath;
};
TEST_F(PluginIntegrationTest, LoadAndProcess) {
if (pluginPath.empty()) {
GTEST_SKIP() << "Test plugin not found";
}
ASSERT_TRUE(host->loadPlugin(pluginPath));
const char input[] = "test";
char output[256] = {};
int result = host->callPluginProcess(input, sizeof(input) - 1, output, sizeof(output));
EXPECT_GE(result, 0);
host->unloadPlugin();
}
directory_iterator는 디렉터리가 없으면 예외를 던지므로, 여기서는 error_code 오버로드를 써서 플러그인이 빌드되지 않은 환경에서 테스트가 에러 대신 SKIP으로 끝나게 했습니다. 다만 CI에서는 SKIP이 조용히 쌓이지 않도록 주의해야 합니다. 플러그인이 반드시 있어야 하는 CI 잡에서는 SKIP 대신 실패시키는 것이 맞습니다.
sequenceDiagram
participant T as 테스트
participant H as PluginHost
participant P as Plugin(.so)
T->>H: loadPlugin(path)
H->>P: dlopen + dlsym
P-->>H: handle
H-->>T: true
T->>H: callPluginProcess(...)
H->>P: process(input, output)
P-->>H: result
H-->>T: result
T->>H: unloadPlugin()
H->>P: dlclose
플러그인 경계에서 제가 가장 자주 본 문제는 “로드와 호출은 되는데 언로드 후 크래시”입니다. 플러그인이 만든 객체나 등록한 콜백이 호스트에 남아 있는 상태에서 dlclose로 코드가 언매핑되면, 나중에 그 함수 포인터를 부르는 순간 죽습니다. 그래서 통합 테스트에는 로드 → 호출 → 언로드 → 다시 로드 → 호출 순서의 시나리오를 꼭 넣어 두는 편이 좋습니다. AddressSanitizer를 켠 빌드에서 돌리면 이런 문제가 더 확실히 드러납니다.
파일 시스템 통합 테스트
// file_service.hpp
#pragma once
#include <string>
#include <vector>
class FileService {
public:
std::vector<std::string> listFiles(const std::string& dir) const;
bool writeFile(const std::string& path, const std::string& content) const;
};
// test_file_integration.cpp
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <memory>
#include "file_service.hpp"
namespace fs = std::filesystem;
class FileIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
// 테스트마다 고유한 디렉터리: 병렬 실행(ctest -j) 시 충돌 방지
const auto* info = ::testing::UnitTest::GetInstance()->current_test_info();
testDir = fs::temp_directory_path() /
(std::string("file_service_") + info->name());
fs::remove_all(testDir);
fs::create_directories(testDir);
service = std::make_unique<FileService>();
}
void TearDown() override {
fs::remove_all(testDir);
}
fs::path testDir;
std::unique_ptr<FileService> service;
};
TEST_F(FileIntegrationTest, ListFiles) {
std::ofstream(testDir / "a.txt") << "a";
std::ofstream(testDir / "b.txt") << "b";
auto files = service->listFiles(testDir.string());
ASSERT_EQ(files.size(), 2u);
}
고정된 이름의 임시 디렉터리(예: /tmp/file_service_test)를 쓰면, 여러 테스트 프로세스를 병렬로 돌리거나 같은 CI 러너에서 여러 잡이 실행될 때 서로의 파일을 지우는 간헐적 실패가 생깁니다. 테스트 이름이나 프로세스 ID를 디렉터리 이름에 넣으면 이런 충돌을 피할 수 있습니다.
전체 앱을 실행하는 E2E 테스트
실행 파일을 서브프로세스로 띄우고 출력과 종료 코드를 검증합니다. 아래 예제는 POSIX의 popen을 사용합니다.
// test_e2e_app.cpp
#include <gtest/gtest.h>
#include <cstdio>
#include <string>
#include <sys/wait.h>
std::string runApp(const std::string& args, int& exitCode) {
std::string cmd = "./myapp " + args + " 2>&1";
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) {
exitCode = -1;
return "";
}
std::string out;
char buf[256];
while (fgets(buf, sizeof(buf), pipe)) {
out += buf;
}
int status = pclose(pipe);
// pclose는 종료 코드가 아니라 wait 상태값을 반환
exitCode = (status != -1 && WIFEXITED(status)) ? WEXITSTATUS(status) : -1;
return out;
}
TEST(E2ETest, AppHelp) {
int exitCode = 0;
auto output = runApp("--help", exitCode);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("Usage"), std::string::npos);
}
TEST(E2ETest, AppFailsOnMissingInput) {
int exitCode = 0;
runApp("--input does_not_exist.txt", exitCode);
EXPECT_NE(exitCode, 0);
}
pclose()의 반환값은 waitpid의 상태값이라서, 앱이 exit(1)로 끝나면 대부분의 시스템에서 256이 돌아옵니다. EXPECT_EQ(pclose(pipe), 1)처럼 쓰면 항상 실패하고, EXPECT_NE(..., 0)처럼 쓰면 우연히 맞아 보이므로 WEXITSTATUS로 꺼내야 합니다.
flowchart LR
subgraph e2e[E2E 범위]
A[앱 시작] --> B[설정 로드]
B --> C[플러그인 로드]
C --> D[입력 처리]
D --> E[출력 생성]
E --> F[정상 종료]
end
E2E 테스트는 적게 유지합니다. --help나 --version 같은 스모크 테스트로 실행 파일이 뜨는지 확인하고, 대표적인 사용 흐름 한두 개와 잘못된 인자·누락된 파일 같은 실패 경로 몇 개를 검증하면 충분한 경우가 많습니다. 세부 로직은 아래 계층에서 검증합니다.
GMock으로 의존성 대체하기
인터페이스 기반 Mock
// database.hpp
#pragma once
#include <string>
struct User {
std::string id;
std::string name;
int balance;
};
class IDatabase {
public:
virtual ~IDatabase() = default;
virtual User* findUser(const std::string& id) = 0;
virtual bool saveUser(const User& user) = 0;
};
// mock_database.hpp
#pragma once
#include <gmock/gmock.h>
#include "database.hpp"
class MockDatabase : public IDatabase {
public:
MOCK_METHOD(User*, findUser, (const std::string&), (override));
MOCK_METHOD(bool, saveUser, (const User&), (override));
};
PaymentService 단위 테스트
// payment_service.hpp
#pragma once
#include "database.hpp"
class PaymentService {
public:
explicit PaymentService(IDatabase* db) : db_(db) {}
bool charge(const std::string& userId, int amount);
private:
IDatabase* db_;
};
// payment_service.cpp
#include "payment_service.hpp"
bool PaymentService::charge(const std::string& userId, int amount) {
auto* user = db_->findUser(userId);
if (!user) return false;
if (user->balance < amount) return false;
user->balance -= amount;
return db_->saveUser(*user);
}
// test_payment_service.cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "payment_service.hpp"
#include "mock_database.hpp"
using ::testing::Return;
using ::testing::_;
TEST(PaymentServiceTest, ChargeSucceedsWhenBalanceSufficient) {
MockDatabase mockDb;
PaymentService service(&mockDb);
User user{"u1", "Alice", 100};
EXPECT_CALL(mockDb, findUser("u1")).WillOnce(Return(&user));
EXPECT_CALL(mockDb, saveUser(_)).WillOnce(Return(true));
EXPECT_TRUE(service.charge("u1", 50));
}
TEST(PaymentServiceTest, ChargeFailsWhenBalanceInsufficient) {
MockDatabase mockDb;
PaymentService service(&mockDb);
User user{"u1", "Alice", 30};
EXPECT_CALL(mockDb, findUser("u1")).WillOnce(Return(&user));
EXPECT_CALL(mockDb, saveUser(_)).Times(0);
EXPECT_FALSE(service.charge("u1", 50));
}
TEST(PaymentServiceTest, ChargeFailsWhenUserNotFound) {
MockDatabase mockDb;
PaymentService service(&mockDb);
EXPECT_CALL(mockDb, findUser("unknown")).WillOnce(Return(nullptr));
EXPECT_CALL(mockDb, saveUser(_)).Times(0);
EXPECT_FALSE(service.charge("unknown", 50));
}
EXPECT_CALL은 대상 코드를 실행하기 전에 설정해야 합니다. 호출이 먼저 일어난 뒤에 설정한 기대는 그 호출과 매칭되지 않습니다.
행위 검증과 상태 검증을 함께
TEST(PaymentServiceTest, ChargeUpdatesBalance) {
MockDatabase mockDb;
PaymentService service(&mockDb);
User user{"u1", "Alice", 100};
EXPECT_CALL(mockDb, findUser("u1")).WillOnce(Return(&user));
EXPECT_CALL(mockDb, saveUser(::testing::Field(&User::balance, 50)))
.WillOnce(Return(true));
EXPECT_TRUE(service.charge("u1", 50));
EXPECT_EQ(user.balance, 50); // 상태 검증
}
saveUser가 한 번 호출되었다는 사실만 확인하면, 잔액을 잘못 계산해서 저장해도 테스트가 통과합니다. Field 매처로 저장되는 값까지 확인하고, 반환값과 최종 상태를 함께 검증해야 로직의 결과를 실제로 보장할 수 있습니다.
호출 순서 검증 (InSequence)
#include <gmock/gmock.h>
using ::testing::InSequence;
using ::testing::Return;
using ::testing::_;
TEST(OrderTest, FindBeforeSave) {
MockDatabase mockDb;
User user{"u1", "Alice", 100};
{
InSequence seq;
EXPECT_CALL(mockDb, findUser("u1")).WillOnce(Return(&user));
EXPECT_CALL(mockDb, saveUser(_)).WillOnce(Return(true));
}
PaymentService service(&mockDb);
EXPECT_TRUE(service.charge("u1", 50));
}
순서 검증은 순서가 실제 요구 사항일 때만 씁니다(예: 트랜잭션 시작 → 쓰기 → 커밋). 단지 현재 구현이 그 순서로 호출한다는 이유로 넣으면, 동작이 같은 리팩터링에도 테스트가 깨집니다.
naggy, NiceMock, StrictMock
GMock에서 “uninteresting call”은 EXPECT_CALL이 하나도 없는 메서드에 대한 호출을 말합니다. 기본 Mock(naggy)은 이 경우 경고를 출력하고, NiceMock은 조용히 허용하며, StrictMock은 테스트를 실패시킵니다.
::testing::NiceMock<MockDatabase> niceDb; // uninteresting call 허용, 경고 없음
::testing::StrictMock<MockDatabase> strictDb; // uninteresting call이면 실패
혼동하기 쉬운 점은 “unexpected call”과의 차이입니다. 메서드에 EXPECT_CALL이 있는데 인자가 어느 기대와도 맞지 않는 호출은 NiceMock이어도 실패합니다. 기대를 설정하지 않은 호출에 기본 반환값을 주고 싶다면 ON_CALL(...).WillByDefault(...)를 쓰고, 호출 횟수를 검증할 때만 EXPECT_CALL을 씁니다.
자주 만나는 문제와 해결
링크 에러: undefined reference to testing::InitGoogleTest
GTest 라이브러리를 링크하지 않았거나 순서가 잘못된 경우입니다. main을 직접 작성하지 않았다면 undefined reference to main도 함께 보일 수 있으며, 이때는 gtest_main을 링크해야 합니다.
# CMakeLists.txt
find_package(GTest REQUIRED)
target_link_libraries(my_test PRIVATE GTest::gtest_main)
# 직접 링크할 때는 의존하는 쪽을 먼저: gtest_main이 gtest에 의존
g++ -o my_test test.cpp -lgtest_main -lgtest -pthread
정적 라이브러리를 쓰는 GNU ld는 명령줄 순서대로 심볼을 해석하므로, -lgtest -lgtest_main 순서로 쓰면 gtest_main이 필요로 하는 gtest 심볼을 찾지 못할 수 있습니다.
EXPECT_CALL이 만족되지 않음
Actual: never called - unsatisfied and active 같은 메시지는 Mock 메서드가 호출되지 않았거나 인자가 매칭되지 않았다는 뜻입니다. 먼저 GMock이 출력하는 “Unexpected mock function call” 블록을 보면 실제로 어떤 인자로 호출되었는지 나옵니다. 인자 공백이나 대소문자 차이 같은 사소한 불일치가 원인인 경우가 많습니다.
// 정확히 "alice"일 때만 매칭
EXPECT_CALL(mockDb, findUser("alice")).WillOnce(Return(&user));
// 인자가 중요하지 않다면 와일드카드
EXPECT_CALL(mockDb, findUser(::testing::_)).WillOnce(Return(&user));
// 부분 조건만 확인
EXPECT_CALL(mockDb, findUser(::testing::StartsWith("alice"))).WillOnce(Return(&user));
다만 매처를 느슨하게 만드는 것은 원인을 확인한 뒤의 선택이어야 합니다. 대상 코드가 정말 잘못된 인자를 넘기고 있다면 매처를 _로 바꾸는 것은 버그를 숨기는 일입니다.
Return()은 값을 설정 시점에 복사함
int limit = 100;
EXPECT_CALL(mock, getLimit()).WillRepeatedly(Return(limit));
limit = 50;
// getLimit()은 여전히 100을 반환: Return(limit)이 이 시점의 값을 복사해 둠
Return(x)는 EXPECT_CALL을 설정할 때 x를 복사해 둡니다. 나중에 바뀐 값을 반환하려면 ::testing::ReturnPointee(&limit)을, 참조를 반환하는 메서드에는 ::testing::ReturnRef(obj)를 씁니다. 반대로 Return(&user)처럼 포인터를 반환할 때는 포인터가 가리키는 객체가 Mock 호출이 끝날 때까지 살아 있어야 합니다. 테스트 함수 안의 지역 변수라면 테스트가 끝날 때까지 유효하므로 문제없지만, 헬퍼 함수 안에서 지역 변수 주소로 기대를 설정하고 반환하면 댕글링 포인터가 됩니다.
Mock 객체가 해제되지 않음
ERROR: this mock object (used in test PaymentServiceTest.X) should be deleted but never is.
Mock의 기대는 Mock 객체가 소멸될 때 검증됩니다. Mock을 new로 만들고 delete하지 않거나, 테스트 대상이 소유권을 가져간 뒤 해제하지 않으면 기대 검증이 아예 일어나지 않고 테스트가 통과한 것처럼 보입니다. GMock은 프로그램 종료 시 이런 누수를 위와 같이 보고합니다. Mock은 스택이나 std::unique_ptr로 관리하고, 대상이 소유권을 가져가야 하는 설계라면 raw 포인터를 따로 보관해 기대를 설정한 뒤 unique_ptr로 넘깁니다. 기대를 중간에 강제로 검증하려면 ::testing::Mock::VerifyAndClearExpectations(&mock)을 씁니다.
통합 테스트에서 cannot open shared object file
dlopen에 슬래시가 포함된 경로를 넘기면 LD_LIBRARY_PATH와 상관없이 그 경로의 파일을 엽니다. 그런데도 이 에러가 난다면 대개 플러그인 자체가 아니라 플러그인이 의존하는 다른 공유 라이브러리를 찾지 못한 것입니다. dlerror() 메시지에 어떤 파일 이름이 나오는지 먼저 확인하고, ldd로 플러그인의 의존성을 보면 원인이 바로 보입니다.
# 테스트 실행 시 환경 변수 지정
add_test(NAME plugin_integration COMMAND plugin_integration_test)
set_tests_properties(plugin_integration PROPERTIES
ENVIRONMENT "LD_LIBRARY_PATH=$<TARGET_FILE_DIR:test_plugin>"
)
# 또는 플러그인에 RPATH를 넣어 자신의 위치 기준으로 의존성을 찾게 함
set_target_properties(test_plugin PROPERTIES
BUILD_RPATH "$ORIGIN"
INSTALL_RPATH "$ORIGIN"
)
환경 변수에 의존하는 방식은 CTest로 실행할 때만 동작하고, 개발자가 테스트 바이너리를 직접 실행하면 다시 실패합니다. 가능하면 RPATH로 해결하는 편이 재현성이 좋습니다.
테스트 순서에 따라 결과가 달라짐
단독으로 실행하면 통과하는데 전체를 돌리면 실패한다면 전역 상태, 정적 변수, 싱글턴을 공유하고 있을 가능성이 큽니다. --gtest_shuffle로 순서를 섞어 실행하면 이런 의존성을 일찍 드러낼 수 있고, 실패했을 때 출력되는 시드를 --gtest_random_seed로 넘겨 재현할 수 있습니다.
class GlobalStateTest : public ::testing::Test {
protected:
void TearDown() override {
resetGlobalState(); // 다음 테스트를 위해 초기화
}
};
TearDown에서 초기화하는 것은 응급처치이고, 근본적으로는 전역 상태를 생성자 인자로 주입받는 구조로 바꾸는 것이 좋습니다.
MOCK_METHOD 매크로 컴파일 에러
MOCK_METHOD의 인자 목록과 한정자 목록은 항상 괄호로 감싸야 합니다. 또 반환 타입이나 인자 타입에 쉼표가 들어 있으면(예: std::map<int, int>) 매크로 인자가 쉼표에서 나뉘므로, 그 타입을 한 번 더 괄호로 감싸야 합니다.
// 에러: 인자 목록과 한정자를 괄호로 감싸지 않음
// MOCK_METHOD(User*, findUser, const std::string&, override);
MOCK_METHOD(User*, findUser, (const std::string&), (override));
// 쉼표가 있는 타입은 괄호로 한 번 더
MOCK_METHOD((std::map<int, int>), getTable, (), (const, override));
테스트 데이터 경로가 환경마다 다름
로컬에서는 되는데 CI에서 파일을 찾지 못한다면 절대 경로나 작업 디렉터리에 의존하고 있을 가능성이 큽니다. 테스트 데이터 위치는 빌드 시스템이 컴파일 정의나 환경 변수로 전달하게 합니다.
#include <cstdlib>
#include <filesystem>
#include <string>
std::string getTestDataDir() {
if (const char* env = std::getenv("TEST_DATA_DIR")) return env;
return (std::filesystem::path(BINARY_DIR) / "testdata").string();
}
테스트 작성 원칙
각 테스트는 다른 테스트에 의존하지 않고 단독으로 실행할 수 있어야 합니다. 공유 상태를 피하고, 필요한 준비는 SetUp에서 매번 새로 합니다.
테스트 이름은 검증하는 동작을 설명해야 합니다. TEST(Payment, Test2)보다 TEST(PaymentServiceTest, ChargeFailsWhenUserNotFound)가 실패 로그만 보고도 무엇이 깨졌는지 알려 줍니다.
본문은 준비(Arrange), 실행(Act), 검증(Assert) 순서로 나누면 읽기 쉽습니다. GMock을 쓸 때는 기대 설정이 준비 단계에 들어간다는 점만 주의하면 됩니다.
TEST(PaymentServiceTest, ChargeSucceedsWhenBalanceSufficient_AAA) {
// Arrange
MockDatabase mockDb;
User user{"u1", "Alice", 100};
EXPECT_CALL(mockDb, findUser("u1")).WillOnce(Return(&user));
EXPECT_CALL(mockDb, saveUser(_)).WillOnce(Return(true));
PaymentService service(&mockDb);
// Act
bool result = service.charge("u1", 50);
// Assert
EXPECT_TRUE(result);
EXPECT_EQ(user.balance, 50);
}
한 테스트는 하나의 동작이나 경로를 검증합니다. 여러 시나리오를 한 테스트에 넣으면 실패했을 때 어떤 시나리오가 원인인지 알기 어렵습니다.
Mock은 추상 인터페이스에 만들고, 테스트 대상은 생성자 등으로 의존성을 주입받게 합니다. 구체 클래스의 비가상 메서드는 GMock으로 대체할 수 없으므로, 레거시 코드에서는 먼저 인터페이스를 추출하는 리팩터링이 필요합니다.
통합 테스트는 임시 디렉터리, 테스트 전용 DB 인스턴스(컨테이너 등), 로컬 Mock 서버처럼 격리된 환경에서 실행해 다른 테스트나 실제 환경에 영향을 주지 않게 합니다.
CMake 테스트 구성과 CI
CMake 테스트 구조
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyProject LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG v1.14.0
)
FetchContent_MakeAvailable(googletest)
enable_testing()
include(GoogleTest)
# 단위 테스트
add_executable(unit_tests
test_calculator.cpp
test_payment_service.cpp
)
target_link_libraries(unit_tests PRIVATE GTest::gtest_main GTest::gmock)
gtest_discover_tests(unit_tests PROPERTIES LABELS unit)
# 통합 테스트
add_executable(integration_tests test_plugin_integration.cpp)
target_compile_definitions(integration_tests PRIVATE BINARY_DIR="${CMAKE_BINARY_DIR}")
target_link_libraries(integration_tests PRIVATE GTest::gtest_main)
gtest_discover_tests(integration_tests PROPERTIES LABELS integration)
gtest_discover_tests는 테스트 바이너리 안의 개별 테스트를 CTest 테스트로 등록합니다. 바이너리 하나를 add_test로 등록하면 CTest 입장에서는 테스트가 하나뿐이라 병렬 실행(ctest -j)과 실패 테스트 재실행이 바이너리 단위로만 됩니다. 레이블을 붙여 두면 ctest -L unit처럼 계층별로 골라 실행할 수 있습니다.
CI 파이프라인 (GitHub Actions)
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
- run: cmake --build build --parallel
- run: ctest --test-dir build --output-on-failure -L unit -j 4
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- run: cmake -B build -DCMAKE_BUILD_TYPE=Debug
- run: cmake --build build --parallel
- run: ctest --test-dir build --output-on-failure -L integration
GitHub의 ubuntu-latest 러너에는 GCC와 CMake가 이미 설치되어 있습니다. 특정 컴파일러 버전이 필요하면 컨테이너 이미지(container:)로 고정하는 방법이 가장 재현성이 좋습니다. ctest --test-dir은 CMake 3.20 이상에서 쓸 수 있습니다.
커버리지 수집
option(ENABLE_COVERAGE "Enable coverage" OFF)
if(ENABLE_COVERAGE)
target_compile_options(unit_tests PRIVATE --coverage -O0)
target_link_options(unit_tests PRIVATE --coverage)
endif()
cmake -B build -DENABLE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build
lcov --capture --directory build --output-file coverage.info
lcov --remove coverage.info '/usr/*' '*/_deps/*' --output-file coverage.info
genhtml coverage.info --output-directory coverage_html
위 설정은 테스트 코드에만 --coverage를 붙였습니다. 테스트 대상 라이브러리를 별도 타깃으로 빌드한다면 그 타깃에도 같은 옵션을 붙여야 실제 제품 코드의 커버리지가 잡힙니다. lcov --remove로 시스템 헤더와 FetchContent로 받은 GoogleTest 코드를 제외하지 않으면 숫자가 왜곡됩니다.
커버리지 숫자는 “이 줄이 실행되었다”는 것만 알려 주고 “결과가 검증되었다”는 것은 알려 주지 않습니다. 목표 수치를 강제하면 단언 없이 코드만 호출하는 테스트가 늘어나기 쉬우므로, 커버리지는 테스트가 전혀 닿지 않는 에러 처리 경로를 찾는 용도로 쓰는 것이 효과적입니다.
느린 테스트 분리
GTest 자체에는 태그 기능이 없으므로, 느린 테스트는 별도 바이너리로 분리해 CTest 레이블로 나누는 방법이 가장 깔끔합니다. 같은 바이너리 안에서 나누려면 이름 규칙을 정하고 --gtest_filter=-*Slow*처럼 필터로 제외하거나, 환경 변수로 실행 여부를 제어합니다.
#include <cstdlib>
class SlowIntegrationTest : public ::testing::Test {
protected:
void SetUp() override {
if (std::getenv("RUN_SLOW_TESTS") == nullptr) {
GTEST_SKIP() << "Set RUN_SLOW_TESTS=1 to run";
}
}
};
TEST_F(SlowIntegrationTest, PluginLoad) {
// ...
}
SetUp()에서 GTEST_SKIP()을 호출하면 해당 픽스처를 쓰는 모든 테스트가 건너뛰어지므로, 테스트마다 같은 검사를 반복할 필요가 없습니다.
테스트 데이터 관리
testdata/
plugins/
libtest_filter.so
fixtures/
sample_input.txt
expected_output.txt
#include <filesystem>
#include <fstream>
#include <iterator>
#include <stdexcept>
#include <string>
std::string loadFixture(const std::string& name) {
auto path = std::filesystem::path(TEST_DATA_DIR) / "fixtures" / name;
std::ifstream f(path, std::ios::binary);
if (!f) throw std::runtime_error("fixture not found: " + path.string());
return std::string(std::istreambuf_iterator<char>(f), {});
}
픽스처 파일을 찾지 못했을 때 빈 문자열을 반환하면 “기대 출력도 비어 있고 실제 출력도 비어 있어서 통과”하는 가짜 성공이 생길 수 있으므로, 없으면 명시적으로 실패하게 만드는 편이 안전합니다.
불안정한(flaky) 테스트 다루기
네트워크처럼 외부 요인으로 가끔 실패하는 테스트를 테스트 코드 안의 재시도 루프로 감싸면, 진짜 회귀(예: 연결 타임아웃이 점점 늘어나는 문제)까지 숨기게 됩니다. 재시도가 필요하다면 CTest의 --repeat until-pass:3(CMake 3.17 이상)처럼 실행기 수준에서 하고, 재시도로 통과한 테스트는 따로 집계해 원인을 고치는 대상으로 관리하는 것이 좋습니다. 가장 좋은 해결은 테스트가 외부 네트워크에 의존하지 않도록 로컬 테스트 서버나 Fake로 바꾸는 것입니다.
자주 묻는 질문 (FAQ)
Q. GMock에서 NiceMock과 StrictMock은 언제 각각 써야 하나요?
기본 Mock(naggy)은 EXPECT_CALL이 없는 메서드가 호출되면 경고를 출력하고, NiceMock은 이런 호출을 조용히 허용하며, StrictMock은 테스트를 실패시킵니다. 관심 없는 부수 호출이 많은 협력 객체라면 NiceMock으로 테스트를 구현 세부 사항에 덜 묶이게 하고, 호출되면 안 되는 메서드가 분명한 경계 테스트에는 StrictMock을 씁니다. StrictMock을 남용하면 내부 구현만 바뀌어도 테스트가 깨지므로 꼭 필요한 곳에만 쓰는 것이 좋습니다.
Q. 프로덕션에서 테스트 커버리지는 얼마나 맞추나요?
모든 프로젝트에 맞는 고정 수치는 없습니다. 커버리지는 테스트가 닿지 않은 코드를 찾는 도구로 쓰고, 핵심 로직과 에러 처리 경로가 실제로 검증되는지는 리뷰에서 확인하는 편이 좋습니다. 커버리지가 크게 떨어지는 PR을 CI에서 알려 주는 정도의 장치가 실무에서 가장 쓸모 있습니다.