给 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_xmlregex="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_sessionfst_channelfst_session_poolfst_pool 四个局部变量,直接用。
  • 要模拟外部输入,用 fst_sched_recv_dtmf("+1", "1")——预约在第 1 秒往 session 注入 DTMF 键"1",用来模拟 IVR 收号。
  • 断言就两个常用的:fst_check(cond) 判真,fst_check_string_equals(a, b) 判字符串相等。
  • FST_SETUPFST_TEARDOWN 就算什么都不做,也必须成对写在套件里,少一个编不过。

写完文件还得让它进构建,否则 make check 根本不编它,你会误以为"测试过了":

  • Core 测试:文件放 tests/unit/,在 tests/unit/Makefile.amnoinst_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. 先写测试,跑出 1/2 failing——确认失败可复现(修之前 action 打的就是 transfer to)。
  2. 打补丁。
  3. 同一批测试跑出 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 集成测试长这样:

  1. 拉起被测的 FreeSWITCH——用一份专门的配置目录(专用 dialplan、profile、只加载需要的模块),干净、可复现、和你的开发机无关。CI 里通常用 Docker 起一个隔离实例。
  2. 用 SIPp 扮演对端打呼叫SIPp 是事实标准的 SIP 压测/仿真工具,你用 XML scenario 描述"发 INVITE → 等 200 → 回 ACK → 挂 BYE",它就按脚本跟 FreeSWITCH 完整走一遍呼叫。它还能带 RTP、放 pcap 媒体、并发拉几百上千路,顺手把负载测试也做了。
  3. 用 ESL 订阅事件做断言。这是集成测试能"自动判定"的关键。你连上 FreeSWITCH 的 Event Socket,订阅 CHANNEL_CREATECHANNEL_ANSWERCHANNEL_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 时,照这个走:

  1. 先分层:问"能不能在一个 session 内复现"。能 → FST;不能 → 集成测试。拿不准就先试 FST,它最快。
  2. FST 建文件:core → tests/unit/xxx.c;模块 → src/mod/.../test/xxx.c。骨架照抄四层宏,FST_SETUP/FST_TEARDOWN 就算空也要写。
  3. 改 Makefile.am:core 加进 tests/unit/Makefile.amnoinst_PROGRAMS;模块加进模块 Makefile.am 并设 TESTS = $(noinst_PROGRAMS)忘了这步测试根本不编。
  4. 先红再绿:make install && make check;确认测试在打补丁前会失败,再打补丁转绿。测试和补丁放同一个 PR,描述里贴出"修之前失败态 / 修之后 PASSED"。
  5. 正反都覆盖:改内存/状态相关的代码,别只测正例;把"该跳过""该失败""相邻路径"也测上(#3141 就配了 5 个 case)。
  6. 集成测试要能自动判定:SIPp 发起呼叫,ESL 订阅事件做断言。判定靠结构化事件(hangup cause、channel 变量),不靠人肉 grep 日志。CI 里用 Docker 起隔离实例。
  7. 内存 bug 叠 Sanitizer:能开 ASan 就开,尤其注意 detect_stack_use_after_return 这类默认关掉的开关——不开它,栈上的 use-after-return 只打乱码不报错。

一句话:

测试的价值一半在"证明修好了",一半在"锁住别人以后别改坏"。 一个见过红、正反都覆盖、能自动判定的测试,才配跟补丁一起进 PR。


总结:先分层,再"先红后绿",两个一起提

给 FreeSWITCH 写测试,难的从来不是语法,是分层:能进程内复现的用 FST,跨协议跨网络的用 SIPp + ESL。选对层,一个下午能钉死一个 bug;选错层,要么白搭环境,要么根本测不到。

而不管哪一层,规矩是同一条——先让测试红,再让它绿,测试跟补丁一起进 PR。#3141 就是这么修完的:5 个 FST case,从 1/2 failingPASSED (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 访问原文并评论。