ruby

A Deep Dive Into RSpec Tests in Ruby on Rails

Thomas Riboulet

Thomas Riboulet on

A Deep Dive Into RSpec Tests in Ruby on Rails

In our last post, we looked at the basics of RSpec and explored how well it works with Behavior Driven Development in Ruby.

Now, we will look at specific types of RSpec tests for different parts of a Ruby on Rails application.

Let's dive straight in!

Unit, Controller, and Integration Tests in Ruby on Rails

A Ruby on Rails application is composed of several layers. As the framework is built around Models, Views, and Controllers, we might think of those three as the only layers of an application. Yet, often, that's barely enough to describe a Ruby on Rails application.

Mailers, Jobs, and Helpers are secondary layers we don't want to miss. When it comes to testing, it's important to remember that these components are largely impacted by how our code has been designed. Using SOLID principles — particularly Single Responsibility — helps keep our code straightforward and more testable.

So, we usually aim to keep things simple in views, controllers, models, jobs, and mailers: we want to assemble and use the bricks of code we have designed and tested separately already. Once we enter the realm of views and controllers, testing becomes much more complex, and slower too.

Categories of Ruby Tests

Usually, we split tests into these categories:

  • Unit tests (models and other plain old Ruby objects — POROs): Ensure class methods are working as expected.
  • Controller tests: Test the expected outcome of controller actions (rendering the right template, redirections, and flash messages).
  • View tests: (Used rarely) to test specific elements or content in views.
  • Helper tests: For when you use a complex helper method.
  • Feature or system tests: Simulate user interactions in the browser — this is where Capybara will help.
  • Request tests: Test an application's response to various HTTP verbs without browser overhead. This is faster than feature tests.
  • Mailer and jobs tests: Specific tests are needed for this secondary layer to ensure emails are sent properly with the right content and jobs trigger the appropriate work.
  • Routing tests: Complex routing is a code smell. Testing routes will help you avoid trouble when you have too many routes or routes that are too complex.

What Kinds of Tests Should We Write?

Do we need to write all of these tests? It depends on your culture and team, but these might be good starting points:

  • Unit tests: Write focused and (as much as possible) database-dependent tests for your models' public methods and other POROs you craft into your application; they should run fast.
  • Controller tests: Write tests that help ensure controller actions are routable and respond as expected for each context they could be in. These should not aim to test the whole response or complex scenarios made of multiple requests.
  • Request tests: Test request scenarios from a machine client (think API controllers), typically: "for a given HTTP request context (HTTP verb, path, and parameters), what HTTP response should we get?"
  • System tests: Test scenarios of human user requests. Typically, this is where we want to test complex scenarios with multiple clicks.
  • Mailer and job tests: These should only test that a mailer and job are doing the specific "mailer" and "job" work properly. In particular, for jobs, most of the "work" should be done through classes and methods tested separately.

Controller tests are often replaced by a good layer of request and system tests, as they ultimately serve the same purpose. Too many tests will cause fatigue in your team or slow down your whole test suite for little benefit. It's up to you. The point is to test your controller layer: figure out which way is best in your case.

Let's see how unit, controller, and integration (both request and system) tests look.

Using RSpec and Ruby on Rails for Testing

As pointed out in our previous post, we can use the rspec-rails gem to integrate RSpec into a Ruby on Rails application's code base.

Unit Tests

Unit tests are at the base of the testing pyramid, so there will be numerous unit tests in a codebase. They need to be fast.

Remember: tests (with RSpec) are focused on testing code behavior, not implementation. That should help to write fast tests. In the case of Ruby on Rails projects, models are a big source of unit tests. As models are related to database access (either read or write access), those tests can have a performance impact. So be careful about reading and writing to the database for those tests. In some cases, you won't be able to avoid calling create, save, update, or find, but you can in most. You should be able to rely on new instead of create, for example.

Here is what an RSpec unit test for a Ruby on Rails model looks like:

