A PlanarFe Adventure

LearnLoveCode

Everyday Rails: Testing With RSpec

What’s a pirate’s favorite testing framework? ArrrrrrrrSpec!

This past week I’ve been working through Aaron Sumner’s “Everyday Rails Testing with RSpec” in order to bone up on my TDD. This is the CliffsNotes version since, unlike ‘Romeo and Juliet’, you can’t run down to Blockbuster and rent the movie. Mostly because good luck finding a Blockbuster and also, not a lot of movies about Rails… or RSpec… I didn’t notice any model specs in the 1995 classic ‘Hackers’.

So here it is, my notes, chapter by chapter.

01. Introduction / 02. Setup

Tests should be:

  • Reliable
  • Easy to write
  • Easy to read

Steps for Setup:

  • Add gems
  • Add test DB
    • config/database.yml (pdf pg 24)
  • Instal Rspec
    • bin/rails g rspec:install
  • Configure RSepc
    • In ‘.rspec’ add ‘—format documentation’
    • Other configurations in ‘/spec/rails_helper.rb’ and ‘/spec/spec_helper.rb’
  • Create Spec executable
    • ‘bundle binstubs rspec-core’

03. Model Specs

Describe a set of expectations

  • Each example only expects one thing
  • Each example is explicit
  • Each example’s description begins with a verb, not should

Describe, Context, Before and After hooks

  • can have nested describe blocks and separate contexts within describe blocks.
    • describe and context technically interchangeable.
      • good style is to use describe to outline general functionality and context to outline a specific state.
  • before block is run BEFORE each example in its describe block
    • before :each is default behavior of before (may omit the each if desired)
  • after for cases such as disconnecting from a service
  • Some developers use method names for the description of nested describe blocks
    • ex) #by_letter

Summary:

  • Active and explicit expectations
  • Test for what should as well as what should not happen
  • Test edge cases
  • Organize for readability

04. Generating Test Data with Factories

  • Locate files in spec/factories directory
1
2
3
4
5
6
7
FactoryGirl.define do
  factory :contact do
    firstname "John"
    lastname "Doe"
    sequence(:email){|n| "johndoe#{n}@example.com" }
  end
end
  • Can pass ruby code into FactoryGirl to dynamically assign values.
    • pass in within a block similar to example above
  • Filenaming convention not as strict as specs (could place all factories in single file if desired) but typically save as a filename that is the plural of the model name.
  • Using FactoryGirl in tests
    • .create persists the instance
    • .build does not (sim to .new for regular ruby objects)
1
FactoryGirl.build(:contact)
  • Override attributes:
1
FactoryGirl.build(:contact, firstname: nil)

Simplifying Syntax

spec/rails_helper.rb

1
2
3
4
5
RSpec.configure do|config|
  # Include Factory Girl syntax to simplify calls to factories
  config.include FactoryGirl::Syntax::Methods
  # other configurations omitted ...
end
  • allows use of “build(:contact), create(:contact), attributes_for(:contact), build_stubbed(:contact)”

Inherited Factories

  • nested ‘factory’ blocks are factories inheriting from top level factory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FactoryGirl.define do
  factory :phone do
    association :contact
    phone '123-555-1234'

    factory :home_phone do
      phone_type 'home'
    end
    factory :work_phone do
      phone_type 'work'
    end
    factory :mobile_phone do
      phone_type 'mobile'
    end
  end
end
  • “association” creates a new ‘Contact’ on the fly for instance of a phone to belong to if one isn’t specifically passed to the factory method
1
2
contact = create(:contact)
create(:home_phone, contact: contact, phone: '785-555-1234')

Fake Data

  • Faker
1
2
3
4
5
6
7
FactoryGirl.define do
  factory :contact do
    firstname { Faker::Name.first_name }
    lastname { Faker::Name.last_name }
    email { Faker::Internet.email }
  end
end

Advanced Associations

  • FactoryGirl callbacks
