news 2026/10/21 5:38:51

MochiWeb服务测试实战:用TDD与EUnit构建可靠Erlang HTTP接口

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MochiWeb服务测试实战:用TDD与EUnit构建可靠Erlang HTTP接口

不管你是从老项目堆里爬出来的运维,还是刚接触Erlang的初学者,只要被“HTTP服务怎么测才靠谱”这个问题卡过,那这篇内容就很适合你。C语言有libevent、Java有Netty、Erlang这边主打轻量并发的HTTP服务器其实是MochiWeb。很多老牌Erlang服务至今还在用它顶着线上流量,我前几年也接手过一个用MochiWeb写的内部API服务,代码逻辑不复杂,但最大的问题在于:谁也不敢改。没有测试,改一行代码都像走钢丝。后来我彻底切换到测试驱动开发(TDD)的方式,把核心路由和响应行为全部用测试锁死,整个维护难度才真正降下来。

这篇文章我会从MochiWeb的架构特点讲起,把测试驱动开发的完整流程拆开:先搭好测试脚手架,再写一个必然失败的测试,然后通过最小实现让它通过,最后重构把业务逻辑接进真正HTTP请求管道里。全部过程用EUnit落地,包含大量可以直接照抄的代码和我在实际项目里踩过的坑。适合想给Erlang HTTP服务补测试的人,也想给不太信任已有测试覆盖率的项目做一次重构的人。

1. 整体设计:MochiWeb项目里TDD的落地思路

1.1 MochiWeb的架构特点:测试入口在哪里

MochiWeb不是一个像Cowboy那样的全功能Web框架,它更接近一个HTTP协议处理库。你只需要提供一个回调函数,MochiWeb负责监听端口、解析HTTP请求、再把请求对象交给你,你处理完通过请求对象返回响应。这个回调的典型形态是:

loop(Req, State) -> Method = Req:get(method), Path = Req:get(path), %% 根据Method和Path分发处理 Req:respond({200, [{"Content-Type", "text/plain"}], <<"ok">>}).

这种设计的最大好处是“测试入口非常清晰”。你既可以把它当作一个普通Erlang模块,直接调用里面的纯函数来测试;也可以把它当作一个真实运行的服务,通过HTTP客户端从外面打请求。这两条测试路径正好对应TDD里的两个层次:快速迭代的单元测试、真实链路下的集成测试。

我从MochiWeb架构里得出的结论是:测试HTTP服务器并不需要所有用例都走socket。凡是能抽出来算的逻辑,都应该先作为纯函数测;只有路由分发、请求体读取、响应头拼接这些真正和HTTP协议绑定的行为,才值得启动端口做端到端测试。这样测试跑得快,也不容易因为环境问题闪崩。

1.2 分层测试策略:从函数级到HTTP级

我给MochiWeb服务设计的测试策略分两层。第一层是函数级测试,直接调用业务处理函数,只关心输入输出,不经过socket。比如一个处理echo的逻辑函数:

echo(Data) -> Data.

那测试就极其轻快:

-ifndef(MYAPI_TEST). -define(MYAPI_TEST, true). -endif.

准确说,直接echo_test() -> ?assertEqual(<<"abc">>, myapi:echo(<<"abc">>)).这种函数测试往往一秒内跑完几千个。第二层是HTTP级测试,启动MochiWeb监听本地临时端口,用 httpc 发真实HTTP请求,验证状态码、响应头、响应体。这类测试更接近用户真实行为,但速度慢、依赖端口和环境,不适合每个函数都来一遍。

两个层次要配合使用。函数级测试负责把业务逻辑的边边角角都测细,HTTP级测试负责守护协议行为,比如/hello必须返回200、POST /echo必须带回请求体、未知路径必须返回404。职责清晰,才不会让测试变成一团乱麻。

1.3 TDD的三拍节奏:红绿重构

