在 WordPress 中使用 assertEqualHTML() 更稳健地测试 HTML

如果你为输出 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 字符引用(&not; 与 ¬) 失败 通过
重复属性(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;"
    not-decimal="&#172;"
    not-decimal-padded="&#0172;"
    not-hex="&#xAC;"
    not-hex-padded="&#x0000AC;"
>
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="{&quot;expanded&quot;: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} 还是 {&quot;expanded&quot;: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,并额外理解区块结构。

资源

如果已经在插件或主题测试套件中用 assertEqualHTML() 替换基于字符串的 HTML 断言,原作者欢迎在评论中分享效果,特别是值得了解的边界情况。

原文感谢 @bph 和 @jonsurrell 审阅。


原文:A better way to test HTML in WordPress with assertEqualHTML()
作者:JuanMa Garrido。原文发表于2026年2月26日。本文为中文译稿;原文与示例权利归原权利人所有。本批转载依据用户明确授权,独立许可文本尚待发布前复核。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容