Testing Ruby with Rspec - Vysakh Sreenivasan (vysakh0)
I was like this before
This is how I met my girlfriend
Then this happened :-/
promise me never to execute the program to see the output i.e ruby file_name.rb Before we start testing
Pinky promise? Yes?
$ mkdir ruby_testing $ cd ruby_testing $ mkdir lib Open terminal & prepare env
$ bundle init $ bundle inject rspec, 3.2 $ bundle --binstubs $ rspec --init Create Gemfile. Insert & install rspec
4 phases of testing
- Setup - Exercise - Verify - Teardown (Testing framework does it for us, duh!)
# setup user = User.new(name: 'yolo') #expect user.save # verify expect(user.name).not_to be_nil
Wth is expect? Hmm, it is a matcher?
Describe the method or class you want to test
# lib/user.rb class User end # spec/user_spec.rb require ‘spec_helper’ require ‘user’ RSpec.describe User do end
# lib/sum.rb def sum(a, b) a + b end # spec/sum_spec.rb require ‘spec_helper’ require ‘sum’ RSpec.describe ‘#sum’ do end
Run the specs by the cmd rspec
# lib/calc.rb class Calc def sum(a, b) a + b end end # spec/calc_spec.rb require ‘spec_helper’ require ‘calc’ RSpec.describe Calc do describe ‘#sum’ do end end
Tip - use Describe strictly for class & methods - describe “ClassName” do end - describe “#instance_method” do end - describe “.class_method” do end
describe tells who is tested We need something to Tell what we are testing of it
it ‘gives sum of 2’ do end # or it {}
require ‘spec_helper’ require ‘calc’ RSpec.describe Calc do describe ‘#sum’ do it “returns sum of 2 numbers” do end end end
Lets apply 4 phases of testing
require ‘spec_helper’ require ‘calc’ RSpec.describe ‘Calc’ do describe ‘#sum’ do it “returns sum of 2 numbers” do calc = Calc.new # setup result = calc.sum(2, 3) # exercise expect(result).to eql(5) # verify end end end
require ‘calc’ RSpec.describe Calc do describe ‘#sum’ do it “returns sum of 2 numbers” do calc = Calc.new # setup expect(calc.sum(2, 3)).to eql(5) # exercise & verify end end end
For brevity in the slides, I’m gonna leave the require ‘spec_helper’
Tip - it statement - don’t use “should” or “should not” in description - say about actual functionality, not what might be happening - Use only one expectation per example.
(lets just take a quick look) Matchers
expect(num.odd?).to be false #=> num = 2 expect(user.email).to be_falsey #=> user.email = nil expect(num).to be >= 3 #=> num = 5
expect(str).to match /testing$/ #=> str = “Welcome to testing” expect(tiger).to be_an_instance_of(Tiger) #=> tiger.class => Tiger expect(tiger).to be_a(Cat) #=> tiger.class.superclass => Cat
expect { sum }.to raise_error ArugmentError #=> sum(a, b) expect(person).to have_attributes(:name => "Jim", :age => 32) #=> person.name => Jim
expect(list).to start_with(36) #=> list = [36, 49, 64, 81] expect(name).to start_with(‘M’) #=> name = “Matz” expect(list).to end_with(81) #=> list = [36, 49, 64, 81] expect(name).to end_with(‘z’) #=> name = “Matz”
expect("a string").to include("str") expect([1, 2]).to include(1, 2) expect(:a => 1, :b => 2).to include(:a, :b) expect(:a => 1, :b => 2).to include(:a => 1)
expect([1, 2]).not_to include(1) expect(name).not_to start_with(‘M’) #=> name = “DHH expect(tiger).not_to be_a(Lion) #=> tiger.class => Tiger
expect([1, 3, 5]).to all( be_odd ) # it is inclusive by default expect(10).to be_between(5, 10) # ...but you can make it exclusive: checks in range 4..9 expect(10).not_to be_between(5, 10).exclusive # ...or explicitly label it inclusive: expect(10).to be_between(5, 10).inclusive
expect([1, 2, 3]).to contain_exactly(2, 1, 3) #=> pass expect([1, 2, 3]).to contain_exactly(2, 1) #=> fail expect([1, 2, 3]).to match_array [2, 1, 3] #=> pass expect([1, 2, 3]).to match_array [2, 1] #=> fail
Normal equality expectations do not work well for floating point values expect(27.5).to be_within(0.5).of(28.0) expect(27.5).to be_within(0.5).of(27.2) expect(27.5).not_to be_within(0.5).of(28.1) expect(27.5).not_to be_within(0.5).of(26.9)
There are aliases for matchers use it based on the context
a_value > 3 be < 3 a_string_matching(/foo/) match(/foo/) a_block_raising(ArgumentError) raise_error(ArgumentError) See this gist for more aliases https://gist.github.com/JunichiIto/f603d3fbfcf99b914f86 Few matchers and their aliases
is_expected.to same as expect(subject).to
require ‘calc’ RSpec.describe Calc do it { expect(Calc).to respond_to(:sum) } end
require ‘calc’ RSpec.describe Calc do it { expect(subject).to respond_to(:sum) } end
require ‘calc’ RSpec.describe Calc do it { is_expected.to respond_to(:sum) } end
Compound matchers using and, or
expect(str).to start_with(“V”).and end_with(“h”) #=> str = “Vysakh”
expect(stoplight.color).to eq("red").or eq("green").or eq("yellow") #=> stoplight.color ⇒ “yellow”
change matcher
# lib/team.rb class Team attr_accessor :goals def score @goals += 1 end end
require ‘team’ RSpec.describe Team do describe ‘#score’ do it ‘increments goals’ do team = Team.new expect { team.score }.to change(team, :goals).by(1) end end end
x = y = 0 expect { x += 1 y += 2 }.to change { x }.to(1).and change { y }.to(2)
Composable matchers
s = "food" expect { s = "barn" }.to change { s }. from( a_string_matching(/foo/) ). to( a_string_matching(/bar/) )
expect(arr).to match [ a_string_ending_with("o"), a_string_including("e") ] #=> arr = [“bozo”, “great”]
Magical(predicate) Matchers
expect(0).to be_zero #=> 0.zero? ⇒ true expect(2).to be_even #=> 2.even? ⇒ true expect(me).to have_job #=> me.has_job? ⇒ true
Scenarios of methods or Contexts
# lib/duh.rb def duh(num) if num.odd? “mumbo” else “jumbo” end end
require ‘duh’ RSpec.describe ‘#duh’ do it ‘says mumbo if number is odd’ do expect(duh(3)).to eq “mumbo” end it ‘says jumbo if number is not odd’ do expect(duh(4)).to eq “jumbo” end end
Never use if inside it Instead use context
require ‘duh’ RSpec.describe ‘#duh’ do context ‘when number is odd’ do it ‘says mumbo’ do expect(duh(3)).to eq “mumbo” end end context ‘when number is not odd’ do it ‘says jumbo’ do expect(duh(4)).to eq “jumbo” end end end
Tip - Context - Always has an opposite negative case - So, never use a single context. - Always begin with “when…”
let helper
require ‘team’ RSpec.describe Team do describe ‘#score’ do it ‘increments goals’ do team = Team.new expect(team.score).to change(Team.goals).by(1) end end describe ‘#matches_won’ do it ‘gives number of matches won by the team” do team = Team.new expect(team.matches_won).to eq 0 end end end
require ‘team’ RSpec.describe Team do let(:team) { Team.new } describe ‘#score’ do it ‘increments goals’ do expect(team.score).to change(Team.goals).by(1) end end describe ‘#matches_won’ do it ‘gives number of watches won by the team” do expect(team.matches_won).to eq 0 end end end
def team Team.new end let(:team) is same as Is invoked only when it is called
before & after helper
require ‘team.’ RSpec.describe Team do before do @team = Team.new puts “Called every time before the it or specify block” end describe ‘#score’ do it ‘increments goals of the match’ do expect(@team.score).to change(Team.goals).by(1) end it ‘increments total goals of the Team’’ do expect(@team.score).to change(Team.total_goals).by(1) end end end
require ‘team’ RSpec.describe Team do before(:suite) do puts “Get ready folks! Testing are coming!! :D ” end describe ‘#score’ do it ‘increments goals of the match’ do expect(@team.score).to change(Team.goals).by(1) end it ‘increments total goals of the Team’’ do expect(@team.score).to change(Team.total_goals).by(1) end end end
Types passed to before/after - :example (runs for each test) - :context (runs for each context) - :suite (runs for entire suite, only once, see database cleaner gem)
Tip - Use let instead of before - To create data for the spec examples. - let blocks get lazily evaluated
# use this: let(:article) { FactoryGirl.create(:article) } # ... instead of this: before { @article = FactoryGirl.create(:article) }
Tip: Use before/after for - actions or - when the same obj/variable needs to be used in different examples
before do @book = Book.new(title: "RSpec Intro") @customer = Customer.new @order = Order.new(@customer, @book) @order.submit end
Use factorygirl to create test objects
Stubs
class PriceCalculator def add(product) products << product end def products @products ||= [] end def total @products.map(&:price).inject(&:+) end end class Product end describe PriceCalculator do it "allows for method stubbing" do calculator = PriceCalculator.new calculator.add(double(price: 25.4)) calculator.add(double(price: 101)) expect(calculator.total).to eq 126.4 end end #This works even if there is no Product class is defined # in the actual program
class Product attr_reader :price end class PriceCalculator def add(product) products << product end def products @products ||= [] end def total @products.map(&:price).inject(&:+) end end describe PriceCalculator do it "allows for method stubbing" do calculator = PriceCalculator.new calculator.add instance_double("Product", price: 25.4) calculator.add instance_double("Product", price: 101) expect(calculator.total).to eq 126.4 end end # throws and error if a Product class or its methods are # not defined
$ irb > require ‘rspec/mocks/standalone’ > class User; end > allow(User).to receive(:wow).and_return(“Yolo”) > User.wow => “Yolo Yolo”
You can also use block to return instead and_return allow(User).to receive(:wow) { (“Yolo”) }
3 types of return for wow method allow(User).to receive(:wow) .and_return(“yolo”, “lol”, “3rd time”)
Diff output when running diff times User.wow #=> yolo User.wow #=> lol User.wow #=> 3rd time User.wow #=> 3rd time User.wow #=> 3rd time
So, you could use it as if it is 2 different objects 2.times { calculator.add product_stub }
Diff between double & instance_double Instance double requires - class to be defined - methods to be defined in that class. - Only then a method can be allowed to it.
Use stubs, mocks, spies with caution
Skip and Focus tests
Say you have 3 failing tests
xit - add x to all but one failing it blocks - xit blocks will be skipped
You can use xit or skip: true xit “does this thing” do end it “asserts name”, skip: true do end it “asserts name”, skip: “Bored right now” do end
it “asserts name” do pending end it “asserts name” do skip end You can use skip/pending inside it
Say you have 20 tests, all passing but very slow
You can use fit to focus specific test fit “asserts name” do end #=> rspec --tag focus #=> only this block will be run
Another way to focus specific test it “asserts name”, focus: true do end #=> rspec --tag focus #=> only this block will be run
You can also use the same in describe or context fdescribe “#save” do end describe “#save”, skip: true do end
Use subject when possible
describe Article do subject { FactoryGirl.create(:article) } it 'is not published on creation' do expect(subject).not_to be_published end end
Shared examples
RSpec.describe FacebookAPI do it "has posts" do expect(FbAPI.new("vysakh0")).to respond_to :posts end it_behaves_like("API", FbAPI.new(“vysakh0”)) end Rspec.describe TwitterAPI do it "has tweets" do expect(TwitterAPI.new("vysakh0")).to respond_to :tweets end it_behaves_like("API", TwitterAPI.new(“vysakh0”)) end
RSpec.shared_examples_for "API" do |api| it "returns a formatted hash" do expect(api.profile).to match [ a_hash_including( name: an_instance_of(String), category: an_instance_of(String), price: an_instance_of(Float)) ] end end
Shared context
RSpec.shared_context "shared stuff" do before { @some_var = :some_value } def shared_method "it works" end let(:shared_let) { {'arbitrary' => 'object'} } subject do 'this is the subject' end end
require "./shared_stuff.rb" RSpec.describe "#using_shared_stuff'" do include_context "shared stuff" it "has access to methods defined in shared context" do expect(shared_method).to eq("it works") end end
Custom Matchers
RSpec::Matchers.define :be_a_multiple_of do |expected| match do |actual| actual % expected == 0 end end # usage: expect(9).to be_a_multiple_of(3)
RSpec::Matchers.define :be_a_palindrome do match do |actual| actual.reverse == actual end end # usage: expect(“ror”).to be_a_palindrome
RSpec::Matchers.define :be_bigger_than do |min| chain :but_smaller_than, :max match do |value| value > min && value < max end end # usage: expect(10).to be_bigger_than(5).but_smaller_than(15)
Define negated matcher
RSpec::Matchers.define define_negated_matcher :exclude, :include # rather than # expect(odd_numbers).not_to include(12) expect((odd_numbers).to exclude(12) # user_a = User.new(“A”); user_b = User.new(“B”) # users = [user_a, user_b] expect(users).to include(user_a).and exclude(user_b)
There are lot more awesomeness!!
- https://relishapp.com/rspec - https://github.com/reachlocal/rspec-style- guide - https://github.com/eliotsykes/rspec-rails- examples (rspec-rails) - http://betterspecs.org/ - Resources
Carpe Diem

Testing Ruby with Rspec (a beginner's guide)