Using RSpec Feature Tests to Actually Test What You Think You're Testing

Given the brittleness of RSpec's have_tag matcher and the presence of new Webrat and Capybara matchers that do a better job, have_tag was not included in rspec-rails-2.

Using RSpec Feature Tests to Actually Test What You Think You're Testing
Photo by DuΕ‘an veverkolog / Unsplash

I can't rest. I must know.

How did has_tag wrap assert_select? Why, and when, was it removed from RSpec?

Let's start confirming where and when it did exist.

A little Googling reveals that it's currently present in a gem called rspec-html-matchers, whose "About" says

Old school have_tag, with_tag(and more) matchers for rspec 3 (Nokogiri powered)

In the README, we see

syntax is similar to have_tag matcher from rspec-rails 1.x, but with own syntactic sugar

Okay, so let's go look at the release notes for rspec-rails 2.0!

Oh... there aren't any.

There is, however, a document on upgrading from rspec-rails-1.x to rspec-rails-2 that explains the situation quite nicely.

Before Webrat came along, rspec-rails had its own have_tag matcher that wrapped Rails' assert_select. Webrat included a replacement for have_tag as well as new matchers (have_selector and have_xpath), all of which rely on Nokogiri to do its work, and are far less brittle than RSpec's have_tag.
Capybara has similar matchers, which will soon be available view specs (they are already available in controller specs with render_views).
Given the brittleness of RSpec's have_tag matcher and the presence of new Webrat and Capybara matchers that do a better job, have_tag was not included in rspec-rails-2.

This begins to explain at least some of my trouble... because render_views isn't available in a request spec. That's a controller thing. According to the rspec-rails documentation, capybara matchers aren't available on request specs, either – those are for feature specs.

So... let's try writing a feature spec! I'm not sure this is the "right" way to test the behavior in question ("does the title tag get generated correctly") but trying it out will help me understand the tradeoffs.

First, can I just swap out type "request" for type "feature?"

require 'rails_helper'

RSpec.describe "StaticPages", type: :feature do

  describe "GET /home" do
    it "returns http success" do
      get "/static_pages/home"
      expect(response).to have_http_status(:success)
      expect(response).to have_title("lupus")
    end
  end

  describe "GET /help" do
    it "returns http success" do
      get "/static_pages/help"
      expect(response).to have_http_status(:success)
    end
  end

end

Nope!

Failures:

  1) StaticPages GET /home returns http success
     Failure/Error: get "/static_pages/home"

     NoMethodError:
       undefined method `get' for #<RSpec::ExampleGroups::StaticPages::GETHome:0x00007fde585414b8>
       Did you mean?  gets
                      gem
     # ./spec/requests/static_pages_spec.rb:7:in `block (3 levels) in <top (required)>'

  2) StaticPages GET /help returns http success
     Failure/Error: get "/static_pages/help"

     NoMethodError:
       undefined method `get' for #<RSpec::ExampleGroups::StaticPages::GETHelp:0x00007fde3de77ea8>
       Did you mean?  gets
                      gem
     # ./spec/requests/static_pages_spec.rb:15:in `block (3 levels) in <top (required)>'
     
Finished in 0.01229 seconds (files took 0.88052 seconds to load)
4 examples, 2 failures, 2 pending

Fair, RSpec. Entirely fair.

Let's try something a little more feature-y

require 'rails_helper'

RSpec.describe "StaticPages", type: :feature do

  describe "GET /home" do
    it "returns http success" do
      visit "/static_pages/home"
      expect(page).to have_title("lupus")
    end
  end

  describe "GET /help" do
    it "returns http success" do
      visit "/static_pages/help"
      expect(page).to have_title("lupus")
    end
  end

end

That's more like it!

Failures:

  1) StaticPages GET /home returns http success
     Failure/Error: expect(page).to have_title("lupus")
       expected "Home | Ruby on Rails Tutorial Sample App" to include "lupus"
     # ./spec/requests/static_pages_spec.rb:8:in `block (3 levels) in <top (required)>'

  2) StaticPages GET /help returns http success
     Failure/Error: expect(page).to have_title("lupus")
       expected "Help | Ruby on Rails Tutorial Sample App" to include "lupus"
     # ./spec/requests/static_pages_spec.rb:15:in `block (3 levels) in <top (required)>'

Finished in 0.09466 seconds (files took 0.85032 seconds to load)
4 examples, 2 failures, 2 pending

These tests are even a bit faster than the rails controller tests that the tutorial has been having us write.

➜  sample_app git:(rspec) βœ— rails test
Running via Spring preloader in process 4833
Run options: --seed 15658

# Running:

E

Error:
StaticPagesControllerTest#test_should_get_about:
ActionController::MissingExactTemplate: StaticPagesController#about is missing a template for request formats: text/html
    test/controllers/static_pages_controller_test.rb:19:in `block in <class:StaticPagesControllerTest>'


rails test test/controllers/static_pages_controller_test.rb:18

..

Finished in 0.204258s, 14.6873 runs/s, 29.3746 assertions/s.
3 runs, 6 assertions, 0 failures, 1 errors, 0 skips

The best part, though, is that this solves entirely the problem that had me awkwardly writing

    assert_select "title", "Home | Ruby on Rails Tutorial Sample App"
    assert_select "title", 1

in the previous test style.

With the assertion

expect(page).to have_title("lupus")

this passes

    <title>lupus</title>
    <title>BingoDingo</title>

but the reverse doesn't.

<title>BingoDingo</title>
<title>lupus</title>

It looks like this test is sensitive to the actual page title, not just the presence of the test string in any title tag.

Now, I do still have a few questions.

  • What does the "type" parameter in RSpec... do? What's the real difference between a "feature" and a "request" spec?
  • What would a view-based test for this detail look like? What are the advantages and disadvantages vs. a feature test?
  • Are these feature tests, in fact, faster than the vanilla Rails controller tests?

For now, though, I'm satisfied, that these tests read naturally to me, are fast enough, and test what they look like they're testing.