DEV Community

Cover image for Introduce RSpec Request Spec
Kevin Luo
Kevin Luo

Posted on • Updated on

Introduce RSpec Request Spec

I'd like to introduce what is RSpec Request spec, how to use it and why to use it. I will also share my experience with actually doing it in real projects.

What is the Request spec and why do I recommend using it?

Writing request specs can help developers be more confident with their code.

Web applications are focused on the interactions of HTTP requests and responses. Although we normally have pretty good Model test coverage in Rails(right?), that only makes sure reading and writing toward the database are fine. Another important part, the communication and interactions between the server and the client, is not tested.

It's pretty common to see that developer teams do manual tests by themselves or just ask the QA team to make sure the features work as expected. To be honest, it's not really an issue for a small or short-term project. However, if we still deliver our code in that way for a bigger and long-term project, I'm afraid that many features will be broken after a new version is deployed. Why? Even if the model tests coverage is 100%, it doesn't mean those methods collaborate seamlessly when we combine them to do complex business logic.

An Failed example of system

Above is a classic example: the machine and the garbage bin both worked fine but the input was unexpected. They just didn't handle that exception. The Request specs reassure the interactions among all models are good.

What's special about the Request spec?

  • Test the entire stack, including rack, routing, view layer, etc.
  • It's fast
  • You can assert a series of requests, and it can even follow the redirection
it "creates a Widget and redirects to the Widget's page" do
  get "/widgets/new"
  expect(response).to render_template(:new)

  post "/widgets", :params => { :widget => {:name => "My Widget"} }
  expect(response).to redirect_to(assigns(:widget))
  follow_redirect!
  expect(response).to render_template(:show)
  expect(response.body).to include("Widget was successfully created.")
end
Enter fullscreen mode Exit fullscreen mode

When you should NOT use the Request spec

  • You want to test JS code because the Request spec doesn't execute JS code at all
  • The goal of the Request test is to test each request, so it's not so useful if you want to assert the user interactions on the web page, like clicking a button.

In both cases above, you should use End-to-end test tools like Capybara or Cypress.

Setup

We can include a routes helper so that we can use helpers root_url to indicate the URL to make a request

RSpec.configure do |config|
  config.include Rails.application.routes.url_helpers, type: :request
  # ...
end
Enter fullscreen mode Exit fullscreen mode
  1. You can add a _spec.rb file under spec/ and add a type: :request to indicate it is a Request spec
RSpec.describe "/some/path", type: :request do
  # spec content
end
Enter fullscreen mode Exit fullscreen mode
  1. or just put the *_spec.rb file under spec/requests/, RSpec will assume the files under the folder are all request specs.

How to make HTTP requests in the request spec?

There are helper methods for HTTP verbs:

  • get
  • post
  • patch
  • put
  • delete

The syntax is like: post(url, options = {})
You can put params and headers inside the options hash, for example,

# you can use explicit path/url or route helper method for the :url
get root_url
get "/articles?page=3"
post users_url, params: "{\"name\": \"Kevin\"}", headers: {"Content-Type" => "application/json"}
patch "/users/2", params: "{\"height\": 183}", headers: {"Content-Type" => "application/json"}
delete user_url(User.find(2)), headers: {"Authorization" => "Bearer #{@token}"}
Enter fullscreen mode Exit fullscreen mode

How to pass file as parameters

We can use Rack::Test::UploadedFile, for example

let(:filepath) { Rails.root.join('spec', 'fixtures', 'blank.jpg') }
let(:file) { Rack::Test::UploadedFile.new(filepath, 'image/jpg') }
# then in the example
post upload_image_url, params: {file: file}
Enter fullscreen mode Exit fullscreen mode

How to do the assertions

The response object of the request can be accessed directly by @response or response.

# we can assert the HTTP status of the response
expect(response).to have_http_status(:ok) # 200
expect(response).to have_http_status(:accepted) # 202
expect(response).to have_http_status(:not_found) # 404

# we can assert the redirected location
expect(response).to redirect_to(articles_url)

# we can assert which template or partials being rendered
expect(response).to render_template(:index)
expect(response).to render_template("articles/_article")

# we can even assert the content of the response body
expect(response.body).to include("<h1>Hello World</h1>")

# or you just want to assert some changes in the controller action
expect {
  post articles_url, params: {title: 'A new article'}
}.to change{ Article.count }.by(1)
Enter fullscreen mode Exit fullscreen mode

Compare to the unit test, I think the Request spec is more like a black box test: we only need to test the input and the output of the programs. All the business logic-related codes should already have their unit tests, including the service objects if your project uses them.

Can it make more detailed DOM assertions? Yes

We can utilize ActionController::Assertions::SelectorAssertions

# it will search the DOM to see if there is an element with id="some_element"
assert_select "#some_element" 

assert_select "ol" do |elements|
  elements.each do |element|
    assert_select element, "li", 4
  end
end

assert_select "ol" do
  assert_select "li", 8
end
Enter fullscreen mode Exit fullscreen mode

More examples can be found on assert_select (ActionController::Assertions::SelectorAssertions) - APIdock

Other variables

Except for the @response we just see, there are some more variables we can access:

  • assigns: instance variable like @user assigned in the controller, We can use assigns[:user] to access the @user
  • sessions
  • flash
  • cookies

Integrate with Devise

We can use Devise helper if we use Devise to do the authentication so it's easy to log in or log out a user.
First, we need to include the Devise::Test::IntegrationHelpers, so that we can use sign_in to sign in the user

# spec_helper.rb
RSpec.configure do |config|
  config.include Devise::Test::IntegrationHelpers, type: :request # to sign_in user by Devise
end

# then in an example
let(:user) { create(:user) }
it "an example" do
  sign_in user
  get "/articles"
  expect(response).to have_http_status(:ok)
  expect(response).to render_template(:index)
end
Enter fullscreen mode Exit fullscreen mode

It's normal that a lot of actions are only allowed the signed-in users, I like to make these steps into a shared context

RSpec.shared_context :login_user do
  let(:user) { create(:user) }
  before { sign_in user }
end

# then use include_context to include it
include_context :login_user
Enter fullscreen mode Exit fullscreen mode

What to test in the Request specs?

I found that the Request specs generated by the rails scaffold generator are pretty good. I thus copy the template generated by the scaffold here:

RSpec.describe "/articles", type: :request do
  let(:valid_attributes) {
    skip("Add a hash of attributes valid for your model")
  }

  let(:invalid_attributes) {
    skip("Add a hash of attributes invalid for your model")
  }

  describe "GET /index" do
    it "renders a successful response" do
      Article.create! valid_attributes
      get articles_url
      expect(response).to be_successful
    end
  end

  describe "GET /show" do
    it "renders a successful response" do
      article = Article.create! valid_attributes
      get article_url(article)
      expect(response).to be_successful
    end
  end

  describe "GET /new" do
    it "renders a successful response" do
      get new_article_url
      expect(response).to be_successful
    end
  end

  describe "GET /edit" do
    it "render a successful response" do
      article = Article.create! valid_attributes
      get edit_article_url(article)
      expect(response).to be_successful
    end
  end

  describe "POST /create" do
    context "with valid parameters" do
      it "creates a new Article" do
        expect {
          post articles_url, params: { article: valid_attributes }
        }.to change(Article, :count).by(1)
      end

      it "redirects to the created article" do
        post articles_url, params: { article: valid_attributes }
        expect(response).to redirect_to(article_url(Article.last))
      end
    end

    context "with invalid parameters" do
      it "does not create a new Article" do
        expect {
          post articles_url, params: { article: invalid_attributes }
        }.to change(Article, :count).by(0)
      end

      it "renders a successful response (i.e. to display the 'new' template)" do
        post articles_url, params: { article: invalid_attributes }
        expect(response).to be_successful
      end
    end
  end

  describe "PATCH /update" do
    context "with valid parameters" do
      let(:new_attributes) {
        skip("Add a hash of attributes valid for your model")
      }

      it "updates the requested article" do
        article = Article.create! valid_attributes
        patch article_url(article), params: { article: new_attributes }
        article.reload
        skip("Add assertions for updated state")
      end

      it "redirects to the article" do
        article = Article.create! valid_attributes
        patch article_url(article), params: { article: new_attributes }
        article.reload
        expect(response).to redirect_to(article_url(article))
      end
    end

    context "with invalid parameters" do
      it "renders a successful response (i.e. to display the 'edit' template)" do
        article = Article.create! valid_attributes
        patch article_url(article), params: { article: invalid_attributes }
        expect(response).to be_successful
      end
    end
  end

  describe "DELETE /destroy" do
    it "destroys the requested article" do
      article = Article.create! valid_attributes
      expect {
        delete article_url(article)
      }.to change(Article, :count).by(-1)
    end

    it "redirects to the articles list" do
      article = Article.create! valid_attributes
      delete article_url(article)
      expect(response).to redirect_to(articles_url)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

You can find it's a pattern and not so hard to write. It can be shorter if we integrate tools like FactoryBot.

Moreover, in the creating or updating spec, I'll further assert the attributes assigned to the record. For example, assuming that the Article has title and content attributes:

RSpec.describe "/articles", type: :request do
  let(:valid_attributes) {
    {
      title: 'A Article Title',
      contenxt: 'It is an article content.'
    }
  }
  describe "POST /create" do
    context "with valid parameters" do
      it "creates a new Article" do
        expect {
          post articles_url, params: { article: valid_attributes }
        }.to change(Article, :count).by(1)
        article = Article.last
        # I always assert every attribute explicitly instead of writing enumerable methods 
        # to reduce the assertions code.
        # I don't want to have a false positive situation.
        expect(article.title).to eq('A Article Title')
        expect(article.content).to eq('It is an article content.')
      end
    end
  end
Enter fullscreen mode Exit fullscreen mode

My personal experience

To be honest, I rarely added the request tests before. One reason is that I think the model method is the place where true logic lives. The controller just brings the results of the model methods into view. Nevertheless, that's an ideal world, the real situation is often mixed with complex views and interactions of model methods. Besides, Ruby is not a strong-typed language, so even if the code has some problems due to the wrong commands you wrote in the controller's actions or ERB, it doesn't raise errors until it really executes.

I started noticing most of the problems came from the controller, no matter the bugs in the response JSON or misusing the methods in Models.

Rails default 500 page

Whenever the red "sorry" page shows up, even the developers can argue that: "ok it crashes but it does not allow corrupted data written into the database", I don't think other stakeholders, like your boss, will be happy to hear that.

Anyway, we can prevent this kind of problem from happening drastically by writing the Request specs. Request specs chain all the parts of your codes and test them all at once: routes, controller actions, rendered views and HTTP status. If the Request specs pass, it should also succeed when it's deployed.

Conclusion

Currently the test pyramid in my head is like below:
testing pyramid

(The system tests means end-to-end test or the integration test, it's named like that in Rails...)
The width of each level mean the number of the tests should exist in the system.

The number of the Model tests often is the largest one, but they are more like unit tests and only in charge of a very small portion of the system.

Many teams only write the model tests and then skip the request tests part, then do the integration tests manually. If the team is "lucky", there will be a QA team to do that job.

However, as the number of features grow by time, QA will have enormous number of test suites to complete every time. QA team will burn out and there will be a lot of bugs.

By adding more request tests, we can effectively reduce the number of manual tests, it's not only for system stability but also do not let the QA team burned out... they can have more time to do more thorough test suites for the system rather than focusing on each unit feature.

If you think this article is helpful, you can buy me a coffee to encourage me 😉
Buy Me A Coffee

Extra: Request spec or Controller specs in RSpec?

RSpec does have a kind of test, Controller Spec, which is used for testing the controller actions. Why don't we just use that?
The first reason is the Request specs don't test only controllers code, but also the full stack of an HTTP request, including routing, views, rack, etc. On the other hand, the Controller specs only test Controller's code.

Another reason is rather simple, Request specs is designed to replace the Controller specs. RSpec Core team also recommends developers use the Request specs:

For new Rails apps: we don't recommend adding the rails-controller-testing gem to your application. The official recommendation of the Rails team and the RSpec core team is to write request specs instead. Request specs allow you to focus on a single controller action, but unlike controller tests involve the router, the middleware stack, and both rack requests and responses. This adds realism to the test that you are writing, and helps avoid many of the issues that are common in controller specs. In Rails 5, request specs are significantly faster than either request or controller specs were in rails 4, thanks to the work by Eileen Uchitelle of the Rails Committer Team.

References:

I also wrote Traditional Chinese version:

Top comments (3)

Collapse
 
juanvqz profile image
Juan Vasquez

it would be nice to add an example using format: :js or format: :cvs, etc...

Collapse
 
jhonnatas profile image
Jhonnatas Alencar

Amazing! THANK YOU!

Collapse
 
epigene profile image
Augusts Bautra

Very thorough!