DEV Community

Cover image for Introduce RSpec Request Spec
LuoKevin
LuoKevin

Posted on • Updated on

Introduce RSpec Request Spec

I also wrote Traditional Chinese version:


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 Request specs and why I recommend to use it?

A web application, in some perspectives, is focused on the interactions of requests and responses.
Although we normally have pretty good model tests coverage in Rails (Am I right?), which only promises us there is no wrong data written into database. The most important part, what will happen when requests delivered to the Rails server?, is not tested.

It's pretty common to see that developer teams do manually tests by themselves or ask QA team to make sure the features function as expected.
For a small or short-term projects, to be honest, it's not really an issue.
But for a long-term project, if we still deliver our code in that way, I'm afraid there will be many errors very often after a new version deployed.

Even we have the model tests coverage is 100%, it doesn't mean those methods can cooperate wonderfully when we combine them to do a complex business logic.
An Failed example of system
Above is an example of wrong input parameters. The machine and the garbage bin worked fine but the input was unexpected. They just didn't handle that exception.

And I think to write request specs can help developers being more confident when deploying their code.

Request spec or Controller specs in RSpec?

RSpec does have a kind of test, Controller Spec, which is used for testing the controller actions.
So why we don't just use that?
The first reason is request tests does not test only the controllers code, but also the full stack of a HTTP request, including, routing, views even the rack.

Another reason is simple, RSpec Core team recommends developers to use Request test:

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.

What's special of request spec?

  • test 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 NOT to use request test

  • you want to test JS rendered DOM, because request spec doesn't execute JS code
  • The goal of 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.

On the both cases above, you should use Capybara to do End-to-end test.
However, for a regular application, I think it can cover fairly enough cases.

Usage

I don't cover how to setup RSpec environment to a Ruby OR Rails application since it's another topic.

Setup

We can include routes helper that we can use 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 and 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 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 most important things in the auto test is the assertion or the expectation.
And for the request spec, we can do the "expect" both for the response and the things being done during the controller action.
Compare to the unit test, I think request test 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.
The response object 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

How the do more detailed DOM assertions

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

Variables accessible during the spec example

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

How to Integrate with Devise

We can use Devise helper if we use Devise to do the authentication.
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 exmaple
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 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

My personal experience

To be honest, I rarely added the request tests before.
One reason is that I think the model methods is the place where true logic lives in. The controller just brings the results of the model methods into views.
Why do I need to test that? That should be the responsibility of the framework, Rails, itself.
But what I thought is a very ideal situation, the real situation is often mixed with complex views and interactions of model methods.
Besides, Ruby is not a strong typed language, so even the code has some problems due to the wrongly commands you wrote in the controller's actions or ERB, it doesn't raise errors until it really executes.

Rails default 500 page

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

Anyway, we can prevent this kind of things happen that often by writing the request test.
Request tests chain all the parts of your system and test them all at once: routes, controller actions, rendered views and http status.
So if the request test pass, the corresponding request should also succeed when it happens in a real browser.

I also found that the request specs generated by the rails scaffold generator is a good way to write request spec. I copy the the request 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 found it's 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 situtaion.
        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

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.

References:

Discussion (0)