C++ 크로스 플랫폼 테스트: Win/Linux/macOS CI 매트릭스, Docker 환경, 엔디안 검증

들어가며: “로컬 Windows에서는 되는데 CI Linux에서만 실패해요”

실제 겪는 문제 시나리오

크로스 플랫폼 C++ 프로젝트를 운영하면 플랫폼마다 다른 동작 때문에 “로컬에서는 통과하는데 CI에서만 실패”하는 일이 자주 발생합니다. Windows·Linux·macOS, x86과 ARM은 경로 표기, 정수 크기, 동적 라이브러리 검색 규칙, 시스템 API가 서로 다르고, 드물게는 엔디안까지 다릅니다. 한 플랫폼에서만 검증하면 이런 차이가 다른 플랫폼의 런타임 오류로 드러납니다. 그래서 CI 매트릭스, Docker, 플랫폼별 테스트, 엔디안 검증을 함께 구성해야 합니다.

flowchart TD
  subgraph wrong[❌ 단일 플랫폼 테스트]
    W1[Windows에서만 테스트] --> W2[Linux 배포]
    W2 --> W3[엔디안·경로 버그 발견]
    W3 --> W4[프로덕션 장애]
  end
  subgraph right[✅ 크로스 플랫폼 테스트]
    R1[CI 매트릭스 Win/Linux/macOS] --> R2[Docker로 환경 일관]
    R2 --> R3[플랫폼별·엔디안 테스트]
    R3 --> R4[배포 전 검증 완료]
  end

GitHub Actions와 GitLab CI에서 Windows·Linux·macOS를 병렬로 돌리는 테스트 매트릭스, Docker로 테스트 환경을 고정하는 방법, #ifdef로 OS별 검증을 나누는 방법, Little/Big Endian 직렬화 테스트를 다룹니다.

요구 환경: C++17 이상, CMake 3.16+, GTest 1.12+


한 플랫폼에서만 테스트가 깨지는 상황

로컬 Windows에서는 ctest가 모두 통과하는데, GitHub Actions의 Ubuntu 러너에서는 플러그인 로드 테스트가 “cannot open shared object file”로 실패하는 경우가 있습니다. Windows는 실행 파일과 같은 디렉터리의 DLL을 먼저 찾기 때문에 로컬에서는 문제가 드러나지 않지만, Linux의 동적 로더는 RPATH/RUNPATH, LD_LIBRARY_PATH, 시스템 경로만 봅니다. CI에서 LD_LIBRARY_PATH를 설정하거나, 더 나은 방법으로 RPATH에 실행 파일 기준 상대 경로($ORIGIN/...)를 넣어 해결합니다.


빅 엔디안 장비에서 바이너리 포맷 파싱 실패

x86_64에서 만든 바이너리 프로토콜이 s390x(IBM Z)나 일부 PowerPC·MIPS 임베디드 장비에서 “잘못된 헤더” 에러로 실패하는 경우입니다. ARM은 이론상 양쪽 엔디안을 지원하지만, 실제로 쓰이는 ARM64 리눅스·macOS·Android는 모두 리틀 엔디안이므로 ARM64 서버로 옮기는 것만으로는 이 문제가 생기지 않습니다. 원인은 직렬화할 때 호스트 바이트 순서를 그대로 썼기 때문입니다. 포맷의 바이트 순서를 하나로 고정하고(네트워크 프로토콜은 관례상 빅 엔디안), 빅 엔디안 환경에서도 직렬화 왕복 테스트를 돌려 검증합니다.


macOS에서만 “dyld: Library not loaded”

Linux·Windows CI는 통과하는데 macOS 러너에서만 동적 라이브러리 로드가 실패하는 경우입니다. macOS의 dyld는 라이브러리에 기록된 install name과 @rpath, @loader_path, @executable_path 규칙으로 경로를 찾으므로 Linux와 규칙이 다릅니다. DYLD_LIBRARY_PATH로 우회할 수도 있지만, SIP(System Integrity Protection)가 보호된 시스템 바이너리(예: /bin/sh)를 거쳐 실행되는 프로세스에서는 이 변수가 지워지므로 신뢰하기 어렵습니다. CMake에서 macOS용 RPATH를 설정하고, 필요하면 install_name_tool로 install name을 고치는 편이 안전합니다.


