Introduction to feature specs in RSpec

July 29, 2014 · Chris Peters

Lately, I've decided to spend a lot of focus on writing feature specs with RSpec and Capybara.

Lately, I’ve decided to spend a lot of focus on writing feature specs with RSpec and Capybara. Feature specs allow you to test your application from the user’s point of view. You use the Capybara gem to test your application’s interface with commands like these:

require 'rails_helper'
feature 'User signs in' do
given!(:user) { FactoryGirl.create(:user) }
scenario 'with valid credentials' do
visit root_path
fill_in 'Email', with: user.email
fill_in 'Password', with: user.password
check 'Remember me'
click_button 'Sign in'
expect(page).to have_content "Welcome back, #{user.first_name}!"
end
end

(A point of confusion: sometimes these kinds of tests are called “acceptance tests” or “integration tests,” and now DHH is calling them system tests and is declaring that a future release of Rails will provide functionality for tests like these.)

I’ve found that this methodology tests 80% of what I need without needing to dive into the minutiae of testing every boundary case in every method in every class. Don’t get me wrong; I still test most custom logic in model specs and some authorization logic in request specs, but I try to not go overboard. A deeper level of detail in my testing can come later when my app becomes a wild success, amiright?

My understanding is that these tests run on the slower side compared to other types of tests that RSpec can run, but I’ve found the trade-off to be worth it to test the whole system from the user’s point of view.

Tutorial: let’s get into some CRUD

Simple CRUD functionality is a great place to start to get a feel for how this all works. Let’s do this for a simplified Product model in the admin area of a fictitious e-commerce application.

$ rails g model product title:string description:text price_cents:integer shipping_price_cents:integer
$ rake db:migrate

And quick configuration of some validations on the Product class at app/models/product.rb:

class Product < ActiveRecord::Base
# money-rails gem
monetize :price_cents
monetize :shipping_price_cents
validates :title, presence: true
validates :description, presence: true
end
view raw product.rb hosted with ❤ by GitHub

(I may as well use this example as a chance to pimp the wonderful money-rails gem for handling currency.)

Testing creation

A good place to start is a product creation area. We can go ahead and setup the route under an admin namespace:

Rails.application.routes.draw do
namespace :admin do
resources :products
end
end
view raw routes.rb hosted with ❤ by GitHub

Then we write our test first at spec/features/products/admin_creates_product_spec.rb. I enjoy the suggestion by Thoughtbot to name your features with an actor and then an action, thus naming the feature Admin creates product.

require 'rails_helper'
feature 'Admin creates product' do
given!(:product) { Product.new(title: 'Widget X', description: 'This is a description.', price: 12.99, shipping_price: 2.95) }
scenario 'with valid input' do
visit admin_products_path
click_link 'New Product'
fill_in 'Title', with: product.title
fill_in 'Description', with: product.description
fill_in 'Price', with: product.price
fill_in 'Shipping price', with: product.shipping_price
click_button 'Create Product'
expect(page).to have_content 'The product was created successfully'
end
end

With this in place, you can build the functionality to make the test pass. The finished product would look something like the following files.

app/controllers/admin/products_controller.rb:

class Admin::ProductsController < ApplicationController
def index
@products = Product.order(:title)
end
def new
@product = Product.new
end
def create
@product = Product.new(product_params)
if @product.save
redirect_to edit_admin_product_path(@product), notice: 'The product was created successfully.'
end
end
def edit
@product = Product.find(params[:id])
end
private
def product_params
params.require(:product).permit(:title, :description, :price, :shipping_price)
end
end

app/views/admin/products/index.html.erb:

<h1>Products</h1>
<p><%= link_to 'New Product', new_admin_product_path %></p>
<% if @products.any? %>
<table>
<thead>
<tr>
<th>Title</th>
<th>Price</th>
<th>Shipping</th>
</tr>
</thead>
<tbody>
<% @products.each do |product| %>
<tr>
<td><%= product.title %></td>
<td><%= number_to_currency product.price %></td>
<td><%= number_to_currency product.shipping_price %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

app/views/admin/products/new.html.erb:

<h1>New Product</h1>
<%= render 'form' %>

app/views/admin/products/_form.html.erb:

<%= simple_form_for [:admin, @product] do |f| %>
<%= f.input :title %>
<%= f.input :description %>
<%= f.input :price %>
<%= f.input :shipping_price %>
<%= f.submit %>
<% end %>

(Note that the code above uses the amazing Simple Form gem.)

app/views/admin/products/edit.html.erb:

<h1>Edit Product</h1>
<%= render 'form' %>

We’d think that this code would make the test pass, but there is a problem:

$ rspec
F
Failures:
1) Admin creates product with valid input
Failure/Error: expect(page).to have_content 'The product was created successfully'
expected to find text "The product was created successfully" in "Edit Product * Title * DescriptionThis is a description. Price Shipping price"
# ./spec/features/products/admin_creates_product_spec.rb:14:in `block (2 levels) in <top (required)>'
Finished in 1.01 seconds (files took 1.4 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/features/products/admin_creates_product_spec.rb:6 # Admin creates product with valid input

We need to add a handler for our flash message to app/views/layouts/application.html.erb:

<% flash.each do |key, message| %>
<div id="flash-<%= key %>" class="flash-<%= key %>">
<%= message %>
</div>
<% end %>
<%= yield %>

Then we should see the test pass:

$ rspec
.
Finished in 0.11697 seconds (files took 1.4 seconds to load)
1 example, 0 failures

Next, we can add a test that the form correctly reports validation errors when the user doesn’t enter data correctly:

require 'rails_helper'
feature 'Admin creates product' do
...
scenario 'with invalid input' do
visit admin_products_path
click_link 'New Product'
click_button 'Create Product'
expect(page).to have_content 'There was an error creating the product'
end
end

And then update the create action to handle validation errors:

def create
@product = Product.new(product_params)
if @product.save
redirect_to edit_admin_product_path(@product), notice: 'The product was created successfully.'
else
flash[:error] = 'There was an error creating the product.'
render :new
end
end

You should get a passing grade from RSpec:

$ rspec
..
Finished in 0.15502 seconds (files took 1.43 seconds to load)
2 examples, 0 failures

To see the code committed up to this point, check out this commit and the tree at that commit on GitHub.

Handling updates and deletes

I assume that you know how to build basic CRUD, so I will leave that as an exercise for the reader. Or hey, you can just look at what I committed to the GitHub repo.

However, this post is about feature specs, so this is how I would write the specs for the update and destroy actions.

spec/features/products/admin_updates_product_spec.rb:

Notice that in this spec’s with valid input scenario, we’re using the form to replace the values persisted by product with the values from new_product.

require 'rails_helper'
feature 'Admin updates product' do
given!(:product) { Product.create!(title: 'Widget X', description: 'This is a description.', price: 12.99, shipping_price: 2.95) }
given(:new_product) { Product.new(title: 'Widget Y', description: 'This is another description.', price: 24.95, shipping_price: 2.95) {
scenario 'with valid input' do
visit admin_products_path
click_link product.title
fill_in 'Title', with: new_product.title
fill_in 'Description', with: new_product.description
fill_in 'Price', with: new_product.price
fill_in 'Shipping price', with: new_product.shipping_price
click_button 'Update Product'
expect(page).to have_content 'The product was updated successfully'
end
scenario 'with invalid input' do
visit admin_products_path
click_link product.title
fill_in 'Title', with: ''
fill_in 'Description', with: ''
fill_in 'Price', with: ''
fill_in 'Shipping price', with: ''
click_button 'Update Product'
expect(page).to have_content 'There was an error updating the product'
end
end

spec/features/products/admin_deletes_product_spec.rb:

require 'rails_helper'
feature 'Admin deletes product' do
given!(:product) { Product.create!(title: 'Widget X', description: 'This is a description.', price: 12.99, shipping_price: 2.95) }
scenario do
visit admin_products_path
click_link 'Delete'
expect(page).to have_content 'The product was deleted successfully'
expect(page).to_not have_content product.title
end
end

Other miscellaneous feature spec examples

This is but a mere blog post, but I could demonstrate a few other features of Capybara that I use quite frequently in feature specs.

If you want to check for content within a specific block of your page, you can pass your expect assertion(s) in a block to within, using a CSS selector:

within '.heading' do
expect(page).to have_content 'Welcome back'
end
view raw within.rb hosted with ❤ by GitHub

Testing email that your app sends and receives is simple with the email_spec gem:

require 'rails_helper'
feature 'User resends unlock instructions' do
... # given! statements to setup data would go here
scenario 'with valid input' do
visit admin_root_path
click_link "Didn't receive unlock instructions?"
fill_in 'Email', with: user.email
click_button 'Send Instructions'
expect(page).to have_content 'You will receive an email with unlock instructions'
expect(unread_emails_for(user.email).size).to eql 1
open_email user.email
expect(current_email).to have_subject 'Unlock Instructions'
expect(current_email).to have_body_text 'Your account has been locked'
click_email_link_matching /#{unlock_path(user.id)}/
expect(page).to have_content 'Your account has been unlocked successfully'
end
end

You can also test JavaScript functionality using the selenium-webdriver gem and by adding js: true to your specs:

scenario 'with JavaScript', js: true do
visit product_path(product)
click_link 'View Larger'
within '.modal-window' do
expect(page).to have_css 'video'
end
end
view raw javascript.rb hosted with ❤ by GitHub

Want to learn more?

I found these to be good resources for fine-tuning my feature spec skills:

About Chris Peters

With over 20 years of experience, I help plan, execute, and optimize digital experiences.

Leave a comment