Let’s Test It Well: Simply and Smartly

by Nastia ShaternikMay 6, 2013
Learn how to write readable tests, what the difference between mock_model and stub_model is, how to use shared_context and shared_examples, etc.

If you are fond of testing, just like our Ruby Developer Nastia Shaternik, you’ll probably be interested to read her post about using RSpec. There, she dwells on how some RSpec features that are not commonly used can help you to simplify testing and make tests clearer.

This article explains how to make tests readable as short documentation and how using mock_model can make your tests run faster. In addition, we exemplify the usage of RSpec’s built-in expectations, two strategies of sharing the same data among different examples, etc.

 

Tests as specification

I’m really fond of tests, which can be read as short documentation and expose the application’s API. To help you to cope with it, run your specs with the --format d[ocumentation] option. The output will be printed in a nested way. If you don’t understand what your code can do, you should rewrite your tests. In order not to write RSpec options every time when running specs, create a .rspec configuration file in your home or application directory. (Options that are stored in ./.rspec take precedence over options stored in ~/.rspec, and any options declared directly on the command line will take precedence over those in either file.) The .rspec file will look as shown below.

--color
--format d[ocumentation]

 

RSpec’s built-in expectations

Avoid using != and remember about should_not. To test the actual.predicate? methods, use actual.should be_[predicate].

actual.should be_true  # passes if actual is truthy (not nil or false)
actual.should be_false # passes if actual is falsy (nil or false)
actual.should be_nil   # passes if actual is nil
actual.should be       # passes if actual is truthy (not nil or false)

Please also use collection’s matchers.

actual.should include(expected)
actual.should have(n).items
actual.should have_exactly(n).items
actual.should have_at_least(n).items
actual.should have_at_most(n).items

 

mock_model vs. stub_model

By default, mock_model produces a mock that acts like an existing record (persisted() returns true). The stub_model method is similar to mock_model except that it creates an actual instance of the model. This requires that the model has a corresponding table in the database. So, the main advantage is obvious, tests written with mock_model, will run faster. Another advantage of mock_model over stub_model is that it’s a true double, so the examples are not dependent on the behavior/misbehavior or even the existence of any other code.

Usage of the mock_model method is quite simple and is illustrated below.

describe SpineData do
  let(:user) { create :user }

  before(:each) do
    SpineData.stub_chain(:controller, :current_user).and_return(user)
  end

  # ...

  context "dealing with content set" do
    let(:set) { mock_model ContentSet }

    describe ".set_updater" do
      subject { SpineData.set_updater set }

      it { should_not be_blank }
      its([:id]) { should == set.id}
    end
  end
end

 

subject and it {}

In an example group, you can use the subject method to define an explicit subject for testing by passing it a block. Now, you can use the it {} constructions to specify matchers. It’s just concise!

describe AccountProcessing do
  include_context :oauth_hash

  # ...


  context 'user is anonymous' do
    let(:acc_processing) { AccountProcessing.new(auth_hash) }

    # ...

    describe '#create_or_update_account' do
      subject { acc_processing.create_or_update_account }
      it { should be_present }
    end

    describe '#account_info' do
      subject { acc_processing.account_info }

      it 'returns valid account info hash' do
        should be == { network: auth_hash['provider'],
                       email: auth_hash['info']['email'],
                       first_name: auth_hash['info']['first_name'],
                       last_name: auth_hash['info']['last_name'],
                       birthday: nil
        }
      end
    end

  end

end

 

DRY!ness

There are two strategies—shared context and shared examples—to share the same data among different examples.

Shared context

Use shared_context to define a block that will be evaluated in the context of example groups by employing include_context. You can put settings (something in the before/after block), variables, data, and methods. All the things you put into shared_contex will be accessible in the example group by name.

Below, you can see how to define shared_context.

shared_context "shared_data" do
  before { @some_var = :some_value }

  def shared_method
    "it works"
  end

  let(:shared_let) { {'arbitrary' => 'object'} }

  subject do
    'this is the shared subject'
  end
end

Here is how you use shared_context.

require "./shared_data.rb"

describe "group that includes a shared context using 'include_context'" do
  include_context :shared_data

  it "has access to methods defined in shared context" do
    shared_method.should eq("it works")
  end

  it "has access to methods defined with let in shared context" do
    shared_let['arbitrary'].should eq('object')
  end

  it "runs the before hooks defined in the shared context" do
    @some_var.should be(:some_value)
  end

  it "accesses the subject defined in the shared context" do
    subject.should eq('this is the shared subject')
  end
end

Shared examples

Shared examples are used to describe a common behavior and encapsulate it into a single example group. Then, examples can be applied to another example group.

This is how you define shared_examples.

require "set"

shared_examples "a collection" do
  let(:collection) { described_class.new([7, 2, 4]) }

  context "initialized with 3 items" do
    it "says it has three items" do
      collection.size.should eq(3)
    end
  end

  describe "#include?" do
    context "with an an item that is in the collection" do
      it "returns true" do
        collection.include?(7).should be_true
      end
    end

    context "with an an item that is not in the collection" do
      it "returns false" do
        collection.include?(9).should be_false
      end
    end
  end
end

Below, you can see how to use shared_examples.

describe Array do
  it_behaves_like "a collection"
end

describe Set do
  it_behaves_like "a collection"
end

For more information about RSpec, you can consult with the official documentation. The book by RSpec’s creator David Chelimsky will also be helpful. Or, you can opt for these guides on Better Specs.

 

Further reading