gap intelligence collects and stores information about various products for the last 15 years. This information is semi-structured and includes product price, specifications, availability, advertising at specific merchant and at certain time and other details. It can vary depending on the nature of the product, its category and the customers' needs, what reflects on design of our applications, communication between them and releasing new version. Therefore, our devteam pays a lot of attention to testing. In this article, I want to share the experience gained by us during the writing of tests. This post can be useful for beginners and experienced programmers.

Computer screen with code

The set of tools we use for testing is quite diverse. However, today I would like to write about how to efficiently write tests using RSpec. For convenience, I will use factory_bot (also known as factory_girl).

Suppose we have OnlineStore class. We will not consider the implementation of this class and its methods, and focus only on the tests. I'll talk about how you can improve the readability of your tests, make them stable and independent of implementation.

# Gemfile

source 'https://rubygems.org'

gem 'rspec', '~> 3.7.0'
gem 'factory_bot', '~> 4.8.2'

Described class and named subject

Lets imagine that we have method to access to store name, this method takes no arguments and returns a string.

Rspec.describe OnlineStore do
  subject(:my_store) { described_class.new(name: 'My Store') }

  describe '#name' do
    it 'returns store name' do
      expect(my_store.name).to eq('My Store')
    end
  end
end

In this snippet, we declared online_store using the explicit subject. The specified subject allows you to refer to the declared value by name. Since rspec allows to override the subject per describe or context, the use of the name makes it easier to read and understand the example. At the same time it allows to use short matchers and refer to the subject in shared examples.
The declaration also specifies described_class to refer to the class that we are describing. Such a record may seem not obvious that we are referring to a class, because it looks like a variable name. However, it guarantees that you will test exactly the objects of the described class. Nothing stops us to do the following:

Rspec.describe OnlineStore do
  # Instantiating object of different class than described
  subject(:my_store) { Product.new }
end

Dynamic scopes

Now consider available_products_count, which queries the database and returns the count of available products. Creating records in the database will be indicated by create method of factory_bot.

describe '#available_products_count' do
  let!(:product) { create(:product, status: 'In Stock') }

  it 'counts In Stock products' do
    expect(my_store.available_products_count).to eq(1)
  end
end

We will extend this example. Suppose that if product's status is Out Of Stock, the result must be 0.

describe '#available_products_count' do
  context 'with In Stock status' do
    let!(:product) { create(:product, status: 'In Stock') }

    it 'counts product' do
      expect(my_store.available_products_count).to eq(1)
    end
  end

  context 'with Out Of Stock status' do
    let!(:product) { create(:product, status: 'Out of Stock') }

    it 'ignores product' do
      expect(my_store.available_products_count).to eq(0)
    end
  end
end

product is defined in both contexts with different status. To avoid repetition, product can be declared in describe, while keeping status different for both contexts.

describe '#available_products_count' do
  let!(:product) { create(:product, status: status) }

  context 'with In Stock status' do
    let!(:status) { 'In Stock' }

    it 'counts product' do
      expect(my_store.available_products_count).to eq(1)
    end
  end

  context 'with Out Of Stock status' do
    let!(:status) { 'Out Of Stock' }

    it 'ignores product' do
      expect(my_store.available_products_count).to eq(0)
    end
  end
end

This type of record improves readability due to the fact that in each context only key variables are declared that will affect the test result. This approach can be used to construct any data structure and redefine only the necessary values.

let(:value_1) { 1 }
let(:value_2) { 5 }
subject(:hash) { { key_1: value_1, key_2: value_2 } }

context do
  let(:value_1) { 3 }
  it { is_expected.to eq(key_1: 3, key_2: 5) }
end

context do
  let(:value_2) { value_1 + 1 }
  it { is_expected.to eq(key_1: 1, key_2: 2) }
end

Test collections

Now suppose we have out_of_stock_products method that returns products unavailable in the store. This method queries the database and returns an array of products. Depends on what database we use if ORDER clause is not specifed explicitly, results can be returned in different order. In this case, the usage of eq or match can randomly fail, since these matchers require the compared arrays have the same order of objects. For the result of out_of_stock_products method the order of the elements is not important as the existence of an object in an array. By this said, contain_exactly can be used alternatively, which is less efficient than eq, but will make your example stable.

it 'returns Out of Stock products' do
  expect(my_store.out_of_stock_products).to contain_exactly(product_1, product_2)
end

Next method report_for_date takes a date and returns hash of stastics for the specific date. The result contains store name, date reported for and count of products sold that day.

it 'returns report for the date' do
  expect(my_store.report_for_date('2018-04-01')).to eq(
    name: 'My Store', date: '2018-04-01', products_sold: 10
  )
end

The resulted hash depends on store name. Whenever store name value is changed, this example will need to be updated, although no change is made directly in report_for_date implementation. To avoid this dependency, eq can be replaced with include.

it 'returns report for the date' do
  expect(my_store.report_for_date('2018-04-01')).to include(
    date: '2018-04-01', products_sold: 10
  )
end

If the result of the method is an array of hashes, matchers can be combined.

it 'returns report for the date range' do
  expect(my_store.report_for_date_rage('2018-04-01', '2018-04-02')).to contain_exactly(
    hash_including(date: '2018-04-01', products_sold: 10),
    hash_including(date: '2018-04-02', products_sold: 12)
  )
end

Explicit before() callback

Consider products_by_category, which takes a category object and returns an array of products associated with that category. In before(:each) callback we want all products to be assigned to defined category. We expect the block to execute before each example starts.

describe '#products_by_category' do
  let!(:category) { create(:category) }

  before { Product.all.each { |product| product.update(category: category) } }

  context 'with exisiting products' do
    let!(:products) { create_list(:product, 3) }

    it 'returns array of products' do
      expect(my_store.products_by_category(category)).to match_array(products)
    end
  end

  context 'with no products' do
    let!(:products) { [] }

    it 'returns empty array' do
      expect(my_store.products_by_category(category)).to be_empty
    end
  end
end

In this case before block is deceptive. Despite the fact that products is defined using let!, creation of products will occur after before block execution. To avoid confusion and be sure that products assigned to category, we need to call that block explicitly in the example.

it 'returns array of products' do
  Product.all.each { |product| product.update(category: category) }
  expect(my_store.products_by_category(category)).to match_array(products)
end

Assigning category to products can also be declared using let and wrapped with Proc expression. Usage of let will keep your test DRY and describe what the proc does. Calling Proc in the example will make execution explicit.

let!(:category) { create(:category) }
let(:assign_category_to_products_proc) { -> { Product.all.each { |product| product.update(category: category) } } }

context 'with parent' do
  let!(:products) { create_list(:product, 3) }

  it 'returns array of products' do
    assign_category_to_products_proc.call
    expect(my_store.products_by_category(category)).to match_array(products)
  end
end

The number of ways to keep tests healthy is much greater. In this article, I reviewed the common techniques for writing tests, which allow us to maintain test readability, prevent random failures, and avoid duplication of code and changes in specs if the implementation changes.

Want to learn more? Email info@gapintelligence.com.