TDD听起来很高大上,实际节奏就三拍:先写测试,运行看到它失败(红);写最小实现代码让测试通过(绿);在测试保护下重构整理代码(重构)。对HTTP服务来说,这个循环尤其适合。因为HTTP接口的本质是一个“契约”,测试就是把这个契约先写下来,实现只是去满足契约。

很多人第一次做TDD时不适应的一点是:“实现还没写,测试怎么写?”我自己的经验是:先想清楚这个接口的行为到底是什么,比如“收到GET /hello时返回200、text/plain、Hello, World!”,然后把这个行为用断言写出来。此时运行测试肯定会失败,因为模块甚至不存在,但这份失败就是最有价值的设计文档。MochiWeb项目由于结构简单,这个循环跑起来非常顺畅。

2. 环境准备:最小可运行的项目脚手架

2.1 用rebar3创建项目并引入MochiWeb

我一般用rebar3来管理Erlang项目,测试、依赖、编译一条龙。MochiWeb的引入也很简单,直接在rebar.config里加上依赖:

{erl_opts, [debug_info, warn_unused_import]}. {deps, [ {mochiweb, "2.22.0"} ]}. {profiles, [ {test, [ {deps, [ {meck, "0.9.2"} ]} ]} ]}.

这里我多说一句关于meck的心得。它用于在测试里mock掉某些不支持测试的模块,但能不用尽量不用。MochiWeb的核心处理函数可以设计为纯函数,根本不需要mock;真正需要启动HTTP服务时,直接启动真实服务反而更可靠。mock用得越多,测试离真实行为越远,“可靠”二字也就名存实亡。

创建项目后目录结构大概是这样:

mochi_tdd/ ├── rebar.config ├── src/ │ ├── mochi_tdd.app.src │ └── myapi.erl └── test/ └── myapi_tests.erl

所有源码放src,所有测试放test,这是rebar3的约定。我建议从一开始就遵循这个约定,后面跑rebar3 eunit时它会自动编译并执行test目录下的所有*_tests.erl模块。

2.2 EUnit基础:第一个必然失败的测试

EUnit是Erlang自带的单元测试框架,语法非常轻,核心是两个东西:_test()测试生成器和?assertEqual断言宏。我在test/myapi_tests.erl里写下的第一个测试,会是一个“注定失败”的测试,目的就是验证TDD的Red阶段:

-module(myapi_tests). -include_lib("eunit/include/eunit.hrl"). echo_test() -> ?assertEqual(<<"hello">>, myapi:echo(<<"hello">>)).

现在运行rebar3 eunit,结果必然是失败的,因为myapi模块还不存在。很多人到这里就慌了,其实这是最好的开始——你已经明确写出了第一个需求:需要一个myapi:echo/1函数,它能把输入原样返回。这个测试文件就是需求规格书,失败状态证明规格书和实现之间存在差异。

我建议初学者把“失败”看作TDD的正常状态,不要一上来就试图写一个能一次通过的巨大测试。每个测试只描述一个行为,越小越好跑,红得越清楚,绿得越有成就感。

2.3 测试目录与模块命名约定

rebar3对测试文件的命名有强约定:只要模块名以_tests结尾,EUnit就会自动收集并执行,比如myapi_tests、router_tests、handler_tests。在这个约定下,我通常按被测模块一对一建文件:被测模块是myapi.erl,测试模块就叫myapi_tests.erl。

如果某个测试文件里需要辅助函数,比如构造URL、启动服务器,可以放在同一个文件里。EUnit只识别_test()结尾或_test_()结尾的函数,其他普通函数不会被当作测试执行,这个机制很巧妙,可以放心写辅助函数。

另外,不同测试文件如果都要启动HTTP服务,端口不要写死某一个固定值。我在多个项目里被固定端口坑过很多次:本地跑得好好的,到了CI环境一跑,端口被别的服务占了,测试全挂。后面我会详细说怎么用动态端口解决这个问题。

3. 红绿重构循环:从测试驱动出核心处理逻辑

3.1 Red阶段:先写行为契约再写实现

