notes

Standing up a Rails 6 project with devise

Create a Ruby-On-Rails project with a devise based user model, one associated model and 100% test coverage.


References


$ rails new social_three -T -d postgresql
$ cd social_three/
$ atom .

add the following gems to the Gemfile

Gemfile

group :development, :test do
  gem 'pry-byebug'
  gem 'rspec-rails', '~> 4.0.0.beta2' # required for rails 6 compatibility
  gem 'rails-controller-testing'      # rspec 4 requirement
  gem 'capybara'
end
gem 'devise'
$ bundle
$ rails g rspec:install
$ rails g devise:install

Follow devise instructions to add config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } to config/environments/development.rb

and root to: "home#index" to config/routes.rb

$ rails g devise:views
$ rails g devise User name:string
$ rails db:create
$ rails db:migrate

$ rails g resource Blog content:string
$ rails g migration add_users_to_blogs user:references
$ rails db:migrate

At this stage we have a db, devise and spec are installed. we can run spec, but there’s no tests to run, and we haven’t started with any models or anything yet.

Following TDD route:

$ touch spec/features/static_page_spec.rb
$ atom .

add the following to the new static_page_spec file

spec/features/static_page_spec.rb

require 'rails_helper'

describe 'navigate' do
  it 'can be reached' do
    visit root_path
    expect(page.status_code).to eq(200)
  end
end

and then

$ bundle exec rspec

error: uninitialized constant HomeController we did add the route (as per the devise instructions), so we do have that, so the first error we see is for the missing controller. Let’s add a controller and a static page:

$ touch app/controllers/home_controller.rb
$ mkdir app/views/home
$ touch app/views/home/index.html.erb

Add the necessary code:

app/controllers/home_controller.rb

class HomeController < ApplicationController
  def index
  end
end

app/views/home/index.html.erb

<h1>Home</h1>
$ rspec spec/features/static_page_spec.rb
.

Finished in 0.97151 seconds (files took 1.15 seconds to load)
1 example, 0 failures

double check by running $ rails s and navigating to http://localhost:3000/


At this stage we can start the web server and go to the home page.

Next, let’s define some requirements in the specs

spec/models/blog_spec.rb

require 'rails_helper'

RSpec.describe Blog, type: :model do

  let(:content) { 'foobar' }
  let(:user) do
    User.create(
      email: 'foo@bar.net',
      password: 'chicken',
      password_confirmation: 'chicken'
    )
  end
  let(:blog) do
    Blog.create(
      user_id: user.id,
      content: content
    )
  end
  # could refactor later to use factories

  describe 'creation' do
    it 'can be created' do
      expect(blog).to be_valid
      expect(Blog.count).to eq(1)
      expect(Blog.first.user_id).to eq(user.id)
    end
  end

  context 'with no content' do
    let(:content) { nil }

    it 'is not created' do
      expect(blog).to_not be_valid
      expect(blog.errors[:content]).to include('can\'t be blank')
      expect(Blog.count).to eq(0)
    end
  end

  context 'with no user' do
    let(:user) { nil }

    it 'is not created' do
      expect{blog}.to raise_error(NoMethodError)
      # in this example we raise an error:
      # undefined method `id' for nil:NilClass
      # RSpec will complain if you don't check for specific errors
    end
  end

  describe 'deletion' do
    it 'can be deleted' do
      blog.destroy
      expect(Blog.count).to eq(0)
    end
  end
end

app/models/blog.rb

class Blog < ApplicationRecord
  validates :content, presence: true
  belongs_to :user
end

At this point you can check the expected functionality in the rails console.

Now, let’s turn to the Application Logic. We will write a minimum set of controller tests and corresponding controller actions. Note that this is the minimum set only, these tests could be more exhaustive, and the controller itself has only as minimum set of logic.

Although the tests and the controller are shown here in full, in practice add them in a piece at a time.

spec/controllers/blogs_controller_spec.rb

require 'rails_helper'

RSpec.describe BlogsController, type: :controller do

  let(:content) { 'foobar' }
  let(:user) do
    User.create(
      email: 'foo@bar.net',
      password: 'chicken',
      password_confirmation: 'chicken'
    )
  end
  let(:blog) do
    Blog.create(
      user_id: user.id,
      content: content
    )
  end

  describe 'GET index' do
    it 'exists and responds' do
      get :index

      expect(response.status).to eq(200)
    end

    it 'assigns @blogs' do
      blog
      get :index
      expect(assigns(:blogs)).to eq([blog])
    end

    it 'works with many records' do
      100.times do
        Blog.create(
          user_id: user.id,
          content: content
        )
      end

      get :index
      expect(assigns(:blogs).count).to eq(100)
    end
  end

  describe 'GET show' do
    it 'exists and responds' do
      get :show, params: {id: blog.id}

      expect(response.status).to eq(200)
    end
  end

  describe 'GET edit' do
    it 'exists and responds' do
      get :edit, params: {id: blog.id}

      expect(response.status).to eq(200)
    end
  end

  describe 'GET new' do
    it 'exists and responds' do
      get :new

      expect(response.status).to eq(200)
    end
  end

  describe 'POST create' do
    it 'adds a record' do
      sign_in user
      post :create, params: {blog: {content: content}}

      expect(Blog.count).to eq(1)
      expect(Blog.first.content).to eq(content)
    end
  end

  describe 'PUT update' do
    it 'modifies a record' do
      put :update, params: { id: blog.id, blog: { content: 'new content' } }

      expect(Blog.count).to eq(1)
      expect(Blog.first.content).to eq('new content')
    end
  end

  describe 'DELETE destroy' do
    it 'deletes a record' do
      delete :destroy, params: {id: blog.id}

      expect(Blog.count).to eq(0)
    end
  end

end

app/controllers/blogs_controller.rb

class BlogsController < ApplicationController

  def index
    @blogs = Blog.all
  end

  def show
    @blog = Blog.find(params[:id])
  end

  def edit
    @blog = Blog.find(params[:id])
  end

  def update
    blog = Blog.find(params[:id])
    blog.update blog_params
  end

  def new
    @blog = Blog.new
  end

  def create
    blog = Blog.new(blog_params)
    blog.user_id = current_user.id
    if blog.save
      redirect_to blog
    else
      redirect_to root_path
    end
  end

  def destroy
    blog = Blog.find(params[:id])
    blog.destroy
  end

  private
  def blog_params
    params.require(:blog).permit(:content)
  end
end

NOTE In order to create a blog you need to be a logged in user. In order for spec to log in you will need to include config.include Devise::Test::ControllerHelpers, type: :controller in the rails_helper.rb file, as shown at the end of this article:

NOTE the index, show, new and edit methods will automatically redirect to the relevant views. Therefore you must add a file for each in the views/blogs folder or you will see an error: ActionController::MissingExactTemplate - just do this:

$ touch app/views/blogs/index.html.erb app/views/blogs/new.html.erb app/views/blogs/edit.html.erb app/views/blogs/show.html.erb

Now we can write the behavioural tests, which use capybara. We could have come straight here, but doesn’t hurt to write model and controller tests first

As per the controller tests, it is necessary to be logged in before you can create a new blog. In order for Capybara to be able to log in add the following to the rails_helper.rb file: (See end of the article for full file)

include Warden::Test::Helpers
Warden.test_mode!

spec/features/blog_spec.rb

require 'rails_helper'

describe 'navigate' do
  let(:content) { 'foobar' }
  let(:user) do
    User.create(
      email: 'foo@bar.net',
      password: 'chicken',
      password_confirmation: 'chicken'
    )
  end
  # could refactor later to use factories

  describe 'index' do
    it 'can be reached' do
      visit blogs_path
      expect(page.status_code).to eq(200)
    end

    it 'has a title of Blogs' do
      visit blogs_path
      expect(page).to have_content(/Blogs/)
    end
  end

  describe 'creation' do
    before do
      login_as(user, scope: :user)
      visit new_blog_path
    end

    it 'has a new form that can be reached' do
      expect(page.status_code).to eq(200)
    end

    it 'can be created from a new form' do

      fill_in 'blog[content]', with: 'words'
      click_on 'Save'

      expect(page).to have_content('words')
      # implicitly tests the show page
      expect(User.last.blogs.last.content).to have_content(/words/)
    end
  end

  describe 'edit' do
    xit 'can be edited' do

    end
  end

  describe 'deletion' do
    xit 'can be deleted' do

    end
  end
end

To get the above tests passing, this is the minimum code required in the view pages:

app/views/blogs/index.html.erb

<h1>Blogs</h1>

app/views/blogs/new.html.erb

<%= form_for @blog do |f| %>
  <%= f.text_area :content %>
  <%= f.submit 'Save' %>
<% end %>

app/views/blogs/show.html.erb

<p><%= @blog.inspect %></p>

Let’s see how our test coverage is looking: add

group :test do
  gem 'simplecov'
end

To the Gemfile, and

$ bundle
$ bundle exec rspec
$ open coverage/index.html

And there we have it, pretty close to 100% coverage, with the only missed line being the redirect in the event of blog.save being unsuccessful.

You can check in the browser and it should all be AOK. There’s no navigation built in, so you will need to visit the following in turn:


Adding the name field to User

So we have a name field in the user model, but by default devise is just using the email field to identify the user. Below we’ll add the user’s name, note this is not a username we can sign up with, just a friendly name for the user. If it were a username we ould have to modify to allow users to login with username, and the username would have to be unique. We are not concerned with that here.

Add the field to the signup page:

app/views/devise/registrations/new.html.erb

  <div class="field">
    <%= f.label :name %><br />
    <%= f.text_field :name, autofocus: true, autocomplete: "name" %>
  </div>

Turn on scoped views in devise config:

config/initializers/devise.rb

config.scoped_views = true

Modify the application controller:

app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :configure_permitted_parameters, if: :devise_controller?

  protected
  
  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:sign_up) do |user|
      user.permit(:name, :email, :password)
    end
    devise_parameter_sanitizer.permit(:account_update) do |user|
      user.permit(:name, :email, :password, :current_password)
    end
  end
end

and of course add a test to make sure it’s working (saving to the database)

spec/models/user_spec.rb

require 'rails_helper'

RSpec.describe User, type: :model do
  let(:user) do
    User.create(
      email: 'foo@bar.net',
      name: 'baz',
      password: 'chicken',
      password_confirmation: 'chicken'
    )
  end

  describe 'creation' do
    it 'can be created' do
      expect(user).to be_valid
      expect(User.count).to eq(1)
      expect(User.first.name).to eq(user.name)
    end
  end

  describe 'update' do
    xit 'can change the password' do
    end

    xit 'can change the name' do
    end
  end
end

Let’s add a test to check the name is exposed in the application. We’ll target a page that you need to be logged in to see, in order to not have to bother checking logged in status on the index page for instance.

Modify the feature/blog spec page as follows:

spec/features/blog_spec.rb

let(:user) do
  User.create(
    email: 'foo@bar.net',
    name: 'baz', #### NEW ####
    password: 'chicken',
    password_confirmation: 'chicken'
  )
end

...

it 'can be created from a new form' do

  fill_in 'blog[content]', with: 'words'
  click_on 'Save'

  expect(page).to have_content('words')
  expect(page).to have_content(user.name) #### NEW ####
  expect(User.last.blogs.last.content).to have_content(/words/)
end

and the blog show page as follows:

app/views/blogs/show.html.erb

<p><%= @blog.inspect %></p>
<p><%= @blog.user.name %></p>

Run $ bundle exec rspec - all should be good. Then $ open coverage/index.html and let’s get this to 100% as promised. Firstly, we have some issues with application_controller.rb so we will cheekily skip it thusly, change the SimpleCov line in rails helper to:

spec/rails_helper.rb

SimpleCov.start do
  add_filter "app/controllers/application_controller.rb"
  # yes, maybe dodgy - whatevs
end

and then finally we need to have a test case for when a blog cannot be saved.

spec/controllers/blogs_controller_spec.rb

it 'won\'t add a record if content is nil' do
  sign_in user
  post :create, params: {blog: {content: nil}}

  expect(Blog.count).to eq(0)
end
All Files (100.0% covered at 1.17 hits/line)
33 files in total. 180 relevant lines. 180 lines covered and 0 lines missed

Conclusion

This project is for practice only, it’s not getting turned into a finished app. To do so you would need to


Finished Files

For reference, below are the files used throughout the project as they stand at the end of the project: You can also see these on the github repo for this project which is really the single source of truth.

Rails Helper

# IMPORTANT ! #
# This allows simlecov to cover all specs. must be at the start
require 'simplecov'
SimpleCov.start do
  add_filter "app/controllers/application_controller.rb"
  # yes, maybe dodgy - whatevs
end

ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
abort("The Rails environment is running in production mode!") if Rails.env.production?


require 'spec_helper'
require 'rspec/rails'
require 'capybara/rails'

# IMPORTANT ! #
# This allows capybara to perform the login_as action
include Warden::Test::Helpers
Warden.test_mode!
# IMPORTANT ! #

begin
  ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
  puts e.to_s.strip
  exit 1
end
RSpec.configure do |config|
  config.fixture_path = "#{::Rails.root}/spec/fixtures"
  config.use_transactional_fixtures = true
  config.infer_spec_type_from_file_location!
  config.filter_rails_from_backtrace!

  # IMPORTANT ! #
  # This allows RSpec to perform the sign_in action in the controller tests
  config.include Devise::Test::ControllerHelpers, type: :controller
  # IMPORTANT ! #

end

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

Gemfile

# frozen_string_literal: true

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '2.6.4'

gem 'bootsnap', '>= 1.4.2', require: false
gem 'jbuilder', '~> 2.7'
gem 'pg', '>= 0.18', '< 2.0'
gem 'puma', '~> 3.11'
gem 'rails', '~> 6.0.0'
gem 'sass-rails', '~> 5'
gem 'turbolinks', '~> 5'
gem 'webpacker', '~> 4.0'

group :development, :test do
  gem 'byebug', platforms: %i[mri mingw x64_mingw]
  gem 'capybara'
  gem 'pry-byebug'
  gem 'rails-controller-testing'
  gem 'rspec-rails', '~> 4.0.0.beta2'
  gem 'rubocop'
end

group :development do
  gem 'listen', '>= 3.0.5', '< 3.2'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
  gem 'web-console', '>= 3.3.0'
end

gem 'devise'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]

group :test do
  gem 'shoulda-matchers'
  gem 'simplecov'
end

routes.rb

Rails.application.routes.draw do
  resources :blogs
  devise_for :users
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html

  root to: "home#index"
end