关于 drupal-testing
面向 Drupal 开发者和编码 Agent 的测试技能,帮助筛选有价值的回归测试、落实修复缺陷时的 TDD,并控制测试成本。内容涵盖测试基类选择及 ExistingSite 实践。
主要功能
- 01按风险筛选测试
- 02修复缺陷须先红后绿
- 03编写负向测试的指导
- 04选择轻量级 Drupal 测试基类
- 05ExistingSite 与测试提速实践
适用场景
- 01判断断言能否防范现实回归
- 02修复生产代码前复现 Drupal 缺陷
- 03为服务、渲染或 HTTP 行为选择测试类型
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ grasmash/ drupal-claude-skills --skill drupal-testing安装后在 Claude Code 中输入/drupal-testing确认已加载。
详细介绍
Drupal 测试(TDD)
在 Drupal 中历经实践打磨的测试规范。与 test-writer 和 test-runner Agent 配合使用——这项技能讲的是 如何做,那些 Agent 讲的是 由谁来做。
哪些值得测试(先读)
编写任何测试之前,都要先判断它是否值得保留。只有在以下两点都成立时,测试才值得提交:
- 未来的改动可能会合理地、悄无声息地破坏它。(不是“理论上有人能删掉它”——而是正常编辑时,现实中可能出现且不易察觉的代码路径。)
- **这种破坏会造成影响:**面向用户的行为、数据完整性、金钱、安全性,或 API/客户端契约。
只要有一条不成立,这个测试就只有成本,没有保护作用——**跳过它。**已提交的断言需要你永远维护,却防范着现实中不太可能发生的回归,这不是安全网,而是维护负担。
修复 Bug 仍必须采用 TDD(见下一节)。这里的标准适用于你主动选择新增的覆盖,而非修复 Bug 时应遵循的规范。
高价值——始终测试
- 业务/算法逻辑、数据转换、评分、归因。
- 金钱/订阅/权益。
- 安全与权限限制——在路由层固定验证(见下文“匿名请求的 403 权限陷阱”)。
- 缓存失效与新鲜度——通过真实路径修改数据,并断言对应界面已更新。
- 外部客户端依赖的 API/JSON 响应结构(契约测试)。这是实践中总结出的经验:某个生产代码库中,面向设备的响应契约曾因一个键被移除,导致所有已连接设备上的数据被清空——响应结构契约值得永久保留测试。
- 任何曾反复出现的回归问题。
低价值——不要编写已提交的断言
- **设计修正中一次性的 UI 元素存在/不存在断言。**典型例子:断言某个已移除的按钮不会出现在模态框中。从一个模板中移除它就是永久改动——现实中没有代码路径会重新引入它,因此该断言毫无保护作用。
- 精确的文案/标签文本——除非文案本身就是契约(例如法律文本)。
- 视觉回归测试已覆盖的像素位置/间距。
- Drupal 核心或 contrib 已经测试过的内容。
- 重述实现细节——只是在复刻被测代码的测试(改代码时,测试也同步变化;它永远无法捕获任何问题)。
“它真的会回归吗?”关卡
如果你说不出哪种合理的代码改动会悄无声息地让 Bug 再次出现,那么在这里编写测试几乎没有保护作用,却要承担维护成本。做个检查:**删除这一行断言——其他地方会失败吗?**如果“依然通过”,那它就没有测试任何东西。
外观/文案/布局修正 → 用截图验证,而非断言
对于设计调整,验证方式是进行视觉检查(截图,例如通过 Playwright),而不是提交 DOM 元素存在性测试。TDD 的“先写失败测试”适用于行为缺陷,而非外观问题——修复设计时,不要为了满足 TDD 而人为编写 expect(locator).toBeHidden() 这类失败断言。检查、确认,然后继续。
选择能证明问题的最简单测试
让测试成本与价值和回归风险相匹配。优先顺序为:**Unit > Kernel > renderInIsolation > HTTP smoke > Playwright。**如果渲染测试(或者根本不需要测试)才是正确选择,就不要为了断言某个 DOM 元素存在而采用耗时数秒的 HTTP drupalGet 或耗时数分钟的 Playwright 运行。
Bug-fix TDD——RED 步骤不可妥协
每个 Bug 修复都必须遵循以下固定顺序。跳过第 2 步,就会导致那些“已修复”的 Bug 再次出现。
- 编写一个能复现 Bug 的测试——执行用户实际触发的真实代码路径,而不是对疑似逻辑的转述。
- **运行测试并确认它失败,且症状与报告一致。**如果测试没有失败,就说明你还没有复现 Bug——继续深入排查,直到它因正确的原因失败。从红转绿的过程是你唯一能证明测试确实执行了 Bug 代码路径的证据;没有这个过程,你无法判断测试究竟捕获了 Bug,还是只是在绿色状态下碰巧通过。
- 修复生产代码。
- 重新运行同一个测试,并确认它现在通过。
负向测试用例(测试 Bug 路径)
修复 Bug 时,编写的测试必须展示失败和成功两种情况:
- 证明测试能捕获 Bug——如果测试在修复前就通过了,那它测试的就不是正确的内容。
- 记录失败模式——帮助未来的开发者理解代码为何要这样编写。
- 防止回归——如果有人把代码“简化”回有问题的做法,测试会说明原因。
- 验证根本原因——确认修复解决了实际问题,而不只是表面症状。
public function testNestedVsFlatFormValues(): void {
// NEGATIVE CASE: demonstrate how nested values FAIL.
// The consumer's array_intersect_key expects flat keys like 'margin_top'.
$nested_input = ['margin' => ['margin_top' => 'medium']]; // WRONG: nested
$preserved_keys = ['margin_top', 'margin_bottom', 'padding_top'];
$preserved_nested = array_intersect_key($nested_input, array_flip($preserved_keys));
$this->assertEmpty($preserved_nested,
'NEGATIVE CASE: nested values are NOT found — this is the bug.');
// POSITIVE CASE: demonstrate how flat values WORK.
$flat_input = ['margin_top' => 'medium']; // CORRECT: flat
$preserved_flat = array_intersect_key($flat_input, array_flip($preserved_keys));
$this->assertEquals('medium', $preserved_flat['margin_top'],
'POSITIVE CASE: flat values ARE correctly found — this is the fix.');
}
以下情况使用负向测试:Bug 修复、安全修复、边界情况(拒绝无效输入)、集成问题(不兼容的做法会失败)。为测试命名时,应清楚表明“这本应因……而失败”的意图。
选择满足需求的最轻量引导方式——它决定了成本
Drupal 测试基类的引导成本相差数个数量级。默认选择能够表达断言的最轻量基类。
| 基类 | 引导 | 适用场景 |
|---|---|---|
UnitTestCase | 无(纯 PHP) | 独立逻辑,不依赖 Drupal 服务 |
KernelTestBase | 最小化;在 protected static $modules 中声明依赖项 | 隔离测试服务/业务逻辑 |
ExistingSite(DTT ExistingSiteBase) | 针对已运行的网站执行(真实配置、贡献模块、字段存储) | 需要完整安装站点的流程 |
Functional/BrowserTestBase | 每个测试类都完整重新安装 | 最后的选择;速度很慢 |
逻辑不需要完整站点时,优先使用 Unit 和 Kernel——速度快得多。标准 BrowserTestBase 类会在 setUp() 中完整安装站点(安装配置文件 + 导入配置),每个测试类通常耗时 20-60+ 秒;DTT 的 ExistingSiteBase 只在安装/播种站点时支付一次该成本,之后每项测试都只是一次 HTTP 往返。默认优先使用 ExistingSiteBase,而不是 BrowserTestBase。
使用 ExistingSite 时,成本不在于引导,而在于 drupalLogin() + drupalGet()(真实 HTTP 请求,每次耗时数秒)。添加这些调用之前,先问问自己:断言针对的是 HTTP/身份验证/重定向,还是渲染后的输出:
- 服务逻辑 → 直接调用
\Drupal::service(...)->method()。无需 HTTP。 - 模板渲染 → 构建渲染数组,并调用
\Drupal::service('renderer')->renderInIsolation($build)。无需 HTTP。 - 实体渲染 → 使用 DTT 的
EntityCrawlerTrait::getRenderedEntityCrawler($entity, $view_mode)。无需 HTTP。 - 控制器连线、身份验证、重定向 → 使用
drupalLogin+drupalGet。每个功能仅保留一个冒烟测试。
快速矩阵模式(数据提供器)
数据提供器会成倍增加成本:11 行矩阵 × drupalLogin + drupalGet ≈ 5 分钟。使用带有 N 行的 #[DataProvider] 时,不要在测试方法中执行 HTTP 请求。将测试拆分为快速的服务/渲染矩阵测试,再加上一个 HTTP 冒烟测试:
class MyMatrixTest extends SomeTestBase {
#[DataProvider('cases')]
public function testServiceOutput(...): void {
// Assert the service directly — no login, no HTTP.
$result = \Drupal::service('my_module.calculator')->score($input);
$this->assertSame($expected, $result);
}
#[DataProvider('cases')]
public function testTemplateRender(...): void {
$build = [
'#theme' => 'my_component',
// ... same variables the controller would pass
];
$html = (string) \Drupal::service('renderer')->renderInIsolation($build);
$this->assertStringContainsString($expected_marker, $html);
}
public function testControllerSmoke(): void {
// One HTTP test to prove controller → template wiring works end-to-end.
$this->drupalGet('/my-page');
$this->assertSession()->statusCodeEquals(200);
}
}
提速方法
- 对不需要完整已安装站点的逻辑使用 Unit/Kernel。
- 通过配置/调试标志,在
setUp()中屏蔽真实外部调用,确保测试不会因真实远程服务而挂起。 - 避免使用
sleep()——这几乎总是意味着缺少断言或基类选错了。 - 尽量减少
drupalLogin/drupalGet,尤其是每个数据提供器行都调用的情况。 - 使用
XDEBUG_MODE=off运行——比保持 Xdebug 加载快 3-5 倍。 - 进程级提速方法:使用项目专属的 PHPUnit 引导程序,以显式 PSR-4 注册替代核心对
*.info.yml的递归扫描(参见drupal-ddev技能中的“PHPUnit Test Performance”);以及——在 DDEV 下——使用 opcache 文件缓存 ini,将编译后的操作码持久化到原生文件系统缓存挂载点(保持validate_timestamps启用,因此编辑的文件仍会重新编译;添加后需要执行ddev restart)。如果更改了自定义引导程序,请确认更改前后的phpunit --list-tests输出逐字节完全一致。
DTT ExistingSite 模式
- 只播种一次,读取共享固定数据。 核心提速方法:只对站点播种一次(使用 drush php-script 固定数据播种脚本效果很好),然后编写读取这些共享固定数据的 ExistingSite 测试,而不是在每个测试中创建实体。仅当共享播种数据无法表示某个固定数据且它确实是测试专用数据时,才使用 DTT 为每个测试创建/清理实体(
$this->createNode()、$this->createUser()——测试结束后会自动删除)。 - 限制元刷新递归次数。 DTT 的供应商默认值
maximumMetaRefreshCount为NULL(不设上限)——如果页面带有<meta http-equiv="Refresh">循环(例如 big_pipe 的无 JS 检测与持续返回该标签的重定向目标组合),运行就会挂起。在共享项目基类中将其设为一个较小的数值(例如 3)。 - 当
setUp()跳过时,为tearDown()添加防护。 如果 ExistingSite 基类在DTT_BASE_URL未设置时于setUp()中跳过(在调用parent::setUp()之前执行$this->markTestSkipped(...)),请同时为tearDown()添加防护——在调用parent::tearDown()之前执行if (!\Drupal::hasContainer()) { return; }。即使setUp()中跳过,PHPUnit 也会在finally块中调用tearDown();而 DTT 自身的tearDown()会无条件访问\Drupal::database()。如果setupDrupal()从未运行,这会因ContainerNotInitializedException而触发致命错误。没有此防护,“Skipped: 9”会悄无声息地变成“Skipped: 9, Errors: 9”。 - 匿名 CSRF 令牌可能无法跨请求验证。 带有
_csrf_token: TRUE的路由会根据当前会话生成令牌;在不会为纯匿名请求持久化会话的站点上(匿名 GET 不会返回Set-Cookie),某个匿名响应中嵌入的令牌不保证能在后续匿名 POST 中通过验证。测试中的解决方法:在测试_csrf_token: TRUE流程前执行$this->drupalLogin($this->createUser())——登录的唯一作用是建立稳定且持久化的会话。然后测试真实表单(对呈现的页面执行$form->submit()),而不是手动构造 URL。 - 新鲜度测试通过真实 HTTP 请求进行更改。 已观察到,在 phpunit 进程内执行
$entity->save(),不会使通过 HTTP 预热的响应失效,因此即使产品在真实请求边界下运行正确,新鲜度断言也会失败。其机制尚未确定(核心会在根事务提交时于进程内执行延迟的标签失效,因此这并非简单的“回调从未运行”;而cachetags表仅在使用数据库校验和后端时存在)。通过应用的实际写入路径执行更改(例如通过 HTTP 携带会话 Cookie 和X-CSRF-Token发送 JSON:APIPOST/PATCH),然后重新获取缓存表面。
ExistingSite:不回滚、共用数据库、进程长期运行
ExistingSiteBase 不会将测试包装在事务中,整个测试套件(或 CI 分片)都在同一个 PHP 进程中针对同一个真实数据库运行。每个副作用都会在测试结束后继续存在。
测试夹具卫生——已发布的测试夹具会泄漏到实际列表中
保存已发布节点的测试会在实际信息流和列表中留下用户可见内容(信息流顶部出现空白或损坏的条目、幽灵卡片),之后这些内容会以 Bug 报告的形式出现。
- 只要被测逻辑允许,就创建未发布的测试夹具(
status => 0)。 - 否则,在
save()之后立即调用$this->markEntityForCleanup($entity)——不要等到方法末尾,否则提前失败时会跳过清理标记。 - 测试或 Agent 群体运行结束后,使用默认采用试运行模式的清理命令清理残留;匹配规则应足够谨慎(标题模式 AND 最近创建 AND bundle)。
drupalLogin() 有持久性副作用
实际的 drupalLogin() HTTP 流程会运行每个登录订阅器,而且它们写入的数据不会回滚。例如(根据 tfa 2.0.0-alpha5 的实现):如果某个账号启用了 TFA,但尚未设置 TFA(TfaLoginContext::isReady() 为 false),又可以不使用 TFA 登录,那么每次成功登录都会记录一次跳过(hasSkipped() 会递增用户数据中持久化的 validation_skipped 计数)。耗尽允许次数后,另一个使用同一账号登录的无关测试就会开始失败。
- 对于需要使用另一个身份的进程内代码,请使用
\Drupal::service('account_switcher')->switchTo($account)/switchBack()(放在finally中),而不是直接调用current_user->setAccount()。tfa 会装饰 account switcher 以设置绕过标记,因此其用户设置订阅器会允许切换。对已认证账号直接调用setAccount()不会获得绕过权限:根据账号的 TFA 状态,可能会记录一次跳过,也可能抛出TfaAccessDenied。 - 选择已有账号执行
drupalLogin()时,请排除属于 TFA 必需角色且尚未设置 TFA 的账号,或者在基类中对validation_skipped进行快照并在登录后恢复。
切勿写入共享配置单例
如果测试在 setUp() 中写入全局配置对象,并在 tearDown() 中恢复,那么进程若在两者之间终止(被杀、超时、OOM),配置值就会残留。一次残留的 honeypot time_limit 就曾导致 honeypot 拒绝全站快速提交的表单——包括登录表单——使后续每个分片中的 drupalLogin() 都失败(约 700 次失败)。请使用作用域限定在测试自身容器中的 ConfigFactoryOverrideInterface(DTT 会为每个方法启动一个全新的容器),这样无论进程如何结束,配置都不会持久化。使用 register_shutdown_function() 恢复并不能解决问题:它会在关闭阶段较晚时触发,此时请求已被拆除,配置保存订阅器可能会抛出异常。
内存增长:进程生命周期静态变量会固定已销毁的内核
DTT 会为每个测试方法启动一个全新的 DrupalKernel,但从不销毁旧内核。任何由进程生命周期静态变量引用的对象,都会让整个已销毁容器和实体缓存图保持可达,因此内存会随测试数量线性增长——某个分片的内存从约 150MB 涨到约 7.8GB,随后收到 SIGKILL(退出码 137);表现为分片被截断,而不是内存错误。主要根源:drupal_static()、Symfony MimeTypes::$default(Drupal 每次启动时都会向其中重新注册一个包装容器的猜测器),以及 drupal_register_shutdown_function() 列表(每次启动都会追加,只有进程真正退出时才会清空)。另外,“加载所有活跃用户”这类 fixture 本身也可能增加约 1GB 内存——绝不要将整张表作为 fixture 加载。
- 在基类的
#[After]钩子中释放这些对象(该钩子会在整个tearDown()链结束后运行):调用drupal_static_reset()、重置 MIME 猜测器默认值、清空关闭函数列表,然后调用gc_collect_cycles()+gc_mem_caches()。 - 在环境变量控制下,将每项测试的内存记录到 CSV,以便回归时重新测量增长情况。
- 不要在该钩子中添加
\Drupal::unsetContainer()。 任何调用Database::startLog()的测试(例如查询预算测试)都会让进程静态 DB 连接继续启用语句事件;DTT 下一次运行setupDrupal()时,会在DrupalKernel::boot()内部、调用\Drupal::setContainer()之前执行cache_container查询,因此容器未设置时,该查询会抛出异常(“event dispatcher service is not available”),之后的每项测试都会在启动时失败。如果某个本地测试子集不包含调用startLog()的测试,它就会“证明”代码通过。若必须释放容器,请先关闭 DB 连接,并将其设为可选操作。
匿名用户 403 权限陷阱
测试“匿名用户会收到 403”不能证明路由的 _permission 得到了强制执行。以下两种情况会造成误判:
- 同时使用
_user_is_logged_in: TRUE和_permission进行限制的路由,会先在登录限制处拒绝匿名用户——因此,即使完全删除_permission,匿名用户测试仍然会通过。 - 当限制权限属于
authenticated角色时,任何已登录用户都不可能被拒绝,因此该限制实际上形同虚设。(这并非假设——生产代码库中的整个 API 范围都曾因此受影响;当时“已登录但权限不足 → 403”的测试根本无法编写。)
_permission 语法:+ 表示或,, 表示且。
要真正测试权限限制,可采用以下任一方式:
- 登录一个确实缺少该权限的用户,并断言响应为 403;或者
- 在路由定义层级锁定权限限制:断言
$route->getRequirement('_permission')等于预期字符串,这样放宽权限限制就会导致测试失败:
public function testRoutePermissionGate(): void {
$route = \Drupal::service('router.route_provider')
->getRouteByName('my_module.my_route');
$this->assertSame('expected permission+administer something', $route->getRequirement('_permission'));
$this->assertSame('TRUE', $route->getRequirement('_user_is_logged_in'));
}
如果要覆盖一组路由,请将它们与显式的预期权限映射逐一比对,并检查两者集合是否相等,这样新路由就无法在没有权限限制的情况下发布。
用 TDD 验证防护:移除权限 → 确认测试变红 → 恢复权限。你从未亲眼见过失败的防护,证明不了任何事。
毫无意义地通过的测试(静默归零陷阱)
如果某一整类测试突然“通过”,但运行时 断言数为 0,应怀疑 PHPUnit 版本不匹配或收集测试时发生致命错误,而不是代码没有问题。
- Drupal core 支持特定的 PHPUnit 主版本。版本锁定错误(例如,Drupal core 仅支持 PHPUnit 11,却使用 PHPUnit 12)会导致所有继承 Drupal 基类(
UnitTestCase/KernelTestBase)的测试收集到 0 个测试并以退出码 0 结束——没有兼容性垫片,因此测试收集阶段的致命错误会被静默处理。与此同时,普通的\PHPUnit\Framework\TestCase+ DTT ExistingSite 测试仍会运行,从而掩盖问题。 setUp()/tearDown()必须与父类的可见性一致。 声明时使用错误的可见性会导致加载时致命错误,php -l检测不到,PHPUnit 也可能将其静默吞掉 → 0 个测试,退出码为 0。- PHPUnit 10+ 使用 PHP 8 属性(
#[Group('x')]),而不是@group文档块注释。--exclude-group/--group无法匹配旧式注释——请迁移到属性。 - 快速检查:通过的测试运行应报告非零的断言数。对于你确定包含测试的测试套件,如果结果是
OK (0 tests, 0 assertions),就说明运行器没有收集到这些测试。
实用的冒烟测试模式
// Service exists and exposes the expected method.
$service = \Drupal::service('my_module.some_service');
$this->assertNotNull($service);
$this->assertTrue(method_exists($service, 'methodName'));
// Route exists.
$route = \Drupal::service('router.route_provider')
->getRouteByName('my_module.my_page');
$this->assertNotNull($route);
// Plugin registered + instantiable.
$definitions = \Drupal::service('plugin.manager.my_type')->getDefinitions();
$this->assertArrayHasKey('plugin_id', $definitions);
// Idempotency / duplicate handling: same identifier twice returns the
// same entity, not a duplicate.
$a = $service->createEntity($key);
$b = $service->createEntity($key);
$this->assertEquals($a->id(), $b->id());
配置验证测试
基于配置的内容(设置、导出的模板)可以在 YAML 层面进行回归测试——无需触发实际功能:
public function testConfigPattern() {
$config_factory = \Drupal::configFactory();
$issues = [];
foreach ($config_factory->listAll('my_prefix.') as $config_name) {
$value = $config_factory->get($config_name)->get('some.key');
if (strpos((string) $value, 'bad_pattern') !== FALSE) {
$issues[] = "$config_name: contains bad_pattern";
}
}
$this->assertEmpty($issues, "Config issues:\n" . implode("\n", $issues));
}
使用 listAll() 扫描所有匹配的配置,确保新的配置对象不会带着反模式发布。
视觉测试(Playwright)
必需: CSS、主题、UI 变更。不需要: 不涉及可见界面的纯后端工作(请明确说明豁免原因)。
实用的项目惯例:桌面端(约 1280px)+ 移动端(约 390px)视口、浅色 + 深色主题(在首次绘制前通过 addInitScript 强制设置主题类)、每种状态截一张图,并断言附加的是构建后的(带哈希的)资源,而非原始源文件路径。
视觉测试检查清单
- 测试前清除缓存(禁用缓存时跳过)
- 在移动端(约 390px)和桌面端(约 1280px)宽度下测试
- 相关时,测试已登录和匿名用户
- 验证浅色和深色主题下的文本对比度
- 检查响应式行为(抽屉式菜单/汉堡菜单状态)
在本地验证真实代码路径——仅通过单元测试还不够
对于任何运行时行为变更(cron 作业、drush 命令、数据处理、API 端点、服务逻辑),在宣布完成前,请在本地执行变更后的代码路径并确认实际结果——不要只满足于单元测试通过。运行实际命令/服务(例如通过 DDEV 运行 ddev drush <command>),然后检查结果状态(数据库行、更新后的字段值、输出内容)。这能发现测试遗漏的问题:环境差异、依赖数据的错误,以及真实安装站点上的集成故障。单元测试/Kernel 测试能证明逻辑在隔离环境中成立;只有实际运行才能证明各部分连接正确。
CI:在本地修复失败的测试,而不是反复推送
CI 因测试错误而失败时,不要通过推送提交并重新运行完整测试套件来反复尝试(每次通常约 20 分钟):
- 从 CI 日志中找出失败的测试。
- 在本地复现(
vendor/bin/phpunit --filter Class::method path/to/Test.php)。 - 修复问题,并逐个运行测试,直到全部通过。
- 提交。
- 只有此时才重新运行完整 CI 测试套件。
易踩的坑(经验之谈)
在 DDEV 下运行
- 始终在
ddev exec中运行 phpunit(需要数据库连接),并设置XDEBUG_MODE=off,同时过滤输出以保留上下文:ddev exec "XDEBUG_MODE=off phpunit path/to/module/tests/ --testdox 2>&1" | grep -E '(✔|✘|OK|FAIL|Tests:|Assertions:)' ddev exec会吞掉长命令的标准输出。 将输出重定向到文件,并在同一次调用中执行 tail,例如:ddev exec "phpunit ... > /tmp/out.txt 2>&1; tail -n 40 /tmp/out.txt"。- Mutagen 挂载是异步的(启用时)。在主机上编辑文件后,运行
ddev mutagen sync,再执行 phpunit,否则读取到的内容可能是旧的。 set -u+ 主机变量 /$(...)展开会导致内联ddev execbash 出错。 请编写脚本文件,然后运行ddev exec bash /path/script.sh。
drupalLogin() 故障(依赖会话的缓存上下文)
- 当某个模块的缓存上下文依赖测试会话包未提供的会话元数据时,
drupalLogin()可能会在登录后的页面渲染检查中间歇性失败(登录成功;断言页面以已登录状态渲染却失败)。这种情况曾出现在masquerade模块中。故障具有间歇性(取决于缓存状态),与被测内容无关。 - 修复方法: 如果测试断言的是控制器/服务的输出(而不是 HTTP 身份验证层),请不要使用
drupalLogin。改为在AccountSwitcher下直接调用控制器:\Drupal::service('account_switcher')->switchTo($user)→ 调用方法 → 在finally中调用switchBack()。在路由定义层单独覆盖访问限制(参见“匿名用户 403 权限陷阱”)。
大量 drupalLogin() 失败 = 环境问题,而非代码问题
如果多个毫无关联的模块中有数百个测试都在登录时失败,请先检查环境,再调试代码:
- 基础 URL/主机不匹配。 如果
SIMPLETEST_BASE_URL/DTT_BASE_URL指定的主机与实际提供站点服务的主机不同,会话 Cookie 名称就会不同,导致所有登录都以同样的Failed asserting that false is true错误失败。请从环境的实际主 URL 导出这两个变量。 - 缺少私钥(例如身份验证模块需要的
private://keys/下的密钥文件)——数百个测试会显示“未运行”。 - 泄漏的共享配置单例(见上文)。
箭头函数在模拟对象中按值捕获
fn() => $cursor 会在定义时冻结 $cursor,之后的更改都无法反映出来。在模拟迭代器的 valid()/current()/next() 方法中,这会导致 valid() 永远返回 TRUE——形成无限循环——或者触发 expects($this->once()),看起来像是生产代码重复调用的问题。请使用普通闭包:function () use (&$cursor) { ... }。
调试测试失败
# Recent errors during a test run
ddev drush watchdog:show --severity=3 --count=10
# Service not found? Check registration:
ddev drush ev "print_r(\Drupal::getContainer()->getServiceIds());"
# Route not found? Rebuild the router:
ddev drush cr
测试文件中的 phpcs
- 在文档块注释前使用分隔注释(
// ---),会同时违反CommentEmptyLine.SpacingAfter和FunctionSpacing.Before。请勿在测试文件中使用。 - 重新提交前,运行
vendor/bin/phpcbf <file>自动修复。 - 判断 phpcs 检查是否通过时,只看退出代码,绝不要通过 grep 输出判断。 phpcs 输出可能带颜色,对带 ANSI 颜色代码的文本运行
grep -c '| ERROR',无论实际结果如何都会计数为 0——这样的检查总是会通过。如果警告不应导致检查失败,请设置ignore_warnings_on_exit=1,并仍然读取退出代码(或者解析--report=json;如果解析器缺失,则按失败处理)。