경로 표기로 인한 테스트 실패

Windows의 파일 API는 /도 구분자로 받아 주므로 testdata/file.txt 같은 경로 자체는 대부분 열립니다. 실제로 테스트를 깨뜨리는 것은 경로를 문자열로 비교하는 코드입니다. std::filesystem::path("a") / "b"는 Windows에서 a\b가 되므로 "a/b"와 문자열로 비교하면 실패하고, 반대로 \를 하드코딩한 경로는 POSIX에서 파일 이름의 일부로 취급됩니다. 경로는 std::filesystem::path로 조립하고, 비교는 path 객체끼리(또는 generic_string()으로) 하며, 테스트 픽스처 위치는 환경 변수나 빌드 시 정의한 매크로로 넘깁니다.


컴파일러 버전 차이

로컬은 GCC 11, CI는 GCC 13인 상황에서 새 버전이 추가한 경고가 -Werror 때문에 에러로 바뀌어 CI만 실패하는 경우가 흔합니다. Docker 이미지로 CI와 같은 컴파일러·라이브러리 버전을 고정하거나, CI 매트릭스에 여러 컴파일러 버전을 넣어 호환성을 함께 검증합니다.


정수 크기·정렬 차이

long을 4바이트로 가정하고 바이너리 파일에 썼다면, 64비트 Linux·macOS(LP64)에서는 long이 8바이트라 다른 플랫폼에서 파싱이 어긋납니다. 64비트 Windows(LLP64)에서는 long이 4바이트로 남기 때문에 Windows에서만 개발하면 이 문제를 놓치기 쉽습니다. 파일·네트워크 포맷에는 int32_t, uint64_t 같은 고정 크기 타입을 쓰고, 가정한 크기를 static_assert나 테스트로 고정해 둡니다.


테스트 계층과 플랫폼 커버리지 설계

테스트 계층과 플랫폼 커버리지

