Integration Testing with Capybara

Sean Doyle

If your application relies on your server to generate and transmit all of its HTML, then the structure and contents of those HTTP requests and responses is crucial.

Testing the overlap between Controllers and Views

Out of the box, Action Dispatch depends on the rails-dom-testing gem to provide tests with a way of asserting the structure and contents of a request’s HTML response body. For example, consider an HTML response for an HTTP GET request to /articles/hello-world:

<html>
  <head>
    <title>Hello, World!</title>
  </head>
  <body>
    <main>
      <h1>Hello, World!</h1>
    </main>
  </body>
</html>

The rails-dom-testing gem provides a collection of methods that transform CSS selectors and text into assertions about the structure and contents of the response body’s HTML:

class ArticlesTest < ActionDispatch::IntegrationTest
  test "index" do
    get article_path("hello-world")

    assert_select "title", "Hello, World!"
    assert_select "body main h1", "Hello, World!"
  end
end

In their most simple form, the rails-dom-testing assertions provide a lot of utility in their flexibility. Despite that flexibility, asserting with assert_select can become complicated in the face of Real World features.

For example, consider an HTML response for an HTTP GET request to /sessions/new:

<html>
  <head>
    <title>Sign in</title>
  </head>
  <body>
    <main>
      <h1>Sign in</h1>

      <form method="post" action="/sessions">
        <label for="session_email_address">Email address</label>
        <input type="email" id="session_email_address" name="session[email_address]">

        <label for="session_password">Password</label>
        <input type="password" id="session_password" name="session[password]">

        <button>Sign in</button>
      </form>
    </main>
  </body>
</html>

The ActionDispatch::IntegrationTest cases for this page might cover several facets of the HTML:

  1. The page has "Sign in" in its <title> element
  2. The page has two different elements with "Sign in": an <h1> heading and a <button> element
  3. The page has an <input type="email"> element to collect the user’s email address, and that field is labeled with the text "Email address"
  4. The page has an <input type="password"> element to collect the user’s password, and that field is labeled with the text "Password".

We could cover those requirements with the following rails-dom-testing-capable test:

class SessionsTest < ActionDispatch::IntegrationTest
  test "new" do
    get new_session_path

    assert_select "title", "Sign in"
    assert_select "body main h1", "Sign in"
    assert_select "body main button", "Sign in"
    assert_select %(input[id="session_email_address"][type="email"])
    assert_select %(label[for="session_email_address"]), "Email address"
    assert_select %(input[id="session_password"][type="password"])
    assert_select %(label[for="session_password"]), "Password"
  end
end

These assertions are sufficient to test the page, making sure it meets all of our requirements. Unfortunately, the assertions have some issues.

For instance, the assertions about the relationships between the <input> and <label> elements are extremely brittle. They encode the session_email_address and session_password element [id] attribute directly into the test. If that page’s HTML were to change, those tests would fail even if the new HTML declared the <label> elements with the same text content and in a way that continued to properly reference their <input> elements.

Similarly, the specificity required by the title, h1, and button selectors is tedious compared to how different a <title>, <h1> and <button> are treated by a browser.

These types of issues could be addressed by introducing abstractions to account for the varying semantics of each requirement. Luckily, a tool that already does that on our behalf exists: System Tests!

If we were to write a System Test to exercise our page to make sure it meets the requirements, it might resemble something like:

class SessionsTest < ActionDispatch::SystemTestCase
  test "new" do
    visit new_session_path

    assert_title "Sign in"
    assert_css "h1", "Sign in"
    assert_button "Sign in"
    assert_field "Email address", type: "email"
    assert_field "Password", type: "password"
  end
end

These assertions are not only more terse, they’re also more robust. For example, the assert_button will continue to pass if the <button> element were replaced with an <input type="submit"> element with the same text. Similarly, the assert_field calls would continue to pass if the <input> elements’ [id] attributes were changed, or if they were moved to be direct descendants of the <label> element. What an improvement!

Unfortunately, these improvements come with a cost.

The Challenges of a growing test suite

In practice, a Rails application’s test suite will exercise its controller and view code in two ways: through ActionDispatch::IntegrationTest tests, and through ActionDispatch::SystemTestCase tests.

