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.
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 ```ruby
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
### 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
```ruby
RSpec.configure do |config|
config.include Rails.application.routes.url_helpers, type: :request
# ...
end
- You can add a
_spec.rb
file underspec/
and add atype: :request
to indicate it is a Request spec ```ruby
RSpec.describe "/some/path", type: :request do
# spec content
end
2. 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,
```ruby
# 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}"}
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}
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)
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
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
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
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
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
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.
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:
(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 😉
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:
- https://relishapp.com/rspec/rspec-rails/v/5-0/docs/request-specs/request-spec
- RSpec 3.5 has been released!
I also wrote Traditional Chinese version:
Top comments (4)
it would be nice to add an example using
format: :js
orformat: :cvs
, etc...Amazing! THANK YOU!
Very thorough!
Thank you for your article! It's full of great real world examples!