Why Unit-Test Server-Side HTML Templates

Server-side rendering is seeing a resurgence, powered by libraries like HTMX and Turbo, making Go and Java attractive again for rich web UIs. But with that shift comes the question of how to test the generated HTML. End-to-end browser tests work, but are slow and costly to maintain. A faster, more reliable alternative is unit-testing templates directly: render them, ensure the output is structurally sane, and use CSS selectors to verify the presence and content of key elements. This technique works in any language with an HTML parser supporting CSS selectors (with examples in Go and Java).

Manual browser checks are still essential—no unit test can prove a template renders perfectly in a real browser. However, they aren't enough: a small change could easily break a template without triggering an end-to-end test. Templates often hold logic (conditionals, loops over dynamic lists) where every branch needs checking. And minor HTML inconsistencies invisible to tolerant browsers can produce divergent behavior on different devices or engines. Unit tests catch these structural issues quickly and reliably.

Step 1: Validate Basic HTML Structure

Start with a test that your template compiles and renders without obvious structural defects—say, an unclosed div or a p tag closing a div. The initial test can render a minimal model to catch such broken output.

In both languages, model a simple todo list with items like "Buy milk" and "Pay bills." After rendering, parse the output with a stricter parser than a browser's default. Go's standard HTML parser is too lenient, but its XML parser can be configured with appropriate HTML settings. Java developers have jsoup available.

For Go, use the standard html/template package to render, then parse with the XML parser configured for HTML:

  func Test_wellFormedHtml(t *testing.T) {
    templ := template.Must(template.ParseFiles("index.tmpl"))
    _ = templ
  }

Java uses jmustache for its simplicity (Freemarker and Velocity work too), followed by jsoup for parsing:

  @Test
  void indexIsSoundHtml() {
      var template = Mustache.compiler().compile(
              new InputStreamReader(
                      getClass().getResourceAsStream("/index.tmpl")));
  }

The initial test will fail (with the index.tmpl file missing), which you resolve by adding broken HTML placeholder content. The rendering step—saving to a buffer or string—is followed by parsing that deliberately raises an error on invalid structures.

  func Test_wellFormedHtml(t *testing.T) {
    templ := template.Must(template.ParseFiles("index.tmpl"))
    model := todo.NewList()
    _ = templ
    _ = model
  }

With an intentionally malformed <div></p>, the parser errors out, providing immediate feedback:

--- FAIL: Test_wellFormedHtml (0.00s)
    index_template_test.go:61: Error parsing html: XML syntax error on line 4: unexpected end element </p>

Replace the placeholder with the full TodoMVC template, and these checks pass. Rather than leaving verbose parse code scattered, extract helpers to clarify intent:

  func Test_wellFormedHtml(t *testing.T) {
    model := todo.NewList()
  
    buf := renderTemplate("index.tmpl", model)
  
    assertWellFormedHtml(t, buf)
  }

These focused tests act as a guardrail: a regression in a template's structure fails fast without a browser. Combined with keeping logic minimal in views, this approach hardens your server-rendered UI painlessly.

Moving Beyond Visual Checks: Testing Template Structure

Since only a human can truly judge how a page looks in a browser, visual verification remains essential. But templates often contain logic that we can and should test automatically. The problem is how to do this without brittle string-equality assertions that are verbose and obscure what they're proving.

The right approach is to assert only on specific parts of the rendered HTML while ignoring irrelevant details. CSS selectors are an ideal tool for this: they let us query the document for exactly the elements we care about, then verify their count and text content.

In a TodoMVC-style UI, several dynamic behaviors need testing:

  1. Item count and text content differ based on the model
  2. Completed items get different styling
  3. The "X items left" label reflects the number of non-completed items
  4. One of the "All", "Active", or "Completed" links is highlighted, depending on the current URL (e.g., /active)
  5. The "Clear completed" button appears only when at least one item is completed

Examining the static template reveals which selectors map to each feature. For instance, listing items uses a selector for list elements, while the navigation state targets the specific link whose href matches the current path. Tests can then focus narrowly on these selectors. For querying the HTML, Go teams can use the jQuery-inspired goquery library; Java developers can extend their use of jsoup, which was already needed for validating HTML soundness.

A first test renders the model's items into the template, selects all todo list entries, and confirms their text. Before wiring up the template, this fails because the static placeholders contain different text; rendering from the model fixes it. The test setup is a bit noisy in both languages, so extracting a parseHtml helper cleans things up. That helper should also perform the sound-HTML check, allowing the original dedicated soundness test to be deleted, since validity is now verified on every run.

With that infrastructure in place, testing a second dynamic feature is straightforward. To verify completed styling, the test sets up a list with both pending and done items, then asserts the class attribute on the relevant element. Adding a conditional to the template renders that class only when the item's completed flag is true.

Parameterizing Tests for Easy Growth

A core practice―highlighted in Russ Cox's talk on Go testing―is to "make it easy to add new test cases". Both current tests share structure, so the code can be consolidated into a parameterized test case in table form. Each case needs:

  • A descriptive name for clear failures
  • A model, here a todo.List
  • A CSS selector targeting the relevant elements
  • Expected text matches to assert once the selector runs

With names on each case, test output becomes highly readable, both on the terminal and in the IDE. Adding a case for the "X items left" label requires a simple vocabulary check in the template, plus a supporting method on the list model to compute the count of active items.

Handling Data Outside the Model

The navigation link highlighting depends on the current URL, which the template cannot know on its own. The URL represents navigation state, not application state, so it shouldn't be stuffed into the model. Instead, the render function should pass a map containing all data the template needs, including both the model and the path.

Since the path is irrelevant for item-related tests and the model is irrelevant for the link-highlighting cases, the table gains a field. For Go, initializing a struct with only relevant fields is fine, and default values can be applied later in the test method when running it. Java's record syntax lacks this flexibility, so a less eloquent developer might start passing irrelevant empty models or dummy paths, creating noise that invites confusion and redundant cases.

Optimizing for the next developer reading the tests, Java can leverage a hand-coded builder pattern for the test case record. This pattern, popularized by Joshua Bloch, lets cases specify only what matters while keeping the table tidy. Defaults for model and path in the builder prevent failures from injection, ensuring tests fail only when the template lacks expected logic. With these refinements, the two new cases fail for the right reason: the template still hardcodes the currently highlighted link.

Implementing the fix in Go's template engine uses conditional logic on the path value. Mustache prohibits equality testing inside templates, so Java's approach must pre-compute boolean flags in the data map, comparing the current path to the navigation options before rendering.

This tradeoff―slightly more complicated test code for much clearer test cases―is well worth it. The parameterized format reduces boilerplate in the testing environment, making future logic tests both simpler to write and easier to digest. This same infrastructure will continue to evolve as the remaining requirements, like the conditional visibility of "Clear completed", are addressed.

Behavioural Testing in a Real Browser Environment

Structural tests verify the HTML that a template produces, but they don't reveal much about how that HTML behaves in practice. Once CSS and JavaScript enter the picture, the possibilities expand well beyond what static analysis can capture. CSS can hide, show, or reposition elements; JavaScript can attach arbitrary behaviours. The gap between generated markup and user-visible behaviour only grows when you introduce libraries like HTMX that encode AJAX interactions directly into attributes—a domain-specific language of sorts within your templates.

Testing these behaviours truly requires a browser engine. Playwright provides one and is available from both Go and Java. Tests will run slower—a few seconds to boot the headless browser—but can retain the isolation of unit tests if you load only your rendered HTML and stub everything else.

Loading Rendered Templates in a Headless Browser

Continuing with the TodoMVC example, a natural next test checks user interaction with a todo item's checkbox. The desired behaviour involves several steps: a POST to inform the server of the state change, new HTML for the dynamic section.todoapp area in response, and finally a swap of that section's content in the page. But before simulating clicks, the test must first load HTML into a browser. The starter Playwright test handles setup, network stubbing, and rendering:

Go

  func Test_toggleTodoItem(t *testing.T) {
    // render the initial HTML
    model := todo.NewList().
      Add("One").
      Add("Two")
    initialHtml := renderTemplate("index.tmpl", model, "/")
  
    // open the browser page with Playwright
    page := openPage()
    defer page.Close()
    logActivity(page)
  
    // stub network calls
    err := page.Route("**", func(route playwright.Route) {
      if route.Request().URL() == "http://localhost:4567/index.html" {
        // serve the initial HTML
        stubResponse(route, initialHtml.String(), "text/html")
      } else {
        // avoid unexpected requests
        panic("unexpected request: " + route.Request().URL())
      }
    })
    if err != nil {
      t.Fatal(err)
    }
  
    // load initial HTML in the page
    response, err := page.Goto("http://localhost:4567/index.html")
    if err != nil {
      t.Fatal(err)
    }
    if response.Status() != 200 {
      t.Fatalf("unexpected status: %d", response.Status())
    }
  }

Java

  public class IndexBehaviourTest {
      static Playwright playwright;
      static Browser browser;
  
      @BeforeAll
      static void launchBrowser() {
          playwright = Playwright.create();
          browser = playwright.chromium().launch();
      }
  
      @AfterAll
      static void closeBrowser() {
          playwright.close();
      }
  
      @Test
      void toggleTodoItem() {
          // Render the initial html
          TodoList model = new TodoList()
                  .add("One")
                  .add("Two");
          String initialHtml = renderTemplate("/index.tmpl", model, "/");
          
          try (Page page = browser.newPage()) {
              logActivity(page);
  
              // stub network calls
              page.route("**", route -> {
                  if (route.request().url().equals("http://localhost:4567/index.html")) {
                      // serve the initial HTML
                      route.fulfill(new Route.FulfillOptions()
                              .setContentType("text/html")
                              .setBody(initialHtml));
                  } else {
                      // we don't want unexpected calls
                      fail(String.format("Unexpected request: %s %s", route.request().method(), route.request().url()));
                  }
              });
          
              // load initial html
              page.navigate("http://localhost:4567/index.html");
          }
      }
  }

The test begins by seeding a model with two todo items, "One" and "Two", and rendering the template as before:

Go

  model := todo.NewList().
    Add("One").
    Add("Two")
  initialHtml := renderTemplate("index.tmpl", model, "/")

Java

  TodoList model = new TodoList()
          .add("One")
          .add("Two");
  String initialHtml = renderTemplate("/index.tmpl", model, "/");

Then it opens a Playwright Page object, which starts a headless browser instance:

Go

  page := openPage()
  defer page.Close()
  logActivity(page)

Java

  try (Page page = browser.newPage()) {
      logActivity(page);

The helper functions provide feedback about page activity and stub all network requests so the page never touches the outside world:

Go

  func openPage() playwright.Page {
    pw, err := playwright.Run()
    if err != nil {
      log.Fatalf("could not start playwright: %v", err)
    }
    browser, err := pw.Chromium.Launch()
    if err != nil {
      log.Fatalf("could not launch browser: %v", err)
    }
    page, err := browser.NewPage()
    if err != nil {
      log.Fatalf("could not create page: %v", err)
    }
    return page
  }
  func logActivity(page playwright.Page) {
    page.OnRequest(func(request playwright.Request) {
      log.Printf(">> %s %s\n", request.Method(), request.URL())
    })
    page.OnResponse(func(response playwright.Response) {
      log.Printf("<< %d %s\n", response.Status(), response.URL())
    })
    page.OnLoad(func(page playwright.Page) {
      log.Println("Loaded: " + page.URL())
    })
    page.OnConsole(func(message playwright.ConsoleMessage) {
      log.Println("!  " + message.Text())
    })
  }

Java

  private void logActivity(Page page) {
      page.onRequest(request -> System.out.printf(">> %s %s%n", request.method(), request.url()));
      page.onResponse(response -> System.out.printf("<< %s %s%n", response.status(), response.url()));
      page.onLoad(page1 -> System.out.println("Loaded: " + page1.url()));
      page.onConsoleMessage(consoleMessage -> System.out.println("!  " + consoleMessage.text()));
  }

With network activity stubbed, the test requests the page to load the initial HTML:

Go

  err := page.Route("**", func(route playwright.Route) {
    if route.Request().URL() == "http://localhost:4567/index.html" {
      // serve the initial HTML
      stubResponse(route, initialHtml.String(), "text/html")
    } else {
      // avoid unexpected requests
      panic("unexpected request: " + route.Request().URL())
    }
  })
  response, err := page.Goto("http://localhost:4567/index.html")

Java

  // stub network calls
  page.route("**", route -> {
      if (route.request().url().equals("http://localhost:4567/index.html")) {
          // serve the initial HTML
          route.fulfill(new Route.FulfillOptions()
                  .setContentType("text/html")
                  .setBody(initialHtml));
      } else {
          // we don't want unexpected calls
          fail(String.format("Unexpected request: %s %s", route.request().method(), route.request().url()));
      }
  });
  page.navigate("http://localhost:4567/index.html");

This test succeeds, logging the stubbed network calls to standard output. The setup now enables loading arbitrary HTML in a browser and observing what happens.

Interacting Through Accessible Roles

Simulating a checkbox click could use CSS selectors, but there's a better approach: interact with the page the way a user would. Instead of locating input[type=checkbox], the test should look for something by its ARIA role and label. That means finding a checkbox labelled "One":

Go

  func Test_toggleTodoItem(t *testing.T) {
    // ...
    // click on the "One" checkbox
    checkbox := page.GetByRole(*playwright.AriaRoleCheckbox, playwright.PageGetByRoleOptions{Name: "One"})
    if err := checkbox.Click(); err != nil {
      t.Fatal(err)
    }
  }

Java

  @Test
  void toggleTodoItem() {
          // ...
          // click on the "One" checkbox
          var checkbox = page.getByRole(AriaRole.CHECKBOX, new Page.GetByRoleOptions().setName("One"));
          checkbox.click();
      }
  }

Playwright's default timeout is 30 seconds. This test fails because the HTML contains no properly labelled checkbox. Inspecting the generated HTML reveals the cause—the label element isn't linked to its checkbox:

  <li>
    <div class="view">
      <input class="toggle" type="checkbox">
      <label>One</label>
      <button class="destroy"></button>
    </div>
  </li>

Adding the for attribute fixes accessibility:

index.tmpl – Go

  <li>
    <div class="view">
      <input id="checkbox-{{.Id}}" class="toggle" type="checkbox">
      <label for="checkbox-{{.Id}}">{{.Title}}</label>
      <button class="destroy"></button>
    </div>
  </li>

index.tmpl – Java

  <li>
    <div class="view">
      <input id="checkbox-{{ id }}" class="toggle" type="checkbox">
      <label for="checkbox-{{ id }}">{{ title }}</label>
      <button class="destroy"></button>
    </div>
  </li>

The corrected HTML now associates the label with the input:

  <li>
    <div class="view">
      <input id="checkbox-101" class="toggle" type="checkbox">
      <label for="checkbox-101">One</label>
      <button class="destroy"></button>
    </div>
  </li>

The test passes. This outcome also demonstrates how behavioural testing via accessibility roles surfaces real defects in generated markup, leading to improved HTML quality.

Verifying a Round-Trip

Extending this test, the next step covers the server round-trip. If the server receives POST /toggle/101, it should return stubbed HTML back, which replaces part of the current document. Additionally, HTMX loads from a local file. The full expected workflow involves the checkbox click triggering a POST, receiving fresh HTML, and swapping the section.todoapp container with the new content.

Go and Java both stub the call and load HTMX locally:

Go

  } else if route.Request().URL() == "http://localhost:4567/toggle/101" && route.Request().Method() == "POST" {
    // we expect that a POST /toggle/101 request is made when we click on the "One" checkbox
    const stubbedHtml = `
      <section class="todoapp">
        <p>Stubbed html</p>
      </section>`
    stubResponse(route, stubbedHtml, "text/html")
  } else if route.Request().URL() == "https://unpkg.com/[email protected]" {
    // serve the htmx library
    stubResponse(route, readFile("testdata/htmx.min.js"), "application/javascript")
  } else if (route.request().url().equals("https://unpkg.com/[email protected]")) {
      // serve the htmx library
      route.fulfill(new Route.FulfillOptions()
              .setContentType("text/html")
              .setBody(readFile("/htmx.min.js")));

Java

  } else if (route.request().url().equals("http://localhost:4567/toggle/101") && route.request().method().equals("POST")) {
      // we expect that a POST /toggle/101 request is made when we click on the "One" checkbox
      String stubbedHtml = """
          <section class="todoapp">
              <p>Stubbed html</p>
          </section>
          """;
      route.fulfill(new Route.FulfillOptions()
              .setContentType("text/html")
              .setBody(stubbedHtml));

Both tests then assert that after clicking, the existing section content is replaced by the response:

Go

  // click on the "One" checkbox
  checkbox := page.GetByRole(*playwright.AriaRoleCheckbox, playwright.PageGetByRoleOptions{Name: "One"})
  if err := checkbox.Click(); err != nil {
    t.Fatal(err)
  }

  // check that the page has been updated
  document := parseHtml(t, content(t, page))
  elements := document.Find("body > section.todoapp > p")
  assert.Equal(t, "Stubbed html", elements.Text(), must(page.Content()))

Java

  // click on the "One" checkbox
  var checkbox = page.getByRole(AriaRole.CHECKBOX, new Page.GetByRoleOptions().setName("One"));
  checkbox.click();

  // check that the page has been updated
  var document = parseHtml(page.content());
  var elements = document.select("body > section.todoapp > p");
  assertThat(elements.text())
          .describedAs(page.content())
          .isEqualTo("Stubbed html");

This test fails initially. Adding the full HTML document to the error message shows the page made no XHR call, meaning the template lacks any instructions to do so. Adding HTMX attributes to the checkboxes in the template instructs the library to dispatch a POST request and update the targeted region:

index.tmpl

  <title>Template • TodoMVC</title>
  <script src="https://unpkg.com/[email protected]"></script>
  <input
     
     
      id="checkbox-{{.Id}}"
      class="toggle"
      type="checkbox">

The data-hx-post attribute triggers a POST to the specified URL, data-hx-target identifies the element to update, and data-hx-swap="outerHTML" replaces the target's DOM node rather than its contents. The test still fails on the response as it's not the expected section:

Go – Java similar

  --- FAIL: Test_toggleTodoItem (2.40s)
  === RUN   Test_toggleTodoItem
  >> GET http://localhost:4567/index.html
  << 200 http://localhost:4567/index.html
  >> GET https://unpkg.com/[email protected]
  << 200 https://unpkg.com/[email protected]
  Loaded: http://localhost:4567/index.html
  >> POST http://localhost:4567/toggle/101
  << 200 http://localhost:4567/toggle/101
      index_behaviour_test.go:67:
            Error Trace:  .../index_behaviour_test.go:67
            Error:        Not equal:
                          expected: "Stubbed html"
                          actual  : ""
                          ...
            Test:         Test_toggleTodoItem
            Messages:     <!DOCTYPE html><html lang="en"><head>
                              <meta charset="utf-8">
                              <meta name="viewport" content="width=device-width, initial-scale=1">
                              <title>Template • TodoMVC</title>
                              <script src="https://unpkg.com/[email protected]"></script>
                          ...
                            <body>
                              <section class="todoapp"><section class="todoapp">
                                    <p>Stubbed html</p>
                                  </section></section>
                          ...
                          </body></html>

The failure shows the problem clearly—fresh content contains a section.todoapp nested inside an existing one. Exchange of content requires replacing the entire outer section element, not the content inside it. The HTMX documentation notes its default swap mode is innerHTML. Altering the template to specify outerHTML corrects the ancestor/descendant problem:

index.tmpl

  <input
     
     
     
      id="checkbox-{{.Id}}"
      class="toggle"
      type="checkbox">

Running the test again now passes in both versions:

Go

  === RUN   Test_toggleTodoItem
  >> GET http://localhost:4567/index.html
  << 200 http://localhost:4567/index.html
  >> GET https://unpkg.com/[email protected]
  << 200 https://unpkg.com/[email protected]
  Loaded: http://localhost:4567/index.html
  >> POST http://localhost:4567/toggle/101
  << 200 http://localhost:4567/toggle/101
  --- PASS: Test_toggleTodoItem (1.39s)

Java

  IndexBehaviourTest > toggleTodoItem() STANDARD_OUT
      >> GET http://localhost:4567/index.html
      << 200 http://localhost:4567/index.html
      >> GET https://unpkg.com/[email protected]
      << 200 https://unpkg.com/[email protected]
      Loaded: http://localhost:4567/index.html
      >> POST http://localhost:4567/toggle/101
      << 200 http://localhost:4567/toggle/101
  
  IndexBehaviourTest > toggleTodoItem() PASSED

This behaviour test uses a headless browser but remains isolated from the wider application. It exercises just the HTML, its CSS and JavaScript, with no controllers or services attached. Its cost is a two-to-three-second startup wait. That relative slowness is offset by stability and clear signalling when the markup doesn't do what the application needs it to do.

An Alternative: Stringly Asserted Tests

TDD practitioner Esko Luontola has proposed an alternative to CSS-selector-based assertions: converting HTML into a canonical, human-readable string. The idea is to strip away the noise of tags while preserving enough structure to make meaningful assertions.

For a given snippet of generated HTML:

<ul class="todo-list">
  <li class="">
    <div class="view">
      <input id="checkbox-100" class="toggle" type="checkbox">
      <label for="checkbox-100">One</label>
      <button class="destroy"></button>
    </div>
  </li>
  <li class="">
    <div class="view">
      <input id="checkbox-200" class="toggle" type="checkbox">
      <label for="checkbox-200">Two</label>
      <button class="destroy"></button>
    </div>
  </li>
  <li class="completed">
    <div class="view">
      <input id="checkbox-300" class="toggle" type="checkbox">
      <label for="checkbox-300">Three</label>
      <button class="destroy"></button>
    </div>
  </li>
</ul>

You can visualize it by deleting all tags and collapsing every sequence of whitespace characters to a single blank, which yields:

One Two Three

That approach loses too much detail — it cannot, for example, distinguish active from completed items. To compensate, Luontola suggests adding a data-test-icon attribute to an element, supplying text that stands in for the element during visualization. An input like <input value="foo" /> renders as [foo], the brackets signaling an editable text box.

With this icon mechanism wired into a template:

  <ul class="todo-list">
      {{ range .model.AllItems }}
      <li class="{{ if .IsCompleted }}completed{{ end }}">
          <div class="view">
              <input
                    
                    
                     id="checkbox-{{ .Id }}"
                     class="toggle"
                     type="checkbox"
                     data-test-icon="{{ if .IsCompleted }}✅{{ else }}⬜{{ end }}">
              <label for="checkbox-{{ .Id }}">{{ .Title }}</label>
              <button class="destroy" data-test-icon="❌️"></button>
          </div>
      </li>
      {{ end }}
  </ul>

you can assert against the resulting canonical form:

  func Test_visualize_html_example(t *testing.T) {
    model := todo.NewList().
      Add("One").
      Add("Two").
      AddCompleted("Three")
  
    buf := renderTemplate("todo-list.tmpl", model, "/")
  
    expected := `
      ⬜ One ❌️
      ⬜ Two ❌️
      ✅ Three ❌️
      `
    assert.Equal(t, normalizeWhitespace(expected), visualizeHtml(buf.String()))
  }

Luontola's Java implementation, and a Go translation of it, look like this:

  func visualizeHtml(html string) string {
    //  custom visualization using data-test-icon attribute
    html = replaceAll(html, "<[^<>]+\\bdata-test-icon=\"(.*?)\".*?>", " $1 ")
    // strip all HTML tags: inline elements
    html = replaceAll(html, "</?(a|abbr|b|big|cite|code|em|i|small|span|strong|tt)\\b.*?>", "")
    // strip all HTML tags: block elements
    html = replaceAll(html, "<[^>]*>", " ")
    // replace HTML character entities
    html = replaceAll(html, "&nbsp;", " ")
    html = replaceAll(html, "&lt;", "<")
    html = replaceAll(html, "&gt;", ">")
    html = replaceAll(html, "&quot;", "\"")
    html = replaceAll(html, "&apos;", "'")
    html = replaceAll(html, "&amp;", "&")
    return normalizeWhitespace(html)
  }
  
  func normalizeWhitespace(s string) string {
    return strings.TrimSpace(replaceAll(s, "\\s+", " "))
  }
  
  func replaceAll(src, regex, repl string) string {
    re := regexp.MustCompile(regex)
    return re.ReplaceAllString(src, repl)
  }

This technique amounts to asserting against a reduced, canonical string version of a complex HTML structure. Martin Fowler has called this approach "stringly asserted," a coinage that gives this section its name. Luontola reports strong results with it; it may prove equally valuable in your own test suites.

What to Test, Concluded

Complex templates in modern web applications rarely produce the exact HTML we expect on the first try. When we do start testing them, we typically uncover errors — and automated tests can catch these far faster than manual debugging of template logic.

Target these template behaviors specifically:

  • For every conditional in the template, test the rendering both when the condition holds and when it does not.
  • For every list iteration, test both the empty-list case and the non-empty case.
  • When a model value may be nil or null, test with both a null value and a non-null value.
  • Always verify the rendered output is sound HTML with the expected essential structure.
  • For client-side behavior in JavaScript or CSS, a headless browser may be worthwhile; the techniques from the section on HTML behavior can help keep those tests economical.

This discipline does not eliminate every possible error, but it prevents many failures that would otherwise surface directly in a user's browser. Testing HTML templates is both feasible and worthwhile — expect to find your own mistakes earlier and with less frustration.