System Tests are often executed by driving real browsers with tools like Selenium, Webdriver, or over the Chrome DevTools Protocol (via Apparition). The good news: driving a browser through your test cases provides an extremely high and valuable level of fidelity and confidence in the end-user experience. The bad news: driving a browser through your test incurs an extremely high cost: speed.

That cost can be recuperated by configuring System Tests to be driven_by :rack_test. When configured this way, test cases rely on a Rack Test “browser” to make HTTP requests, “click” on buttons, and “fill in” form fields on their behalf. These types of tests are valuable in their own right, but they don’t provide the same level of control as ActionDispatch::IntegrationTest cases.

For example, an ActionDispatch::IntegrationTest can submit an HTTP request with any HTTP verb, whereas a System Test can only directly submit GET requests through calls to visit. Similarly, System Tests can’t access the response’s status code or its headers directly, nor can the read from Rails-specific utilities like the cookies, flash, or session. For example, consider a test that exercises a controller’s response to an invalid request:

class ArticlesTest < ActionDispatch::IntegrationTest
  test "update" do
    valid_attributes    = { title: "A valid Article", body: "It's valid!" }
    invalid_attributes  = { title: "Will be invalid", body: "" }
    article             = Article.create! valid_attributes

    assert_no_changes -> { article.reload.attributes } do
      put article_path(article), params: { article: invalid_attributes }
    end

    assert_response :unprocessable_entity
    assert_equal "Failed to create Article!", flash[:alert]
  end
end

Conversely, ActionDispatch::IntegrationTest cases exercise the application one layer of abstraction below the browser: at the HTTP request-response layer. They don’t drive a real browser, but they do make the same kinds of HTTP requests! On top of that, they provide built-in mechanisms to read directly from the response, session, flash, or cookies.

It can be challenging to strike a balance between the confidence & fidelity of System Tests and the speed and granularity of Integration Tests when exercising the structure and contents of a feature’s HTML. It can be even more challenging if you need to maintain and context switch between two parallel sets of HTML assertions. Both System and Integration Tests are compatible with Rack::Test. What if there was a way to get the best of both worlds?

Combining ActionDispatch::IntegrationTest with Capybara

Capybara provides Rails’ System Tests with its collection of finders, actions, and assertions. Conceptually, the value provided by Capybara’s finders and assertions overlaps entirely with the assertions provided by rails-dom-testing. Plus, if your application has a suite of System Tests, you’re already depending on Capybara!

If we wanted to share the same Rack::Test session between our ActionDispatch::IntegrationTest case and our Capybara assertions, we’d need to integrate a Capybara::Session instance with the integration_session at the heart of our tests. If we wanted to modify the ActionDispatch::IntegrationTest class, we could do so with an ActiveSupport::Concern mixed-into the class from within an ActiveSupport.on_load hook:

ActiveSupport.on_load :action_dispatch_integration_test do
  include(Module.new do
    extend ActiveSupport::Concern

    included do
      setup do
        integration_session.extend(Module.new do
          # ...
        end)
      end
    end
  end)
end

Within that concern, we’d want to declare a #page method like the one our System Tests provide. Within the #page method, we’d construct, memoize, and return an instance of a Capybara::Session:

 ActiveSupport.on_load :action_dispatch_integration_test do
   include(Module.new do
     extend ActiveSupport::Concern

     included do
       setup do
         integration_session.extend(Module.new do
+          def page
+            @page ||= ::Capybara::Session.new(:rack_test, @app)
+          end
         end)
       end
     end
   end)
 end

Once we constructed that instance, we’d need to make it available to the inner-working mechanics of the ActionDispatch::IntegrationTest case. Right now, the only avenue toward that goal is re-declaring the _mock_session private method:

 ActiveSupport.on_load :action_dispatch_integration_test do
   include(Module.new do
     extend ActiveSupport::Concern

     included do
       setup do
         integration_session.extend(Module.new do
           def page
             @page ||= ::Capybara::Session.new(:rack_test, @app)
           end
+
+          def _mock_session
+            @_mock_session ||= page.driver.browser.rack_mock_session
+          end
         end)
       end
     end
   end)
 end

