From this tutorial.
Gemfile.rb:
- Rspec-rails to development and test groups
group :development, :test do
gem 'rspec-rails', '~> 3.5'
end
- Add FactoryGirl, Shoulda, Faker, and DatabaseCleaner
group :test do
gem 'factory_girl_rails', '~> 4.0'
gem 'shoulda-matchers', '~> 3.1'
gem 'faker'
gem 'database_cleaner'
end
-
Install:
$ bundle install
-
Initialize RSpec:
$ rails g rspec:install
-
Create a /factories directory for FactoryGirl:
$ mkdir spec/factories
- Add the following to
spec/rails_helper.rb
:
require 'database_cleaner'
# [...]
# configure shoulda matchers to use rspec as the test framework and full matcher libraries for rails
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
# [...]
RSpec.configuration do |config|
# [...]
# add `FactoryGirl` methods
config.include FactoryGirl::Syntax::Methods
# start by truncating all the tables but then use the faster transaction strategy the rest of the time.
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
DatabaseCleaner.strategy = :transaction
end
# start the transaction strategy as examples are run
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end
# [...]
end
-
Create ToDo model:
$ rails g model Todo title created_by
-
Create the Item model:
$ rails g model Item name done:boolean todo:references
-
Run the migrations:
$ rails db:migrate
- In todo_spec.rb:
require 'rails_helper'
# Test suite for the Todo model
RSpec.describe Todo, type: :model do
# Association test
# ensure Todo model has a 1:m relationship with the Item model
it { should have_many(:items).dependent(:destroy) }
# Validation tests
# ensure columns title and created_by are present before saving
it { should validate_presence_of(:title) }
it { should validate_presence_of(:created_by) }
end
- In item_spec.rb:
require 'rails_helper'
# Test suite for the Item model
RSpec.describe Item, type: :model do
# Association test
# ensure an item record belongs to a single todo record
it { should belong_to(:todo) }
# Validation test
# ensure column name is present before saving
it { should validate_presence_of(:name) }
end
-
Tests should fail.
-
Fix Todo model:
class Todo < ApplicationRecord
# model association
has_many :items, dependent: :destroy
# validations
validates_presence_of :title, :created_by
end
- Fix Item model:
class Item < ApplicationRecord
# model association
belongs_to :todo
# validation
validates_presence_of :name
end
- All tests should pass
- Create controllers:
$ rails g controller Todos
$ rails g controller Items
- Since this is an API, we'll use REQUEST specs instead of CONTROLLER specs. Request specs hit endpoints like a client would while controller specs only test controller actions. Create requests directory and specs:
$ mkdir spec/requests/ && spec/requests/{todos_spec.rb,items_spec.rb}
- Create factory files:
$ touch spec/factories/{todos.rb,items.rb}
- Define the factories:
# spec/factories/todos.rb
FactoryGirl.define do
factory :todo do
title { Faker::Lorem.word }
created_by { Faker::Number.number(10) }
end
end
spec/factories/items.rb
FactoryGirl.define do
factory :item do
name { Faker::StarWars.character }
done false
todo_id nil
end
end
- Helpful to scaffold out all tests based on endpoints you know you'll have, example:
describe 'GET /todos' do
end
- Full API test suite:
# spec/requests/todos_spec.rb
require 'rails_helper'
RSpec.describe 'Todos API', type: :request do
# initialize test data
let!(:todos) { create_list(:todo, 10) }
let(:todo_id) { todos.first.id }
# Test suite for GET /todos
describe 'GET /todos' do
# make HTTP get request before each example
before { get '/todos' }
it 'returns todos' do
# Note `json` is a custom helper to parse JSON responses
expect(json).not_to be_empty
expect(json.size).to eq(10)
end
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
end
# Test suite for GET /todos/:id
describe 'GET /todos/:id' do
before { get "/todos/#{todo_id}" }
context 'when the record exists' do
it 'returns the todo' do
expect(json).not_to be_empty
expect(json['id']).to eq(todo_id)
end
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
end
context 'when the record does not exist' do
let(:todo_id) { 100 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Todo/)
end
end
end
# Test suite for POST /todos
describe 'POST /todos' do
# valid payload
let(:valid_attributes) { { title: 'Learn Elm', created_by: '1' } }
context 'when the request is valid' do
before { post '/todos', params: valid_attributes }
it 'creates a todo' do
expect(json['title']).to eq('Learn Elm')
end
it 'returns status code 201' do
expect(response).to have_http_status(201)
end
end
context 'when the request is invalid' do
before { post '/todos', params: { title: 'Foobar' } }
it 'returns status code 422' do
expect(response).to have_http_status(422)
end
it 'returns a validation failure message' do
expect(response.body)
.to match(/Validation failed: Created by can't be blank/)
end
end
end
# Test suite for PUT /todos/:id
describe 'PUT /todos/:id' do
let(:valid_attributes) { { title: 'Shopping' } }
context 'when the record exists' do
before { put "/todos/#{todo_id}", params: valid_attributes }
it 'updates the record' do
expect(response.body).to be_empty
end
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
# Test suite for DELETE /todos/:id
describe 'DELETE /todos/:id' do
before { delete "/todos/#{todo_id}" }
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
- Create
json
helper method:
$ mkdir spec/support && touch spec/support/request_spec_helper.rb
- ...in the file:
module RequestSpecHelper
# Parse JSON response to ruby hash
def json
JSON.parse(response.body)
end
end
- In
rails_helper.rb
, comment out support directory autoloading. Make sure the following lines are included:
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
config.include RequestSpecHelper
(See this example for rails_helper.rb config)
- Tests should fail (routes are not yet defined).
- Create the routes, the nested items routes enforces 1:m relationship (See this explanation)
# config/routes.rb
Rails.application.routes.draw do
resources :todos do
resources :items
end
end
- Run tests, and failures come from uninitialized controllers
- First, create helper methods json_responses for organizing data in controller, and exception handler to rescue errors
$ touch app/controllers/concerns/{response.rb,exception_handler.rb}
- In
response.rb
:
module Response
def json_response(object, status = :ok)
render json: object, status: status
end
end
- In
exception_handerl.rb
module ExceptionHandler
# provides the more graceful `included` method
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound do |e|
json_response({ message: e.message }, :not_found)
end
rescue_from ActiveRecord::RecordInvalid do |e|
json_response({ message: e.message }, :unprocessable_entity)
end
end
end
- Create Todos Controller (note, I'm using
touch
instead of Rails generator because spec is not necessary ):
$ touch app/controllers/todos_controller.rb
- Add the following actions to
todos_controller.rb
:
class TodosController < ApplicationController
before_action :set_todo, only: [:show, :update, :destroy]
# GET /todos
def index
@todos = Todo.all
json_response(@todos)
end
# POST /todos
def create
@todo = Todo.create!(todo_params)
json_response(@todo, :created)
end
# GET /todos/:id
def show
json_response(@todo)
end
# PUT /todos/:id
def update
@todo.update(todo_params)
head :no_content
end
# DELETE /todos/:id
def destroy
@todo.destroy
head :no_content
end
private
def todo_params
# whitelist params
params.permit(:title, :created_by)
end
def set_todo
@todo = Todo.find(params[:id])
end
end
spec/requests/items_spec.rb
:
require 'rails_helper'
RSpec.describe 'Items API' do
# Initialize the test data
let!(:todo) { create(:todo) }
let!(:items) { create_list(:item, 20, todo_id: todo.id) }
let(:todo_id) { todo.id }
let(:id) { items.first.id }
# Test suite for GET /todos/:todo_id/items
describe 'GET /todos/:todo_id/items' do
before { get "/todos/#{todo_id}/items" }
context 'when todo exists' do
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
it 'returns all todo items' do
expect(json.size).to eq(20)
end
end
context 'when todo does not exist' do
let(:todo_id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Todo/)
end
end
end
# Test suite for GET /todos/:todo_id/items/:id
describe 'GET /todos/:todo_id/items/:id' do
before { get "/todos/#{todo_id}/items/#{id}" }
context 'when todo item exists' do
it 'returns status code 200' do
expect(response).to have_http_status(200)
end
it 'returns the item' do
expect(json['id']).to eq(id)
end
end
context 'when todo item does not exist' do
let(:id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Item/)
end
end
end
# Test suite for PUT /todos/:todo_id/items
describe 'POST /todos/:todo_id/items' do
let(:valid_attributes) { { name: 'Visit Narnia', done: false } }
context 'when request attributes are valid' do
before { post "/todos/#{todo_id}/items", params: valid_attributes }
it 'returns status code 201' do
expect(response).to have_http_status(201)
end
end
context 'when an invalid request' do
before { post "/todos/#{todo_id}/items", params: {} }
it 'returns status code 422' do
expect(response).to have_http_status(422)
end
it 'returns a failure message' do
expect(response.body).to match(/Validation failed: Name can't be blank/)
end
end
end
# Test suite for PUT /todos/:todo_id/items/:id
describe 'PUT /todos/:todo_id/items/:id' do
let(:valid_attributes) { { name: 'Mozart' } }
before { put "/todos/#{todo_id}/items/#{id}", params: valid_attributes }
context 'when item exists' do
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
it 'updates the item' do
updated_item = Item.find(id)
expect(updated_item.name).to match(/Mozart/)
end
end
context 'when the item does not exist' do
let(:id) { 0 }
it 'returns status code 404' do
expect(response).to have_http_status(404)
end
it 'returns a not found message' do
expect(response.body).to match(/Couldn't find Item/)
end
end
end
# Test suite for DELETE /todos/:id
describe 'DELETE /todos/:id' do
before { delete "/todos/#{todo_id}/items/#{id}" }
it 'returns status code 204' do
expect(response).to have_http_status(204)
end
end
end
$ touch app/controllers/items_controller
. Items controller actions:
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
before_action :set_todo
before_action :set_todo_item, only: [:show, :update, :destroy]
# GET /todos/:todo_id/items
def index
json_response(@todo.items)
end
# GET /todos/:todo_id/items/:id
def show
json_response(@item)
end
# POST /todos/:todo_id/items
def create
@todo.items.create!(item_params)
json_response(@todo, :created)
end
# PUT /todos/:todo_id/items/:id
def update
@item.update(item_params)
head :no_content
end
# DELETE /todos/:todo_id/items/:id
def destroy
@item.destroy
head :no_content
end
private
def item_params
params.permit(:name, :done)
end
def set_todo
@todo = Todo.find(params[:todo_id])
end
def set_todo_item
@item = @todo.items.find_by!(id: params[:id]) if @todo
end
end
Was searching for the original article and found it thanks to this gist 🎉
Thank you 🙏 🙌