1
2
3
4
5
6
7
8
9
10
11
12
FactoryGirl.define do
  factory :contact do
    firstname {Faker::Name.first_name}
    lastname {Faker::Name.last_name}
    email {Faker::Internet.email
    after(:build) do |contact|
      [:home_phone, :work_phone, :mobile_phone].each do |phone|
        contact.phones << FactoryGirl.build(:phone, phone_type: phone, contact: contact)
      end
    end
  end
end

05. Basic Controller Specs

  • place in ‘spec/controllers’

Why test controllers?

  • Controllers are classes with methods too
  • Controller specs can often be written more quickly than their integration spec counterparts
  • Controller specs usually run more quickly than integration specs

Syntax

  • Takes http method (get/post), controller method (:index, :create, :new, :show, :edit, :update, :destroy), and (optional) parameters passed to the method
  • attributes_for
    • ‘FactoryGirl.attributes_for(:contact)’ produces hash
  • Create factories for invalid data to test controllers
1
2
3
4
5
6
7
8
9
10
11
FactoryGirl.define do
  factory :contact do
    firstname {Faker::Name.first_name}
    lastname {Faker::Name.last_name}
    email {Faker::Internet.email}
  end

  factory :invalid_contact do
    firstname nil
  end
end

Controller Spec DSL

1
2
3
4
5
6
7
8
9
10
11
12
describe 'GET #show' do
  it "assigns the requested contact to @contacts" do
    contact = create(:contact)
    get :show, id: contact
    expect(assigns(:contact)).to eq contact
  end
  it "renders the :show template" do
    contact = create(:contact)
    get :show, id: contact
    expect(response).to render_template :show
  end
end
  • expects(assigns(:contact)).to eq contact checks that @contact varible created in the controller is equal to contact created in test.
  • expect(response).to render_template :show checks that the response from controller back to browser should render using show.html.er
  • Can pass procs to expect
    • expect{ post :create, contact: attributes_for(:contact, phones_attributes: @phones)}.to change(Contact, :count).by(1)

06. Advanced Controller Specs

  • Testing authentication/login, authorization/roles

07. Controller Spec Cleanup

  • Reducing redundancy and brittleness w/o sacrificing readability
    • share examples across describe and context blocks
    • helper macros to reduce repetition
    • create custom RSpec matchers

Shared Examples

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
shared_examples 'public access to contacts' do
    before :each do
      @smith = create(:contact, firstname: 'Lawrence', lastname: 'Smith')
      @jones = create(:contact, lastname: 'Jones')
    end
    describe 'GET #index' do
      context 'with params[:letter]' do
        it "populates an array of contacts starting with the letter" do
          get :index, letter: 'S'
          expect(assigns(:contacts)).to match_array([@smith])
        end
        it "renders the :index template" do
          get :index, letter: 'S'
          expect(response).to render_template :index
        end
      end
  end

describe admin access do
  it_behaves_like public access to contacts
end

Helper Macros

  • Place macro files in ‘spec/support’ as a module to be included in RSpec’s configuration
  • Inside spec/rails_helper.rb -> RSpec.configure block, add “config.include ModuleName”, and require the file at the top
    • when using authentication through devise or similar refer to their docs for incorporation into test suite

Using Custom RSpec Matchers

  • place custom matchers in ‘spec/support/matchers’, one matcher per file. Should detect files by default
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
RSpec::Matchers.define :require_login do |expected|
  match do |actual|
    expect(actual).to redirect_to \
      Rails.application.routes.url_helpers.login_path
  end
  failure_message do |actual|
    "expected to require login to access the method"
  end
  failure_message_when_negated do |actual|
    "expected not to require login to access the method"
  end
  description do
    "redirect to the login form"
  end
end
  • In Spec
1
2
3
4
it 'requires login' do
  post :create, id: create(:contact), contact: attributes_for(:contact)
  expect(response).to require_login
end

08. Feature Specs

  • Feature specs/ integration testing, aka. acceptance tests
  • Represents how users will interact with your code
  • within feature specs it is ok to have multiple expectations for a given scenario.
    • can also have expectations mid-test
  • Launchy- saves the feature spec’s current HTML to a temp file and renders in default browser to see result of previous step in spec insert ‘save_and_open_page’ into spec.

Including Javascript Interactions

  • Default Capybara web-driver (Rack::Test) cannot handle javascript, so ignores it.
    • Use Selenium instead.
    • js: true takes into account javascript by running a javascript capable web-driver
1
2
3
4
5
feature "About BigCo modal"  do
  scenario "toggles display of the modal about display", js: true do
  
  end
end
  • If firefox hangs at a black page and the spec fails and returns the error:
1
Selenium::WebDriver::Error::WebDriverError: unable to obtain stable firefox connection in 60 seconds
  • check gem file and remove version numbers (if present) from selenium web-driver to use the latest version.
    • if that fails ‘bundle update selenium-webdriver’
  • Configure database cleaner in ‘RSpec.configure’
1
2
3
4
5
6
7
8
9
10
11
12
config.before(:suite) do
  DatabaseCleaner.strategy = :transaction
  DatabaseCleaner.clean_with :truncation
end
config.around(:each) do |example|
  DatabaseCleaner.cleaning do
    example.run
  end
    end
config.after(:each) do
  DatabaseCleaner.clean
end
  • Next, monkey patch ActiveRecord to use threads
    • Add additional file spec/support/shared_db_connection.rb
    • Needed to share data state across the Selenium web server and the test code itself
1
2
3
4
5
6
7
8
class ActiveRecord::Base
  mattr_accessor :shared_connection
  @@shared_connection = nil
  def self.connection
    @@shared_connection || retrieve_connection
  end
end
ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection
  • Headless options for Javascript (run test without waiting for firefox to launch):
    • capybara-webkit
    • Poltergeist

Waiting for Javascript

  • In ‘rails_helper.rb’ set Capybara.default_wait_time = ## to change the amount of time that Capybara will wait before giving up on finding an item from default value of 2 to desired number of seconds.

09. Speeding up Specs

  • Refactoring for speed
    • Amount of time it takes to run specs
    • How quickly you can create meaningful, clear specs

Optional Terse Syntax

  • let() in place of before :each
    • caches the value without assigning it to an instance variable
    • lazily evaluated. does not get assigned until a spec calls upon it.
    • let(:smith){create(:contact, firstname: ‘Lawrence’, lastname: ‘Smith’)}
    • refer to with non-symbol version of item inside let(), i.e.) :smith to smith
      • for tests requiring persisted data call the variable before the expects to instantiate the object and persist it to the db
      • let()! forces the var to be assigned prior to each example
  • subject{}
  • it{} and specify{} (synonymous)- one line versions of it statements
    • subject{ build(:user, name1: ‘J’, name2: ‘Doe’) }; it{ is_expected.to be_named ‘J Doe’}
  • Shoulda - shoulda-matcher gem
    • include gem in :test group for access to many helpful matchers

