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