CMake 工程指南 - 工程场景(5)

CMake 进阶:CTest 自动化测试与 CPack 跨平台打包实战

前面章节我们已经彻底掌握了 CMake 的核心逻辑:如何用 find_package 查找第三方库、如何用 add_library 构建自己的库、如何用 target_link_libraries 管理依赖。但一个成熟的项目,光 "能编译运行" 是不够的,必须经过 "严格测试" 和 "标准化打包发布" 两个环节。

这就是我们要补的最后两块拼图:CTest(测试)CPack(打包) 。本节将结合之前的 Protobuf 实战案例,手把手教你如何在 CMakeLists.txt 中配置测试与打包,特别是针对你自己编写的库(自研库) 打包时的关键注意事项。


CTest:CMake 内置的自动化测试框架

CTest 是 CMake 自带的测试驱动工具,它不是测试框架 ,而是一个测试执行器。它的核心逻辑非常简单:

我们在 CMake 中用 add_test() 注册一个测试(通常是一个可执行程序)。

CTest 运行这个程序,只看程序的返回码(Exit Code)

  • 返回 0 → 测试 通过(Passed)
  • 返回非 0 → 测试 失败(Failed)

下面我们为 Protobuf 项目添加测试,我们沿用之前的 cmake_proto 项目,为它添加测试。

Step 1:新建测试文件 test_main.cpp

cpp 复制代码
#include <iostream>
#include "person.pb.h"

int main() {
    // 1. 构建测试数据
    example::Person person;
    person.set_name("TestUser");
    person.set_id(10086);
    person.set_email("test@example.com");

    // 2. 序列化
    std::string data;
    bool serialize_ok = person.SerializeToString(&data);
    if (!serialize_ok) {
        std::cerr << "Serialization failed!" << std::endl;
        return 1; // 测试失败,返回非0
    }

    // 3. 反序列化
    example::Person parsed_person;
    bool parse_ok = parsed_person.ParseFromString(data);
    if (!parse_ok) {
        std::cerr << "Parse failed!" << std::endl;
        return 1;
    }

    // 4. 验证数据一致性
    if (parsed_person.name() != "TestUser" || parsed_person.id() != 10086) {
        std::cerr << "Data mismatch!" << std::endl;
        return 1;
    }

    std::cout << "All tests passed successfully!" << std::endl;
    return 0; // 测试通过,返回0
}

Step 2:修改 CMakeLists.txt 开启测试

只需在原有的 Protobuf 配置基础上,添加 3 行核心代码

bash 复制代码
cmake_minimum_required(VERSION 3.18)
project(ProtoExample)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 1. 查找Protobuf
find_package(Protobuf REQUIRED)
include_directories(${Protobuf_INCLUDE_DIRS})
include_directories(${CMAKE_CURRENT_BINARY_DIR})

set(PROTO_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/proto)
file(MAKE_DIRECTORY ${PROTO_OUT_DIR})

set(GEN_SRCS "")
file(GLOB PROTO_FILES ${CMAKE_CURRENT_SOURCE_DIR}/proto/*.proto)
foreach(PROTO_FILE ${PROTO_FILES})
    get_filename_component(BASE_NAME ${PROTO_FILE} NAME_WE)
    set(GEN_CC ${PROTO_OUT_DIR}/${BASE_NAME}.pb.cc)
    set(GEN_H ${PROTO_OUT_DIR}/${BASE_NAME}.pb.h)
    add_custom_command(
        OUTPUT ${GEN_CC} ${GEN_H}
        COMMAND protoc --cpp_out=${PROTO_OUT_DIR} -I${CMAKE_CURRENT_SOURCE_DIR}/proto ${PROTO_FILE}
        VERBATIM
    )
    list(APPEND GEN_SRCS ${GEN_CC})
endforeach()

# 2. 生成静态库
add_library(MyProto STATIC ${GEN_SRCS})
target_include_directories(MyProto PUBLIC ${Protobuf_INCLUDE_DIRS})
target_link_libraries(MyProto PUBLIC ${Protobuf_LIBRARIES})

# 3. 生成主程序
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE MyProto)

# ===================== 以下是 CTest 核心配置 =====================
# 4. 开启测试支持(必须写)
enable_testing()

# 5. 生成测试可执行文件
add_executable(test_proto test_main.cpp)
target_link_libraries(test_proto PRIVATE MyProto)

# 6. 注册测试:名字 -> 命令
# 格式:add_test(NAME <测试名> COMMAND <可执行文件>)
add_test(NAME Proto_Serialize_Test COMMAND test_proto)

Step 3:运行测试

bash 复制代码
mkdir build && cd build
cmake ..
make

# 运行测试
ctest
# 或者用详细模式查看输出
ctest -v

预期输出:

bash 复制代码
Test project /xxx/cmake_proto/build
    Start 1: Proto_Serialize_Test
1/1 Test #1: Proto_Serialize_Test .............   Passed    0.00 sec

100% tests passed, 0 tests failed out of 1

CTest 常用命令【命令章节有详细说明】

命令 作用
ctest 运行所有测试
ctest -v 详细模式,显示测试输出
ctest -R <正则> 只运行匹配的测试(如 ctest -R Proto
ctest --output-on-failure 只在测试失败时输出详细日志

CPack:CMake 内置的跨平台打包工具

CPack 是 CMake 的打包工具,它负责把我们写好的程序和库,打包成跨平台的安装包(如 .deb, .rpm, .exe, .tar.gz, .dmg)。

⚠️ 关键大前提:

CPack 打包的内容 = install() 命令安装的内容! CPack 不会凭空打包,它会扫描你在 CMakeLists.txt 中用 install() 指定的文件,然后把它们打包成安装包。

所以,打包我们的自研库,必须先写好 install() 规则。

下面我们进行实战:为 Protobuf 项目打包(含自研库)

Step 1:完善 CMakeLists.txt 增加安装规则

我们需要指定:

  1. 安装哪个可执行文件。
  2. 安装哪个库文件(静态库 / 动态库)。
  3. 安装哪个头文件(给开发者用)。
  4. 配置 CPack 基本信息。
bash 复制代码
cmake_minimum_required(VERSION 3.18)
project(ProtoExample)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(Protobuf REQUIRED)
include_directories(${Protobuf_INCLUDE_DIRS})
include_directories(${CMAKE_CURRENT_BINARY_DIR})

set(PROTO_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/proto)
file(MAKE_DIRECTORY ${PROTO_OUT_DIR})

set(GEN_SRCS "")
file(GLOB PROTO_FILES ${CMAKE_CURRENT_SOURCE_DIR}/proto/*.proto)
foreach(PROTO_FILE ${PROTO_FILES})
    get_filename_component(BASE_NAME ${PROTO_FILE} NAME_WE)
    set(GEN_CC ${PROTO_OUT_DIR}/${BASE_NAME}.pb.cc)
    set(GEN_H ${PROTO_OUT_DIR}/${BASE_NAME}.pb.h)
    add_custom_command(
        OUTPUT ${GEN_CC} ${GEN_H}
        COMMAND protoc --cpp_out=${PROTO_OUT_DIR} -I${CMAKE_CURRENT_SOURCE_DIR}/proto ${PROTO_FILE}
        VERBATIM
    )
    list(APPEND GEN_SRCS ${GEN_CC})
endforeach()

# 生成静态库
add_library(MyProto STATIC ${GEN_SRCS})
target_include_directories(MyProto PUBLIC 
    $<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}> # 构建时路径
    $<INSTALL_INTERFACE:include> # 安装时路径 (关键:告诉用户头文件在 include)
)
target_link_libraries(MyProto PUBLIC ${Protobuf_LIBRARIES})

# 生成主程序
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE MyProto)

# ===================== CTest 配置 =====================
enable_testing()
add_executable(test_proto test_main.cpp)
target_link_libraries(test_proto PRIVATE MyProto)
add_test(NAME Proto_Serialize_Test COMMAND test_proto)

# ===================== CPack 核心配置 =====================
# 1. 安装规则 (Install Rules) ------ 这是打包的基础!
# 安装可执行文件到 bin 目录
install(TARGETS myapp
    RUNTIME DESTINATION bin # Windows: bin/, Linux: bin/
)

# 2. 安装自研库到 lib 目录
# 注意:静态库用 ARCHIVE,动态库用 LIBRARY
install(TARGETS MyProto
    ARCHIVE DESTINATION lib # 静态库 .a/.lib
    LIBRARY DESTINATION lib # 动态库 .so/.dylib
    INCLUDES DESTINATION include # 导出头文件路径
)

# 3. 安装头文件 (供外部使用 MyProto 的人必须有 .h 文件)
# 我们需要把生成的 .pb.h 文件安装出去
install(FILES ${PROTO_OUT_DIR}/person.pb.h
    DESTINATION include/proto # 安装到 include/proto 目录
)

# 4. CPack 配置 (Package Configuration)
include(CPack) # 引入打包模块

# 5. 设置包基本信息 (必须设置,否则包名会是 unknown)
set(CPACK_PACKAGE_NAME "MyProtoExample")
set(CPACK_PACKAGE_VERSION "1.0.0")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Protobuf CMake Demo Package")
set(CPACK_PACKAGE_VENDOR "MyCompany")

# 6. 设置生成器 (Generators) ------ 决定打包成什么格式
# 推荐:ZIP (通用) + DEB (Ubuntu) + RPM (CentOS) + NSIS (Windows)
if(WIN32)
    set(CPACK_GENERATOR "ZIP;NSIS")
elseif(APPLE)
    set(CPACK_GENERATOR "ZIP;DragNDrop")
elseif(UNIX)
    # Linux 通常同时生成 deb 和 rpm
    set(CPACK_GENERATOR "ZIP;DEB;RPM")
endif()

# Linux 特定配置
if(UNIX AND NOT APPLE)
    set(CPACK_DEBIAN_PACKAGE_MAINTAINER "dev@mycompany.com")
    set(CPACK_RPM_PACKAGE_LICENSE "MIT")
endif()

运行打包命令

bash 复制代码
cd build
cmake ..

# 方式一:直接运行 cpack (推荐)
cpack

# 方式二:构建目标 (make package)
make package

打包结果(build 目录下):

  • MyProtoExample-1.0.0-Linux.tar.gz (通用压缩包)
  • MyProtoExample-1.0.0-Linux.deb (Debian 包)
  • MyProtoExample-1.0.0-Linux.rpm (RPM 包)

自研库打包的 4 个致命注意事项

"自己写的库,字节要用到的程序都要打包,这些注意点体现在哪?"

答案是:全部体现在顶层 CMakeLists.txtinstall()CPack 配置中。 以下是开发库时必须遵守的 4 条铁律:

注意点 1:路径必须使用 INSTALL_INTERFACE

错误写法:

bash 复制代码
# 这样写只有你自己编译时能用,别人打包后用你的库会找不到头文件
target_include_directories(MyProto PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

正确写法(使用生成器表达式):

bash 复制代码
target_include_directories(MyProto PUBLIC
    # $<BUILD_INTERFACE> : 只有在构建本项目时有效
    $<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}>
    # $<INSTALL_INTERFACE> : 只有在安装后使用本库时有效 (路径是相对于安装前缀的)
    $<INSTALL_INTERFACE:include>
)

解释: 告诉 CMake,当用户安装你的库后,头文件在 include 目录下。

注意点 2:区分 RUNTIME / LIBRARY / ARCHIVE

CMake 对不同类型文件安装路径有严格区分,不能乱填:

文件类型 关键字 示例 含义
可执行文件 RUNTIME .exe, 无后缀 安装到 bin
动态库 LIBRARY .so, .dylib 安装到 lib
静态库 ARCHIVE .a, .lib 安装到 lib

配置示例:

bash 复制代码
# 安装动态库
install(TARGETS MyProto
    LIBRARY DESTINATION lib # Linux: lib/libMyProto.so
    ARCHIVE DESTINATION lib # Windows: lib/MyProto.lib
)
注意点 3:必须安装头文件(.h/.hpp)

这是最容易遗漏的点! 如果不安装头文件,用户拿到你的 .so.a 文件,因为没有 .h,根本无法调用你的库接口。

bash 复制代码
# 安装头文件到 include 目录
install(DIRECTORY ${CMAKE_SOURCE_DIR}/include/
    DESTINATION include
    FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp"
)
# 如果是生成的头文件,也要单独安装
install(FILES ${PROTO_OUT_DIR}/person.pb.h DESTINATION include/proto)
注意点 4:处理 RPATH(动态库运行时路径)

如果我们的项目是动态库 ,打包后在其他机器运行可能会报 error while loading shared libraries。解决方法是在打包前设置 RPATH,告诉程序去哪里找动态库。

bash 复制代码
# 在生成可执行文件时设置 RPATH
if(UNIX AND NOT APPLE)
    set_target_properties(myapp PROPERTIES
        INSTALL_RPATH "$ORIGIN/../lib" # 假设可执行文件在 bin,库在 lib
        BUILD_WITH_INSTALL_RPATH TRUE
    )
endif()

所以完整的 CMake 项目生命周期大概是:

  1. 编写代码 :写 .cpp.h.proto
  2. 配置构建 :写 CMakeLists.txt,用 add_library / add_executable
  3. 测试 :写 test_*.cpp,开启 enable_testing(),用 ctest 验证功能。
  4. 安装 :写 install(TARGETS ...),指定安装路径。
  5. 打包 :引入 CPack,设置 CPACK_* 变量,用 cpack 生成跨平台安装包。

通过这一套流程,你真正实现了从 "代码开发" 到 "测试验证" 再到 "跨平台发布" 的全流程闭环,这才是工业级 CMake 开发的标准形态。

通用 CMake 模板工程(含测试 + 打包 + 自研库)

下面是一份开箱即用的 CMake 模板,覆盖了「项目结构 → 库构建 → 测试 → 安装 → 打包」全流程,你可以直接复制到新项目中使用。


一、标准项目目录结构

bash 复制代码
my_project/
├── CMakeLists.txt          # 顶层 CMake 配置(总入口)
├── include/                # 公共头文件(对外暴露的接口)
│   └── my_lib/
│       └── my_lib.h
├── src/                    # 库源码
│   ├── CMakeLists.txt
│   ├── add.cpp
│   └── sub.cpp
├── app/                    # 可执行程序(调用库)
│   ├── CMakeLists.txt
│   └── main.cpp
├── tests/                  # 测试代码
│   ├── CMakeLists.txt
│   └── test_my_lib.cpp
└── cmake/                 # (可选)自定义 CMake 模块
    └── MyLibConfig.cmake.in

二、顶层 CMakeLists.txt(总入口)

bash 复制代码
cmake_minimum_required(VERSION 3.18)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

# ===================== 全局配置 =====================
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 输出目录统一管理(可选,让构建产物更整洁)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)

# ===================== 子模块 =====================
add_subdirectory(src)    # 构建库
add_subdirectory(app)    # 构建可执行程序
add_subdirectory(tests)  # 构建测试

# ===================== 测试支持(CTest) =====================
enable_testing()
include(CTest)

# ===================== 打包支持(CPack) =====================
include(CPack)

# 包基本信息
set(CPACK_PACKAGE_NAME "MyProject")
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "A CMake template project with library, app, test and package.")
set(CPACK_PACKAGE_VENDOR "YourName/YourCompany")
set(CPACK_PACKAGE_CONTACT "your@email.com")

# 生成器配置(跨平台)
if(WIN32)
    set(CPACK_GENERATOR "ZIP;NSIS")
elseif(APPLE)
    set(CPACK_GENERATOR "ZIP;DragNDrop")
elseif(UNIX)
    set(CPACK_GENERATOR "ZIP;DEB;RPM")
endif()

# Linux 特定配置
if(UNIX AND NOT APPLE)
    set(CPACK_DEBIAN_PACKAGE_MAINTAINER ${CPACK_PACKAGE_CONTACT})
    set(CPACK_RPM_PACKAGE_LICENSE "MIT")
endif()

三、库模块 src/CMakeLists.txt

bash 复制代码
# 收集源码
file(GLOB_RECURSE LIB_SOURCES "*.cpp")
file(GLOB_RECURSE LIB_HEADERS "${PROJECT_SOURCE_DIR}/include/my_lib/*.h")

# 构建静态库(或 SHARED 动态库)
add_library(MyLib STATIC ${LIB_SOURCES} ${LIB_HEADERS})

# 头文件路径(关键:区分构建时/安装时)
target_include_directories(MyLib
    PUBLIC
        $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)

# 链接依赖(如果有第三方库)
# find_package(XXX REQUIRED)
# target_link_libraries(MyLib PUBLIC XXX::XXX)

# ===================== 安装规则(打包基础) =====================
# 安装库文件
install(TARGETS MyLib
    EXPORT MyLibTargets
    ARCHIVE DESTINATION lib    # 静态库 .a/.lib
    LIBRARY DESTINATION lib    # 动态库 .so/.dylib
    RUNTIME DESTINATION bin    # Windows DLL
    INCLUDES DESTINATION include
)

# 安装头文件
install(DIRECTORY ${PROJECT_SOURCE_DIR}/include/
    DESTINATION include
    FILES_MATCHING PATTERN "*.h" PATTERN "*.hpp"
)

# 导出目标配置(让别人能 find_package(MyLib))
install(EXPORT MyLibTargets
    FILE MyLibTargets.cmake
    NAMESPACE MyLib::
    DESTINATION lib/cmake/MyLib
)

# 生成 Config.cmake(让 find_package 能找到)
include(CMakePackageConfigHelpers)
configure_package_config_file(
    ${PROJECT_SOURCE_DIR}/cmake/MyLibConfig.cmake.in
    ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake
    INSTALL_DESTINATION lib/cmake/MyLib
)
write_basic_package_version_file(
    ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion
)
install(FILES
    ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfig.cmake
    ${CMAKE_CURRENT_BINARY_DIR}/MyLibConfigVersion.cmake
    DESTINATION lib/cmake/MyLib
)

四、可执行程序模块 app/CMakeLists.txt

bash 复制代码
# 收集源码
file(GLOB_RECURSE APP_SOURCES "*.cpp")

# 构建可执行文件
add_executable(my_app ${APP_SOURCES})

# 链接自研库
target_link_libraries(my_app PRIVATE MyLib)

# ===================== RPATH 配置(动态库运行时路径) =====================
if(UNIX AND NOT APPLE)
    set_target_properties(my_app PROPERTIES
        INSTALL_RPATH "$ORIGIN/../lib"  # 可执行文件在 bin,库在 lib
        BUILD_WITH_INSTALL_RPATH TRUE
    )
endif()

# ===================== 安装规则 =====================
install(TARGETS my_app
    RUNTIME DESTINATION bin
)

五、测试模块 tests/CMakeLists.txt

bash 复制代码
# 收集测试源码
file(GLOB_RECURSE TEST_SOURCES "*.cpp")

# 构建测试可执行文件
add_executable(test_my_lib ${TEST_SOURCES})

# 链接库
target_link_libraries(test_my_lib PRIVATE MyLib)

# 注册测试(CTest)
add_test(NAME MyLib_Add_Test COMMAND test_my_lib)
add_test(NAME MyLib_Sub_Test COMMAND test_my_lib)

六、测试示例 tests/test_my_lib.cpp

cpp 复制代码
#include <cassert>
#include "my_lib/my_lib.h"

int main() {
    // 测试加法
    assert(add(2, 3) == 5);
    // 测试减法
    assert(sub(5, 2) == 3);
    return 0;
}

七、使用流程(一键构建 + 测试 + 打包)

bash 复制代码
# 1. 创建构建目录
mkdir build && cd build

# 2. 配置项目
cmake ..

# 3. 编译
make -j$(nproc)

# 4. 运行测试
ctest -v

# 5. 打包(生成安装包)
cpack

# 6. (可选)安装到系统
sudo make install
相关推荐
handler012 小时前
算法:字符串哈希
c语言·数据结构·c++·笔记·算法·哈希算法·散列表
想做后端的前端2 小时前
Lua的元表和元方法
开发语言·junit·lua
大尚来也2 小时前
Spring Boot 3 + Spring Cloud 2026 微服务实战:云原生、AI 融合与架构演进
开发语言
a1117762 小时前
Three.js 3D模型动画展示项目(开源)
开发语言·javascript·ecmascript
handler012 小时前
算法:查并集
开发语言·数据结构·c++·笔记·学习·算法·c
雨落在了我的手上2 小时前
C语言之数据结构初见篇(5):单链表的介绍(1)
c语言·开发语言·数据结构
Bert.Cai2 小时前
Python flush函数作用
开发语言·python
比昨天多敲两行2 小时前
C++ Lsit
开发语言·c++·算法
野犬寒鸦2 小时前
从零起步学习计算机操作系统:I/O篇
服务器·开发语言·网络·后端·面试