第十四章:单元测试与Makefile:集成测试到构建系统、自动化测试流程

说实话,很多嵌入式工程师写单元测试,都是手动敲命令跑一跑。项目小还好说,一旦模块多了,几十个测试文件,你一个个编译、链接、运行?那效率太低了。我早年在一个车载项目上吃过这个亏——测试用例写了三百多个,结果每次改完代码,光跑测试就要花半小时手动操作。后来我痛定思痛,把测试集成到了Makefile里。嗯,今天我们就聊聊这个。

为什么要把测试塞进构建系统?

你想想看,构建系统(Makefile)本来就是用来管理编译流程的。测试本质上也是一段可执行程序,只不过它的“输出”是 PASS/FAIL。那为什么不把测试当成一个特殊的 target 来管理呢?

这样做有三个好处:

  • 一键执行:make test 搞定所有,不用记一堆命令
  • 持续集成:CI 脚本里直接调用 make test,自动化程度拉满
  • 回归保护:每次构建时自动跑测试,防止改出 bug

核心思想:让测试成为构建流程中不可跳过的一环,而不是事后补丁。

Makefile 集成测试的基本结构

我们先看一个最简的 Makefile 结构。假设你的项目目录是这样的:

project/
├── src/          # 源代码
├── inc/          # 头文件
├── test/         # 测试用例
│   ├── test_math.c
│   └── test_utils.c
└── Makefile

我习惯在 Makefile 里单独定义一个 test target。它负责编译所有测试文件,链接被测模块,然后运行。来看代码:

CC = gcc
CFLAGS = -Iinc -Wall -Wextra -g
LDFLAGS = -lcmocka  # 假设我们用 cmocka 框架

SRC = $(wildcard src/*.c)
TEST_SRC = $(wildcard test/*.c)
TEST_BINS = $(TEST_SRC:.c=)

.PHONY: all test clean

all: test

test: $(TEST_BINS)
    @echo "Running all tests..."
    @for bin in $^; do \
        echo "--- Running $$bin ---"; \
        ./$$bin || exit 1; \
    done
    @echo "All tests passed!"

test/test_math: test/test_math.c $(filter-out src/main.c, $(SRC))
    $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

test/test_utils: test/test_utils.c $(filter-out src/main.c, $(SRC))
    $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)

clean:
    rm -f test/test_math test/test_utils

这里有个细节:$(filter-out src/main.c, $(SRC))。为什么要过滤掉 main.c?因为测试程序有自己的 main 函数(由测试框架提供),你不能链接两个 main 进去。我在项目中遇到过有人忘了这茬,链接时报重复定义,排查了半天……

自动化测试流程:从编译到报告

光能跑还不够,我们还要让流程自动化。一个完整的自动化测试流程应该包含:

  1. 编译测试目标:确保测试代码本身没有语法错误
  2. 链接被测模块:把要测的 .c 文件(除了 main)链接进来
  3. 执行测试:运行所有测试二进制文件
  4. 收集结果:判断 PASS/FAIL,生成汇总
  5. 失败即停止:任何一个测试失败,整个构建就失败

我曾经在一个项目中,测试脚本里用了 || true 来忽略错误。结果呢?测试红了,CI 还是绿的。上线后才发现功能被破坏了。所以记住:测试失败必须让 make 返回非零值

警告:不要在测试命令后面加 || trueset +e。测试失败就应该让构建失败,否则自动化就失去了意义。

进阶技巧:并行测试与覆盖率

当测试用例多起来,串行跑会很慢。Makefile 天然支持 -j 并行,但测试之间如果有共享资源(比如写同一个文件),并行就会出问题。我的做法是:每个测试用例使用独立的临时目录,或者用 flock 加锁。

另外,覆盖率也是自动化测试的重要一环。我们可以用 gcov/lcov 来生成覆盖率报告:

COVERAGE_FLAGS = -fprofile-arcs -ftest-coverage

test_coverage: CFLAGS += $(COVERAGE_FLAGS)
test_coverage: test
    @echo "Generating coverage report..."
    @lcov --capture --directory . --output-file coverage.info
    @genhtml coverage.info --output-directory coverage_report
    @echo "Coverage report at coverage_report/index.html"

这样,你跑 make test_coverage,就能看到哪些代码被测试覆盖了,哪些没覆盖到。嗯,这个在代码评审时特别有用。

避坑指南:我踩过的几个坑

  • 坑一:测试文件依赖没写全。改了 src 里的代码,make 不知道要重新编译测试。解决方案:用 gcc -MM 自动生成依赖。
  • 坑二:测试二进制文件没清理。旧版本的测试二进制还在,跑的是老代码。我建议 make clean 时把测试二进制也删掉。
  • 坑三:测试框架的链接顺序。有些链接器对库的顺序敏感,cmocka 的库要放在最后。我曾经被这个坑了整整一个下午。

小技巧:在 Makefile 里加一个 make test_verbose target,输出每个测试的详细信息。调试时特别管用。

知识体系图:测试集成到构建系统

下面这张图展示了测试集成到 Makefile 后的完整流程。你可以看到,从代码修改到测试报告,整个链路是自动化的。

测试集成到构建系统流程图 修改源代码 src/*.c / inc/*.h 执行 make test 自动检测依赖变化 编译测试二进制文件 链接被测模块(排除 main.c) 运行所有测试用例 串行或并行执行 测试失败 ❌ make 返回非零 测试通过 ✅ 继续后续流程 虚线表示可选路径,实线表示主流程

总结一下

把单元测试集成到 Makefile 里,说白了就是让机器替你干活。你只需要写一次规则,之后每次改代码,跑一下 make test,所有测试自动编译、链接、运行、出报告。我个人觉得,这是嵌入式项目从“野路子”走向“正规军”的第一步。

嗯,最后提醒一句:别把测试当成负担。它其实是你的安全网。网织得越密,你改代码时就越敢下手。

一句话记住:测试不集成到构建系统,等于没写测试。


公众号:蓝海资料掘金营,微信 deep3321