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 |
(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 |
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 |
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 |
Want to learn more?
I found these to be good resources for fine-tuning my feature spec skills:
- Rails 4 in Action, Ryan Bigg, Yehuda Katz, and Steve Klabnik
- Multitenancy with Rails, Ryan Bigg
- How I Test, Ryan Bates, RailsCasts
- End-to-End Testing with RSpec Integration Tests and Capybara, Harlow Ward, Thoughtbot
- How We Test Rails Applications, Josh Steiner, Thoughtbot