我实际项目里最常用到的一个接口是POST /api/echo,它接收请求体,并在响应里原样返回。这个接口看起来简单,却是验证HTTP服务器是否正常工作的最佳探针:能测路由分发、能测请求体读取、能测响应构造。TDD的第一步,是给这个接口写一个HTTP级别的测试:

-module(myapi_tests). -include_lib("eunit/include/eunit.hrl"). myapi_test_() -> {setup, fun start_server/0, fun stop_server/1, {inorder, [fun test_echo_returns_request_body/0, fun test_hello_returns_200/0, fun test_unknown_path_returns_404/0]}}.

但注意,这里start_server都还没有定义,测试当然跑不过。写到这里我先停住,问自己一个问题:我到底希望服务器具备哪些行为?答案被我用测试用例写下来:

  • 请求POST /api/echo,响应体等于请求体。
  • 请求GET /hello,响应200且内容为Hello, World!。
  • 请求不存在的路径,响应404。

这三条就是服务的行为契约。它们的价值在于:不管底层实现怎么改,只要这三个测试全绿,服务对外行为就是稳的。

3.2 Green阶段:用最小实现通过测试

为了让第一条测试通过,我先实现一个最小的HTTP服务器。MochiWeb的启动方式很简单,在myapi.erl里写:

-module(myapi). -export([start/0, stop/0, loop/2]). start() -> mochiweb_http:start([ {name, ?MODULE}, {loop, fun ?MODULE:loop/2}, {port, 8090}, {binary, true} ]). stop() -> mochiweb_http:stop(?MODULE). loop(Req, _State) -> Path = Req:get(path), Method = Req:get(method), case {Method, Path} of {post, <<"/api/echo">>} -> Body = Req:recv_body(), Req:ok({"text/plain", Body}); {get, <<"/hello">>} -> Req:ok({"text/plain", <<"Hello, World!">>}); _ -> Req:respond({404, [{"Content-Type", "text/plain"}], <<"Not Found">>}) end.

这段代码的关键点在于{post, <<"/api/echo">>}的模式匹配。MochiWeb在{binary, true}配置下,Req:get(path)返回的是二进制,Req:get(method)返回的是小写原子,比如post、get。很多新手在这里被坑,因为早期MochiWeb文档里的示例用的是字符串path,一旦版本或配置不同,模式匹配直接匹配不上,路由全部走到404。

补上这段代码后,重新运行rebar3 eunit。如果前面测试文件里的三个用例都还没写完,也可以先只跑echo那一条。当第一条HTTP测试变绿,说明请求从socket进来、经过路由分发、读取body、构造响应、返回给客户端的整条链路已经打通。这就是HTTP服务器TDD最关键的里程碑。

3.3 Refactor阶段:把业务逻辑接进HTTP管道

测试变绿之后,不要急着写下一个功能,先回头看一眼这段loop代码。现在它把所有行为都揉在一个case里,以后路由多了会越来越臃肿。在测试保护下,我顺手把分发逻辑重构一下,让每个路径对应单独的处理函数:

loop(Req, _State) -> Path = Req:get(path), Method = Req:get(method), try route(Method, Path, Req) catch error:badarg -> Req:respond({400, [{"Content-Type", "text/plain"}], <<"Bad Request">>}) end. route(post, <<"/api/echo">>, Req) -> Body = Req:recv_body(), Req:ok({"text/plain", Body}); route(get, <<"/hello">>, Req) -> Req:ok({"text/plain", <<"Hello, World!">>}); route(_, _, Req) -> Req:respond({404, [{"Content-Type", "text/plain"}], <<"Not Found">>}).

重构完成后立刻重新跑测试,三个用例还是全绿,说明重构没有破坏任何已有行为。这个“先让它绿再变漂亮”的节奏,就是TDD里Refactor阶段的标准做法。没有测试的时候,谁都不敢这么随意抽函数;有了测试,重构就变成一件很有安全感的事。

4. 集成测试:在真实端口上验证HTTP行为

4.1 测试夹具:服务器的启动与停止

