If you are new to Rails testing and RSpec, I recommend going through the beginner tutorial for Rails testing using RSpec and Capybara before proceeding with this tutorial. RSpec is the testing framework we will be using for this tutorial.
Sometimes your Rails app might have API endpoints exposed for mobile apps, separate web frontend like React or even for third party to consume, it is important to write test for these API endpoints to ensure they are returning the correct response and also perform actions as specified.
For this tutorial, we will create a Rails app with API mode (no UI) and write specs to test the API endpoints, you can use your own Rails app and follow along as long as it has API endpoints exposed to public.
This article will cover testing fixed JSON response, API that handles CRUD operation of a model, and authentication. To follow along this tutorial, I recommend downloading a GUI HTTP API tool like Postman, Insomnia or Paw to create and send HTTP request to your own Rails API.
Table of contents
First, letâs create a API mode Rails app using rails new
rails new bookmarkapi --api -T
--api
flag is used to tell Rails we will use it mainly for API purpose, this will configure the generators to skip creating views and helpers when we create a new controller or scaffold, and the ApplicationController
class will inherit from the more lightweight ActionController::API
class instead of the usual ActionController::Base
class.
From Rails official documentation of ActionController::API :
it doesnât include a number of features that are usually required by browser access only: layouts and templates rendering, flash, assets, and so on. This makes the entire controller stack thinner, suitable for
API
applications.
You can use any app name you want, the -T
flag is used to tell Rails not to use MiniTest. By default, Rails will use a test framework called âMiniTestâ if we didnât specify -T
flag, we want to use RSpec as the test framework instead of MiniTest.
Next, create the database of the Rails app so that we can run the app later :
cd bookmarkapi
rake db:create
Now, we will install the rspec-rails into our Rails app.
rspec-rails is the rspec testing framework, plus some adopted Rails magic. For example when you run rails generate model User
to create user , it will also automatically create a model test file for that user : user_spec.rb .
Include it in the Gemfile, inside the :development, :test group like this :
# Gemfile
# ...
group :development, :test do
gem 'rspec-rails'
end
Then run bundle install
to install it.
After installing these gems, we still need to install RSpec into our app using
rails generate rspec:install
.
This will create a few boilerplate files, and update some configuration in our Rails app.
Usually for API endpoints, I will create a health check endpoint that returns a fixed HTTP 200 (OK) response so the requester can know that the API is working as intended.
In this section we will create an endpoint that return a fixed JSON response, and write a test to verify the content of the JSON response and also HTTP status.
Letâs create a controller HealthController with just a single action index :
rails generate controller health index
This will create health_controller.rb and the associated route to the index action (eg: GET â/health/indexâ).
Open health_controller.rb, and edit the index action to return a JSON response with HTTP 200 status.
# health_controller.rb
class HealthController < ApplicationController
def index
render json: { status: 'online' }, status: 200
end
end
If you have created the Rails app in API mode, RSpec will auto generate request spec files for you when you create a new controller using rails generate controller
.
This should be located in spec/requests/health_request_spec.rb.
If this is not auto generated on your side, then you would need to create a health_request_spec.rb file, and place it under spec/requests folder.
Modify the content of the health_request_spec.rb file to look like this :
require 'rails_helper'
RSpec.describe "Healths", type: :request do
describe "GET /index" do
it "returns http success" do
# this will perform a GET request to the /health/index route
get "/health/index"
# 'response' is a special object which contain HTTP response received after a request is sent
# response.body is the body of the HTTP response, which here contain a JSON string
expect(response.body).to eq('{"status":"online"}')
# we can also check the http status of the response
expect(response.status).to eq(200)
end
end
end
Then in your terminal, navigate to your Rails app root path, then run
rspec spec/requests/health_request_spec.rb
You should see the test pass successfully :
Congratulation! You have written your first RSpec request spec (if you havenât already before đŹ) . The convention of RSpec is that all test files must end with â_specâ in their filename.
Below is the explanation of what each line does in the test file.
require 'rails_helper'
This line will load the necessary test configuration from spec/rails_helper.rb, which is needed for the test to run.
RSpec.describe "Healths", type: :request do
This line is how we start writing the test, the test code usually reside inside the describe block.
describe "GET /index" do
We used another describe block inside the main describe block, to further categorize. This is useful when you have multiple routes in the same test file like GET /bookmark/2 and PUT /bookmark/2 .
You can replace âHealthsâ with any string you want, it is for your own documentation purpose.
The type: :request will tell RSpec that this is a system test.
it "returns http success" do
This line is also for documentation purpose, you replace the string with any string you want, but note that the expect() assertion will only works inside a it or scenario block.
get "/health/index"
This will perform a GET request to the /health/index route. Similarly, you can also use âpostâ, âputâ, âpatchâ, âdeleteâ methods to send POST, PUT, PATCH and DELETE HTTP request. These methods are from Railsâs ActionDispatch::Integration::RequestHelpers module.
response is a variable representing the HTTP response we receive after making a HTTP request, its class is ActionDispatch::TestResponse, we can use itsâ body method to get the response body to check its content, status attribute to get the HTTP status code, and headers attribute to get the HTTP headers.
expect(response.body).to eq('{"status":"online"}')
expect(response.status).to eq(200)
This will tell RSpec to ensure the response body is equal to the JSON string {âstatusâ: âonlineâ}, and the response status is 200 (HTTP 200 means ok).
The above syntax might look magical at first, it is equivalent to
expect(response.body).to(eq('{"status":"online"}'))
expect(response.status).to(eq(200))
With ruby magic, we can omit the parentheses for the to method.
Sometimes when testing a deeply nested JSON string response, we can make it easier to read by parsing it to a Ruby hash using JSON.parse like this :
# by default, JSON has string key like {"status": "online"},
# we then convert the key to symbol, {status: 'online'} for better readability,
# and type less two quotes character
json = JSON.parse(response.body).deep_symbolize_keys
# easier to read in ruby hash form, compared to a full JSON string
expect(json).to eq({
order_id: 123,
user: {
id: 456,
name: 'Asriel Dreemurr'
},
product: {
id: 789,
name: 'Butterscotch Pie'
}
})
# this is a lot harder to read
expect(response.body).to eq('{"order_id":123,"user":{"id":456,"name":"Asriel Dreemurr"},"product":{"id":789,"name":"Butterscotch Pie"}}')
How do we know our test actually work? What if the test we wrote always passes because we didnât check for enough condition? This might render the test useless as it doesnât guard us from breaking the API.
To ensure the test actually work, we can try break it on purpose. As our test check for the JSON status online, we can modify the controller to return a different JSON, for example: {status: âupâ}.
Then run the same test again :
rspec spec/requests/health_request_spec.rb
Sure enough, the test fails :
If you are wondering where does the âHealths GET /index returns http successâ string comes from, it is from the strings you used in the describe and it blocks :
The strings you used in the describe and it / scenario block will serve as documentation when your test goes wrong, to help you pinpoint which part of your test has failed.
Next, we are going to create scaffold (model + controller) so that we can write test for basic CRUD features (create, read, update, delete). Usually scaffold will work out of the box without needing to test it, nevertheless in this section I will try explain my experience / thought on how to test CRUD.
Letâs create a sample scaffold, I used âBookmarkâ, with two string properties : âtitleâ and âurlâ.
rails generate scaffold Bookmark title:string url:string
Running this command generate the model, controller and prefilled request specs file for the bookmark routes.
As we donât want user to input empty values into title and url column, open up db/migrate/âŠ._create_bookmarks.rb and add ânull: falseâ to them
# 20201120102131_create_bookmarks.rb
class CreateBookmarks < ActiveRecord::Migration[6.0]
def change
create_table :bookmarks do |t|
t.string :title, null: false
t.string :url, null: false
t.timestamps
end
end
end
then run database migration :
rake db:migrate
Next, open the bookmark model file in app/models/bookmark.rb , and add validation for presence (not null), and disallow blank value :
# app/models/bookmark.rb
class Bookmark < ApplicationRecord
validates :title, presence: true, allow_blank: false
validates :url, presence: true, allow_blank: false
end
The scaffold generates a bookmarks_controller.rb file which comes with a few action we can test with :
# app/controllers/bookmarks_controller.rb
# GET /bookmarks
# ....
def index
@bookmarks = Bookmark.all
render json: @bookmarks
end
# GET /bookmarks/1
def show
render json: @bookmark
end
# POST /bookmarks
def create
@bookmark = Bookmark.new(bookmark_params)
if @bookmark.save
render json: @bookmark, status: :created, location: @bookmark
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
#...
Run rails server
, then use a HTTP API tool like Postman, Insomnia or Paw to generate a HTTP request to the rails server running at localhost:3000 .
Here I send a JSON containing url and title of a bookmark object I want to create, and send it over via POST request to the /bookmarks/ endpoint, which calls the create action.
As the url and title of the bookmark object is valid, the API returns back the JSON of the created bookmark with status 201 (:created).
Next, letâs create a bookmark again , but with a blank URL :
As the url is blank and thus invalid, the API returns back a JSON containing error message for the url key, and a 422 Unprocessable Entity HTTP status.
In the next section, we will write request spec to simulate these two situations (valid and invalid), and check the format of the response returned.
For API specs, usually I will place the test (spec) files inside **spec/requests/
To test the creation function of bookmark, letâs create a test file at spec/requests/bookmarks/create_spec.rb .
We then automate the earlier manual actions, which is to create new bookmark with
# spec/requests/bookmarks/create_spec.rb
require 'rails_helper'
describe 'POST /bookmarks' do
# 'scenario' is similar to 'it', use which you see fit
scenario 'valid bookmark attributes' do
# send a POST request to /bookmarks, with these parameters
# The controller will treat them as JSON
post '/bookmarks', params: {
bookmark: {
url: 'https://rubyyagi.com',
title: 'RubyYagi blog'
}
}
# response should have HTTP Status 201 Created
expect(response.status).to eq(201)
json = JSON.parse(response.body).deep_symbolize_keys
# check the value of the returned response hash
expect(json[:url]).to eq('https://rubyyagi.com')
expect(json[:title]).to eq('RubyYagi blog')
# 1 new bookmark record is created
expect(Bookmark.count).to eq(1)
# Optionally, you can check the latest record data
expect(Bookmark.last.title).to eq('RubyYagi blog')
end
scenario 'invalid bookmark attributes' do
post '/bookmarks', params: {
bookmark: {
url: '',
title: 'RubyYagi blog'
}
}
# response should have HTTP Status 201 Created
expect(response.status).to eq(422)
json = JSON.parse(response.body).deep_symbolize_keys
expect(json[:url]).to eq(["can't be blank"])
# no new bookmark record is created
expect(Bookmark.count).to eq(0)
end
end
Now run the test: rspec spec/requests/bookmarks/create_spec.rb
, and we will see two test cases pass, yay! đ
Now that we have a wrote test for the bookmark create action, we can move on to the update action.
If we want to update a bookmark, we would send a PATCH or PUT request to the /bookmarks/:id route. As we have created a bookmark earlier, which have the ID 1, we will send a PUT request to /bookmarks/1 .
Letâs create a new test file for the update action at spec/requests/bookmarks/update_spec.rb.
For the update action test, we would first need to create an existing bookmark record before we can edit it. We can create this bookmark record before the test is run, using let! helper method.
For example, let!(:bookmark) { Bookmark.create(title: 'Ruby Yagi') }
will be executed before each âscenarioâ or âitâ blocks, it will only execute once and its return value will be cached (subsequent call to it will always return the same value).
You can think of let!(:bookmark) { Bookmark.create(title: 'Ruby Yagi') }
as a function like this :
def bookmark
Bookmark.create(title: 'Ruby Yagi')
end
then you can use bookmark method in your test code to access the created bookmark record. Jason Swettâs article on let, let! and instance variables has explained this in more detail.
# spec/requests/bookmarks/update_spec
require 'rails_helper'
describe 'PUT /bookmarks' do
# this will create a 'bookmark' method, which return the created bookmark object,
# before each scenario is ran
let!(:bookmark) { Bookmark.create(url: 'https://rubyyagi.com', title: 'Ruby Yagi') }
scenario 'valid bookmark attributes' do
# send put request to /bookmarks/:id
put "/bookmarks/#{bookmark.id}", params: {
bookmark: {
url: 'https://fluffy.es',
title: 'Fluffy'
}
}
# response should have HTTP Status 200 OK
expect(response.status).to eq(200)
# response should contain JSON of the updated object
json = JSON.parse(response.body).deep_symbolize_keys
expect(json[:url]).to eq('https://fluffy.es')
expect(json[:title]).to eq('Fluffy')
# The bookmark title and url should be updated
expect(bookmark.reload.title).to eq('Fluffy')
expect(bookmark.reload.url).to eq('https://fluffy.es')
end
scenario 'invalid bookmark attributes' do
# send put request to /bookmarks/:id
put "/bookmarks/#{bookmark.id}", params: {
bookmark: {
url: '',
title: 'Fluffy'
}
}
# response should have HTTP Status 422 Unprocessable entity
expect(response.status).to eq(422)
# response should contain error message
json = JSON.parse(response.body).deep_symbolize_keys
expect(json[:url]).to eq(["can't be blank"])
# The bookmark title and url remain unchanged
expect(bookmark.reload.title).to eq('Ruby Yagi')
expect(bookmark.reload.url).to eq('https://rubyyagi.com')
end
end
The reload method will ask the bookmark (object) to query the database and get its latest value, instead of using the values stored in memory (which we did it in the let! helper method).
At this point, you know the basics of writing request spec that involves creating and updating a record. I will leave writing test for the delete action and list (index) action for you to complete on your own, try writing your own test! Reading a guided tutorial helps but to reinforce the knowledge you have learned in memory, you have to do type the code on your own.
To run all the test files at once, you can tell rspec to run all files inside the spec/requests folder like this :
rspec spec/requests
.
If your Rails app has exposed API for consumption, most likely it will require authentication like API key, or user token to prevent abuse and also track usage for different users.
In this section we will update the tests we wrote earlier to include authentication checks.
Letâs create a User model which contains username and authentication_token attributes,
rails g model user username:string authentication_token:string
Then run database migration to create the user table :
rake db:migrate
Then in the bookmarks_controller.rb file, we will add a method to validate the request based on the username and authentication_token received on the request.
# bookmarks_controller.rb
class BookmarksController < ApplicationController
before_action :authenticate_user
# ....
private
def authenticate_user
# find the user based on the headers from HTTP request
@current_user = User.find_by(
username: request.headers['X-Username'],
authentication_token: request.headers['X-Token']
)
# return error message with 403 HTTP status if there's no such user
return render(json: { message: 'Invalid User' }, status: 403) unless @current_user
end
end
Now if we send a HTTP request without authentication headers, we will get an error :
Letâs create a user on the rails console (with username and authentication token), and try again :
Alright now we have an authentication mechanism in place, letâs modify our previous tests to include these authentication details.
For spec/requests/bookmarks/create_spec.rb :
# spec/requests/bookmarks/create_spec.rb
require 'rails_helper'
describe 'POST /bookmarks' do
# create a user before the test scenarios are run
let!(:user) { User.create(username: 'soulchild', authentication_token: 'abcdef') }
# pass the user username and authentication to the header
scenario 'valid bookmark attributes' do
post '/bookmarks', params: {
bookmark: {
url: 'https://rubyyagi.com',
title: 'RubyYagi blog'
}
}, headers: { 'X-Username': user.username, 'X-Token': user.authentication_token }
# ...
end
# pass the user username and authentication to the header
scenario 'invalid bookmark attributes' do
post '/bookmarks', params: {
bookmark: {
url: '',
title: 'RubyYagi blog'
}
}, headers: { 'X-Username': user.username, 'X-Token': user.authentication_token }
# ...
end
end
And for spec/requests/bookmarks/update_spec.rb :
require 'rails_helper'
describe 'PUT /bookmarks' do
let!(:bookmark) { Bookmark.create(url: 'https://rubyyagi.com', title: 'Ruby Yagi') }
# create a user before the test scenarios are run
let!(:user) { User.create(username: 'soulchild', authentication_token: 'abcdef') }
scenario 'valid bookmark attributes' do
# send put request to /bookmarks/:id
# pass the user username and authentication to the header
put "/bookmarks/#{bookmark.id}", params: {
bookmark: {
url: 'https://fluffy.es',
title: 'Fluffy'
}
}, headers: { 'X-Username': user.username, 'X-Token': user.authentication_token }
# ...
end
scenario 'invalid bookmark attributes' do
# send put request to /bookmarks/:id
# pass the user username and authentication to the header
put "/bookmarks/#{bookmark.id}", params: {
bookmark: {
url: '',
title: 'Fluffy'
}
}, headers: { 'X-Username': user.username, 'X-Token': user.authentication_token }
# ...
end
end
Before the test scenarios is run, we can create a âuserâ method using let!, which will returned the cache value of user.
let!(:user) { User.create(username: 'soulchild', authentication_token: 'abcdef') }
And then we will use the userâs username and authentication token in the request headers of the HTTP requests :
post '/bookmarks', params: {
bookmark: {
url: '',
title: 'RubyYagi blog'
}
}, headers: { 'X-Username': user.username, 'X-Token': user.authentication_token }
Run the test again rspec spec/requests/bookmarks/create_spec.rb
(and update_spec.rb), the tests should pass.
Next, we are going to write one more scenario to test the response returned when there is no authentication header provided, it should return HTTP status 403 forbidden.
Before that, letâs group the previous scenarios with valid authentication into a context for better readability :
# spec/requests/bookmarks/create_spec.rb
require 'rails_helper'
describe 'POST /bookmarks' do
# group scenarios with authenticated user into this context block
context 'authenticated user' do
# create a user before the test scenarios are run
let!(:user) { User.create(username: 'soulchild', authentication_token: 'abcdef') }
# pass the user username and authentication to the header
scenario 'valid bookmark attributes' do
# .....
end
# pass the user username and authentication to the header
scenario 'invalid bookmark attributes' do
# ....
end
end
# scenario with unauthenticated user
context 'unauthenticated user' do
# we will talk about this next.
end
end
Alright, now we can write test for when the HTTP request doesnât contain the authentication headers information, it should return a response with HTTP status 403 (Forbidden), and a message saying âinvalid userâ.
# spec/requests/bookmarks/create_spec.rb
require 'rails_helper'
describe 'POST /bookmarks' do
# group scenarios with authenticated user into this context block
context 'authenticated user' do
# create a user before the test scenarios are run
let!(:user) { User.create(username: 'soulchild', authentication_token: 'abcdef') }
# pass the user username and authentication to the header
scenario 'valid bookmark attributes' do
# .....
end
# pass the user username and authentication to the header
scenario 'invalid bookmark attributes' do
# ....
end
end
# scenario with unauthenticated user
context 'unauthenticated user' do
it 'should return forbidden error' do
post '/bookmarks', params: {
bookmark: {
url: 'https://rubyyagi.com',
title: 'RubyYagi blog'
}
}
# response should have HTTP Status 403 Forbidden
expect(response.status).to eq(403)
# response contain error message
json = JSON.parse(response.body).deep_symbolize_keys
expect(json[:message]).to eq('Invalid User')
end
end
end
You can write similar test with unauthenticated user on update_spec.rb as well.
Most likely your API will return the same HTTP 403 response with identical error message across different endpoints, you might find yourself writing the same unauthenticated request test scenario (ie. response return 403 and same error message) across different API spec. To reduce repetition, you can look into shared_examples . (I will write a tutorial on this in the future)