Write Modular RSpec
RSpec is the most popular test framework in Ruby/Rails environments. Two of its biggest benefits is the ability to write clean, concise, and modular tests and combine it with many different frameworks (backend, frontend, API..etc.) One of the biggest pains in writing test scripts is their refactoring which is inevitable. This article aims to demonstrate how to make test scripts that are easy to understand and maintain, along with reducing refactoring nightmares.
Your Imaginary Project
For the sake of this article, we will start with following premise:
1. Your site (“Weather is Good”) is an application that offers information about weather forecasts and current weather in some areas based on user input.
2. It exposes a REST API (data format is JSON).
3. In order to access the API, users need to register and obtain an authorization token used for subsequent requests.
Methods that are offered through API are:
GET/
– Current weather in a city (token, city)
– Weather in an area of city (token, city, area)
– User input on weather in city (token, city)
POST/
– Create user entry for weather in city (token, city, comment)
– Create user (token, firstname, lastname, email, password)
– Login (email, password)
– Logout (token)
DELETE/
– Delete user (token, user)
– Delete user entry for weather in city (token, user, city)
The Problem
The project has just started. You heard about this RSpec tool and you want to use it as main framework for writing tests. You have never worked with it and are new to Ruby. You are slowly progressing with writing your tests and are learning more RSpec/Ruby every day. You currently have some methodology for writing your tests and it’s working. Good! As the project progresses and the application has more and more features, you are comfortable with writing your test scripts. It becomes second nature to you, but you are starting to realize that you’re writing the same code in multiple spots. You don’t have time to refactor existing test scripts and you fear of losing time and breaking things that are already working.
It is often said: “If it ain’t broken, don’t fix it.” But in this case, that does not apply. There are many test scripts and, eventually, we realize that they are not maintainable. Technical dept is piling up because we didn’t write the specs in a smart way.
The Solution
Test refactoring cannot be avoided, simply because features change daily. The question is: Will you do it the easy or hard way? To do it the easy way, you need to write tests modularly to avoid code duplication. Considering the API that is exposed in the application, here are some possible use cases to cover:
- Create a new user and verify it is possible to login and logout with this user.
- NEG – Try to login with non-existing user.
- With an existing user, get weather forecast for a particular city (city exists).
- NEG – With an existing user, get weather forecast for a particular city (city does not exist).
- With an existing user, get weather forecast for a particular area of the city.
- NEG – With an existing user, get weather forecast for p articular area of the city (area is in minus range).
- With an existing user, get input from users for a particular city.
- With an existing user, add new input for particular city and check this input is in the list of inputs.
- NEG – Delete existing user and try to login with this user.
- Delete existing user, create new user, login with this user and ensure that comments from previous user for particular city have been deleted.
These are just some of the cases and, certainly, there are more cases to be covered. Sticking to the cases described here, there are some reusable patterns already. We will need a common class to provide methods for sending REST requests. Also, some actions will be repeatable, like creating a new user, login, logout, getting the forecast for a city, getting user input for weather in a city, etc.
Setting up RSpec
First things first, it’s time to setup RSpec. Use the standard rspec --init command
which sets up the initial structure for the RSpec tests:
<root_project_directory>
- spec
spec_helper.rb
.rspec
Since the tests will be modular and will use some initial config data, a good way to start is to create two additional directories: config and lib under the project root directory. The config for each environment will live in a config.yml. The lib will hold reusable code for our test scripts.
Setting Up config.yml
The config.yml looks like:
environment:
server: http://test.weatherisgood.com
port: 7000
Reusable Parts
As previously stated, we need a common class to send REST requests. This will be the Sender
class inside sender.rb file:
require 'rubygems'
require 'rest_client'
require 'json'
require 'yaml'
class Sender
attr_reader :server_url
def initialize
config_yml = YAML::load(File.open("./config/config.yaml"))
@server_url = config_yml['environment']['server'].to_s + ":" + config_yml['environment']['port'].to_s
end
def send_request(endpoint, method, args={}, headers={})
unless ['GET','POST','DELETE'].include? method
raise "Incorrect REST method has been specified"
end
if method == 'GET'
begin
response = RestClient.get(@server_url + endpoint, headers)
return JSON.parse(response)
rescue => e
return JSON.parse(e.response)
end
elsif method == 'POST'
begin
response = RestClient.post(@server_url + endpoint, args, headers)
return JSON.parse(response)
rescue => e
return JSON.parse(e.response)
end
elsif method == 'DELETE'
begin
response = RestClient.delete(@server_url + endpoint, headers)
return JSON.parse(response)
rescue => e
return JSON.parse(e.response)
end
end
end
end
The code is pretty self-explanatory. In the constructor, create server_url
from the configuration data. The send_request
method specifies the endpoint, REST method, possible arguments, and headers (for authentication). Now, we have centralized class used for sending and parsing REST requests.
The next thing we need is an implementation of common actions (login/logout, create new user, get weather forecast..etc.) For the sake of simplicity, these methods live inside a single *.rb
file, but we could use multiple files to divide code base on functionality. Let’s call it weatherisgood_api.rb (Paradoxically, naming is one of the hardest things in software development and there are likely better names for this :) ):
require 'rubygems'
require 'json'
require 'sender'
class WeatherIsGoodApi
attr_reader :sender, :authorization_token
def initialize
@sender = Sender.new
end
def login(email, password)
response = @sender.send_request('/auth/login', 'POST', {:email=>email, :password=>password})
@authorization_token = response['data']['token'] unless response['data']['token'].nil?
return response['data']['token']
end
def logout
response = @sender.send_request('/auth/logout','POST', {}, {:token=>@authorization_token})
end
def create_user(firstname, lastname, email, password)
response = @sender.send_request('/user/create', 'POST', {:firstname=>firstname, :lastname=>lastname, :email=>email, :password=>password})
end
def get_weather_today_for_city(city)
response = @sender.send_request("/weather/#{city}/today", 'GET', {}, {:token=>@authorization_token})
end
def get_users_input_for_weather(city)
response = @sender.send_request("/weather/#{city}/today/user", 'GET', {}, {:token=>@authorization_token})
end
end
As you can see, there are methods for each of the actions that will be performed against the API. Logging in gets the authorization token to be used in subsequent requests.
RSpec Reusable Magic
With the base implementation for REST calls and common actions, we can utilize the RSpec sharing concept to further divide actions performed in the test steps. This is important because certain test steps will provide different input for these actions and expect different output (A good example is positive and negative tests for login.) For this purpose, use RSpec’s shared_context
which defines actions that take input parameters and perform checks inside of it
blocks in the same manner as regular describe
blocks:
require 'rubygems'
require 'json'
shared_context "Login" do |weatherisgood, email, password|
it "succeeds for user: #{email}" do
user_login_success = false
output = weatherisgood.login(email, password)
user_login_success = true unless output.nil?
expect(user_login_success).to eq(true)
end
end
shared_context "NEG - Login" do |weatherisgood, email, password|
it "does not succeed for invalid user: #{email}" do
user_login_failure = false
output = weatherisgood.login(email, password)
user_login_failure = true if output.nil?
expect(user_login_failure).to eq(true)
end
end
shared_context "Create user" do |weatherisgood, firstname, lastname, email, password|
it "creates user account for user: #{firstname} #{lastname}" do
user_account_created = false
output = weatherisgood.create_user(firstname, lastname, email, password)
user_account_created = true unless output['data']['success'].nil?
expect(user_account_created).to eq(true)
end
end
shared_context "NEG - Create user (user already exists)" do |weatherisgood, firstname, lastname, email, password|
it "does not create user account for user: #{firstname} #{lastname} since it already exist" do
error_message = ""
output = weatherisgood.create_user(firstname, lastname, email, password)
if output['data']['success'].nil?
error_message = output['data']['error']
end
expect(error_message).to eq('User already exists!')
end
end
shared_context "Get weather for city" do |weatherisgood, city|
it "gets weather forecast for city #{city}" do
weather_forecast_retrieved = false
output = weatherisgood.get_weather_today_for_city(city)
# Do some checks here for the content and if everything is ok set weather_forecast_retrieved to true
expect(weather_forecast_retrieved).to eq(true)
end
end
shared_context "NEG - Get weather for city" do |weatherisgood, city|
it "does not get weather forecast for city #{city} that does not exist" do
error_message = ""
output = weatherisgood.get_weather_today_for_city(city)
if output['data']['success'].nil?
error_message = output['data']['error']
end
expect(error_message).to eq('City does not exist!')
end
end
shared_context "Get user input on weather for city" do |weatherisgood, city|
it "gets users input on weather forecast for city #{city}" do
weather_forecast_retrieved = false
output = weatherisgood.get_users_input_for_weather(city)
# Do some checks here for the content and if everything is ok set weather_forecast_retrieved to true
expect(weather_forecast_retrieved).to eq(true)
end
end
Notice that I use the prefix “NEG” in some shared contexts to indicate a negative check (for example, login with invalid credentials). shared_context
s are called from test cases. Create testcase1_spec.rb and place it inside the spec directory. I usually put one test case in one *.rb
file for the sake of understanding and maintenance. Here’s an example:
require 'rubygems'
require './lib/weatherisgood_api'
require './lib/shared/weatherisgood_spec'
user = "bakir"
password = "mypassword"
city = "Sarajevo"
city_doesnot_exist = "MyCity"
weatherisgood = WeatherIsGoodApi.new
describe "Test Case 1: Login with user and get weather check for city that exists and doesn't exist" do
context "Login with user #{user}" do
include_context "Login", weatherisgood, user, password
end
context "Get weather for city #{city} that exists" do
include_context "Get weather for city", weatherisgood, city
end
context "Get weather for city #{city_doesnot_exist} that does not exist" do
include_context "NEG - Get weather for city", weatherisgood, city_doesnot_exist
end
end
A few things to notice:
1. I’m using context
to divide the test steps.
2. The test case is described using describe
. Test steps are described using context
. This is a personal preference to differentiate between test case and test step.
3. Inside every context
we have include_context
and the name of the shared_context
to invoke, passing parameters as necessary.
4. When running this test case, the output (using --format
documentation in .rspec file) looks like:
Test Case 1: Login with user and get weather check for city that exists and doesn't exist
Login with user bakir@myemail.com
succeeds for user: bakir@myemail.com
Get weather for city that exists
gets weather forecast for city Sarajevo
Get weather for city that does not exist
does not get weather forecast for city MyCity that does not exist
Finished in 0.00124 seconds
3 examples, 0 failures
As you can see, the output is very clear and includes:
– Description of the test step (“Login with user bakir@myemail.com”)
– Expected result of the test step (“succeeds for user: bakir@myemail.com”)
It’s advisable to describe your test steps as clearly as possible so the output is readable and useful.
Conclusion
In this tutorial I’ve tried to explain why we need reusability in our RSpec test code. Every change needed for the REST api can be made in one place (the WeatherIsGoodApi
class). Also, expected results for the particular feature under test is adjusted in a single location place (shared_context
in weatherisgood_spec.rb) This is one approach for reusability, but you could certainly come up with others. The Point is: Think before you write tests, because it can easily bite you in the long run. If that happens, the process of coming back and changing the design of your tests can be worse than writing them from scratch.