Depending on Rails’ private interfaces is very risky and highly discouraged. There is an ongoing discussion about adding public-facing hooks for this type of integration (see rails/rails#41291 and rails/rails#43361). Since these are test-level dependencies, changes to the private implementation won’t lead to production-level outages. Ideally, this will only be temporary!

With those changes in place, the last step is to mix in Capybara’s assertions:

+require "capybara/minitest"
+
 ActiveSupport.on_load :action_dispatch_integration_test do
   include(Module.new do
     extend ActiveSupport::Concern

     included do
+      include Capybara::Minitest::Assertions
+
       setup do
         integration_session.extend(Module.new do
           def page
             @page ||= ::Capybara::Session.new(:rack_test, @app)
           end

           def _mock_session
             @_mock_session ||= page.driver.browser.rack_mock_session
           end
         end)
       end
     end
   end)
 end

Now we can re-write our SessionsTest to inherit from ActionDispatch::IntegrationTest instead of ActionDispatch::SystemTestCase:

-class SessionsTest < ActionDispatch::SystemTestCase
+class SessionsTest < ActionDispatch::IntegrationTest
   test "new" do
-    visit new_session_path
+    get new_session_path

     assert_title "Sign in"
     assert_css "h1", "Sign in"
     assert_button "Sign in"
     assert_field "Email address", type: "email"
     assert_field "Password", type: "password"
   end
 end

If we wanted to use other Capybara-provided helpers like within, we could delegate those calls to our page instance:

 require "capybara/minitest"

 ActiveSupport.on_load :action_dispatch_integration_test do
   include(Module.new do
     extend ActiveSupport::Concern

     included do
       include Capybara::Minitest::Assertions
+
+      delegate :within, to: :page

       setup do
         integration_session.extend(Module.new do
           def page
             @page ||= ::Capybara::Session.new(:rack_test, @app)
           end

           def _mock_session
             @_mock_session ||= page.driver.browser.rack_mock_session
           end
         end)
       end
     end
   end)
 end

Then we could use them in our tests:

 class SessionsTest < ActionDispatch::IntegrationTest
   test "new" do
     get new_session_path

     assert_title "Sign in"
+    within "main" do
       assert_css "h1", "Sign in"
       assert_button "Sign in"
       assert_field "Email address", type: "email"
       assert_field "Password", type: "password"
+    end
   end
 end

With this integration in place, there’s nothing stopping us from declaring custom Capybara selectors, or adding a dependency like citizensadvice/capybara_accessible_selectors that declares them on our behalf:

 class SessionsTest < ActionDispatch::IntegrationTest
   test "new" do
     get new_session_path

     assert_title "Sign in"
-    within "main" do
+    within_section "Sign in" do
-      assert_css "h1", "Sign in"
       assert_button "Sign in"
       assert_field "Email address", type: "email"
       assert_field "Password", type: "password"
     end
   end
 end

With access to assertions and selectors like the ones that Capybara or citizensadvice/capybara_accessible_selectors provide (like assert_field, the described_by: filter, and the alert selector), we have an opportunity to make assertions about the structure and semantics of the response to an invalid request:

 class ArticlesTest < ActionDispatch::IntegrationTest
   test "update" do
     valid_attributes    = { title: "A valid Article", body: "It's valid!" }
     invalid_attributes  = { title: "Will be invalid", body: "" }
     article             = Article.create! valid_attributes

     assert_no_changes -> { article.reload.attributes } do
       put article_path(article), params: { article: invalid_attributes }
     end

     assert_response :unprocessable_entity
-    assert_equal "Failed to create Article!", flash[:alert]
+    assert_selector :alert, "Failed to create Article!"
+    assert_field "Body", described_by: "can't be blank"
   end
 end

Hopefully, an integration like this will become publicly supported in the future. In the meantime, if you’d like to add support for Capybara assertions in your Minitest suite’s ActionDispatch::IntegrationTest cases, or your RSpec suite’s type: :request tests, check out the thoughtbot/action_dispatch-testing-integration-capybara gem today!