到了这一节,前面的小循环已经建立起信心,现在要做的是把集成测试工程化,让它可靠地反复执行。EUnit的{setup, Setup, Cleanup, Tests}结构天然适合集成测试:每个测试用例(或整组用例)运行之前执行setup,跑完之后执行cleanup。

我实际用的setup和cleanup长这样:

start_server() -> {ok, _} = application:ensure_all_started(inets), myapi:start(), ok. stop_server(_) -> myapi:stop(), ok.

这里第一行application:ensure_all_started(inets)很重要。inets是Erlang标准库里的HTTP客户端(httpc)所在的application,不启动它,httpc发请求会直接返回{error, not_started}。我经常在别人代码里看到httpc请求失败,最后发现就是少了这行启动语句。

stop里用myapi:stop()停掉MochiWeb监听。一个容易被忽略的细节是:如果测试中途进程崩了或者端口没释放,下一次setup再启动时会报端口已被占用。我在后面“常见问题”小节里会专门讲这个。

4.2 用httpc写真实断言

有了服务器,下一步就是用真实HTTP请求来断言。httpc是Erlang自带的客户端,不需要额外依赖。我完整的集成测试是这样的:

base_url() -> "http://localhost:8090". test_hello_returns_200() -> {ok, {{_, 200, _}, _, Body}} = httpc:request(get, {base_url() ++ "/hello", [{"Connection", "close"}]}, [{timeout, 2000}], [{body_format, binary}]), ?assertEqual(<<"Hello, World!">>, Body).

写这段时有两个细节我特意说明一下。第一,请求头里带了{"Connection", "close"}。httpc默认会尝试复用HTTP连接,在测试环境下如果连接没有正常关闭,下一次请求会挂在那里直到超时。加这个头让服务端处理完就断开,测试稳定性会大幅提升。第二,[ {body_format, binary} ]这个option把响应体格式指定为binary,这样断言时可以直接和<<"Hello, World!">>比较。如果不加,httpc返回的Body是字符串列表,断言会变成字符串和二进制比较然后白红一场,纯属浪费时间。

4.3 路由分支覆盖:成功、参数与404

集成测试不能只测happy path,必须把每个路由分支都覆盖到。下面是我在一个服务里实际用过的测试集,覆盖了正常路径、POST请求体读取、未知路径404:

test_echo_returns_request_body() -> URL = base_url() ++ "/api/echo", {ok, {{_, 200, _}, _, Body}} = httpc:request(post, {URL, [{"Connection", "close"}], "application/octet-stream", <<"ping">>}, [{timeout, 2000}], [{body_format, binary}]), ?assertEqual(<<"ping">>, Body). test_unknown_path_returns_404() -> {ok, {{_, 404, _}, _, _}} = httpc:request(get, {base_url() ++ "/no-such-path", [{"Connection", "close"}]}, [{timeout, 2000}], []), ok.

这里最值得说的一点是,对404这类失败场景也要显式断言状态码。很多项目的测试只覆盖成功接口,遇到问题全靠在线上日志里翻,实属被动。把404、400、405这些边界状态码也写进测试,等于提前给服务的异常行为上了保险。

test_echo_returns_request_body里post请求的请求体用的是binary<<"ping">>,响应体用{body_format, binary}取回也是binary,两者可以直接?assertEqual。这样断言清晰、可读、不容易误报。

4.4 测试隔离:避免数据与端口互相污染

做集成测试最怕的就是“测试之间互相影响”。假设你的服务启动时要往ETS表里写配置,或者内存里存了状态,前一个用例改坏了数据,后一个用例就跟着挂。解决办法是:每个测试用例组setup时都重启服务,让状态从零开始。

比如我上面这个myapi_test_()用了{setup, fun start_server/0, fun stop_server/1, [...]},EUnit会在这个测试组开始时调用start_server,结束时调用stop_server。不同测试组之间互不影响。端口隔离我也有个习惯:不要在代码里硬编码端口,而是让测试环境用一个独立端口,比如固定7000、8000到9000之间选一个不太起眼的。真要更稳妥,可以动态从操作系统申请空闲端口,但那些代码多了之后会干扰测试可读性,我一般只在特殊场景用。

