In this post, I will describe different techniques I generally use for testing Ruby on Rails apps. I’m going to use as an example a simplified version of a blogging platform we have developed for a client. The app basically allows users to manage and publish stories, which are posts, but more complex – e.g. Stories are versioned. To simplify the examples I will leave complexities aside and talk about stories as if they were posts.
Note: I’m assuming that you already know the importance of automated testing, so I’m not arguing its importance here.
Diving into the details, let’s start with the Story model:
class Story < ActiveRecord::Base scope :drafts, -> { where(draft: true) } end
Within the Story model we have a scope called “drafts”, that returns all the stories from our database which are drafts (have the attribute draft set in true). By default stories are not drafts. Now that we have defined the scope, we need to think about all the possible scenarios that need to be tested.
If we take a look at the scope, it’s easy to realize that we have two possible situations:
The first one is when the story is a draft and the other one is when it’s not. Hence, we can divide our tests like this:
require 'rails_helper' describe Story do describe '.drafts' do subject { described_class.drafts } context 'when it is a draft' do end context 'when it is not a draft' do end end end
Now that we have a clearer idea of the possible scenarios, we can start adding in tests. In this case, since each scenario (context) will only have one test, we can take advantage of the “let!” method.
Note: using the let! method might not always be considered as a good practice, because it generates a before hook and then executes and memorizes the content of the given block. Thus, for each test we will have the block content available, and if it’s a database query it will be executed several times, even if we don’t need it.
In our case we can easily realize that we won’t have more tests per scenario, so we can use let! without worrying about the performance.
Let’s create the tests:
require 'rails_helper' describe Story do describe '.drafts' do subject { described_class.drafts } context 'when it is a draft' do let!(:story) { create(:story, draft: true) } # FactoryGirl with sugar syntax it { expect(subject).to include(story) } end context 'when it is not a draft' do let!(:story) { create(:story, draft: false) } it { expect(subject).to_not include(story) } end end end
In this example our tests are based on data, meaning that they rely on database queries. If our method implementation changes, the tests will still pass, as long as the result is the expected one. These tests are recommended for model methods handling data, changing the model status or, like in this case, for database queries.
Now we can create stories and make them “draft” or “published”, but we are not done yet. Having a story without a cover photo will not look nice. So our next step will be adding the ability for a story to have one.
We will do some photo processing, to make sure that all the cover photos follow the same standard:
class Story < ActiveRecord::Base scope :drafts, -> { where(draft: true) } # More code here # The cover is processed in the background. Before the transformations # finish, the original cover is returned. Once the process have # finished, a reduced version of the transformed cover is returned. # # @note The cover could not exist. In such a case, the fallback url will be # returned when we call cover.url. def cover_photo_url if cover_photo.is_processed? cover_photo.normal.url else cover_photo.url end end # More code here end
This is a more complex situation: we have a few scenarios when the cover photo is processed and some more when it is not. Furthermore, the cover photo could even not exist.
Let’s start with the contexts we need to define. A good approach could be defining one case for when the cover photo hasn’t been fully processed yet, and another case for when the background processing has completed. For the first case, we have two scenarios, depending on whether the cover photo exists or not.
Contexts definition:
require 'rails_helper' describe Story do let(:story) { create(:story) } describe '#cover_photo_url' do subject { story.cover_photo_url } context 'when cover exists' do pending 'returns the fallback url' end context 'when cover does not exist' do pending 'returns cover url' pending 'returns cover processed url if is processed' end end
Now that our contexts and test cases are well defined, we can start implementing them:
require 'rails_helper' describe Story do let(:story) { create(:story) } describe '#cover_photo_url' do subject { story.cover_photo_url } context 'when cover exists' do it 'returns the fallback url' do expect(subject).to include('fallbacks/cover_photo/default.png') # This is the path to the default image. end end context 'when cover does not exist' do it 'returns cover url' do story.update(cover_photo: File.new(Rails.root.join('spec', 'support', 'files', 'image.jpg'))) expect(subject).to include('story/cover_photo/image.jpg') end it 'returns cover processed url if is processed' do story.update(cover_photo: File.new(Rails.root.join('spec', 'support', 'files', 'image.jpg'))) # Run all background jobs to process the avatars ... expect(subject).to include('story/cover_photo/image_processed.jpg') end end end
This is, once again, the same pattern: tests based on data.
Now our model is ready to create stories, but we still need to define an endpoint to allow our users to create a story. For this purpose, we will create a new controller.
Testing controllers
To test controllers we usually create “stubs”.
Our controller will look like this:
class StoriesController < ApplicationController respond_to :html def create @story = CreateStoryService.run(current_user, story_params) respond_with @story end private def story_params params.require(:story).permit(:content, :draft, :cover_photo) end end
The class “CreateStoryService” is responsible for creating a story for the given user, and then returning the newly created story.
Note: We are assuming that the person who created this class, also wrote the proper tests for it. Once again, I will only focus on what I actually want to test and assume that the rest is safe and sound.
Let’s continue by defining all the possible scenarios for this controller:
require 'rails_helper' describe StoriesController do let(:user) { create(:user) } describe '#create' do # Here we use shared_examples_for because we can have multiples roles that # behave the same way for this action, and we don't want to repeat the tests. shared_examples_for 'a logged out user' do pending 'should not be able to create stories' end shared_examples_for 'a logged in user' do pending 'should be able to create stories' pending 'should ignore incorrect params' pending 'should correctly respond after a story is created' pending 'should handle the errors correctly' end context 'when user is not logged in' do it_should_behave_like 'a logged out user' end # We can have other roles and the context will be similar but # instead of sign_in the user we can sign_in an admin, for # example, and have the same behaviour. context 'when user is logged in as a normal user' do # Assume that the sign_in method will login the user and stub that user # on the controller. That way we can refer to it on the tests. before { sign_in user } it_should_behave_like 'a logged in user' end end end
In this case, we will not base our tests on data. If we did so, we would end up duplicating the same logic several times. Instead, we are going to stub the method like this:
Let me show you how.
require 'rails_helper' describe StoriesController do let(:user) { create(:user) } describe '#create' do # Here, we will use shared_examples_for because we can have multiples roles that # behave the same for this action, and we don't want to repeat the tests. shared_examples_for 'a logged out user' do it 'should not be able to create stories' do expect { post(:create, {story: {content: 'Some content', draft: true}}) }.to_not change(Story, :count) expect(response).to redirect_to(root_url) end end shared_examples_for 'a logged in user' do it 'should be able to create stories' do valid_params = {content: 'Some content', draft: true} # With this we can make sure that the correct Service is being called # with the correct parameters. We leave the job to the Service # anyway to make sure that the service exists with those parameters. expect(CreateStoryService).to receive(:run).with(valid_params).and_call_original expect { post(:create, {story: valid_params}) }.to change(user.stories, :count).by(1) # In this test we can make sure a post is created and for the correct user. end it 'should ignore incorrect params' do valid_params = {content: 'Some content', draft: true} invalid_params = {some: 'Some'} expect(CreateStoryService).to receive(:run).with(valid_params).and_call_original post(:create, {story: valid_params.merge(invalid_params)}) end it 'should correctly respond after a story is created' do valid_params = {content: 'Some content', draft: true} expect(CreateStoryService).to receive(:run).with(valid_params).and_call_original post(:create, {story: valid_params}) expect(response).to redirect_to(assigns[:story]) end it 'should handling the errors correctly' do params = {draft: true} # Assume that the story must have a content. expect(CreateStoryService).to receive(:run).with(params).and_call_original post(:create, {story: params}) expect(assigns[:story].valid?).to be_falsey expect(response).to render_template("new") end end context 'when user is not logged in' do it_should_behave_like 'a logged out user' end # We can have other roles and the context will be similar but # instead of sign_in the user we can sign_in an admin, for # example, and have the same behavior. context 'when user is logged in as a normal user' do # Assume that the sign_in method will login the user and stub that user # on the controller, that way we can refer to it on the tests. before { sign_in user } it_should_behave_like 'a logged in user' end end end
If we repeat these techniques all over our app, we’ll be several steps closer to getting a well-tested and high-quality code.
Hope you liked it! Next time, we’ll look at how to test AngularJS applications, stay tuned!