Testing in Rails

Abhishek Yadav

@h6165

Testing models

Testing models

  • Sometimes call unit tests. But model tests are not always unit tests

Testing models: no Db

# Within Post model

def summary
  title + "-" + body[0..20]
end

## post_spec.rb

it "should be *Fixing Rails-This should be easy*" do

  post = Post.new(title: 'Fixing Rails', body: 'This should be easy')
  expect(post.summary).to eq('Fixing Rails-This should be easy')
end

Testing models: with Db

# Within Post model

def self.create_sample
  create(title: 'Sample', body: '')
end

## post_spec.rb

it "should create a post with title of sample" do

  p1 = Post.find_by_title('Sample')
  Post.create_sample
  p2 = Post.find_by_title('Sample')

  expect(p1).to be_nil
  expect(p2.title).to eq('Sample')
end

## Other ways

Testing models - with associations

# Within Post model

 def self.having_comments
   Post.where(id: Post.joins(:comments).select('posts.id'))
 end

## post_spec.rb

it "should give posts having comments" do

  p1 = Post.create(title: 'p1', body: 'p1-body')
  p2 = Post.create(title: 'p2', body: 'p2-body')

  3.times{ p1.comments.create(body: '+1', author_name: "author-#{rand}") }

  ps = Post.having_comments
  expect(ps).to include(p1)
  expect(ps).to_not include(p2)
end

Testing model - with associations

## post.rb

def self.posts_about_rails
  Post.where("title LIKE %rails%")
end


## post_spec.rb

it "it shows posts with 'rails' in title" do

  p1 = Post.create(title: 'Rails refactoring', body: 'sample-body')
  p2 = Post.create(title: 'TDD in Ruby', body: 'sample-body')
  p3 = Post.create(title: 'Patterns in Rails', body: 'sample-body')

  ps = Post.posts_about_rails
  expect(ps).to include(p1, p2)
end

2. Data creation with Factories

Simplify data creation with Factories

  • Factories are one way to create data for tests 
  • Data creation can become difficult when there are validations or there are too many fields

  • Gems: factory_girl, machinist, fabrication

  • Rails default is Fixtures 

  • Factories are: declared once, use everywhere

# Simple example


factory :post do
  title 'sample-title'
  body 'This is the body of the sample post'
end



## Usage: post_spec.rb

it "it shows posts with 'rails' in title" do

  p1 = create(:post, title: 'Rails refactoring')
  p2 = create(:post, title: 'TDD in Ruby')
  p3 = create(:post, title: 'Patterns in Rails architecture')

  expect(Post.posts_about_rails).to include(p1, p2)
end

Simplify data creation with Factories

## Slightly more evolved: creating associated data

factory :post_with_comments, class: Post do
  title 'sample-title'
  body 'This is the body of the sample post'
  after_create do |post, evaluator|
    3.times do
      post.comments.create(body: 'sample comment', author_name: 'Abhi')
    end
  end
end

## Usage: post_spec.rb

it "should give posts having comments" do

  p1 = create(:post_with_comments)
  p2 = create(:post)

  ps = Post.having_comments
  expect(ps).to include(p1)
  expect(ps).to_not include(p2)
end

Simplify data creation with Factories

# Creating associated data using another factory
## spec/factories/comments.rb

factory :comment do
  body: 'sample-comment'
  author_name: 'sample-author'
end

## spec/factories/posts.rb

factory :post_with_comments, class: Post do
  title 'sample-title'
  body 'This is the body of the sample post'
  after_create do |post, evaluator|
    3.times do
      FactoryGirl.create(:comment, post_id: post.id)
    end
  end
end

Simplify data creation with Factories

# Kitchen sink: for User model

factory :registered_user, class: User do

 name 'example-name'
 date_of_birth 35.years.ago
 gender 'Male'

 # unique email using sequence
 sequence(:email) { |n| "registered#{n}@example.com" }

 # using variables
 password 'example-password'
 password_confirmation { |u| u.password }

 # Creating belongs_to association
 company { create(:company) }

 # Callbacks
 after(:create) do |user, e|
   user.add_role(:general_user)
 end
end

Simplify data creation with Factories

3. Mocks and stubs

Mocks an stubs: the need

  • Simulating difficult behaviour
    • A complex query involving several models
    • A slow step (like external API calls)
    • A cost ($) sensitive step (API call involving a paid service )

 

  • Simplifying dependent objects
    • Our code could be dependant on objects of other types (class/model).

    • While writing tests, we have to make sure these objects are created and configured correct to have the desired outcome

       

# The syntax: allow


allow(user).to receive(:send_registration_email).and_return(true)


allow(address).to receive(:geocode) { [13.5, 81.5] }


allow(deep_thought).to \
  receive(
    :answer_to_the_ultimate_question_of_life_the_universe_and_everything
  ).and_return(42)

Mocks and stubs: syntax

# The syntax: double

post_double = double(title: "Im a double")
allow(comment).to receive(:post).and_return(post_double)


allow(comment).to receive(:user){ double(username: "abhi") }

Mocks and stubs: syntax

# The syntax: stub at class level

allow_any_instance_of(Address)
  .to receive(:geocode)
  .and_return([13.5, 81.5])

Mocks and stubs: syntax

# Method under test: (within User model)
def send_email
  UserMailer.registered(self).deliver!
end

def complete_registration
  self.state = 'registered'
  self.save

  send_email
end

# In this test case, we are only checking the value of state
# We can have a separate test for email delivery,
# but in this one, we are not concerned with the email part

it 'should mark the state as registered' do

  allow(user).to receive(:send_email).and_return(true)  # <= here

  user.complete_registration
  expect(user.state).to eq('registered')
end

Mocks and stubs: stubbing email

# Summary of comments, contains the user's name,
# and the first few characters of the comment body
#

def comment_summary
  post.title + "-" +
  self.body[0..10]
end


it "shows post title in comment summary" do
  c = Comment.new(body: 'Hello')

  allow(c).to receive(:post){ double(title: 'abc') }

  expect(c.comment_summary).to eq('Hello-abc')
end

Mocks and stubs: dependent objects

Mocks and stubs: definitions

Test double Object that stands for the real object.
Much like 'stunt double'.
Stub Object with a method overridden to return fake value
Mock Object with a method overridden to return fake value, and the method must be called
Spy Test double + testable expectation later

Note: 

Definitions are not standardised, so this as a general idea at best

def send_email
  UserMailer.registered(self).deliver!
end

def complete_registration
  self.state = 'registered'
  self.save
  send_email
end

# We want to make sure 'send_email' is invoked,
# even if email is not actually sent
# This test fails if we remove the 'send_email' call from above code

it 'should try sending the email' do

  expect(user).to receive(:send_email)
  user.complete_registration
end

Mocks and stubs: mock example

# Complex example: within the User model

## Create Location record, geocode it, and associate it
def update_locations(country, state, city)
  if !country.blank?

  l = Location.where(city: city, state: state, country: country).last
  l = Location.new(city: city, state: state, country: country)

  if l.latitude.nil? || l.longitude.nil?
    lat, lng = Geocoder.coordinates("#{country}, #{state}, #{city}")
    if lat && lng
      l.latitude = lat
      l.longitude = lng
    end
  end

  l.save!

  # Associate the Location with partner
  ul = UserLocation.where(user_id: self.id, location_id: l.id).last
  if ul.nil?
    ul = UserLocation.new(user_id: self.id, location_id: l.id)
    ul.save!
  end
end

Mocks and stubs: use in complex code

## Test case 1
## Location does not exist, Geocoding is possible

it "test-1" do
  user = User.new
  allow(user).to receive(:id).and_return(1)
  allow(Geocoder).to receive(:coordinates).and_return([13, 83])

  user.update_locations('India', 'Tamil Nadu', 'Chennai')

  location = Location.last
  expect([location.latitude, location.longitude]).to eq([13, 83])
end

Mocks and stubs: use in complex code

Here we stub the Geocoder to return fake values we can use.

We also stub the partner#id to avoid saving it (it is used in the last part of the code)


## Test case: 2
## Location exists, Geocoding should not be invoked

it "test-2" do
  user = User.create
  Location.create(country: 'India', state: 'Tamil Nadu', city: 'Chennai')

  expect(Geocoder).to_not receive(:coordinates)
  user.update_locations('India', 'Tamil Nadu', 'Chennai')
end

Mocks and stubs: use in complex code

Here we mock the Geocoder to make sure it is not called.

Mocks and stubs: too much mocking

  • Too much mocking is not good because its hard to follow the code. Its hard to differentiate what is tested and what is mocked.
  • If creating test-doubles starts taking too much effort, its better to create real objects

Thats it. Thanks

 

@h6165

Workshop: Testing in Rails

By Abhishek Yadav

Workshop: Testing in Rails

  • 1,106