2024-11-22
read time: 3 min
cat ~/posts/rust/creating-html-compare-rs.md

Creating html-compare-rs: A Journey into Rust Macros

As a developer working extensively with HTML generation in Rust, I found myself repeatedly writing similar test assertions to compare HTML output. This led me to create html-compare-rs, a library for comparing HTML content while ignoring differences that don't affect the rendered result. The journey taught me a lot about Rust macros and test ergonomics.

The Problem

Testing HTML output is surprisingly tricky. Consider these equivalent pieces of HTML:

<div><p>Hello</p></div>
<div>
  <p>Hello</p>
</div>

They render identically, but a simple string comparison would mark them as different. Add in variations in attribute order, comments, and whitespace handling, and you quickly end up with brittle tests that break on formatting changes.

Enter html-compare-rs

I wanted a library that would:

  1. Handle common HTML comparison edge cases
  2. Provide configurable comparison options
  3. Feel natural to use in tests
  4. Give clear error messages when tests fail

The last two points led me to explore Rust macros for the first time.

Writing My First Macro

The core of the library is fairly straightforward - parse the HTML, walk the DOM trees, compare nodes. But I wanted the test experience to be seamless. Instead of:

let comparer = HtmlComparer::new();
assert!(comparer.compare(html1, html2).unwrap());

I wanted:

assert_html_eq!(html1, html2);

This meant diving into Rust's declarative macros. Here's what I learned:

1. Macro Basics

My first attempt was simple:

macro_rules! assert_html_eq {
    ($left:expr, $right:expr) => {
        let comparer = HtmlComparer::new();
        assert!(comparer.compare($left, $right).unwrap());
    };
}

This worked, but the error messages were terrible. When a test failed, you'd just see "assertion failed" with no context about what was different.

2. Better Error Messages

The next iteration added proper error handling:

macro_rules! assert_html_eq {
    ($left:expr, $right:expr) => {{
        match (&$left, &$right) {
            (left_val, right_val) => {
                let comparer = HtmlComparer::new();
                if let Err(err) = comparer.compare(left_val, right_val) {
                    panic!(
                        "\nHTML comparison failed:\n{}\n\nleft HTML:\n{}\n\nright HTML:\n{}\n",
                        err, left_val, right_val
                    );
                }
            }
        }
    }};
}

Key learnings here:

3. Optional Arguments

The final challenge was supporting optional comparison options:

macro_rules! assert_html_eq {
    ($left:expr, $right:expr) => {
        $crate::assert_html_eq!($left, $right, $crate::HtmlCompareOptions::default())
    };
    ($left:expr, $right:expr, $options:expr) => {{
        // ... comparison code ...
    }};
}

This pattern of having one macro rule delegate to another is common in Rust. The $crate prefix ensures the macro works correctly when used from other crates.

Lessons Learned

  1. Start Simple: My first macro was basic but working. Adding features incrementally made the process manageable.

  2. Error Messages Matter: Good error messages are crucial for testing libraries. Taking time to format them well pays off.

  3. Consider Ergonomics: The macro interface feels natural because it mirrors Rust's built-in assert_eq!. When designing APIs, familiarity helps adoption.

  4. Macro Hygiene: Using $crate and being careful with identifier scope is important for macros that will be used in other crates.

  5. Testing is Crucial: The library has extensive tests for both the comparison logic and the macros themselves. This caught several edge cases in macro expansion.

Next Steps

While the library works well for my needs, there's room for improvement:

Conclusion

Creating html-compare-rs taught me a lot about both Rust macros and library design. What started as a simple testing utility led to diving deep into Rust's macro system. The result is a library that makes HTML testing more reliable and maintainable.

Most importantly, I learned that good library design isn't just about the core functionality - it's about creating an interface that feels natural and helps users understand what went wrong when things fail.

The code is open source and available on GitHub. Contributions and feedback are welcome!

// Contents