Write Modular RSpec

Share this article

Cubes block. Assembling concept. On white.

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:

  1. Create a new user and verify it is possible to login and logout with this user.
  2. NEG – Try to login with non-existing user.
  3. With an existing user, get weather forecast for a particular city (city exists).
  4. NEG – With an existing user, get weather forecast for a particular city (city does not exist).
  5. With an existing user, get weather forecast for a particular area of the city.
  6. NEG – With an existing user, get weather forecast for p articular area of the city (area is in minus range).
  7. With an existing user, get input from users for a particular city.
  8. With an existing user, add new input for particular city and check this input is in the list of inputs.
  9. NEG – Delete existing user and try to login with this user.
  10. 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_contexts 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.

Frequently Asked Questions about Writing Modular RSpec

How can I simulate a login with RSpec in a modular way?

Simulating a login with RSpec in a modular way involves creating a helper method that can be reused across different test cases. This method will typically involve creating a user, signing them in, and then using that user in your tests. Here’s an example of how you might do this:

module LoginHelper
def login_as(user)
post login_path, params: { session: { email: user.email, password: user.password } }
end
end

You can then include this module in your tests and use the login_as method to simulate a user login.

How can I check for a JSON response using RSpec in a modular way?

Checking for a JSON response involves parsing the response body and then checking the values of the parsed JSON. Here’s an example of how you might do this in a modular way:

module JsonResponseHelper
def json_response
JSON.parse(response.body)
end
end

You can then include this module in your tests and use the json_response method to parse the response body into JSON.

How can I use Warden with RSpec in a modular way?

Warden is a middleware for Rack that provides a mechanism for authentication. When using Warden with RSpec, you can create a helper method that logs in a user using Warden. Here’s an example:

module WardenHelper
include Warden::Test::Helpers

def login_as(user)
Warden.test_mode!
login_as(user, scope: :user)
end
end

You can then include this module in your tests and use the login_as method to log in a user using Warden.

How can I write controller specs with RSpec in a modular way?

Writing controller specs with RSpec in a modular way involves creating shared examples that can be reused across different controller specs. Here’s an example:

shared_examples "a controller that handles resource not found" do
it "returns a 404 status code" do
expect(response.status).to eq(404)
end

it "renders the 'not_found' template" do
expect(response).to render_template("not_found")
end
end

You can then include these shared examples in your controller specs and use them to test common behavior.

How can I write modular RSpec tests for JSON APIs?

Writing modular RSpec tests for JSON APIs involves creating shared examples that can be reused across different API specs. Here’s an example:

shared_examples "a JSON API that returns a single resource" do
it "returns a 200 status code" do
expect(response.status).to eq(200)
end

it "returns the resource in the response body" do
expect(json_response).to eq(resource.as_json)
end
end

You can then include these shared examples in your API specs and use them to test common behavior.

Bakir JusufbegovicBakir Jusufbegovic
View Author

IT professional with 4+ years experience in software automation and testing who is currently employed in AtlantBH. Always willing to learn new technologies and improve my skills as well as improve existing software solutions on which I'm working

GlennG
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week