Rails tests with RSpec: good approaches

#RubyOnRails

There are a lot of ways how to write good tests, but there are always some specific cases which can’t be easily described with widely-used guides. And of course you always need to keep in mind a lot of things when you writing tests for enterprise applications. So I’ve put the thoughts in a 6-point-list on the good approaches to write better tests and I want to share it with you.


First of all, let’s start with the ground rules. I would prefer to stub all the things. Yes, it’s a little bit too aggressive, but it’s about unit tests, after all. I’m pretty convinced that mocking is good, of course, you have to have integration tests (assuming, that everybody knows about the testing piramide or the testing trophy for frontenders).


I haven’t noticed a lot of enterprise projects with TestUnit, Minitest or ActiveSupport::Testing. So, I will mention RSpec as a standard for Rails testing. RSpec provides a lot of different APIs to allow testing your code at any layer. I would recommend using rubocop-rspec to adopt community best practices on writing good test. But, unfortunately static syntax analysis isn’t just enough.


So, here are the top-6 good approaches we use to write better tests with RSpec


1. Use FactoryBot for models

Prefer to use FactoryBot over instance_double with model class. FactoryBot provides a full-featured model with attributes, columns and so on. build, build_stubbed and even new methods prepare a Model stub, not a generic class double.

# not ok
instance_double(User)

# ok
 build_stubbed(:user)
User.new

2. Use correct type of test-doubles

It’s better to avoid usage strings as an argument for instance_double or class_double. In the first example, developer stubs a method (namely non_existing_scope) which does not exist, but RSpec is unable to detect contract vialoation. Better approach is to use class_double with relation class.

# not ok
let(:users) { instance_double('user_scope', first: user) }
 expect(users).to receive(:non_existing_scope).and_return(users)

# ok
 let(:users) { class_double(User, first: user) }
expect(users).to receive(:non_existing_scope).and_return(users)
#=> the User class does not implement the class method: non_existing_scope

3. Use doubles declaration to stub calls

Prefer not to overuse expect(smth).to receive(smth) construction. The main idea is to use double definitions instead of mocking it in before block. It makes definition more concise and respects code locality principle.

# not ok
 let(:users) { class_double(User) }
before { expect(users).to receive(:first).and_return(user) }

# ok
let(:users) { class_double(User, first: user) }

4. Group stubs definitions

It may be a good idea to separate DB-related stubs and other service stubbing. You can clearly see that DB-related stubs are at the top group and services stubs are in the group bellow.

let(:document) { build_stubbed(:document, owner: user) }
let(:user)     { build_stubbed(:user) }

let(:very_imp_service) { instance_double(VeryImpService, call: 1) }
let(:less_imp_service) { instance_double(LessImpService, full?: true) }

5. Do not use any gem to emulate workers calls

There is no need to use any libs to run simple workers (especially with ActiveJob). Workers are just Ruby classes, right?

# not ok
Sidekiq::Testing.inline! { described_class.perform_async(document_id) }

# ok
described_class.new.perform(document_id)

6. Do not stub Time/Date

There is no need to use Timecop. First reason is — you can use default Rails’ travel_to. The second reason (more important one) is that you can use the dependency injection pattern for this. Moreover, some libs relay on Time class to measure tests duration.

class User
  def initialize(birthday)
    @birthday = birthday
  end

  def age
    (Time.zone.now - @birthday) / 1.year
  end
end

TIME = Time.new(2010, 1, 1)

# not ok
Timecop.freeze(TIME) { expect(use.age).to eq(20) }

# a little bit not ok
travel_to(TIME) { expect(use.age).to eq(20) }

# ok
def age(today: Time.zone.now)
  (today - @birthday) / 1.year
end
expect(use.age(today: TIME)).to eq(20)

Summary

There are a lot of ways to write good tests and neither of them is ideal. The ones I have listed look pretty reasonable and allow you to write more maintainable tests. And of course, use the ideas I have mentioned above and share your ideas on how to improve them.