社区新闻

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

如果你曾为生成 HTML 输出的 WordPress 插件(例如区块渲染回调、格式化过滤器、短代码)编写过 PHPUnit 测试,你一定体会过其中的挫败感。测试在你的机器上通过了。然后它在 CI 上失败了,因为某个属性返回的顺序不同。或者你给内联样式加了一个尾随分号,突然三个测试就失败了——即使浏览器渲染的结果完全一样。

WordPress 6.9 引入了一个新的断言方法——assertEqualHTML()——在 WP_UnitTestCase 中可用,它解决了这个问题。它从语义上比较 HTML,而不是字面上。属性顺序、类名顺序、样式空格、属性引号风格的差异——这些都不会导致失败。只有当标记在语义上不同时,测试才会失败。

本文介绍了如何使用 assertEqualHTML()、它标准化了什么,以及如何替换现有测试套件中脆弱的字符串断言。

使用 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 的一个方法。它通过将两个 HTML 字符串解析为使用 WP_HTML_Processor 规范化的树,并用 assertSame 比较这些树来工作——因此,两个产生相同树的字符串被视为相等,即使它们的原始文本不同。

规范化处理所有不影响浏览器渲染的内容:

此处有一个表格但无法显示。

该方法的签名是:

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 将外部链接包装在 <span> 中以进行样式设置,注入一个 data-external 属性,并将 noopener noreferrer 附加到 rel 属性。

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-externalrel 相对于现有属性的确切顺序。使用 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(),你可以以可读的、良好缩进的格式编写预期的 HTML,使用 NOWDOC 语法(<<<'HTML'),这避免了变量插值并保持标记的整洁。比较会规范化属性顺序和类名——因此 is-style-outlined my-custom-class wp-block-my-plugin-cardwp-block-my-plugin-card is-style-outlined my-custom-class 是等价的。

测试交互性 API 指令注入

最常使用 assertEqualHTML() 的场景之一是测试将交互性 API 指令注入区块 HTML 的过滤器。这些过滤器向元素添加 data-wp-* 属性——而这些属性在输出中的确切位置与交互性 API 的行为无关。

这里有一个例子:一个插件钩入 render_block,向列表区块添加交互性 API 上下文和指令,以在鼠标点击时切换展开状态。

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 内容和区块输出是正确的。传递 null 来比较完整的 HTML 文档。当你只需要设置自定义消息时,显式传递 '<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,并在基础上增加了区块感知能力。

资源