对MochiWeb这种轻量级服务,固定端口+每个测试组重启服务已经足够可靠。只要保证CI上这个端口不和其他服务冲突,测试基本不会因为环境问题挂掉。

5. 常见问题与排查技巧实录

5.1 端口占用与connect refused

集成测试最常见的两个报错分别是eaddrinuse和econnrefused。前者是启动服务时端口已经被占,后者是请求发出时服务还没起来或已经挂掉。

端口占用这种事,我排查思路很简单:先确认是否有测试进程没被清理,比如上一次跑测试时Ctrl+C强制中断,MochiWeb进程成了孤儿,继续霸占端口。解决办法先别急着改代码,手动执行stop()确认能正常停掉,再查一下是否有残留的beam进程。如果确认是残留,直接结束对应进程,重新跑测试。

我后来给自己定了一条规矩:setup里启动服务前,先执行一次catch myapi:stop()。这相当于“清理战场”,确保上一次残留的实例被清掉,再启动新的,能规避掉绝大多数端口冲突问题。

5.2 httpc请求挂起与超时设置

集成测试挂起是个很恼人的问题,表现是跑测试时卡在某条httpc请求上,最后才报超时。原因通常是两个。一是连接被复用但没有正确关闭,二是服务端处理请求时出了问题没返回响应。第二种情况属于代码bug,第一种属于httpc使用习惯问题。

我建议测试里的每一条httpc请求都显式带上超时参数{timeout, 2000},再配合请求头{"Connection", "close"}。这样即便服务端出了问题,测试最多卡2秒就报错,而不是无限期挂下去。这条经验虽然普通,但关键时刻能救命。

还有一个隐藏很深的坑:如果服务端返回的Content-Length和实际body长度不一致,httpc会一直等body读完。MochiWeb的Req:ok({ContentType, Body})会自动计算Content-Length,正常不会出问题,但如果你是自己拼respond且手动写了错误的Content-Length,就会出现偶发挂起。所以我在代码审查时都会特别留意:手写响应头时Content-Length不要乱填,最好交给MochiWeb去算。

5.3 断言对象格式:binary、list与JSON

Erlang的HTTP世界里,类型问题永远是新手重灾区。MochiWeb的服务端接口在不同配置下返回的path可能是list也可能是binary,httpc返回的body可能是list也可能是binary。一旦两边数据类型对不上,断言就会失败,而代码本身其实没问题。

我的统一处理方式很粗暴:服务端启动时加{binary, true},让MochiWeb内部path、body都保持binary;客户端httpc请求时加[{body_format, binary}],让响应体也统一为binary。这样两边都是binary,断言直接比较,不转换。遇到要处理JSON接口的情况,我会先把JSON解析成map再断言字段,不建议直接对JSON字符串做子串匹配,那样不仅脆弱,可读性还差。

test_json_field_assertion() -> {ok, {{_, 200, _}, _, Body}} = httpc:request(get, {base_url() ++ "/api/user/1", [{"Connection", "close"}]}, [{timeout, 2000}], [{body_format, binary}]), User = jsx:decode(Body, [return_maps]), ?assertEqual(<<"alice">>, maps:get(<<"name">>, User)).

5.4 rebar3 eunit的运行与调试

最后聊一下跑测试这个动作。rebar3 eunit这条命令会自动编译项目、处理依赖、跑所有*_tests.erl模块。但它的一些细节值得知道:

rebar3 eunit --module=myapi_tests

只想跑某一个测试文件时用--module参数。想要verbose输出时用-v,能看到每个测试函数的耗时和结果。偶尔遇到测试错误需要看完整堆栈时,我会把-v和--cover一起用,便于定位是哪个分支没覆盖到。

调试的时候我最常用的还是加io:format,但Erlang的io:format输出在EUnit里经常被吞。解决办法是在测试函数里用?debugFmt代替io:format,EUnit会把它归入测试日志。如果不想改代码,也可以设置rebar3 eunit --verbose --sys.config之类的参数,但效果没有?debugFmt直接。

结尾

我实际把MochiWeb服务的测试体系搭起来之后,最大的感受是:测试驱动开发真正的价值不是“多写测试”,而是“先想清楚行为再写代码”。每一条HTTP接口测试都是一份可执行的需求文档,它们锁死的不是实现细节,而是服务对外部的承诺。在MochiWeb面前,那些承诺无非是某个路径该返回什么状态码、什么内容,测试写清楚后,后面的重构就变成一项很有安全感的工作。

再多分享一个我自己的习惯:每次新增路由,我强制自己先加一条红测试再写实现。一旦某段时间工作忙、压力大,这个习惯会守住最后一道防线。测试不是给项目找麻烦,恰恰是给未来的自己减少麻烦。希望这篇文章能帮你把自己项目里的MochiWeb服务,从“不敢改”变成“随便改”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 5:38:51

NUMA架构详解:多核服务器性能调优的关键实战

前两年我拿到一台双路服务器&#xff0c;十六核起步、内存两百多G&#xff0c;本以为跑什么业务都绰绰余。结果把一个有状态服务部署上去&#xff0c;压测时线程处理耗时忽高忽低&#xff0c;整体吞吐远低于硬件配置应有的水平。排查到最后发现&#xff0c;问题根本不在代码逻辑…

作者头像 李华
网站建设 2026/10/21 5:38:31

基于Spring Boot的网络安全意识教育平台:钓鱼演练与行为闭环设计

1. 为什么这个平台值得做&#xff1a;安全意识教育的真实缺口之前我负责维护一个内部管理系统时&#xff0c;最头疼的事情之一就是每年反复组织的网络安全培训。培训邮件发下去&#xff0c;学习时长刷完&#xff0c;考试抄个答案&#xff0c;表格上填"已完成"&#x…

作者头像 李华
网站建设 2026/10/21 5:37:25

从零搭建分布式微服务架构:技术选型、服务拆分与治理实战

1. 为什么我要从零搭一套分布式微服务1.1 单体架构撑不住之后&#xff0c;我踩过的那些坑去年下半年&#xff0c;我负责维护的一个内部业务系统开始频繁出问题。这套系统最早是一个典型的单体应用&#xff0c;所有模块——用户管理、订单处理、库存扣减、消息通知——全部打包在…

作者头像 李华
网站建设 2026/10/21 5:37:22

STC8H1K28中功率三相无刷电机控制原理图与代码实现

简介&#xff1a;这份资源是面向电子工程师与嵌入式开发者的中功率三相无刷直流电机控制原理图&#xff0c;基于STC8H1K28微控制器设计&#xff0c;适合从事电机驱动、BLDC控制方案开发及调试的技术人员参考。压缩包内共1个PDF文件&#xff0c;约52KB&#xff0c;以电路原理图形…

作者头像 李华
网站建设 2026/10/21 5:36:46

knowledge-work-plugins:知识工作插件体系从架构到实战

1. 这个插件体系到底在解决什么问题第一次看到 knowledge-work-plugins 这个命名&#xff0c;我脑子里蹦出来的第一个判断是&#xff1a;这大概率是一套面向知识工作者的插件集合&#xff0c;而不是某个单一工具。知识工作这件事&#xff0c;说白了就是围绕信息做采集、整理、加…

作者头像 李华
网站建设 2026/10/21 5:36:11

基于LabVIEW的纯电驱动测试系统上位机平台架构与工程实践

简介&#xff1a;这份基于Labview的纯电动汽车电力驱动测试系统研究PDF&#xff0c;面向新能源汽车专业的学生、科研人员及测试工程师&#xff0c;系统阐述了以Labview构建电驱动测试平台的核心方法。内容覆盖控制器算法模拟、电机模型建立、电池充放电仿真、测功机台架交互、数…

作者头像 李华