Write Reliable, Asynchronous Integration Tests With Capybara

Joe Ferris

We’ve come to rely on integration tests as part of a balanced testing approach. They support outside-in development, they catch regressions, and they bridge the gap between Ruby and JavaScript unit tests.

However, integration tests involving Ruby and JavaScript are fraught with danger. Developers frequently complain of tests which fail erratically. Debugging these tests can be somewhat of a mystery: you see records inserted into your test database during logs, but they somehow don’t show up on the page. These annoyances drive some developers to abandon integration tests entirely.

Why are integration tests with JavaScript so much harder?

rack-test

First, a little background.

Integration tests in Rails applications work by simulating a user’s experience through the HTML interface. You load up your Rails application, and your integration test harness (such as Capybara) simulates a user clicking around on the site.

Without JavaScript, Capybara uses rack-test as a driver by default and looks something like this:

  • You trigger user action by invoking one of Capybara’s DSL methods, such as visit.
  • Capybara tells its driver (rack-test) to load the requested URL.
  • rack-test uses the URL to generate a fake Rack request and passes it directly to the in-memory instance of your Rails application.
  • The Rack response is parsed by rack-test and saved as the current page.

Other actions like click_link work similarly, and rack-test will do things like save cookies, follow redirects, and post forms to make it feel like a real browser. This simulated browser can do many of the things a real browser can, but it’s missing one killer feature: JavaScript.

Selenium, capybara-webkit, and Poltergeist

JavaScript drivers work a little differently. Because browser tools like WebKit are difficult to load inside of a Ruby process, these drivers boot up an external process which can interact with the browser engine. Because the external process doesn’t have access to the in-memory instance of your Rails application, they must make actual HTTP requests.

Interactions performed using Capybara’s JavaScript drivers look something like this:

  • You trigger user action by invoking one of Capybara’s DSL methods, such as visit.
  • Capybara tells its driver (such as capybara-webkit) to load the requested URL.
  • The driver starts its external process to hold the browser engine.
  • In order to serve actual HTTP requests, Capybara boots a new instance of your Rails application in a background thread.
  • The driver’s browser process uses the URL to create a real HTTP request which is passed to your application server, such as Thin.
  • Thin translates the HTTP request into a Rack request, which is passed to your Rails application in the background thread.
  • Thin also translates your Rack response into a real HTTP response, which is accepted by the browser process.

There’s a lot of extra machinery in here to make this work: process forking, HTTP requests, and background threads. However, there’s really only one bump which frequently affects users: that pesky background thread.

If requests are served in a background thread, that means that your tests keep running while your application responds to simulated interactions. This provides for an endless number of race conditions, where your tests look for elements on the page which have not appeared yet.

Capybara to the Rescue

Much of Capybara’s source code is dedicated to battling this asynchronous problem. Capybara is smart enough to understand that a page may not have loaded by the time you try to interact with it. A typical interaction might look like this:

Test Thread Application Thread
Your test invokes visit. Waiting for a request.
Capybara tells the driver to load the page. Waiting for a request.
The driver performs a request. Waiting for a request.
Your test invokes click_link. Your application receives the request.
Capybara looks for the link on the page, but it isn’t there. Your application sends a response.
Capybara tries to find the element again, but it’s not there. The driver receives the response.
Capybara successfully finds the element from the response. Waiting for a request.

As you can see, Capybara handles this interaction gracefully, even though the test starts looking for a link to click on before the page has finished loading.

However, if Capybara handles these asynchronous issues for you, why is it so easy to write flapping tests with Capybara, where sometimes the tests pass and sometimes they fail?

There are a few tricks to properly using the Capybara API so as to minimize the number of possible race conditions.

Find the first matching element

Bad:

first(".active").click

If there isn’t an .active element on the page yet, first will return nil and the click will fail.

Good:

# If you want to make sure there's exactly one
find(".active").click

# If you just want the first element
find(".active", match: :first).click

Capybara will wait for the element to appear before trying to click. Note that match: :first is more brittle, because it will silently click on a different element if you introduce new elements which match.

Interact with all matching elements

Bad:

all(".active").each(&:click)

If there are no matching elements yet, an empty array will be returned, and no elements will be affected.

Good:

find(".active", match: :first)
all(".active").each(&:click)

Capybara will wait for the first matching element before trying to click on the rest.

Note: there is usually a better way to test things than iterating over matching elements, but that is beyond the scope of this post. Think carefully before using all.

Directly interacting with JavaScript

Bad:

execute_script("$('.active').focus()")

JavaScript expressions may be evaluated before the action is complete, and the wrong element or no element may be affected.

Good:

find(".active")
execute_script("$('.active').focus()")

Capybara will wait until a matching element is on the page, and then dispatch a JavaScript command which interacts with it.

Note: execute_script should only be used as a last resort when running into driver limitations or other issues which make it impossible to use other Capybara methods.

Checking a field’s value

Bad:

expect(find_field("Username").value).to eq("Joe")

Capybara will wait for the matching element and then immediately return its value. If the value changes from a page load or Ajax request, it will be too late.

Good:

expect(page).to have_field("Username", with: "Joe")

Capybara will wait for a matching element and then wait until its value matches, up to two seconds.

Checking an element’s attribute

Bad:

expect(find(".user")["data-name"]).to eq("Joe")

Capybara will wait for the matching element and then immediately return the requested attribute.

Good:

expect(page).to have_css(".user[data-name='Joe']")

Capybara will wait for the element to appear and have the correct attribute.

Looking for matching CSS

Bad:

it "doesn't have an active class name" do
  expect(has_active_class).to be_false
end

def has_active_class
  has_css?(".active")
end

Capybara will immediately return true if the element hasn’t been removed from the page yet, causing the test to fail. It will also wait two seconds before returning false, meaning the test will be slow when it passes.

Good:

it "doesn't have an active class name" do
  expect(page).not_to have_active_class
end

def have_active_class
  have_css(".active")
end

Capybara will wait up to two seconds for the element to disappear before failing, and will pass immediately when the element isn’t on the page as expected.

Summary

When interacting with the page, use action methods like click_on instead of finder methods like find whenever possible. Capybara knows the most about what you’re doing with those methods, and can more intelligently handle odd edge cases.

When verifying that elements are on the page as expected, use RSpec matchers like have_css instead of node methods like text whenever possible. Capybara can wait for the result you want with a matcher, but doesn’t know what text you’re expecting when you invoke methods on a node.