资讯动态

Caveman:轻量级BDD测试框架,让老PHP项目也能优雅补测试

发布时间:2026/10/29 12:33:56 来源:尧图企业网站定制
说实话能在这个满屏PHPUnit的生态里遇到Caveman纯属一次无意的搜索。当时我的诉求特别简单给一个历史包袱很重的老项目补测试但又不想引入一整套复杂的测试基础设施。Caveman就是这时候冒出来的——一个走BDD路线的PHP轻量级测试框架把断言写成自然语言链条跑一圈下来比我想象中还省心。这篇内容适合那些受够了PHPUnit样板代码、想用更接近业务语言的方式写测试又没精力折腾Behat这类重框架的团队。我会从安装、断言语义、测试组织讲到我在实际项目里踩过的坑尽量让文章可落地。1. 先想清楚Caveman到底解决什么问题1.1 传统PHPUnit写测试的痛点在聊Caveman之前得先说清楚它想取代的那部分痛。PHPUnit作为PHP生态里的老大哥能力确实全面但对一部分项目来说它有点“重”。重在哪不是性能而是心智负担。PHPUnit的测试类要求继承TestCase方法名必须以test开头断言方法一大堆assertEquals、assertSame、assertArrayHasKey、assertCount、assertGreaterThan……写一两个还好当你的用例规模上来之后满屏的assert开头看着眼睛累读起来也费劲。尤其当断言逻辑稍微复杂一点一个用例里塞五六个assert基本只能靠注释才能看懂在测什么。更麻烦的是备份与恢复机制。PHPUnit在跑每个用例前都要处理全局变量备份、静态属性备份、注解解析这些自动化流程在普通项目里是省心的但在一些老旧的、到处用全局变量和静态状态的代码库里反而会成为头疼的源头你可能得花半天时间排查某个静态属性为什么没有按照预期“恢复现场”。我当时的情况是目标项目没有PHPUnit没有composer是个从十年前留下来的老系统。我需要补测试但是我想要的不是再学一遍庞大的断言API而是一种能把业务意图直接写进代码的测试方式。Caveman恰好就是为这种场景设计的。1.2 Caveman的设计哲学不魔改、不神奇Caveman的定位非常朴素一个“没有魔法”的PHP测试框架。它没有复杂的注解解析没有自动的全局变量备份也不强制你继承任何基类。它做的最核心的事情只有两件提供一个自然的链式断言表达式以及一个能把断言结果收集起来的运行器。它最出名的那种写法是模仿Gherkin风格的Given-When-Then语义写进PHP代码里。你不需要去学.feature文件格式也不需要像Behat那样维护一套“步骤定义”的概念。Caveman直接让PHP代码本身就能读出业务场景的味道。我用一个生活化的类比来解释PHPUnit像是一套家用多功能一体机能打印、能扫描、能复印但你把电源线、驱动光盘、Wi-Fi设置都弄明白需要大半天Caveman则像一把碳钢菜刀没有花哨的功能却能在你熟悉的那块案板上让你切得又快又顺手。两者没有绝对的谁好谁坏关键看你的后厨环境。所以Caveman适合谁适合那些想迅速在老项目里建立回归测试防线、又不愿意被测试框架本身的复杂度绑架的团队。它不逼你改项目结构不要求你理解“测试金字塔”的全部方法论你只需要按业务场景写断言然后运行看结果。2. 环境准备与一条龙上手2.1 环境要求与安装先说环境。Caveman是纯PHP实现所以对PHP版本有基本要求我使用的版本要求PHP 7.4以上如果你还在跑PHP 5.6这种上古版本那基本告别了——但说真的都2025年了还在跑5.6的项目第一优先级应该是升级运行时而不是引进新测试框架。安装方式以Composer为主。如果你的项目已经建立了composer管理直接在项目根目录执行composer require --dev caveman/caveman这里有个实操细节包名在不同的仓库分支里可能略有差异。我在接入时发现Packagist上存在多个同名但组织不同的包如果你直接用上述命令装不到可以在composer.json里用VCS仓库方式引入{ repositories: [ { type: vcs, url: https://github.com/your-mirror/caveman.git } ], require-dev: { caveman/caveman: dev-main } }如果你遇到的是一个压根没有composer项目的纯老代码库也别慌。我当时的做法是先给这个老项目初始化一个最简单的composer.json不引入任何业务依赖只做autoload和dev依赖管理。这样既不影响线上部署又能把测试工具链装进去。安装完成后验证一下版本vendor/bin/caveman --version如果输出版本号说明命令解析正常接下来可以写第一个测试文件了。2.2 第一个测试文件从加法开始Caveman的核心运行单元是一个世界World实例。名字听起来唬人其实就是一个收集测试用例、组织断言结果的对象。我用加法计算器这种经典例子来演示基础结构?php require __DIR__ . /vendor/autoload.php; use Caveman\World; $world new World(); $world -describe(加法计算器) -it(两个正整数相加得到正确结果, function () use ($world) { $world -given(第一个加数为 1, 1) -and(第二个加数为 2, 2) -when(调用 add 方法进行相加) -then(结果应该等于 3) -should-be-equalTo(3); }); $world-run();这段代码读完基本一个没写过PHP的人都能猜出在测什么。这就是BDD风格最直观的价值测试代码本身就是业务文档。需要说明的是不同小版本的API可能有微调比如有人更喜欢用expecting()函数而不是given/when/then链。我给出的写法是我在项目里实际用的如果你是第一次接触Caveman建议先跑通这个最小样例再根据你安装版本的README调整语法。运行测试的方式也很简单php tests/run.php如果一切都正常你会看到类似输出加法计算器 ✓ 两个正整数相加得到正确结果 1 tests passed, 0 failed这就算入坑了。2.3 断言失败了会怎样写测试首先得知道失败长什么样。我把上面的断言故意改错期望结果改成5重新运行php tests/run.php控制台会输出红色标记的失败信息包含用例名称、期望值和实际值。这里有一个小提醒Caveman默认的失败信息比较简洁不会像PHPUnit那样打印一堆堆栈和diff。对新手来说反而友好因为它逼你关注“这个用例本身的断言是否合理”而不是沉迷在调试器中。我习惯的做法是给每条given的参数起一个能描述业务含义的名字比如given(用户已登录session_id为abc123, abc123)。这样即使半年后回来看失败输出也能立刻想起这个用例的上下文不需要再去翻代码。3. 核心功能拆解与实操要点3.1 链式断言的可读性原理Caveman最值得聊的是它的链式断言风格。它的设计思路不是重新发明一套断言函数而是用PHP的public属性和方法链拼出一条读起来像英文句子的自然语言表达式。以实际例子来感受一下$world-expect(计算结果)-toBe(4); $world-expect(用户列表)-not-toBeEmpty(); $world-expect(接口返回)-to-haveKey(data); $world-expect(异常对象)-toBeInstanceOf(RuntimeException::class);这个写法背后是当访问expect返回的对象时not、toBe、to这些不是普通方法而是通过魔术方法动态处理的属性或方法调用。这样在代码里写出来的断言几乎和一句英文语法一致。这种设计的代价是IDE的自动补全基本废掉但换来的是可读性的巨大提升。我在实际使用中还总结了一个小规律当断言链超过三个节点时就该拆分了。比如// 太长了不推荐 $world-expect(响应对象)-to-be-an-instanceOf(Response::class);这种纯为了念着顺而堆砌的链子其实已经偏离了可读性本意。更清晰的写法是$world-expect(响应对象)-toBeInstanceOf(Response::class);选最接近英文自然语序、节点数最少的表达如果涉及多种断言比如同时校验状态码和响应体拆成两行expect比硬串一条长链更直观。3.2 掌握常见的断言类型为了不让你在翻代码的时候懵圈我把我在项目中利用率最高的断言类型整理成了一张速查表意图链式写法PHPUnit对应写法值相等expect($value)-toBe($expected)assertEquals严格相等expect($value)-toBeSameAs($expected)assertSame取反expect($value)-not-toBeNull()assertNotNull大于expect($value)-toBeGreaterThan(10)assertGreaterThan包含键expect($arr)-toHaveKey(name)assertArrayHasKey包含子串expect($str)-toContain(token)assertStringContainsString类型检查expect($obj)-toBeInstanceOf(User::class)assertInstanceOf布尔真/假expect($flag)-toBeTrue()assertTrue这张表是我从实际代码里反推整理的不同版本的Caveman命名可能有出入但思路是一致的用语义化短语代替assert前缀。另外提一个和PHPUnit不同的习惯Caveman非常鼓励你把“断言意图”写在expect第一个参数里也就是那句话。比如$world-expect(登录后的重定向地址应该指向首页)-toBe(/home);这句话不是装饰它在失败时会作为描述信息显示出来帮助你快速定位失败到底来自哪个业务点。3.3 用describe/it组织场景单个用例写起来容易组织成什么结构才是项目成败的关键。Caveman提供了describe和it两层组织模式类似于RSpec中的上下文分组。我的组织习惯是这样的$world -describe(订单模块) -describe(创建订单) -it(未登录用户创建订单应返回401, function () use ($world) { // 模拟未登录请求 }) -it(登录用户创建订单并扣减库存, function () use ($world) { // 正常链路 });从我的项目经验来看场景名称要尽量写“用户视角的动词”而不是“代码视角的函数名”。举例来说“创建订单”比“testOrderCreate”好得多因为前者描述的是业务行为后者描述的是系统内部名词。同时我建议一个文件只测一个业务模块。比如tests/OrderTest.php只放订单相关用例tests/UserTest.php只放用户相关用例。这样后续扩展用例时不会出现一个文件几百行的恐怖情况。3.4 数据驱动与闭包陷阱测试里少不了循环跑数据集的场景。Caveman里我会这样写$cases [ [, 空字符串应被拒绝], [a, 长度不足应被拒绝], [abcdefghijk, 超长字符串应被拒绝], [valid_name, 正常用户名应通过], ]; foreach ($cases as [$value, $desc]) { $world-it($desc, function () use ($world, $value) { $world -given(输入值, $value) -when(执行用户名校验) -then(得到预期校验结果) -should-not-toBeNull(); }); }这里有一个必须注意的PHP闭包坑如果你在for循环里用use ($value)取引用最终所有闭包拿到的都是循环结束后的最后一个值。Light在PHP 7.4之前foreach的迭代变量如果被闭包引用也会出现类似问题。我在老项目上就因为这个坑跑出了十条一模一样的测试数据排查了快两小时。正确姿势是要么像我上面那样用解构后直接传入use ($value)让值拷贝进闭包要么在循环体内用匿名函数的默认参数绑定。简单说闭包捕获的变量优先级是拷贝值不要图省事加引用符。4. 常见问题与排查技巧实录4.1 断言失败信息不够直观怎么办Caveman默认的失败输出格式表达能力有限特别是有时候你断言$response数据结构里有某个字段失败打印出的却是一堆数组转储。我的解决方案是“上下文打包断言法”把预期值和实际值放在一起人为制造出信息量充足的失败提示$response $api-call(); $world -given(接口返回数据, json_encode($response)) -when(解析响应中的 status 字段) -then(status 应该为 success) -expect(status值) -toBe($response[status] ?? );这样即使断言失败given里带上的json数据也会跟着输出一眼就能看到响应全貌。实际使用下来这个习惯能减少大约一半的排查时间。另外Caveman在输出失败时默认不显示文件路径和行号只有一个用例标题。要快速定位我习惯在用例标题里直接写明“文件:行号”自定义一个前缀比如it(UserTest.php:35 用户余额扣减后不小于零, ...)。这样能省去在日志里翻找代码位置的时间。当然如果你觉得这样太丑可以考虑在自定义运行器里包装一下异常捕获打印调试信息。4.2 闭包共享状态导致用例互相污染在传统PHPUnit里每个测试方法之间是隔离的因为它会重新创建TestCase实例。但Caveman的测试世界是同一个对象多个用例闭包会共享一些外部变量如果不加注意很容易出现前一个用例写入的全局状态把后一个用例带偏。我遇到的一个真实案例用户模块测试里我在第一个用例中通过$this-user createUser()创建了一个用户对象并把它塞进全局静态缓存里。第二个用例一进来就读取全局缓存却拿到了第一个用例留下的数据导致断言结果莫名其妙。排查时打死都想不到是顺序依赖。解决的办法是封装一个reset()函数在每个用例开头重置相关状态function reset_user_context(): void { UserCache::clear(); $_SESSION []; $GLOBALS[current_user] null; } $world-it(注销后session清空, function () use ($world) { reset_user_context(); // 业务逻辑… });这里想强调一个理念没有框架的自动隔离不代表你不需要隔离只是隔离的责任从框架移交到了你手上。我建议所有涉及全局变量的用例开头都至少重置环境这也是Caveman“无魔法”哲学的一部分——你不报错但你要对自己用的全局状态负责。4.3 与PHPUnit共存时的自动加载冲突有些团队不会立刻全面切到Caveman而是想先在一个模块里试点这就涉及到框架并存的问题。我用过的共存方案有两个都经过了实际项目的验证。方案A把Caveman测试放在独立目录比如tests/caveman/并用独立的入口脚本运行完全不加载PHPUnit。这样旧测试继续走vendor/bin/phpunit新测试走php tests/caveman-runner.php两边的composer依赖互不干涉。这个方案最省心。方案B在同一进程中混用目录。这种方式我不推荐因为两个框架都可能声明同名异常类比如AssertionFailedErrorcomposer的autoload会把先加载的那个类映射锁住后加载的框架在类型判断时就会出现诡异的“类不存在”或“类型不匹配”问题。如果你真的需要混用建议把两个框架的自动加载提前做好别名映射但说实话收效甚微我投入过一轮之后果断放弃了。我的建议是如果只是过渡阶段用方案A如果确定要长期使用Caveman干脆别混用把增量用例都写在Caveman里存量PHPUnit用例按模块逐个迁移。4.4 老项目没有自动加载设施怎么跑这是我最想展开讲的一个场景。目标项目是一个2014年的老系统没有Composer、没有命名空间、代码全靠require硬链。要把Caveman塞进去第一反应就是全部重构命名空间规范但现实是重构成本太高、风险不可控。我的做法是分两步走。第一步在项目根目录初始化一个最小的composer.json只声明dev依赖不碰业务代码{ name: legacy/project, autoload: { files: [ src/bootstrap.php ] }, require-dev: { caveman/caveman: ^1.0 } }第二步写一个测试入口文件先把老项目的核心文件全部require进来再引入Caveman的autoload?php // tests/index.php require_once __DIR__ . /../src/legacy_libs.php; require_once __DIR__ . /../vendor/autoload.php; $world new Caveman\World(); // 这里再添加测试用例……这里有一个踩坑提醒老项目里经常出现函数重名或者全局变量重名的糟糕设计直接把所有老库require进来会立刻产生冲突。我当时的解决办法是写一个白名单只加载当前测试涉及到的模块require_once __DIR__ . /../src/helpers/format.php; require_once __DIR__ . /../src/services/order_service.php; require_once __DIR__ . /../src/models/user.php;这样每新增一个测试模块按需拉取依赖文件冲突面被控制在最小范围。虽然手动维护加载清单有点累但对老项目的渐进式改造来说这已经是最省力的路径。4.5 CI里怎么跑Caveman既然测试写出来了自然要接进CI。这里提一个其他人容易忽略的点Caveman不是一个守护进程型的测试工具它就是一个CLI脚本。所以CI接入成本极低。我用GitHub Actions举例name: tests on: [push] jobs: caveman: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: shivammathur/setup-phpv2 with: php-version: 8.2 - run: composer install --no-interaction - run: php tests/index.php注意最后一条命令我是直接跑入口文件而不是跑vendor/bin/caveman。理由特别现实项目入口还负责加载老代码依赖文件直接跑框架命令等于跳过我的初始化步骤必然报“类找不到”。所以如果你的业务代码里有非标准加载方式记得把测试入口脚本作为CI执行单元而不是默认去调二进制文件。如果用例很大的话Caveman跑起来速度快得让人怀疑人生——我目前跑三百多个用例加上老代码加载时间稳定在两秒内。4.6 断言失败和PHPUnit传参习惯的差异还有一个容易让老江湖翻车的地方Caveman的expect参数顺序和PHPUnit大部分断言相反。assertEquals(3, $actual)是预期值在前、实际值在后Caveman的expect($actual)-toBe(3)则是实际值在前、预期值在后。这个顺序差异最坑的还不是写错的问题而是写错之后输出的失败信息会反过来分析问题大半天。我在给团队做分享时专门提了这个点接Caveman的人先忘掉之前“值在前”的肌肉记忆。宁可多写几个用例再统一跑也不要边写边猜。另外凡是涉及到“字符串包含”这类断言我强烈建议把预期子串放前面长文本放后面这样失败输出里的信息展示会友好一点。5. 一些好用的工程习惯5.1 自定义TestCase封装虽然Caveman“无魔法”但这不代表你只能做一堆散装的闭包。我项目里养成了一个习惯为每个业务模块封装一个测试上下文类。比如用户模块我会写一个UserTestCase里面内置好创建用户、清理用户表等公共动作测试闭包直接调用class UserTestCase { public function createUser(string $name default): User { // 创建用户并返回实例 } public function cleanUp(): void { // 清空用户相关表或缓存 } } $case new UserTestCase(); $world -describe(用户模块) -it(创建用户后密码必须加密存储, function () use ($world, $case) { $user $case-createUser(kite); $world-expect($user-password)-not-toBe(123456); $case-cleanUp(); });这样做的好处有三个。第一公共逻辑只写一遍不会在几十个用例里重复粘贴。第二所有测试上下文显式声明不再依赖隐形的全局状态。第三为后续迁移到其他测试框架保留了清晰的业务侧边界。5.2 场景化命名与断言文案规范命名规范听起来不值钱但在真实项目里是维护成本的重要分水岭。我给团队定的规则只有两条一describe动词用业务模块it动词用用户可感知的行为。比如describe(购物车)下面写it(删除已选中的商品后合计金额同步减少)而不是it(test_delete_cart_item)。二每个given的第一个参数必须能独立回答问题“这个值是什么业务含义”。禁止写given(val, 1)这种无意义命名。这点对半年后的自己特别重要。5.3 多模块测试world怎么归口当用例数量上来之后一个入口文件里几百个$world-describe会非常难维护。我的工程决策是每个模块使用独立的World实例然后在入口文件里统一归总$worlds []; $worlds[] require __DIR__ . /user_test.php; $worlds[] require __DIR__ . /order_test.php; $worlds[] require __DIR__ . /invoice_test.php; foreach ($worlds as $world) { $world-run(); }这个方案简单可靠模块之间互不干扰跑一个模块时也不会殃及另一个模块。而且加入新模块时只需添加一行load代码不需要改动框架本身。5.4 用测试结果反向梳理业务规则我自己最喜欢Caveman的扩展玩法是拿它当业务规则整理工具。老项目往往没有文档代码又盘根错节。我会把数据库里的一些业务校验规则一个一个转写成Caveman的测试用例。比如“订单金额不能小于0”“优惠券不能与满减同用”“历史订单不允许修改收货地址”。这些用例写完之后就成了一面活的业务反光镜。每当我调整某个模块时跑一遍测试发现哪些规则被触发了就能快速判断这次改动的影响范围。这个体验在用PHPUnit时很少能获得因为可读性差异规则和代码的对应关系不够直观。6. 踩坑之后的一些实在建议回到我最初接Caveman时的心态别为了BDD而BDD。Caveman不是银弹它适合的项目画像很清晰存量PHP项目、测试基础薄弱、希望低成本快速建立回归网络。如果你的团队供应链完善、业务复杂度极高、测试人员以非技术为主那还是老实上Behat那一套场景文件体系更好。从我的实际体会看Caveman最大的价值不是那些花哨的链式语法而是它把“写测试”这件事的门槛拉得足够低——你不用理解测试框架的复杂生命周期不用维护一堆配置文件只需要会写PHP闭包就能开始。对一个长期缺测试的老系统来说这种低门路的启动方式比技术先进性重要得多。最后再分享一个小技巧我每次接新项目时都先写一个只有一条用例的“冒烟测试”验证Caveman从安装到输出全链路是通的。然后再慢慢往上垒业务用例。这让“测试不可用”和“业务用例失败”两个问题彻底隔离避免一开始就被环境问题劝退。如果你正面临类似的局面不妨拿前两周的时间把Caveman接到一个边缘模块试试。它不像PHPUnit那样能换来简历上的安全感但至少能让你的老项目在重构时不再裸奔——这点我用过之后是真的回不去了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