ruby
# spec/models/user.rb require 'rails_helper' # a file generated with rspec-rails containing configuration for rspec in a rails context RSpec.describe User, type: :model do describe '#valid_email?' do subject(:user) { User.new(email: email) } context 'when email is valid' do let(:email) { Faker::Internet.email } it { expect(user.valid_email?).to be(true) } end context 'when email is not valid' do let(:email) { 'bob@example' } it 'returns false' do expect(user.valid_email?).to be(false) } end end end end

While those two examples are written a bit differently, they basically do the same thing. One has no description and reads pretty nicely still; the other one has a description but carries a bit of a duplication, don't you think?

Note that we use User.new to instantiate a user. This call doesn't require a read or write to the database. Since our valid_email? method only works with the instance's attributes, it won't slow the test down either.

Those two are good unit tests: simple, focused, and fast.

Unit tests are not just for Ruby on Rails models. They are also good for any classes you build around models and the rest of an application's architecture. We have specified the test type in the first line (RSpec.describe User, type: :model), but we can totally avoid that if we write tests for a plain Ruby class.

Controller and Request Specs

Up until recently, controller tests were the main way to test controllers. Nowadays, we tend to rely on request specs instead. Similarly to controller tests, they are designed to test an application from a machine client. They allow us to test one, or multiple, controller action/s. They are tests, so we need to define a context, then our expectation. As they are functional tests, we have to define a context composed of a given HTTP verb (get, post, put, ...), a path (/, /users, ...), parameters, and (potentially) a body. The expectation is then focused on the response (HTTP status code, response/s header/s, and body).

With request specs, it's not a matter of UI or Javascript.

A simple request spec will look like this.

ruby
require "rails_helper" RSpec.describe "User management", type: :request do it "does not render the incorrect template" do get "/users/new" expect(response).to_not render_template(:show) end end

It's simple, but you can see the context in the part doing the request: get "/users/new". In turn, the expectation itself is centered on the response. We see a new matcher here, allowing us to test if a specific template is rendered. Another one (redirect_to) allows us to test that we are properly redirected.

Request specs can also be used to test more complex scenarios.

ruby
require "rails_helper" RSpec.describe "User management", type: :request do it "creates a user and redirects to the user's page" do get "/users/new" expect(response).to render_template(:new) post "/users", params: { user: { first_name: "John", last_name: "Doe", email: "john@example.org" } } expect(response).to redirect_to(assigns(:user)) follow_redirect! expect(response).to render_template(:show) expect(response.body).to include("User John Doe (john@example.org) created.") end end

However, request specs are better adapted to test behavior from a machine client's perspective. So, request specs are best used to test an API backend's controller parts.

ruby
require "rails_helper" RSpec.describe "User management", type: :request do it "creates a user and returns the user's details" do headers = { "CONTENT_TYPE" => "application/json" } post "/users", params: { user: { first_name: "John", last_name: "Doe", email: "john@example.org" } } expect(response.content_type).to eq("application/json; charset=utf-8") expect(response).to have_http_status(:created) expect(response.body).to include() # TODO end end

While we haven't included any in this example, we can also express different contexts to test behaviors. The RSpec vocabulary and grammar still applies fully in that kind of spec. We merely use a bit more to handle the parts specific to making an HTTP request and its response.

System Specs

System specs are more complete integration tests than request ones. They are used to test an application in a real or headless browser, and require a driver for the browser (the default is Selenium; this can be changed).

System specs are similar to request specs in concept, but with improved grammar to focus on a user's actions in the browser rather than the requests being made. Here is an example.

ruby
require "rails_helper" RSpec.describe "Account management", type: :system do it "enables me to create an account" do visit "/account/new" fill_in "Name", with: "Acm'e Ltd." fill_in "Address", with: "6 Av. Elysian fields" click_button "Create Account" expect(page).to have_text("Account successfully created.") end end

