Testing a New Page Loaded (or not) with Capybara

When writing feature specs, I usually test a new page has loaded using a custom matcher that checks the current URL path, <title>, and headline of the expected page, as shown in these example be_*_page matchers:

...

expect(page).to be_dashboard_page
click_link "Help"
expect(page).to be_help_page

...

matcher :be_dashboard_page do
  match_unless_raises do |page|
    expect(page).to have_current_path "/dashboard"
    expect(page).to have_title "Your Dashboard"
    expect(page).to have_css "h1", text: "Dashboard"
  end
end

matcher :be_help_page do
  match_unless_raises do |page|
    expect(page).to have_current_path "/help"
    expect(page).to have_title "Help"
    expect(page).to have_css "h1", text: "How can we help?"
  end
end

Very rarely, I encounter a situation where the be_*_page technique isn't the right tool for the job.

Perhaps I need to test that a new page has not loaded, say when exercising HTML5 input validations (HTML5 browser native validation errors cannot be accessed through the DOM). Or maybe I need to test the previous page has reloaded (and so the page content and URL path do not change). This can happen when exercising server-side validations and I want to be explicit about asserting the page reloaded, to make the test intent clearer.

In these validation-exercising moments, I would ordinarily test the displayed error messages.

But what do you do if the form error message is the same across page reloads (say due to consecutive form submits that are integral to the test)?

Because the browser driver works asynchronously, you can't assume the page would have reloaded instantly after clicking a submit button.

After clicking the submit button the old page content will still be present for a short time. Not accommodating for this delay in your test design will result in false positive passes where the previous content of the page is being asserted on before the new page has loaded.

Here's a (contrived) test that illustrates the problem when a form submission does not change the content of the page, but keeps showing the same error message:

visit "/subscribe"

expect(page).to be_subscribe_page

fill_in "Email", with: "invalid-email"

click_button "Submit"

# Note the error message text expectation after the next button
# click is the same as this one:
expect(page).to have_flash :alert, text: "You have form errors"

click_button "Submit"

# Sometimes the page will *not* have reloaded by the time this
# expectation runs, but the expectation still passes (incorrectly)
# as its testing the previous page content:
expect(page).to have_flash :alert, text: "You have form errors"

(Admittedly, there are ways to fix this in the UI, for example by having a dismissable flash that is closed before clicking the submit button, but pretend we need to write test coverage for existing behaviour).

So here's how this can be resolved, by using a custom replace_page block matcher:

expect(page).to have_flash :alert, text: "You have form errors"

-click_button "Submit"
+expect { click_button "Submit" }.to replace_page

expect(page).to have_flash :alert, text: "You have form errors"

And here's the replace_page matcher:

# Matcher checks if the original page has been unloaded
# and replaced with a new page:
#
# expect { click_link '' }.to replace_page
# expect { click_button 'Submit' }.not_to replace_page
matcher :replace_page do
  supports_block_expectations

  match do |interaction|
    # Generate a unique page id:
    page_id = "test-page-id-#{SecureRandom.hex}"

    execute_script <<-JS.strip_heredoc
      window.addEventListener("beforeunload", function(event) {
        // Precaution (may not be necessary) to ensure that during unload
        // the state is not set to undefined. Only want undefined state
        // to be present on the new page.
        window.__test_page_load_state = "before-unloading";
      });

      // Store the load state and the page id as part of
      // the current page:
      window.__test_page_load_state = "loaded";
      document.documentElement.classList.add("#{page_id}");
    JS

    # Execute the block passed to `expect`:
    interaction.call

    # Return true if the original page has been replaced:
    page_replaced = evaluate_script <<-JS.strip_heredoc
      window.__test_page_load_state === undefined && !document.documentElement.classList.contains("#{page_id}")
    JS
  end
end

Note the above matcher only works with JavaScript-capable Capybara drivers, so it won't work with the :rack_test driver. My guess is its possible to get something similar working with the :rack_test driver (get in touch if this is something you've done or would like help figuring out).