给 FreeSWITCH 写测试:从单元测试到集成测试,用一个真 bug 串起来
Posted on 四 10 9月 2026 in Tech
| Abstract | 给 FreeSWITCH 写测试:从单元测试到集成测试 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Version | v1.0 |
| Updated | 2026-09-10 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- 核心结论:给 FreeSWITCH 写测试不是"要不要写",而是"写哪一层"。能在进程内用 FST 复现的,就别去搭一整套 SIP 环境;FST 够不着的(媒体、协议交互、并发时序),才上集成测试。
- FST 是什么:FreeSWITCH 自带的单元测试框架,
FST_CORE_BEGIN起一个真 core,FST_SESSION_BEGIN起一个真 session,你直接调switch_*函数——不是 mock,是真跑。 - 一个真实案例:
mod_dialplan_xml里regex="all"下的$1变空,根因是 PCRE2 subject 的 use-after-free。这种内存 bug 光读代码不放心,值钱的是能复现的测试。 - 单元测试的边界:FST 测的是"函数在活 session 上的行为"。一旦问题跨进程、跨协议、跨网络(SIP 信令、RTP 媒体、编解码协商、多方呼叫),单元测试就够不着了。
- 集成测试怎么搭:SIPp 打真呼叫、ESL 订阅事件、专用 dialplan 和配置目录、CI 里用 Docker 拉起整套。核心是"能自动判定通过/失败",而不是人肉盯日志。
- 两种测试的分工:一张决策表——给定一个 bug 或一个 feature,先问"能不能在一个 session 内复现",答案决定你写哪种。
- 可抄的东西:FST 骨架、Makefile.am 改法、单个测试的跑法、集成测试的判定思路、一张检查清单。
先说个场景。你在 FreeSWITCH 的拨号计划里写了这么一段:一个 <condition regex="all">,里面第一条判 ${did_number} 为空,第二条从 ${sip_h_Diversion} 里用正则 sip:1(\d+)@.+$ 抓号码,然后 action 里 log("transfer to $1")。来一个 Diversion: sip:17001234567@... 的 INVITE,你满心以为日志会打 transfer to 7001234567。
结果它打了 transfer to——后面是空的。
同样的正则,写成经典的单 <condition> 就正常;field 用裸的 caller-profile 名字也正常。只有 regex="all" 配 field="${var}" 这一种组合会丢捕获。这是 issue #3141,我提了 PR #3151 去修它。修的过程里我真正想聊的不是这个 bug 本身,而是一件更普遍的事:给 FreeSWITCH 这种"跑起来才是它"的软件写测试,到底该怎么写。
答案有两层。一层是单元测试,在进程内测一个函数;一层是集成测试,拉起整个服务打真呼叫。选错层,要么白搭一套重环境,要么根本测不到问题。
第一层:FST——在进程内起一个真 FreeSWITCH
FreeSWITCH 自带一个 header-only 的单元测试框架,底子是 fctx,在 src/include/test/switch_test.h 里包了一层,把"起 core、起 session、注入 DTMF、断言"都做成了宏。现成的例子在 tests/unit/(测 core),以及各模块自己的 test/ 子目录(测模块)。
它和你平时写的 C 单元测试有个根本区别:不打桩。它是真的把 FreeSWITCH 的 core 跑起来,真的开一个 session,然后让你在这个活着的 session 上调 switch_* 函数。你测的是真实行为,不是替身的行为。
打桩(mock):用一个假的、行为可控的对象顶替真实依赖,好处是快、隔离,坏处是"测过了"不等于"真环境里对"。 FST 反其道而行——宁可重一点,也要测真的。对 telephony 这种时序和状态极多的系统,这个取舍是对的。
一个测试文件的骨架是四层宏,从外到内套:
FST_CORE_BEGIN("./conf_playsay") // 起 core,参数是这份实例的配置目录
{
FST_SUITE_BEGIN(switch_ivr_play_say) // 一个测试套件
{
FST_SETUP_BEGIN() // 每个 test 前跑,常用来确认依赖模块在
{
fst_requires_module("mod_sndfile");
}
FST_SETUP_END()
FST_TEARDOWN_BEGIN() {} // 每个 test 后跑;就算空也必须写
FST_TEARDOWN_END()
FST_SESSION_BEGIN(play_and_collect) // 一个用 session 的 test
{
// 自动给你四个局部变量:
// fst_session / fst_channel / fst_session_pool / fst_pool
status = switch_ivr_play_and_collect_input(fst_session, ...);
fst_check(status == SWITCH_STATUS_SUCCESS);
fst_check_string_equals(digits_collected, "123");
}
FST_SESSION_END()
}
FST_SUITE_END()
}
FST_CORE_END() // 关掉 core
几个要点,都是踩过才记得住的:
FST_CORE_BEGIN的参数是一个真的配置目录(如./conf_playsay),里面得放能让 core 起来的 FreeSWITCH 配置,不是随便一个空文件夹。FST_SESSION_BEGIN免费给你fst_session、fst_channel、fst_session_pool、fst_pool四个局部变量,直接用。- 要模拟外部输入,用
fst_sched_recv_dtmf("+1", "1")——预约在第 1 秒往 session 注入 DTMF 键"1",用来模拟 IVR 收号。 - 断言就两个常用的:
fst_check(cond)判真,fst_check_string_equals(a, b)判字符串相等。 FST_SETUP和FST_TEARDOWN就算什么都不做,也必须成对写在套件里,少一个编不过。
写完文件还得让它进构建,否则 make check 根本不编它,你会误以为"测试过了":
- Core 测试:文件放
tests/unit/,在tests/unit/Makefile.am的noinst_PROGRAMS里加上文件名。 - 模块测试:文件放模块的
test/子目录,改模块的Makefile.am——把测试加进noinst_PROGRAMS,设TESTS = $(noinst_PROGRAMS)(make check认这个变量)。如果是这个模块第一个测试,还得在noinst_LTLIBRARIES里建一个库让测试链接过去。
跑法:make install 之后 make check 跑全部;调试单个就进目录直接跑那个二进制;只跑某几个用名字前缀过滤:
$ cd tests/unit
$ ./switch_dialplan_xml # 跑这个程序里所有 case
$ ./switch_dialplan_xml regex_all_var_field # 只跑名字以此为前缀的 case
回到那个 bug:单元测试怎么把它钉死
mod_dialplan_xml 的 $1 变空,根因是一个 use-after-free。switch_regex_perform() 会把 PCRE2 的匹配偏移量存进 subject 字符串里;而 regex="all" 的循环里,${var} 被展开进一块堆缓冲 field_expanded,匹配成功后代码做了 strdup(field_data) 再 switch_safe_free(field_expanded)——可 save_match_data 还指着那块刚被释放的原缓冲。后面 switch_perform_substitution() 去取 $1,取的就是悬垂指针里的内容。
那个
strdup的本意只是给后面算strlen用,好定 buffer 大小;它并没有把 PCRE2 的 subject 指针重新绑到新内存上。这是最典型的"看起来 copy 了一份,其实关键的那根指针还指着旧的"。
这种 bug 有个讨厌的性质:它可能不崩,只是算错。靠肉眼 review 很难拍胸脯说"改对了"。值钱的东西是能复现的测试。我在 tests/unit/switch_dialplan_xml.c 里加了 5 个 FST case:
| 测试 case | 测什么 |
|---|---|
regex_all_var_field_keeps_capture |
正面复现:regex="all" + field="${var}" 匹配后 $1 必须拿到号码 |
classic_var_field_keeps_capture |
经典 condition 路径不能被这次修改改坏 |
regex_all_second_regex_fail_skips_action |
第二条 regex 不匹配时,action 不该跑 |
regex_all_did_already_set_skips_action |
前置条件已满足时正确跳过 |
classic_var_field_mismatch_skips_action |
不匹配就跳过 action |
注意这 5 个不只是测"修好没有",还测了"别的路径有没有被连累"和"该跳过的时候跳没跳"。改内存相关的代码,正例反例都得覆盖,不然你只知道那一个 case 绿了,不知道有没有按下葫芦浮起瓢。
流程是死的,顺序不能颠倒:
- 先写测试,跑出
1/2 failing——确认失败可复现(修之前 action 打的就是transfer to)。 - 打补丁。
- 同一批测试跑出
PASSED (5/5)(transfer to 7001234567)。
没见它红过的测试,等于没测。 只跑到"修完是绿的"这一步,你无法证明这个测试真的在测这个 bug——说不定它对着一个永远成立的条件在断言。先让它红,再让它绿。
整套跑法是 ./tests/unit/switch_dialplan_xml,全程在 in-tree 的 FST 二进制上,不需要一个真的 SIP 环境。这就引出了下一个问题:什么时候你就非得要真 SIP 环境了?
第二层:集成测试——单元测试够不着的地方
FST 强在"真 core + 真 session",但它的边界也正在这里:它测的是一个进程内、一个 session 上的函数行为。上面那个 dialplan bug 之所以能用 FST 测,是因为它的因果链完全落在进程内——喂一个 header 变量,跑正则,看捕获。没有一个字节需要过网络。
可 FreeSWITCH 的一大半价值恰恰在进程之外:
- SIP 信令交互:INVITE / 100 Trying / 180 Ringing / 200 OK / ACK / BYE 这一整套握手,重传、超时、Re-INVITE、CANCEL 的时序。
- RTP 媒体:声音有没有真的通、编解码协商(codec negotiation)对不对、DTMF 是走 RFC 2833 还是 SIP INFO。
- 多方交互:两条腿的桥接(bridge)、转接(transfer)、会议、排队。
- 和外部系统的集成:注册到上游、ESL(Event Socket Library)事件流、数据库、其他媒体网关。
这些东西,你没法在一个 FST_SESSION_BEGIN 里"调个函数"就测出来。你得把整个 FreeSWITCH 拉起来,从外面像一个真话机/真对端那样跟它对话,然后判断它的反应对不对。这就是集成测试。
一套典型的 FreeSWITCH 集成测试长这样:
- 拉起被测的 FreeSWITCH——用一份专门的配置目录(专用 dialplan、profile、只加载需要的模块),干净、可复现、和你的开发机无关。CI 里通常用 Docker 起一个隔离实例。
- 用 SIPp 扮演对端打呼叫。SIPp 是事实标准的 SIP 压测/仿真工具,你用 XML scenario 描述"发 INVITE → 等 200 → 回 ACK → 挂 BYE",它就按脚本跟 FreeSWITCH 完整走一遍呼叫。它还能带 RTP、放 pcap 媒体、并发拉几百上千路,顺手把负载测试也做了。
- 用 ESL 订阅事件做断言。这是集成测试能"自动判定"的关键。你连上 FreeSWITCH 的 Event Socket,订阅
CHANNEL_CREATE、CHANNEL_ANSWER、CHANNEL_HANGUP这些事件,然后断言:呼叫有没有接通?挂断原因(hangup cause)是不是NORMAL_CLEARING?通道变量对不对?——判定依据是结构化的事件,不是人肉去 grep 日志。
一段示意(概念示意,不同项目脚手架各异):
# 1. 起一个隔离的 FreeSWITCH 实例(专用 conf)
$ docker run -d --name fs-under-test \
-v $PWD/test-conf:/etc/freeswitch freeswitch:test
# 2. SIPp 按 scenario 打一路呼叫
$ sipp -sf uac_invite.xml 127.0.0.1:5060 -m 1 -mp 6000
# 3. 从 ESL 侧确认事件序列符合预期(伪代码)
# connect ESL -> subscribe CHANNEL_HANGUP
# assert hangup_cause == "NORMAL_CLEARING"
集成测试的死穴是"判定"而不是"发起"。 打一路呼叫很容易,难的是让机器自动、稳定地判断这一路呼叫对不对。所以 ESL 事件这条线才是重点——它给了你一个可断言的、结构化的观察点,让集成测试能进 CI 自动跑,而不是变成"发布前人工拨个号听听声音"。
两种测试怎么分工:一张决策表
别纠结"该写单元还是集成",先问一个问题:这个行为能不能在一个 session、一个进程内复现? 答案基本就决定了。
| 你要测的东西 | 能在一个 session 内复现? | 写哪种 | 理由 |
|---|---|---|---|
switch_* 核心函数的返回值/副作用 |
能 | FST 单元测试 | 进程内,FST_SESSION_BEGIN 直接调 |
| dialplan 条件/正则/变量替换(如 #3141) | 能 | FST 单元测试 | 因果链全在进程内,喂变量看结果 |
| IVR 收号、放音、语音识别接口 | 能 | FST 单元测试 | fst_sched_recv_dtmf 注入即可 |
| SIP 握手时序、重传、Re-INVITE | 不能 | 集成测试 | 需要真对端,SIPp 打真信令 |
| RTP 媒体是否连通、codec 协商 | 不能 | 集成测试 | 需要真媒体流,单元测试摸不到 |
| 桥接、转接、会议、排队 | 不能 | 集成测试 | 跨多条腿,跨进程状态 |
| 挂断原因、事件序列是否正确 | 不能 | 集成测试 | 用 ESL 订阅事件断言 |
| 高并发下的稳定性/泄漏 | 不能 | 集成测试 + Sanitizer | SIPp 拉并发,ASan/valgrind 盯内存 |
一句话记住这个分工:
能进程内复现的,一律 FST,别搭重环境;跨进程/跨协议/跨网络的,才上 SIPp + ESL 的集成测试。 内存类 bug(像 #3141 这种 use-after-free)是个特例——即便能被集成测试间接触发,也优先用 FST 精确复现,再叠一层 ASan 兜底。因为集成测试告诉你"结果错了",FST + ASan 才告诉你"错在哪一行"。
可抄的检查清单
修 bug 或加 feature 时,照这个走:
- 先分层:问"能不能在一个 session 内复现"。能 → FST;不能 → 集成测试。拿不准就先试 FST,它最快。
- FST 建文件:core →
tests/unit/xxx.c;模块 →src/mod/.../test/xxx.c。骨架照抄四层宏,FST_SETUP/FST_TEARDOWN就算空也要写。 - 改 Makefile.am:core 加进
tests/unit/Makefile.am的noinst_PROGRAMS;模块加进模块Makefile.am并设TESTS = $(noinst_PROGRAMS)。忘了这步测试根本不编。 - 先红再绿:
make install && make check;确认测试在打补丁前会失败,再打补丁转绿。测试和补丁放同一个 PR,描述里贴出"修之前失败态 / 修之后 PASSED"。 - 正反都覆盖:改内存/状态相关的代码,别只测正例;把"该跳过""该失败""相邻路径"也测上(#3141 就配了 5 个 case)。
- 集成测试要能自动判定:SIPp 发起呼叫,ESL 订阅事件做断言。判定靠结构化事件(hangup cause、channel 变量),不靠人肉 grep 日志。CI 里用 Docker 起隔离实例。
- 内存 bug 叠 Sanitizer:能开 ASan 就开,尤其注意
detect_stack_use_after_return这类默认关掉的开关——不开它,栈上的 use-after-return 只打乱码不报错。
一句话:
测试的价值一半在"证明修好了",一半在"锁住别人以后别改坏"。 一个见过红、正反都覆盖、能自动判定的测试,才配跟补丁一起进 PR。
总结:先分层,再"先红后绿",两个一起提
给 FreeSWITCH 写测试,难的从来不是语法,是分层:能进程内复现的用 FST,跨协议跨网络的用 SIPp + ESL。选对层,一个下午能钉死一个 bug;选错层,要么白搭环境,要么根本测不到。
而不管哪一层,规矩是同一条——先让测试红,再让它绿,测试跟补丁一起进 PR。#3141 就是这么修完的:5 个 FST case,从 1/2 failing 到 PASSED (5/5),补丁和测试同在 PR #3151 里。修 bug 交出去的不该只是一行改动,而是一份"我改对了、而且以后谁都别想再改坏"的证据。
全文思维导图
@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* 给 FreeSWITCH 写测试
** 单元测试 (FST)
*** 真 core + 真 session,不打桩
*** 四层宏 CORE/SUITE/SETUP/SESSION
*** fst_check / fst_sched_recv_dtmf
*** 改 Makefile.am 才进构建
** 真实案例 #3141/#3151
*** regex=all 下 $1 变空
*** 根因 PCRE2 subject use-after-free
*** 5 个 FST case,正反覆盖
*** 先红(1/2 fail)后绿(5/5 pass)
** 集成测试
*** SIPp 打真呼叫
*** ESL 订阅事件做断言
*** 专用 conf + Docker 隔离
*** 判定靠结构化事件,非人肉日志
** 分工决策
*** 能否一个 session 内复现
*** 能 -> FST
*** 不能 -> 集成测试
*** 内存 bug 优先 FST + ASan
@endmindmap
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。