flowchart TB
  subgraph layers[테스트 계층]
    L1[단위 테스트\n플랫폼 독립 로직]
    L2[플랫폼별 테스트\n#ifdef 분기 검증]
    L3[통합 테스트\n경로·동적 로딩·파일]
    L4[엔디안 테스트\n직렬화·프로토콜]
  end
  subgraph platforms[플랫폼 커버리지]
    P1[Windows x64]
    P2[Linux x64]
    P3[macOS arm64/x64]
    P4[Linux ARM64]
  end
  L1 --> P1
  L1 --> P2
  L1 --> P3
  L2 --> P1
  L2 --> P2
  L2 --> P3
  L3 --> P1
  L3 --> P2
  L3 --> P3
  L4 --> P2
  L4 --> P5[Linux s390x\nQEMU, 빅 엔디안]
계층목적플랫폼CI 실행
단위순수 로직, 플랫폼 독립모든 플랫폼매트릭스
플랫폼별#ifdef 분기, OS API해당 OS만조건부
통합경로, dlopen, 파일모든 플랫폼매트릭스
엔디안직렬화, 프로토콜리틀 엔디안 + 빅 엔디안(QEMU)별도 job

GitHub Actions·GitLab CI 매트릭스와 CTest 등록

GitHub Actions: Windows·Linux·macOS 매트릭스

# .github/workflows/cross-platform-test.yml
name: Cross-Platform Test
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
jobs:
  build-and-test:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            cc: gcc
            cxx: g++
            cmake_args: -DCMAKE_BUILD_TYPE=Release
          - os: ubuntu-latest
            cc: clang
            cxx: clang++
            cmake_args: -DCMAKE_BUILD_TYPE=Release
          - os: windows-latest
            cc: cl
            cxx: cl
            cmake_args: -DCMAKE_BUILD_TYPE=Release -G "Visual Studio 17 2022" -A x64
          - os: macos-latest
            cc: clang
            cxx: clang++
            cmake_args: -DCMAKE_BUILD_TYPE=Release
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies (Ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: |
          sudo apt-get update
          sudo apt-get install -y cmake build-essential libgtest-dev
      - name: Configure
        shell: bash  # Windows 러너의 기본 셸은 pwsh라 bash 문법을 쓰려면 지정해야 함
        run: |
          if [ "${{ matrix.os }}" = "windows-latest" ]; then
            cmake -B build ${{ matrix.cmake_args }}
          else
            cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCMAKE_CXX_COMPILER=${{ matrix.cxx }} ${{ matrix.cmake_args }}
          fi
      - name: Build
        run: cmake --build build --config Release
      - name: Run tests
        shell: bash
        run: |
          if [ "${{ matrix.os }}" = "windows-latest" ]; then
            ctest --test-dir build -C Release --output-on-failure
          else
            cd build && ctest --output-on-failure
          fi

fail-fast: false를 지정해야 한 플랫폼이 실패해도 나머지 플랫폼의 작업이 취소되지 않고 결과를 끝까지 확인할 수 있습니다. 이 예제는 GoogleTest를 FetchContent로 받으므로 libgtest-dev 설치는 시스템 패키지를 쓰는 경우에만 필요합니다.

GitLab CI 매트릭스

# .gitlab-ci.yml
stages:
  - test
variables:
  GIT_SUBMODULE_STRATEGY: recursive
.linux_test: &linux_test
  stage: test
  image: ubuntu:22.04
  script:
    - apt-get update && apt-get install -y cmake git $PACKAGES
    - cmake -B build -DCMAKE_BUILD_TYPE=Release $CMAKE_EXTRA
    - cmake --build build
    - ctest --test-dir build --output-on-failure
test:linux-gcc:
  <<: *linux_test
  variables:
    PACKAGES: "g++"
    CMAKE_EXTRA: "-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++"
test:linux-clang:
  <<: *linux_test
  variables:
    PACKAGES: "clang"
    CMAKE_EXTRA: "-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++"
test:windows:
  stage: test
  tags: [windows]   # Visual Studio와 CMake가 설치된 Windows 러너의 태그로 바꿀 것
  script:
    - cmake -B build -G "Visual Studio 17 2022" -A x64
    - cmake --build build --config Release
    - ctest --test-dir build -C Release --output-on-failure

GitLab에서 Windows 작업은 Linux 컨테이너 이미지로 돌릴 수 없으므로 Windows 러너가 필요합니다. 매 작업마다 Visual Studio Build Tools를 설치하면 시간이 오래 걸리므로, 도구가 미리 설치된 러너나 이미지를 쓰는 편이 현실적입니다.

CMake 테스트 등록 (크로스 플랫폼)

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(CrossPlatformApp 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()
# 단위 테스트 — 모든 플랫폼
add_executable(unit_tests
  test_calculator.cpp
  test_endian.cpp
)
target_link_libraries(unit_tests PRIVATE GTest::gtest_main)
add_test(NAME unit_tests COMMAND unit_tests)
# 통합 테스트 — 플러그인 경로 플랫폼별 설정
add_executable(integration_tests test_plugin_integration.cpp)
target_link_libraries(integration_tests PRIVATE GTest::gtest_main)
add_test(NAME integration_tests COMMAND integration_tests)
# 플랫폼별 환경 변수 설정
if(UNIX AND NOT APPLE)
  set_tests_properties(integration_tests PROPERTIES
    ENVIRONMENT "LD_LIBRARY_PATH=${CMAKE_BINARY_DIR}/plugins:$ENV{LD_LIBRARY_PATH}"
  )
elseif(APPLE)
  set_tests_properties(integration_tests PROPERTIES
    ENVIRONMENT "DYLD_LIBRARY_PATH=${CMAKE_BINARY_DIR}/plugins:$ENV{DYLD_LIBRARY_PATH}"
  )
endif()

Docker 컨테이너와 QEMU ARM64 테스트

멀티 스테이지 Dockerfile (테스트용)

# Dockerfile.test
FROM ubuntu:22.04 AS builder
RUN apt-get update && apt-get install -y \
    cmake \
    g++ \
    git \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release && \
    cmake --build build
# 테스트 실행 스테이지
FROM ubuntu:22.04 AS test-runner
# ctest는 cmake 패키지에 들어 있으므로 실행 스테이지에도 설치해야 함
RUN apt-get update && apt-get install -y \
    cmake \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/build /src/build
WORKDIR /src/build
CMD ["ctest", "--output-on-failure"]

Docker Compose로 여러 환경 테스트

# docker-compose.test.yml
services:
  test-ubuntu-gcc:
    build:
      context: .
      dockerfile: Dockerfile.test
    image: myapp-test:ubuntu-gcc
  test-ubuntu-clang:
    build:
      context: .
      dockerfile: Dockerfile.test.clang
    image: myapp-test:ubuntu-clang
  test-alpine:
    build:
      context: .
      dockerfile: Dockerfile.test.alpine
    image: myapp-test:alpine

CI에서 Docker 테스트 실행

# .github/workflows/docker-test.yml
name: Docker Test
on: [push, pull_request]
jobs:
  docker-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and run tests in Docker
        run: |
          docker build -f Dockerfile.test -t myapp-test .
          docker run --rm myapp-test

ARM64 에뮬레이션 (QEMU)으로 크로스 아키텍처 테스트

# ARM64 테스트 (GitHub Actions)
  test-arm64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Build and test on ARM64
        run: |
          docker build --platform linux/arm64 -f Dockerfile.test -t myapp-test-arm64 .
          docker run --rm --platform linux/arm64 myapp-test-arm64
      - name: Build and test on s390x (big endian)
        run: |
          docker build --platform linux/s390x -f Dockerfile.test -t myapp-test-s390x .
          docker run --rm --platform linux/s390x myapp-test-s390x

ARM64 테스트는 char의 부호(ARM 리눅스에서 char는 기본적으로 unsigned), 메모리 순서에 민감한 동시성 코드, 아키텍처별 SIMD 분기를 잡는 데 의미가 있고, 엔디안 검증은 되지 않습니다. 빅 엔디안 검증이 필요하면 ubuntu:22.04가 공식 이미지를 제공하는 s390x를 함께 돌립니다. 두 작업 모두 QEMU 에뮬레이션이라 네이티브보다 훨씬 느리므로 timeout-minutes를 넉넉히 잡아야 합니다.


플랫폼 감지와 조건부 테스트

플랫폼 감지 및 조건부 테스트

// platform_detect.hpp
#pragma once
#if defined(_WIN32) || defined(_WIN64)
  #define PLATFORM_WINDOWS 1
  #define PLATFORM_NAME "Windows"
#elif defined(__APPLE__)
  #include <TargetConditionals.h>
  #if TARGET_OS_IPHONE
    #define PLATFORM_IOS 1
    #define PLATFORM_NAME "iOS"
  #else
    #define PLATFORM_MACOS 1
    #define PLATFORM_NAME "macOS"
  #endif
#elif defined(__linux__)
  #if defined(__ANDROID__)
    #define PLATFORM_ANDROID 1
    #define PLATFORM_NAME "Android"
  #else
    #define PLATFORM_LINUX 1
    #define PLATFORM_NAME "Linux"
  #endif
#else
  #define PLATFORM_UNKNOWN 1
  #define PLATFORM_NAME "Unknown"
#endif

플랫폼별 테스트 케이스 (GTest)

// test_platform_path.cpp
#include <gtest/gtest.h>
#include <filesystem>
#include <string>
#include "platform_detect.hpp"
TEST(PlatformPathTest, PathSeparator) {
  // std::filesystem::path는 모든 플랫폼에서 / 지원
  auto p = std::filesystem::path("config") / "settings.json";
  EXPECT_FALSE(p.string().empty());
#if defined(PLATFORM_WINDOWS)
  EXPECT_TRUE(p.string().find('\\') != std::string::npos ||
              p.string().find('/') != std::string::npos);
#else
  EXPECT_TRUE(p.string().find('/') != std::string::npos);
#endif
}
TEST(PlatformPathTest, CurrentPathExists) {
  auto cwd = std::filesystem::current_path();
  EXPECT_TRUE(std::filesystem::exists(cwd));
}
#if defined(PLATFORM_LINUX) || defined(PLATFORM_MACOS)
TEST(PlatformPathTest, HomeDirectory) {
  const char* home = std::getenv("HOME");
  if (home) {
    std::filesystem::path homePath(home);
    EXPECT_TRUE(std::filesystem::exists(homePath));
  }
}
#endif
#if defined(PLATFORM_WINDOWS)
TEST(PlatformPathTest, WindowsUserProfile) {
  const char* userProfile = std::getenv("USERPROFILE");
  if (userProfile) {
    std::filesystem::path profilePath(userProfile);
    EXPECT_TRUE(std::filesystem::exists(profilePath));
  }
}
#endif

동적 라이브러리 확장자 테스트

// test_platform_dlopen.cpp
#include <gtest/gtest.h>
#include <string>
std::string getSharedLibExtension() {
#if defined(_WIN32)
  return ".dll";
#elif defined(__APPLE__)
  return ".dylib";
#else
  return ".so";
#endif
}
std::string getSharedLibPrefix() {
#if defined(_WIN32)
  return "";
#else
  return "lib";
#endif
}
TEST(PlatformDlopenTest, ExtensionMatchesPlatform) {
  auto ext = getSharedLibExtension();
#if defined(_WIN32)
  EXPECT_EQ(ext, ".dll");
#elif defined(__APPLE__)
  EXPECT_EQ(ext, ".dylib");
#else
  EXPECT_EQ(ext, ".so");
#endif
}
TEST(PlatformDlopenTest, PrefixMatchesPlatform) {
  auto prefix = getSharedLibPrefix();
#if defined(_WIN32)
  EXPECT_EQ(prefix, "");
#else
  EXPECT_EQ(prefix, "lib");
#endif
}

정수 크기 검증 테스트

// test_integer_sizes.cpp
#include <gtest/gtest.h>
#include <cstdint>
#include <cstddef>
TEST(IntegerSizeTest, FixedWidthTypes) {
  EXPECT_EQ(sizeof(int8_t), 1u);
  EXPECT_EQ(sizeof(int16_t), 2u);
  EXPECT_EQ(sizeof(int32_t), 4u);
  EXPECT_EQ(sizeof(int64_t), 8u);
  EXPECT_EQ(sizeof(uint8_t), 1u);
  EXPECT_EQ(sizeof(uint32_t), 4u);
  EXPECT_EQ(sizeof(uint64_t), 8u);
}
TEST(IntegerSizeTest, SizeTMatchesPointer) {
  EXPECT_EQ(sizeof(size_t), sizeof(void*));
}
// long은 플랫폼마다 다를 수 있음 — 경고
TEST(IntegerSizeTest, LongSizeDocumented) {
  // Windows 64: 4, Linux/macOS 64: 8
  EXPECT_GE(sizeof(long), 4u);
  EXPECT_LE(sizeof(long), 8u);
}

엔디안 유틸리티와 직렬화 테스트

엔디안 유틸리티

// endian_utils.hpp
#pragma once
#include <cstdint>
#include <cstring>
namespace endian {
inline bool isLittleEndian() {
  uint16_t x = 0x0001;
  return *reinterpret_cast<uint8_t*>(&x) == 1;
}
inline bool isBigEndian() {
  return !isLittleEndian();
}
inline uint16_t swap16(uint16_t x) {
  return ((x >> 8) & 0xFF) | ((x << 8) & 0xFF00);
}
inline uint32_t swap32(uint32_t x) {
  return ((x >> 24) & 0xFF) | ((x >> 8) & 0xFF00) |
         ((x << 8) & 0xFF0000) | ((x << 24) & 0xFF000000);
}
inline uint64_t swap64(uint64_t x) {
  return ((x >> 56) & 0xFF) | ((x >> 40) & 0xFF00) |
         ((x >> 24) & 0xFF0000) | ((x >> 8) & 0xFF000000) |
         ((x << 8) & 0xFF00000000) | ((x << 24) & 0xFF0000000000) |
         ((x << 40) & 0xFF000000000000) | ((x << 56) & 0xFF00000000000000);
}
// 네트워크 바이트 순서(Big Endian)로 변환
// MSVC는 __BYTE_ORDER__를 정의하지 않지만 Windows 대상은 모두 리틀 엔디안이라 #else로 간다
inline uint32_t toNetworkOrder(uint32_t host) {
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
  return host;
#else
  return swap32(host);
#endif
}
inline uint32_t fromNetworkOrder(uint32_t net) {
  return toNetworkOrder(net);  // 대칭
}
inline uint16_t toNetworkOrder16(uint16_t host) {
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
  return host;
#else
  return swap16(host);
#endif
}
inline uint16_t fromNetworkOrder16(uint16_t net) {
  return toNetworkOrder16(net);
}
}  // namespace endian

C++20 이상이면 매크로 대신 <bit>의 std::endian::native로 엔디안을 판별할 수 있고, C++23에는 std::byteswap이 추가되었습니다. 다만 아래 테스트처럼 swap 함수 자체를 검증하는 테스트는 리틀 엔디안 호스트에서도 돌아가지만, toNetworkOrder의 #if 분기 중 빅 엔디안 쪽은 실제 빅 엔디안 환경에서만 실행된다는 점을 기억해야 합니다.

엔디안 테스트 케이스

// test_endian.cpp
#include <gtest/gtest.h>
#include "endian_utils.hpp"
#include <cstring>
TEST(EndianTest, DetectEndianness) {
  bool little = endian::isLittleEndian();
  bool big = endian::isBigEndian();
  EXPECT_NE(little, big);
  EXPECT_TRUE(little || big);
}
TEST(EndianTest, Swap16RoundTrip) {
  uint16_t original = 0x1234;
  uint16_t swapped = endian::swap16(original);
  uint16_t back = endian::swap16(swapped);
  EXPECT_EQ(original, back);
}
TEST(EndianTest, Swap32RoundTrip) {
  uint32_t original = 0x12345678;
  uint32_t swapped = endian::swap32(original);
  uint32_t back = endian::swap32(swapped);
  EXPECT_EQ(original, back);
}
TEST(EndianTest, Swap32ChangesByteOrder) {
  uint32_t x = 0x01020304;
  uint32_t s = endian::swap32(x);
  // Little: 04 03 02 01, Big: 01 02 03 04
  EXPECT_NE(x, s);
  EXPECT_EQ(endian::swap32(s), x);
}
TEST(EndianTest, ToNetworkOrderRoundTrip) {
  uint32_t host = 0x12345678;
  uint32_t net = endian::toNetworkOrder(host);
  uint32_t back = endian::fromNetworkOrder(net);
  EXPECT_EQ(host, back);
}
TEST(EndianTest, BinarySerializationConsistency) {
  uint32_t value = 0xDEADBEEF;
  uint8_t buf[4];
  // 호스트 순서로 쓰고 읽기
  std::memcpy(buf, &value, 4);
  uint32_t read;
  std::memcpy(&read, buf, 4);
  EXPECT_EQ(value, read);
  // 네트워크 순서로 쓰고 읽기 (플랫폼 독립)
  uint32_t net = endian::toNetworkOrder(value);
  std::memcpy(buf, &net, 4);
  uint32_t netRead;
  std::memcpy(&netRead, buf, 4);
  EXPECT_EQ(endian::fromNetworkOrder(netRead), value);
}

프로토콜 헤더 직렬화 테스트

// protocol.hpp
#pragma once
#include <cstdint>
#include "endian_utils.hpp"
struct PacketHeader {
  uint32_t magic;
  uint32_t length;
  uint16_t version;
  uint16_t flags;
  void toNetwork() {
    magic = endian::toNetworkOrder(magic);
    length = endian::toNetworkOrder(length);
    version = endian::toNetworkOrder16(version);  // swap16을 무조건 호출하면 빅 엔디안 호스트에서 틀림
    flags = endian::toNetworkOrder16(flags);
  }
  void fromNetwork() {
    magic = endian::fromNetworkOrder(magic);
    length = endian::fromNetworkOrder(length);
    version = endian::fromNetworkOrder16(version);
    flags = endian::fromNetworkOrder16(flags);
  }
};
static_assert(sizeof(PacketHeader) == 12, "패딩이 생기면 와이어 포맷이 달라짐");
// test_protocol.cpp
#include <gtest/gtest.h>
#include "protocol.hpp"
#include <cstring>
TEST(ProtocolTest, HeaderSerializationRoundTrip) {
  PacketHeader orig{0xCAFEBABE, 1024, 1, 0};
  PacketHeader sent = orig;
  sent.toNetwork();
  uint8_t buf[sizeof(PacketHeader)];
  std::memcpy(buf, &sent, sizeof(PacketHeader));
  PacketHeader recv;
  std::memcpy(&recv, buf, sizeof(PacketHeader));
  recv.fromNetwork();
  EXPECT_EQ(recv.magic, orig.magic);
  EXPECT_EQ(recv.length, orig.length);
  EXPECT_EQ(recv.version, orig.version);
  EXPECT_EQ(recv.flags, orig.flags);
}

라이브러리 로드·경로 구분자·엔디안 파싱 실패

”cannot open shared object file” (Linux CI)

로컬에서는 통과하는데 CI에서 플러그인 로드 테스트가 실패한다면 테스트 실행 시 LD_LIBRARY_PATH(또는 RPATH)가 플러그인 디렉터리를 가리키지 않는 경우가 대부분입니다.

# CMakeLists.txt
set_tests_properties(integration_tests PROPERTIES
  ENVIRONMENT "LD_LIBRARY_PATH=${CMAKE_BINARY_DIR}/plugins:$ENV{LD_LIBRARY_PATH}"
)
# 또는 실행 전 수동 설정
export LD_LIBRARY_PATH=$PWD/build/plugins:$LD_LIBRARY_PATH
./integration_tests

”dyld: Library not loaded” (macOS)

macOS에서만 로드가 실패하면 @rpath가 해석될 RPATH 항목이 없는 경우가 많습니다. 빌드 트리용 BUILD_RPATH와 설치용 INSTALL_RPATH를 함께 지정합니다.

if(APPLE)
  set_tests_properties(integration_tests PROPERTIES
    ENVIRONMENT "DYLD_LIBRARY_PATH=${CMAKE_BINARY_DIR}/plugins:$ENV{DYLD_LIBRARY_PATH}"
  )
  set_target_properties(integration_tests PROPERTIES
    BUILD_RPATH "${CMAKE_BINARY_DIR}/plugins"
    INSTALL_RPATH "@loader_path/../plugins"
  )
endif()

Windows에서 “DLL not found”

LoadLibrary가 실패한다면 DLL이 실행 파일과 같은 디렉터리나 PATH에 없는 경우입니다. Windows에는 RPATH가 없으므로, 빌드 후 DLL을 테스트 실행 파일 옆으로 복사하는 방법이 가장 단순합니다. 작업 디렉터리를 바꾸는 것만으로는 표준 DLL 검색 순서에서 현재 디렉터리가 뒤로 밀려 있어 확실하지 않습니다.

add_custom_command(TARGET integration_tests POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E copy_if_different
    $<TARGET_FILE:test_plugin>
    $<TARGET_FILE_DIR:integration_tests>
)

경로 문자열 비교 실패

Windows에서만 경로 관련 단언이 실패한다면, 대개 경로를 문자열로 조립하거나 비교하는 코드가 원인입니다.

// ❌ 잘못된 예: Windows에서 p.string()은 "testdata\\input.txt"
auto p = std::filesystem::path("testdata") / "input.txt";
EXPECT_EQ(p.string(), "testdata/input.txt");
// ✅ 올바른 예: path끼리 비교하거나 generic_string()으로 비교
EXPECT_EQ(p, std::filesystem::path("testdata/input.txt"));
EXPECT_EQ(p.generic_string(), "testdata/input.txt");

테스트 데이터 위치는 실행 디렉터리에 의존하지 않도록 환경 변수나 빌드 시 정의한 매크로로 넘깁니다. path를 std::string으로 바꿀 때는 Windows에서 내부 문자열이 wstring이라 암시적 변환이 안 되므로 .string()을 명시합니다.

std::string getTestDataDir() {
  if (const char* env = std::getenv("TEST_DATA_DIR")) return env;
  return (std::filesystem::path(BINARY_DIR) / "testdata").string();  // BINARY_DIR는 CMake에서 정의
}

엔디안으로 인한 파싱 오류

x86에서 쓴 파일을 빅 엔디안 장비에서 읽을 때 값이 뒤집히는 경우로, 호스트 바이트 순서를 그대로 저장한 것이 원인입니다.

// ❌ 잘못된 예 — 호스트 순서 그대로 저장
uint32_t len = 1000;
file.write(reinterpret_cast<char*>(&len), 4);
// ✅ 올바른 예 — 네트워크 바이트 순서로 저장
uint32_t len = 1000;
uint32_t netLen = endian::toNetworkOrder(len);
file.write(reinterpret_cast<char*>(&netLen), 4);

”unistd.h not found” (Windows CI)

Windows 빌드에서 POSIX 헤더를 찾지 못한다면 플랫폼별 #include 분기가 빠진 것입니다.

// ❌ 잘못된 예
#include <unistd.h>
// ✅ 올바른 예
#if defined(_WIN32)
  #include <io.h>
  #include <process.h>
#else
  #include <unistd.h>
#endif

테스트 순서에 따른 플래키

단독 실행하면 통과하는데 전체를 돌리면 실패하는 테스트는 대개 전역 상태나 환경 변수를 다른 테스트와 공유합니다. 환경 변수를 바꾸는 테스트는 원래 값을 복사해 두었다가 되돌려야 합니다. getenv가 돌려준 포인터는 이후 setenv로 무효화될 수 있으므로 std::string으로 복사해 두고, 원래 없던 변수는 지워야 합니다.

class IsolatedTest : public ::testing::Test {
protected:
  void SetUp() override {
    if (const char* v = std::getenv("LD_LIBRARY_PATH")) saved_ = v;
  }
  void TearDown() override {
    if (saved_) setenv("LD_LIBRARY_PATH", saved_->c_str(), 1);  // POSIX 전용, Windows는 _putenv_s
    else unsetenv("LD_LIBRARY_PATH");
  }
  std::optional<std::string> saved_;
};

Docker 내부에서 테스트 타임아웃

QEMU 에뮬레이션 작업은 네이티브보다 훨씬 느리므로 작업 타임아웃을 늘리고, 오래 걸리는 테스트는 선택적으로 실행하게 만듭니다.

# GitHub Actions
  test-arm64:
    timeout-minutes: 30
    steps: ...
// 느린 테스트는 환경 변수로 스킵
TEST(IntegrationTest, SlowNetwork) {
  if (!std::getenv("RUN_SLOW_TESTS")) {
    GTEST_SKIP() << "Set RUN_SLOW_TESTS=1 to run";
  }
  // ...
}

단계별 테스트·아티팩트 보존·커버리지 비용 줄이기

통합 크로스 플랫폼 CI 워크플로우

sequenceDiagram
  participant Dev as 개발자
  participant GH as GitHub Actions
  participant Win as Windows Runner
  participant Linux as Linux Runner
  participant Mac as macOS Runner
  Dev->>GH: push
  GH->>Win: build + test
  GH->>Linux: build + test
  GH->>Mac: build + test
  Win-->>GH: result
  Linux-->>GH: result
  Mac-->>GH: result
  GH->>Dev: 모든 플랫폼 통과 시 merge 가능

단계별 테스트 (빠른 것 먼저)

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cmake -B build && cmake --build build
      - run: ctest --test-dir build -R unit_tests  # -R은 정규식 하나만 받음, 여러 개면 a|b 형태
  integration:
    needs: unit
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - run: cmake -B build && cmake --build build --config Debug
      - run: ctest --test-dir build -C Debug -R integration_tests

아티팩트로 플랫폼별 빌드 보존

- name: Upload artifacts
  uses: actions/upload-artifact@v4
  with:
    name: build-${{ matrix.os }}
    path: build/

커버리지는 Linux에서만 (비용 절감)

coverage:
  runs-on: ubuntu-latest
  steps:
    - run: cmake -B build -DENABLE_COVERAGE=ON
    - run: cmake --build build
    - run: ctest --test-dir build
    - run: lcov --capture --directory build --output-file coverage.info

플랫폼별 릴리스 태그

release:
  if: startsWith(github.ref, 'refs/tags/')
  strategy:
    matrix:
      os: [ubuntu-latest, windows-latest, macos-latest]
  runs-on: ${{ matrix.os }}
  steps:
    - uses: actions/checkout@v4
    - run: cmake -B build -DCMAKE_BUILD_TYPE=Release
    - run: cmake --build build --config Release
    - run: cpack --config build/CPackConfig.cmake -C Release -B build/dist
    - uses: actions/upload-artifact@v4
      with:
        name: ${{ matrix.os }}-release
        path: build/dist/

참고 자료


같이 보면 좋은 글