How I Cut my Capybara Spec Suite's Time by Almost 50%

I won't bury the lede here. The problem with my Capybara spec suite was that I didn't fully understand how Capybara's implicit waits worked. I made two mistakes in how I write some Capybara specs, and they ended up inflating my test time by a bunch. First, Capybara matchers don't invert themselves when they're called by RSpec's #not_to method if they're inside another method. Second, using Capybara matchers in a conditional will result in a long delay when the matcher is looking for something that will never be there.

First, Capybara is a smart framework that knows that when you take an action on a webpage, it sometimes takes a while before the result shows up. For example, if you click a link to go to a blog post, you can't expect the text of that blog post to show up right away. In a Capybara test, this situation might look like this:

# This is okay

require "rails_helper"

let(:post) { create :post }
let(:show_page) { ShowPage.new }

feature "show post" do
  it "shows the post's body" do
    show_page.visit_page(post)
    expect(show_page).to have_post_body(post)
  end
end

class ShowPage
  def visit_page(post)
    visit "/"
    click_link post.title
  end

  def has_post_body?(post)
    has_text? post.body
  end
end

in this case, Capybara's has_text method will check the text of the page for post.body continuously until one of two things happens: either it finds post.body or a certain amount of time expires. In the first case, it returns true. In the second case, it returns false.

This is great because Capybara assumes your tests are going to pass and tries its best to not have an false failures.

The problems crop up when people like me don't use these matchers correctly

Don't Negate Methods that Call Capybara Matchers

Consider this second case:

# Don't do this

let(:post) { create :post }
let(:other_post) { create :post }
let(:show_page) { ShowPage.new }

feature "show post" do
  it "doesn't show another post's body" do
    show_page.visit_page(post)
    expect(show_page).not_to have_post_body(other_post)
  end
end

class ShowPage
  def visit_page(post)
    visit "/"
    click_link post.title
  end

  def has_post_body?(post)
    has_text? post.body
  end
end

In this second case, the line has_text? post.body will time out because the other post's body will never show up on the page. Ordinarily, this expectation would be written as expect(show_page).not_to have_text(other_post.body) and work fine. Capybara is smart enough to work with RSpec and run the negative matcher instead then. The negative matcher would look for the absense of post.body and return right away when it's not there.

However, when the Capybara matcher is inside a method, it doesn't know the RSpec context from which it was called and assumes it should run the positive matcher. To fix this, the test should be re-written as seen below:

# This is okay

let(:post) { create :post }
let(:other_post) { create :post }
let(:show_page) { ShowPage.new }

feature "show post" do
  it "doesn't show another post's body" do
    show_page.visit_page(post)
    expect(show_page).to have_no_post_body(other_post)
  end
end

class ShowPage
  def visit_page(post)
    visit "/"
    click_link post.title
  end

  def has_no_post_body?(post)
    has_no_text? post.body
  end
end

Disable Waiting for Capybara Matchers Used in Conditionals

I'd advise you not to use Capybara matchers in any conditionals to be honest. My friend wrote some code that did this, so when I was in the spec file later, I cleaned it up for him.

What did his code look like? Well, it looked pretty harmless actually:

# Don't do this

if has_text? "featured blog post"
  expect(show_page).to have_css "#star-banner"
else
  expect(show_page).to have_text "new post!"
end

(Obviously, his code didn't actually look anythign like that, but you get the idea.)

So the problem with that code is if it's run on a non-featured blog post, it will time out on the has_text? "featured blog post" line and make the spec take a long time to run while eventually passing.

How could this be re-written? Well, you have to be careful. You have to make sure that whatever page content you're switching on is definitely loaded before you run this conditional. Otherwise it's possible that you will shoot through the positive branch right away.

Here's how I adjusted his code to make it complete more quickly all the time:

# This is okay

if has_text? "featured blog post", wait: false
  expect(show_page).to have_css "#star-banner"
else
  expect(show_page).to have_text "new post!"
end

The wait option is not very well documented for Capybara matchers, but it does exist! You can set it to an integer to tell Capybara how many seconds to wait before giving up and returning false, or you can pass it false to make it return immediately regardless of the result.

Summary

Use negative Capybara matchers whenever you expect something to not be there. If you're not sure if something will be there or not, but you know it's loaded, you can pass the option wait: false to tell Capybara to return the result right away.

I hope these tips helped you. For me, it dropped my projects spec suite time from about 14 minutes to about 7 minutes. Let me know if this was helpful in the comments!

Photo by Julia Craice