如果你为输出 HTML 的 WordPress 插件编写过 PHPUnit 测试——例如区块渲染回调、格式化过滤器、短代码——大概遇到过这样的困扰:本地测试通过,CI 却因为属性顺序不同而失败;或者只是给内联样式补了一个末尾分号,三项测试便突然失败,尽管浏览器渲染的结果完全相同。
WordPress 6.9 为 WP_UnitTestCase 引入了新断言 assertEqualHTML()。它按语义比较 HTML:属性顺序、类名顺序、样式空白以及属性引号形式的差异都不会使测试失败。只有标记的语义发生变化,测试才会失败。
本文介绍它的用法、规范化范围,以及如何替换已有测试套件中脆弱的字符串断言。
用 assertSame() 比较 HTML 的问题
考虑下面的过滤器:它通过 HTML API 为文章内容中的图片添加 loading="lazy"。
function my_plugin_lazy_load_images( string $content ): string {
$processor = new WP_HTML_Tag_Processor( $content );
while ( $processor->next_tag( 'img' ) ) {
$processor->set_attribute( 'loading', 'lazy' );
}
return $processor->get_updated_html();
}
add_filter( 'the_content', 'my_plugin_lazy_load_images' );
使用 assertSame() 的测试如下:
public function test_lazy_load_images_adds_loading_attribute(): void {
$input = '<p><img src="photo.jpg" alt="A photo" class="size-full"></p>';
$expected = '<p><img loading="lazy" src="photo.jpg" alt="A photo" class="size-full"></p>';
$this->assertSame( $expected, my_plugin_lazy_load_images( $input ) );
}
这个测试很脆弱。HTML API 将 loading="lazy" 插入属性列表的最前面,预期字符串必须精确匹配该位置。只要未来的 WordPress 版本改变属性序列化顺序,或者你改用其他解析器,测试就会失败,尽管浏览器呈现的内容没有变化。
正常的代码重构也会使它失败。例如,把 HTML 输入统一为双引号,或者在过滤器中规范化类名顺序,都会迫使你更新每一处断言。
本文提到的全部示例和测试位于 示例仓库。可以克隆仓库,在本地运行测试,进一步了解这些例子。
assertEqualHTML() 做了什么
assertEqualHTML() 是 WordPress 6.9 加入 WP_UnitTestCase 的方法,相关记录为 #63527 和 PR #8882。它使用 WP_HTML_Processor 把两个 HTML 字符串解析为规范化的树,再用 assertSame 比较这些树。因此,即使原始文本不同,只要产生相同的树,两个字符串就会被视为相等。
原文列举的规范化行为如下:
| 差异 | assertSame() | assertEqualHTML() |
|---|---|---|
| 属性顺序 | 失败 | 通过 |
| 类名顺序 | 失败 | 通过 |
| 样式空白或末尾分号 | 失败 | 通过 |
| 标签名大小写 | 失败 | 通过 |
HTML 字符引用(¬ 与 ¬) |
失败 | 通过 |
| 重复属性(HTML 规范规定第一个生效) | 失败 | 通过 |
| 区块注释中的属性顺序 | 失败 | 通过 |
| 区块类名顺序 | 失败 | 通过 |
| HTML 内容不同 | 能检出 | 能检出 |
| 属性值不同 | 能检出 | 能检出 |
| 缺少或多出属性 | 能检出 | 能检出 |
| 类名不同,且不仅是顺序不同 | 能检出 | 能检出 |
| 样式值不同 | 能检出 | 能检出 |
方法签名为:
public function assertEqualHTML(
string $expected,
string $actual,
?string $fragment_context = '<body>',
string $message = 'HTML markup was not equivalent.'
): void
基本用法
将前面的测试改为使用 assertEqualHTML(),它就不会再受属性排序影响:
public function test_lazy_load_images_adds_loading_attribute(): void {
$input = '<p><img src="photo.jpg" alt="A photo" class="size-full"></p>';
$expected = '<p><img src="photo.jpg" alt="A photo" class="size-full" loading="lazy"></p>';
$this->assertEqualHTML( $expected, my_plugin_lazy_load_images( $input ) );
}
现在,loading="lazy" 在属性列表中的确切位置已经不重要。<img loading="lazy" src="..." alt="..."> 和 <img src="..." alt="..." loading="lazy"> 会产生相同的树表示,因此断言能够通过。
它也处理 HTML 字符引用的等价性。同一个字符可以采用字面字符、命名引用、十进制、十六进制、补零形式,甚至不带分号的命名引用;assertEqualHTML() 会将这些形式视为相等:
$expected = <<<HTML
<meta
not-literal="¬"
not-named="¬"
not-decimal="¬"
not-decimal-padded="¬"
not-hex="¬"
not-hex-padded="¬"
>
HTML;
$actual = <<<HTML
<meta
not-literal="¬"
not-named="¬"
not-decimal="¬"
not-decimal-padded="¬"
not-hex="¬"
not-hex-padded="¬"
>
HTML;
$this->assertEqualHTML( $expected, $actual );
测试 HTML API 转换
下面是更完整的例子。原文介绍的插件函数通过 HTML API 为外链注入 data-external 属性,并向 rel 属性追加 noopener noreferrer。
function my_plugin_mark_external_links( string $content ): string {
$processor = new WP_HTML_Tag_Processor( $content );
while ( $processor->next_tag( 'a' ) ) {
$href = $processor->get_attribute( 'href' );
if ( $href && str_starts_with( $href, 'http' ) && ! str_contains( $href, home_url() ) ) {
$processor->set_attribute( 'data-external', 'true' );
$rel = $processor->get_attribute( 'rel' );
$processor->set_attribute( 'rel', trim( ( $rel ?? '' ) . ' noopener noreferrer' ) );
}
}
return $processor->get_updated_html();
}
用 assertSame() 测试时,必须知道 WordPress 将 data-external 和 rel 相对于已有属性放在什么位置。使用 assertEqualHTML,只需描述预期的语义结果:
public function test_external_links_get_marked(): void {
$input = '<p>Visit <a href="https://example.com" class="external-link">example.com</a></p>';
$expected = '<p>Visit <a href="https://example.com" class="external-link" data-external="true" rel="noopener noreferrer">example.com</a></p>';
$this->assertEqualHTML( $expected, my_plugin_mark_external_links( $input ) );
}
public function test_internal_links_are_unchanged(): void {
$input = '<p>Read <a href="' . home_url( '/about' ) . '">about us</a></p>';
$expected = $input;
$this->assertEqualHTML( $expected, my_plugin_mark_external_links( $input ) );
}
无论 HTML API 按什么顺序序列化新属性,这两个测试都能正确验证语义意图。
测试区块渲染回调
区块渲染回调经常生成元素深度嵌套的复杂标记。assertEqualHTML() 特别适合这种情况,因为不同 WordPress 版本的区块序列化结果可能存在轻微的空白或属性排序差异。
下面的动态区块渲染一个卡片组件:
function my_plugin_render_card_block( array $attributes, string $content ): string {
$tag = new WP_HTML_Tag_Processor(
'<div class="wp-block-my-plugin-card"></div>'
);
$tag->next_tag();
if ( ! empty( $attributes['backgroundColor'] ) ) {
$tag->set_attribute(
'style',
'background-color: ' . esc_attr( $attributes['backgroundColor'] ) . ';'
);
}
if ( ! empty( $attributes['className'] ) ) {
foreach ( explode( ' ', $attributes['className'] ) as $class ) {
$tag->add_class( $class );
}
}
return str_replace(
'</div>',
$content . '</div>',
$tag->get_updated_html()
);
}
这个函数使用 WP_HTML_Tag_Processor 创建外层 <div>,然后根据区块属性有条件地设置背景颜色样式并追加额外类名。内部内容通过替换闭合标签插入。这样的函数有多个相互作用的部分,而 assertEqualHTML 很适合处理它的输出。
完整区块输出的测试,包括外层元素属性,如下:
public function test_card_block_renders_with_background_color(): void {
$attributes = array(
'backgroundColor' => '#f5f5f5',
'className' => 'is-style-outlined my-custom-class',
);
$inner_content = '<p class="wp-block-paragraph">Hello</p>';
$output = my_plugin_render_card_block( $attributes, $inner_content );
$expected = <<<'HTML'
<div
class="is-style-outlined my-custom-class wp-block-my-plugin-card"
style="background-color: #f5f5f5;"
><p class="wp-block-paragraph">Hello</p></div>
HTML;
$this->assertEqualHTML( $expected, $output );
}
使用 assertEqualHTML(),可以通过 NOWDOC 语法(<<<'HTML')将预期 HTML 写成易读、缩进清楚的形式,避免变量插值,并保持标记整洁。比较过程会规范化属性顺序和类名,因此 is-style-outlined my-custom-class wp-block-my-plugin-card 与 wp-block-my-plugin-card is-style-outlined my-custom-class 等价。
测试 Interactivity API 指令注入
测试向区块 HTML 注入 Interactivity API 指令的过滤器,是 assertEqualHTML() 最常见的使用场景之一。这些过滤器把 data-wp-* 属性添加到元素上,而属性在输出中的确切位置不会影响 Interactivity API 的行为。
下面的插件挂接到 render_block,为列表区块添加上下文和指令,以便通过鼠标点击切换展开状态:
function my_plugin_add_list_interactivity( string $block_content, array $block ): string {
if ( 'core/list' !== $block['blockName'] ) {
return $block_content;
}
$p = new WP_HTML_Tag_Processor( $block_content );
if ( $p->next_tag( 'ul' ) ) {
$p->set_attribute( 'data-wp-interactive', 'my-plugin/list' );
$p->set_attribute( 'data-wp-context', wp_json_encode( array( 'expanded' => false ) ) );
}
while ( $p->next_tag( 'li' ) ) {
$p->set_attribute( 'data-wp-on--click', 'actions.toggle' );
}
return $p->get_updated_html();
}
add_filter( 'render_block', 'my_plugin_add_list_interactivity', 10, 2 );
该过滤器的测试如下:
public function test_list_block_gets_interactivity_directives(): void {
$input = '
<ul class="wp-block-list">
<li>First item</li>
<li>Second item</li>
</ul>
';
$block = array( 'blockName' => 'core/list' );
$output = my_plugin_add_list_interactivity( $input, $block );
$expected = '
<ul
class="wp-block-list"
data-wp-interactive="my-plugin/list"
data-wp-context="{"expanded":false}"
>
<li data-wp-on--click="actions.toggle">First item</li>
<li data-wp-on--click="actions.toggle">Second item</li>
</ul>
';
$this->assertEqualHTML( $expected, $output );
}
data-wp-context 属性包含 JSON。无论函数输出 {"expanded":false} 还是 {"expanded":false},HTML API 都会在比较前解码实体引用,因此断言可以正确处理两种形式。
理解失败输出
当 assertEqualHTML() 失败时,错误消息显示两个字符串的规范化树表示,而不是原始 HTML 的差异。这有助于发现有意义的变化。
例如,假设过滤器意外丢失了 rel 属性,原文给出的输出为:
HTML markup was not equivalent.
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
<a>
href="https://example.com"
+ rel="noopener noreferrer"
"example.com"
同一问题的 assertSame() 失败输出为:
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-<a href="https://example.com" rel="noopener noreferrer">example.com</a>
+<a href="https://example.com">example.com</a>
两种输出都呈现了 rel 的差异,但 assertEqualHTML() 的树格式也适用于复杂、深度嵌套的区块标记:每个属性单独一行,缩进一致。当渲染回调输出超过20行 HTML 而测试失败时,树差异能指出哪个元素上的哪个属性发生了变化。
树格式也理解区块分隔符。对于区块标记,它将区块显示为 BLOCK["namespace/name"],并把区块属性表示为格式化 JSON,与 HTML 结构分开。因此,区块输出测试会在独立、可读的部分中分别显示区块属性变化和 HTML 属性变化。
Failed asserting that two strings are identical.
---·Expected
+++·Actual
@@ @@
class="wp-block-group"
BLOCK["core/paragraph"]
{
-········"align":·"center"
+········"align":·"left"
}
"
"
<p>
-········class="has-text-align-center"
+········class="has-text-align-left"
"Hello world"
"
"
添加自定义失败消息
将自定义消息作为第四个参数传入,可以更容易定位失败:
$this->assertEqualHTML( $expected, $actual, '<body>', 'Card block output did not match.' );
第三个参数 $fragment_context 的默认值是 '<body>',适用于大多数 WordPress 内容和区块输出。若要比较完整 HTML 文档,请传入 null。如果只想设置自定义消息,显式传入 '<body>',以保留默认解析上下文。
什么时候保留 assertSame()
assertEqualHTML() 不能在所有场合替代 assertSame()。以下情况应继续使用 assertSame():
- 精确的字符串输出很重要,例如其他代码会将返回值重新交给要求特定序列化格式的解析器。
- 测试非 HTML 输出;
assertEqualHTML()只适用于 HTML 字符串。 - 测试纯文本输出;例如函数返回完全没有 HTML 标记的字符串(如
strip_tags()的输出),应使用assertSame()。如果输出可能包含 HTML 字符引用,assertEqualHTML()仍然更合适,因为它会规范化这些引用。
另一个应使用 assertSame() 的具体情况是验证 data-wp-context 属性值的精确序列化形式,或验证 <script> 标签中的内容。若精确字符串本身很重要,就直接用 assertSame() 测试。
迁移建议
迁移已有测试套件时,可以先找出用 assertSame() 比较 HTML 字符串的测试,并问自己:“如果属性顺序改变,这个测试是否仍然有意义?”
如果答案是肯定的,就改为 assertEqualHTML()。对于常见模式,它可以直接替换:
// Before:
$this->assertSame( $expected_html, $actual_html );
// After:
$this->assertEqualHTML( $expected_html, $actual_html );
只要测试类直接或通过子类继承 WP_UnitTestCase,WordPress 6.9及以上版本就能立即使用这个方法。
有些测试套件自定义了 assertEqualMarkup() 方法,或先使用 DOMDocument 解析 HTML 再比较。这些辅助方法也可以替换为 assertEqualHTML(),它采用更新的 WP_HTML_Processor,并额外理解区块结构。
资源
- Tests: Add test assertion to compare HTML for equivalence #63527:最初提案和讨论。
- Tests: Add new assertEqualHTML assertion #8882:实现该断言的 PR。
- Updates to the HTML API in 6.9:@dmsnell 关于 WordPress 6.9 HTML API 变化的开发说明。
- WP_HTML_Tag_Processor 参考:用于编写待测试的过滤器。
- Interactivity API 参考:指令和 store API。
- WordPress 核心中的实际用法:原文提供了对应链接;当前抓取未能解析该链接,保留为发布前核验项。
如果已经在插件或主题测试套件中用 assertEqualHTML() 替换基于字符串的 HTML 断言,原作者欢迎在评论中分享效果,特别是值得了解的边界情况。
原文感谢 @bph 和 @jonsurrell 审阅。
原文:A better way to test HTML in WordPress with assertEqualHTML()
作者:JuanMa Garrido。原文发表于2026年2月26日。本文为中文译稿;原文与示例权利归原权利人所有。本批转载依据用户明确授权,独立许可文本尚待发布前复核。











暂无评论内容