Notice that here, the context (verb, path, parameters) is defined underneath but still there: we visit a page, fill out a form (which sets parameters), and then click a button (trigger a POST request). Finally, the expectation is defined as the content of the page rendered. Indirectly, it's still about testing the response's content, although the browser interprets that.

Because system specs use and drive the complete stack, they are a lot slower than other specs. As such, they should be limited in number and potentially run at particular times within the CI pipeline.

Complimentary Test Types in Ruby

As mentioned, Ruby on Rails applications are composed of a few more components with specific roles: mailers, jobs, serializers, and decorators. Those require tests as well — ones closer to unit than integration tests. It can be useful to test out specifics regarding mailers and jobs.

Mailer Specs

Thanks to ActiveMailer, Ruby on Rails has a simple way to abstract interactions with a third party that sends emails. Thus, instead of testing email sends, you can focus on testing what matters: behavior. In the case of emails, what matters is the subject, from and to addresses, and the body of the response.

ruby
require "rails_helper" RSpec.describe Notifications, type: :mailer do describe "notify" do let(:mail) { Announcements.account_renewal } it "prepares the email headers properly" do expect(mail.subject).to eq("Renewal") expect(mail.to).to eq(["company@example.org"]) expect(mail.from).to eq(["bot@example.com"]) end it "renders the body" do expect(mail.body.encoded).to match("A customer has renewed their account.") end end end

Job Specs

Similarly, job specs allow you to test specific behavior around jobs: if they have been enqueued, performed, etc. This includes testing arguments using Delayed Job.

We can be tempted to test ActiveJob's behavior. We do know that calling perform and perform_later bill queues a job; what we want to know is if, and when, that happens.

So, you should test if a job is queued in contexts requiring it. Then, test that a job properly calls upon code that has its own unit tests.

ruby
describe Building do subject(:building) { Building.new(name: 'Bank') } describe '#setup' do it 'triggers the preparation job' do ActiveJob::Base.queue_adapter = :test expect { building.setup }.to have_enqueued_job.with('setup').on_queue('low') end # alternative it 'triggers the preparation job' do ActiveJob::Base.queue_adapter = :test expect(Building::PreparationJob).to have_been_enqueued.with('setup').exactly(:once) end end end

This expresses and tests a job's enqueued behavior with certain parameters when a specific method is called.

A Note on Concerns in Ruby on Rails

Ruby on Rails has a way to factorize code used in multiple models or controllers through a disguised Ruby module called a concern. Concerns are a great way to avoid duplication of code. Make sure you test the content of concerns. Proper unit tests should also cover models. In the case of controllers, those are tested through request and system tests.

Some Thoughts on Testing with RSpec for Ruby

It's worth reiterating that your first layer of testing should be unit tests. Tests for models and other POROs should represent the vast majority of your tests in a code base.

Tests should run fast and avoid database and external services access as much as possible. To test the controller layer, request and system specs are best, respectively, for machine or human-driven activity on controller actions. They are slower than unit tests due to their inherent complexity, so there should be less of them than unit tests.

Finally, components like mailers and jobs should be used wisely and in their specific context, not to test the behavior of the lower library (like ActiveMailer and ActiveJob).

Wrapping Up

As we saw in part one of this series, we can factorize a lot of code and avoid duplication by making use of RSpec's basics: before and after hooks, let, and a proper structure built with describe and context.

In this part, we took a deep dive into testing with RSpec for Ruby, focusing on unit, controller, and integration tests.

We have explored how it's usually easy to keep unit tests small, while minimizing request and system tests is more difficult. So, after you have written (and made green) system and request specs, don't hesitate to spend some time slimming them down. This will keep them readable and easier to maintain.

Happy testing!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Thomas Riboulet

Thomas Riboulet

Our guest author Thomas is a Consultant Backend and Cloud Infrastructure Engineer based in France. For over 13 years, he has worked with startups and companies to scale their teams, products, and infrastructure. He has also been published several times in France's GNU/Linux magazine and on his blog.

All articles by Thomas Riboulet

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps