If you’ve written PHPUnit tests for WordPress plugins that produce HTML output — block render callbacks, formatting filters, shortcodes — you know the frustration. The test passes on your machine. Then it fails on CI because an attribute comes back in a different order. Or you add a trailing semicolon to an inline style, and suddenly three tests break — even though the browser renders exactly the same thing.
WordPress 6.9 introduced a new assertion — assertEqualHTML() — available on WP_UnitTestCase that solves this. It compares HTML semantically, not literally. Attribute order, class name order, style whitespace, attribute quoting style differences — none of those trigger a failure. The test breaks only when the markup is semantically different.
This article walks through how to use assertEqualHTML(), what it normalizes, and how to replace fragile string assertions in your existing test suite.
Table of Contents
The problem with assertSame() for HTML
Consider a filter that uses the HTML API to add loading="lazy" to images in post content:
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' );
A test using assertSame() looks like this:
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 ) );
}
This test is fragile. The HTML API inserts loading="lazy" as the first attribute. Your expected string must match that exact position. The moment a future WordPress version changes attribute serialization order — or you switch to a different parser — the test fails, even though the browser renders the same thing.
The test is also fragile when the underlying code is legitimately refactored. If you clean up the HTML input to use double quotes consistently, or normalize class name order in your filter, every assertion needs updating.
All the examples and tests referenced in this article are available in the following repository:
https://github.com/wptrainingteam/assert-equal-html-examples
You can clone the repository and run the tests locally to explore the examples in more detail.
What assertEqualHTML() does
assertEqualHTML() is a method on WP_UnitTestCase added in WordPress 6.9 (#63527, PR #8882). It works by parsing both HTML strings into a normalized tree with WP_HTML_Processor and comparing those trees with assertSame – so two strings that produce the same tree are treated as equal, even if their raw text differs.
The normalization handles everything that doesn’t affect what a browser renders:
| What differs | assertSame() | assertEqualHTML() |
|---|---|---|
| Attribute order | ❌ fails | ✅ passes |
| Class name order | ❌ fails | ✅ passes |
Style whitespace / trailing ; | ❌ fails | ✅ passes |
| Tag name capitalization | ❌ fails | ✅ passes |
HTML character references(¬ vs ¬) | ❌ fails | ✅ passes |
| Duplicate attributes (HTML spec: first wins) | ❌ fails | ✅ passes |
| Block comment attribute order | ❌ fails | ✅ passes |
| Block class name order | ❌ fails | ✅ passes |
| Different HTML content | ✅ catches | ✅ catches |
| Different attribute values | ✅ catches | ✅ catches |
| Missing / extra attributes | ✅ catches | ✅ catches |
| Different class names (not just order) | ✅ catches | ✅ catches |
| Different style values | ✅ catches | ✅ catches |
The method signature is:
public function assertEqualHTML(
string $expected,
string $actual,
?string $fragment_context = '<body>',
string $message = 'HTML markup was not equivalent.'
): void
Basic usage
Rewriting the earlier test to use assertEqualHTML() makes it resilient to attribute ordering:
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 ) );
}
Now the exact position of loading="lazy" in the attribute list doesn’t matter. Both <img loading="lazy" src="..." alt="..."> and <img src="..." alt="..." loading="lazy"> produce the same tree representation, so the assertion passes.
The assertion also handles HTML character reference equivalence. Any given character has multiple representations — literal, named, decimal, hex, padded variants, even named references without a semicolon — and assertEqualHTML() treats them all as equal:
$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 );
Testing HTML API transformations
Here’s a more complete example: a plugin function that wraps external links in a <span> for styling, injects a data-external attribute and appends noopener noreferrer to the rel attribute using the HTML API.
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();
}
Testing this with assertSame() would require knowing the exact order WordPress serializes data-external and rel relative to existing attributes. With assertEqualHTML, you only need to describe the expected semantic result:
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 ) );
}
No matter what order the HTML API serializes the new attributes, both tests correctly verify the semantic intent.
Testing block render callbacks
Block render callbacks often produce complex markup with deeply nested elements. The assertEqualHTML() assertion is especially useful here because block serialization can produce minor whitespace or attribute-order variations depending on the WordPress version.
Consider a dynamic block that renders a card component:
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()
);
}
The function above creates a wrapper <div> using WP_HTML_Tag_Processor, then conditionally applies a background-color style and appends extra class names based on the block’s attributes. The content is injected by replacing the closing tag. This gives us a function with several moving parts – the kind of output assertEqualHTML handles well.
Testing the full block output — including the wrapper attributes — looks like this:
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 );
}
With assertEqualHTML(), you can write the expected HTML in a readable, well-indented format using NOWDOC syntax (<<<'HTML'), which avoids variable interpolation and keeps the markup clean. The comparison normalizes attribute order and class names — so is-style-outlined my-custom-class wp-block-my-plugin-card and wp-block-my-plugin-card is-style-outlined my-custom-class are equivalent.
Testing Interactivity API directive injection
One of the most common places to reach for assertEqualHTML() is when testing filters that inject Interactivity API directives into block HTML. These filters add data-wp-* attributes to elements — and the exact position of those attributes in the output is irrelevant to the Interactivity API’s behavior.
Here’s an example: a plugin that hooks into render_block to add Interactivity API context and directives to a list block to toggle expanded state on mouse click.
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 );
Writing a test for this filter:
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 );
}
The data-wp-context attribute contains JSON. Whether your function outputs {"expanded":false} or {"expanded":false}, the HTML API decodes entity references before comparison, so the assertion handles both forms correctly.
Understanding failure output
When assertEqualHTML() fails, the error message shows the normalized tree representation of both strings — not the raw HTML diff. This makes it much easier to spot meaningful differences.
Here’s an example. Suppose a filter accidentally drops the rel attribute:
HTML markup was not equivalent.
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
<a>
href="https://example.com"
+ rel="noopener noreferrer"
"example.com"
Compare this to a assertSame() failure for the same problem:
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>
Both show the missing rel, but the tree format from assertEqualHTML() also works well for complex, deeply nested block markup — each attribute on its own line, consistently indented. When a test fails on a render callback that outputs 20+ lines of HTML, the tree diff pinpoints exactly which attribute on which element changed.
The tree format also understands block delimiters. For block markup, it renders blocks as BLOCK["namespace/name"] with their attributes as formatted JSON, separate from the HTML structure. That means a test for block output shows both the block attribute changes and the HTML attribute changes in separate, readable sections.
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"
"
"
Adding a custom failure message
Pass a custom message as the fourth argument to make failures easier to identify:
$this->assertEqualHTML( $expected, $actual, '<body>', 'Card block output did not match.' );
The third argument, $fragment_context, defaults to '<body>' — correct for most WordPress content and block output. Pass null to compare full HTML documents. When you only need to set a custom message, pass '<body>' explicitly to keep the default parsing context.
When to keep assertSame
assertEqualHTML() is not a universal replacement for assertSame(). Keep using assertSame() when:
- Exact string output matters — for example, testing a function that other code feeds back into a parser that requires a specific serialization format.
- Testing non-HTML output —
assertEqualHTML()only makes sense for HTML strings. - Testing plain-text output — for a function that returns a string with no HTML markup at all (e.g., the output of
strip_tags()), useassertSame(). If the output can contain HTML character references,assertEqualHTML()is still the better choice since it normalizes them.
One specific case where assertSame() is still correct: verifying the exact serialization of a data-wp-context attribute value or the content of a <script> tag. If exact string output matters there, test it directly with assertSame().
Migration tips
For existing test suites, the a basic migration path would be to find tests that compare HTML strings with assertSame() and ask, “Would this test still be meaningful if attribute order changed?”
If yes, switch to assertEqualHTML(). The method is a drop-in replacement for the common pattern:
// Before:
$this->assertSame( $expected_html, $actual_html );
// After:
$this->assertEqualHTML( $expected_html, $actual_html );
If your test class extends WP_UnitTestCase (either directly or through a subclass), the method is available immediately in WordPress 6.9+.
Some test suites have custom assertEqualMarkup() methods or helpers that parse HTML with DOMDocument before comparing. Those can be replaced with assertEqualHTML() — which uses the more modern WP_HTML_Processor and adds block-awareness on top.
Resources
- Tests: Add test assertion to compare HTML for equivalence #63527 — TRAC ticket with the original proposal and discussion
- Tests: Add new assertEqualHTML assertion #8882 — PR with the implementation
- Updates to the HTML API in 6.9 — dev note by @dmsnell covering all HTML API changes in WordPress 6.9
- WP_HTML_Tag_Processor reference — for writing the filters you’ll want to test
- Interactivity API reference — directives and store API
- Real-world uses in WordPress core — see how core uses
assertEqualHTML()across its test suite
If you’ve replaced string-based HTML assertions in your plugin or theme’s test suite with assertEqualHTML(), share how it worked in the comments — especially if you ran into edge cases worth knowing about.
Props to @bph and @jonsurrell for reviewing this post
Leave a Reply