Rails 6 RSpec - Why You Should Use It And How It Helps
Rails 6 RSpec is one of the topics that I’ve been asked to cover a few times on the YouTube channel. So what exactly is RSpec, and what does it have to do with Ruby on Rails? Well, RSpec is a behavior-driven development (BDD) testing framework.
Behavior-driven development, as far as how I like to define it, is test-driven development (TDD) where the behavior of the feature is the focus. Instead of saying, “This unit test is for this functionality,” a BDD approach might be to say, “this unit test is for this business value.”
So back to RSpec, how does it help you? What is RSpec’s value proposition to you, the user? Well, RSpec allows you to quickly create Ruby specs, hence the name, for testing various logic in your application. As you continue reading, you’ll see some, but not all, of the testing capabilities of using Rails 6 RSpec. We’ll start by creating an app and adding a basic scaffold and Devise users. Then we’ll set about testing some of the functionality.
If you’re interested in learning more about testing in Ruby on Rails, I highly recommend the book “Rails 5 Test Prescriptions” by Noel Rappin. You can check it out here via my affiliate link on Amazon: Rails Testing On Amazon.
Video Version Of Tutorial
Part 1 - The Application Setup
This isn’t the first time we’ve used Rails Bytes templates before. In fact, for week 8 of the 20 in 20 series, we made a template that is very similar to those available on RailsBytes.com. If you’re interested in checking out the RSpec template we’re using, it’s available here. I’d like to start by creating a new app that doesn’t have any tests, so we’ll run it with the -t flag.
Afterward we’ll run the RailsBytes template to add Rails 6 Rspec. Just as a note, this command might fail. If it does, you’ll want to run a bundle add rspec command. For some reason, RSpec doesn’t like that first initial add to a project.
# First, you can generate your app with the -t flag to skip adding the default Rails tests.
rails new your_app_name -t
# You might need to install the rspec gem before running this template.
# In your terminal, add RSpec with a RailsBytes template:
rails app:template LOCATION='https://railsbytes.com/script/z0gsLX'
# Then we'll add Devise to the application.
bundle add devise
rails g devise:install
rails g devise User
# Next we'll create our posts scaffold.
rails g scaffold posts title:string body:text user:references views:integer
And here’s what the migration should look like after you’re done with the commands. Now that we’ve created our model, run your migrations (rails db:migrate command) and you’ll be good to go! Just make sure you set your views to default to 0 first.
class CreatePosts < ActiveRecord::Migration[6.0]
def change
create_table :posts do |t|
t.string :title
t.text :body
t.references :user, null: false, foreign_key: true
t.integer :views, default: 0
t.timestamps
end
end
end
Preparing The MVC For Rails 6 Rspec Testing
Preparing the MVC structure for testing isn’t too bad, but there are a few things to note. In the video tutorial for this blog post, I assigned some homework for you to do after you’re done with the tutorial. That said, I’d like you to remember that we have this post views counter. I will not be hooking this up, and instead will save it as an exercise for you to try implementing via TDD later. I figured this is a fairly simple feature that should be safe enough to leave out. I usually try to leave things extendable so that you can build off of the tutorial a bit.
We’ll also want to update the create action to assign a current user to the post. For this, we’re going to create a helper method called assign post creator. See the snippet below the controller for what the helper method looks like! Just make sure you clean up your post params and remove the views and user_id!
The Controllers
class PostsController < ApplicationController
before_action :set_post, only: %i[show edit update destroy]
include PostsHelper
# GET /posts
# GET /posts.json
def index
@posts = Post.all
end
# GET /posts/1
# GET /posts/1.json
def show; end
# GET /posts/new
def new
@post = Post.new
end
# GET /posts/1/edit
def edit; end
# POST /posts
# POST /posts.json
def create
@post = Post.new(post_params)
@post = assign_post_creator(@post, current_user)
respond_to do |format|
if @post.save
format.html { redirect_to @post, notice: 'Post was successfully created.' }
format.json { render :show, status: :created, location: @post }
else
format.html { render :new }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /posts/1
# PATCH/PUT /posts/1.json
def update
respond_to do |format|
if @post.update(post_params)
format.html { redirect_to @post, notice: 'Post was successfully updated.' }
format.json { render :show, status: :ok, location: @post }
else
format.html { render :edit }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
# DELETE /posts/1
# DELETE /posts/1.json
def destroy
@post.destroy
respond_to do |format|
format.html { redirect_to posts_url, notice: 'Post was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_post
@post = Post.find(params[:id])
end
# Only allow a list of trusted parameters through.
def post_params
params.require(:post).permit(:title, :body)
end
end
The Helper Methods
# app/helpers/posts_helper.rb
module PostsHelper
def assign_post_creator(post, creator)
post.user = creator
post
end
end
The Models
The models are fairly straightforward. We’re going to add our 1 to many relationship by saying posts belong to a user, and a user has many posts. Validations are added to the post to ensure we have things to test. We also validate the type of the views to show how you can perform type checking!
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
validates :title, presence: true, length: { minimum: 2 }
validates :body, presence: true, length: { in: 5..100 }
validates :views, numericality: { only_integer: true }
end
# app/models/user.rb
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :posts, dependent: :destroy
end
The Views
For the views, we just want to remove the user field and the views field. We have a helper method in the controller to set a user for each post in a bit of a contrived manner. Finally, the views default to 0 and should auto increment if you do your homework. 🙂
# app/views/posts/_form.html.erb
<%= form_with(model: post, local: true) do |form| %>
<% if post.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>
<ul>
<% post.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :body %>
<%= form.text_area :body %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
Part 2 - Testing The Models
It’s finally time for some Rails 6 Rspec testing! We’ll start by testing our models. For this, we’re going to create these it ‘has contents’ do blocks. These are essentially going to be our test methods. Each code block should follow the arrange, act, and assert (AAA) strategy. This means it should test only one piece of functionality, so I’m breaking some rules here.
What rules am I breaking? Well, each test has a positive and a negative test. The negative test should be its own test, there is no reason for the “Has a title” test to also test if it does not have a title. That should be its own thing. That said, this post would be massive if everything followed best practices. You should follow this tutorial as a starting point, not as a style guide!
Make sure to run rails db:migrate RAILS_ENV=development to migrate your test database!
require 'rails_helper'
RSpec.describe Post, type: :model do
current_user = User.first_or_create!(email: 'dean@example.com', password: 'password', password_confirmation: 'password')
it 'has a title' do
post = Post.new(
title: '',
body: 'A Valid Body',
user: current_user,
views: 0
)
expect(post).to_not be_valid
post.title = 'Has a title'
expect(post).to be_valid
end
it 'has a body' do
post = Post.new(
title: 'A Valid Title',
body: '',
user: current_user,
views: 0
)
expect(post).to_not be_valid
post.body = 'Has a title'
expect(post).to be_valid
end
it 'has a title at least 2 characters long' do
post = Post.new(
title: '1',
body: 'A Valid Body',
user: current_user,
views: 0
)
expect(post).to_not be_valid
post.title = '12'
expect(post).to be_valid
end
it 'has a body between 5 and 100 characters' do
post = Post.new(
title: '12',
body: '1234',
user: current_user,
views: 0
)
expect(post).to_not be_valid
post.body = '12345'
expect(post).to be_valid
hundred_char_string = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'
post.body = hundred_char_string
expect(post).to be_valid
post.body = hundred_char_string + '1'
expect(post).to_not be_valid
end
it 'has numerical views' do
post = Post.new(
title: '12',
body: '1234',
user: current_user,
views: 0
)
expect(post.views).to be_a(Integer)
end
end
Part 3 - Testing the Requests & Helpers
Next up we’ll test our controllers. In the context of Rails 6 Rspec, controller logic falls a bit under requests. Since we make a “request” to a controller action, this makes sense. Because the controller contains most of this app’s logic, this file will be very large. Just try your best and take it one method at a time!
require 'rails_helper'
# This spec was generated by rspec-rails when you ran the scaffold generator.
# It demonstrates how one might use RSpec to test the controller code that
# was generated by Rails when you ran the scaffold generator.
#
# It assumes that the implementation code is generated by the rails scaffold
# generator. If you are using any extension libraries to generate different
# controller code, this generated spec may or may not pass.
#
# It only uses APIs available in rails and/or rspec-rails. There are a number
# of tools you can use to make these specs even more expressive, but we're
# sticking to rails and rspec-rails APIs to keep things simple and stable.
RSpec.describe '/posts', type: :request do
# Post. As you add validations to Post, be sure to
# adjust the attributes here as well.
current_user = User.first_or_create!(email: 'dean@example.com', password: 'password', password_confirmation: 'password')
let(:valid_attributes) do
{
'id' => '1',
'title' => 'Test',
'body' => '12345',
'user' => current_user
}
end
let(:invalid_attributes) do
{
'id' => 'a',
'title' => '1',
'body' => '1234'
}
end
describe 'GET /index' do
it 'renders a successful response' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
get posts_url
expect(response).to be_successful
end
end
describe 'GET /show' do
it 'renders a successful response' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
get post_url(post)
expect(response).to be_successful
end
end
describe 'GET /new' do
it 'renders a successful response' do
get new_post_url
expect(response).to be_successful
end
end
describe 'GET /edit' do
it 'render a successful response' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
get edit_post_url(post)
expect(response).to be_successful
end
end
describe 'POST /create' do
context 'with valid parameters' do
it 'creates a new Post' do
expect do
post = Post.new(valid_attributes)
post.user = current_user
post.save
post posts_url, params: { post: valid_attributes }
end.to change(Post, :count).by(1)
end
it 'redirects to the created post' do
post posts_url, params: { post: valid_attributes }
expect(response).to be_successful
end
end
context 'with invalid parameters' do
it 'does not create a new Post' do
expect do
post posts_url, params: { post: invalid_attributes }
end.to change(Post, :count).by(0)
end
it "renders a successful response (i.e. to display the 'new' template)" do
post posts_url, params: { post: invalid_attributes }
expect(response).to be_successful
end
end
end
describe 'PATCH /update' do
context 'with valid parameters' do
let(:new_attributes) do
{
'id' => '1',
'title' => 'Test',
'body' => '12345',
'user' => current_user
}
end
it 'updates the requested post' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
patch post_url(post), params: { post: new_attributes }
post.reload
skip('Add assertions for updated state')
end
it 'redirects to the post' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
patch post_url(post), params: { post: new_attributes }
post.reload
expect(response).to redirect_to(post_url(post))
end
end
context 'with invalid parameters' do
it "renders a successful response (i.e. to display the 'edit' template)" do
post = Post.create! valid_attributes
patch post_url(post), params: { post: invalid_attributes }
expect(response).to be_successful
end
end
end
describe 'DELETE /destroy' do
it 'destroys the requested post' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
expect do
delete post_url(post)
end.to change(Post, :count).by(-1)
end
it 'redirects to the posts list' do
post = Post.new(valid_attributes)
post.user = current_user
post.save
delete post_url(post)
expect(response).to redirect_to(posts_url)
end
end
end
The Helper Test!
include PostsHelper
RSpec.describe PostsHelper, type: :helper do
it 'assigns a user to a post' do
creator = User.first_or_create!(email: 'dean@example.com', password: 'password', password_confirmation: 'password')
@post = Post.new(
title: 'MyString',
body: 'MyText',
views: 1
)
assign_post_creator(@post, creator)
expect(@post.user).to be(creator)
end
end
Part 4 - Testing The Views
Finally, we’ve arrived at the views. Testing these is fairly simple, so all we’ll really have to do is remove areas mentioning the user and the views. This is done because we do not pass these parameters into our controller params, so there are no reasons to have them in our forms or expect them on the new or edit pages.
Edit Page
require 'rails_helper'
RSpec.describe 'posts/edit', type: :view do
before(:each) do
current_user = User.first_or_create!(email: 'dean@example.com', password: 'password', password_confirmation: 'password')
@post = assign(:post, Post.create!(
title: 'MyString',
body: 'MyText',
user: current_user,
views: 1
))
end
it 'renders the edit post form' do
render
assert_select 'form[action=?][method=?]', post_path(@post), 'post' do
assert_select 'input[name=?]', 'post[title]'
assert_select 'textarea[name=?]', 'post[body]'
end
end
end
Index Page
require 'rails_helper'
RSpec.describe 'posts/index', type: :view do
current_user = User.first_or_create!(email: 'dean@example.com', password: 'password', password_confirmation: 'password')
before(:each) do
assign(:posts, [
Post.create!(
title: 'Title',
body: 'MyText',
user: current_user,
views: 14
),
Post.create!(
title: 'Title',
body: 'MyText',
user: current_user,
views: 12
)
])
end
it 'renders a list of posts' do
render
assert_select 'tr>td', text: 'Title'.to_s, count: 2
assert_select 'tr>td', text: 'MyText'.to_s, count: 2
assert_select 'tr>td', text: current_user.id.to_s, count: 2
assert_select 'tr>td', text: 14.to_s, count: 1
assert_select 'tr>td', text: 12.to_s, count: 1
end
end
New Page
require 'rails_helper'
RSpec.describe 'posts/new', type: :view do
before(:each) do
assign(:post, Post.new(
title: 'MyString',
body: 'MyText',
user: nil,
views: 0
))
end
it 'renders new post form' do
render
assert_select 'form[action=?][method=?]', posts_path, 'post' do
assert_select 'input[name=?]', 'post[title]'
assert_select 'textarea[name=?]', 'post[body]'
end
end
end
Show Page
require 'rails_helper'
RSpec.describe 'posts/show', type: :view do
before(:each) do
current_user = User.first_or_create!(email: 'dean@example.com', password: 'password', password_confirmation: 'password')
@post = assign(:post, Post.create!(
title: 'Title',
body: 'MyText',
user: current_user,
views: 0
))
end
it 'renders attributes in <p>' do
render
expect(rendered).to match(/Title/)
expect(rendered).to match(/MyText/)
expect(rendered).to match(//)
expect(rendered).to match(/0/)
end
end
Conclusion
That about does it for this Rails 6 RSpec tutorial! As with most pull requests I create, I’ve attached screenshots of my tests passing below. I hope that you found this not quite a Test-Driven Development tutorial helpful. At the very least, I hope it will allow you to actually do some TDD. RSpec was very new to me at the start of this project, so I definitely grew a lot as well.
Hopefully, I was able to clearly convey what I learned, and that provided some value to you. If you have any questions, feel free to ask them below. I do also run a Discord that you can join here if you have any pressing questions.
One reply on “Iterate Fast – How You Can Have Fewer Bugs By Using Rspec TDD In Ruby On Rails 6 | 20in20 – Week 14”
very helpfull, thanks for taking your time to write this !