Mocks and Stubs

  • Mock- some object representing a real object for testing purposes. aka test doubles. Mocks do not touch the database- thus less time to set up in a test
    • use FactoryGirl build_stubbed() method to generate a fully-stubbed fake. Does not persist.
  • Stub- overrides a method call on a given object and returns a predetermined value for it.
    • allow(Contact).to receive(:order).with(‘lastname, firstname’).and_return([contact])

Automation with Guard and Spring

  • Guard watches specified files and executes actions based on what it observes
    • add guard-spec to test and development groups
    • create Guardfile from the command line: guard init spec
    • run ‘guard’ in terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
guard :rspec, cmd: 'spring rspec --color --format documentation',
  all_on_start: false, all_after_pass: false do
  watch(%r{^spec/.+_spec\.rb$})
  watch(%r{^lib/(.+)\.rb$})     { |m| "spec/lib/#{m[1]}_spec.rb" }
  watch('spec/spec_helper.rb')  { "spec" }

  watch(%r{^app/(.+)\.rb$})                           { |m| "spec/#{m[1]}_spec.rb" }
  watch(%r{^app/(.*)(\.erb|\.haml)$})                 { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
  watch(%r{^app/controllers/(.+)_(controller)\.rb$})  { |m| "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb" }
  watch(%r{^spec/support/(.+)\.rb$})                  { "spec" }
  watch('app/controllers/application_controller.rb')  { "spec/controllers" }

  watch(%r{^app/views/(.+)/.*\.(erb|haml)$})          { |m| "spec/features/#{m[1]}_spec.rb" }

  watch(%r{^spec/.+_spec\.rb$})
  watch(%r{^lib/(.+)\.rb$})     { |m| "spec/lib/#{m[1]}_spec.rb" }
  watch('spec/rails_helper.rb')  { "spec" }
end
  • Spring keeps rails app running after 1st run of test suite to eliminate spin-up time and make test suite run faster on subsequent executions
    • add ‘spring-commands-rspec’ to gemfile :development
    • make the new ‘bin/rspec’ available as a binstub
      • ‘spring binstub rspec’
      • ‘spring stop’

Add Tags to Tests

1
2
3
it processes a credit card, focus: true do
  
end
1
$ rspec tag focus
  • runs only test tagged with focus: true
  • add to rails_helper:
    • ‘config.run_all_when_everything_filtered = true’
  • Can configure RSpec to only run/never run examples with specific tags
1
2
3
4
RSpec.configure do | config |
  config.filter_run focus: true
  config.filter_run_excluding slow: true
end
  • Skipping unneeded tests
1
2
3
it loads a lot of data do
  skip no longer needed
end

10. Testing the Rest

  • Email delivery, file uploads, manipulating time within specs, testing against external web services, how to test your application’s API, testing rake tasks.
  • Emails: ‘email_spec’ gem
    • add to rails helper:
      • ‘config.include(EmailSpec::Helpers)’
      • ‘config.include(EmailSpec::Matchers)’
  • Allows addition to spec such as:
    • ‘expect( open_last_email ).to be_delivered_from sender.email’
    • ‘expect( open_last_email ).to have_reply_to sender.email’
    • ‘expect( open_last_email ).to be_delivered_to recipient.email’
    • ‘expect( open_last_email ).to have_subject message.subject’
    • ‘expect( open_last_email ).to have_body_text message.message’
      • open_last_email is helper that opens the most recently sent email and gives you access to its attributes
  • Can also create new email objects and work directly with them
1
2
3
email = MessageMailer.create_friend_request(someguy@someplace.com)
expect( email ).to deliver_to(otherguy@otherplace.com)
expect(email).to have_subject Friend Request
  • Can also access and test against messages without extra dependency

    • Each time a message is sent out it gets pushed to ActionMailer::Base.deliveries
    • Can access latest by ActionMailer::Base.deliveries.last
      • clear delivered emails by ActionMailer::Base.deliveries = []
    • Testing: expect(ActionMailer::Base.deliveries.last).to include user.email
  • Testing the Time

    • Timecop gem - freeze time
1
2
3
4
5
6
it wishes the visitor a Happy New Year on Jan 1
Timecop.travel Time.parse(January 1)
visti root_url
expect( page ).to have_content Happy New Year!
Timecop.return
end
  • Testing Web Services
    • VCR gem- watches for external http requests coming from your code. When it encounters one it causes the test to fail. In order to make it pass you must create a ‘cassette’ onto which to record the http transaction. Run the test again, and the vcr captures the request and response into a file. Now future test making the same request will use data from the file instead of making another network request to the api.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
require'rails_helper'
RSpec.describeAddress,:type=>:modeldo describe 'geocoding' do
  it 'geocodes a new address' do
    VCR.use_cassette('allen_fieldhouse') do
      address = FactoryGirl.create(:address,
      street: '1651 Naismith Drive',
      city:   'Lawrence',
      state:  'KS'
      )
      expect(address.latitude).to eq 38.9541438
      expect(address.longitude).to eq -95.2527379
    end
  end
end
  • Testing Your Application’s API
    • API specific test go in ‘spec/requests’
    • use simple get, post, delete, patch http verbs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
require rails_helper
describe Contacts API, type: :request do
  it sends an individual contact do
    contact = FactoryGirl.create(:contact)
    get /api/contacts/#{contact.id}”, nil, {‘HTTP_ACCEPT’ => ‘application/vnd.contacts.v1’}

    expect(response).to have_http_status(:success)

    json = JSON.parse(response.body)
    expect(json[ firstname ]).to eq contact.firstname
    expect(json[ lastname ]).to eq contact.lastname
    expect(json[ email ]).to eq contact.email
  end
end

Quick Ref:

Matchers:

  • .to, .not_to, .to_not
  • be_valid
  • include()
  • eq / eql
  • not==
  • match_array - ignores order in array, contents only
  • render_template
  • redirect_to
  • be_a_new()
  • change(Contact, :count)
  • match - used whenever a regex is being compared to the actual result

Gems Used: