Video and Blog thumbnail that states: Rspect Tests - Reduce your bugs in Rails 6 and iterate faster with TDD.

Iterate Fast – How You Can Have Fewer Bugs By Using Rspec TDD In Ruby On Rails 6 | 20in20 – Week 14

Iterate fast with TDD and Rspec - Test Driven Development in Ruby on Rails 6 helps reduce bugs | Week 14 of Deanin's 20 in 20 challenge!

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.

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

Ad Break To Pay The Bills

Disclaimer: I may be provided an Amazon Affiliate commission for any sales made through any Amazon links on this website. You pay the same as you normally would, but a small portion of what Amazon is paid goes to me.

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.

Disclaimer: I may be provided an Amazon Affiliate commission for any sales made through any Amazon links on this website. You pay the same as you normally would, but a small portion of what Amazon is paid goes to me.

Share this post

Share on twitter
Share on facebook
Share on linkedin
Share on reddit
Share on pinterest
Share on email

One reply on “Iterate Fast – How You Can Have Fewer Bugs By Using Rspec TDD In Ruby On Rails 6 | 20in20 – Week 14”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.