Usually we start with integration test, than we go in details for frontend controller (Angular), than backend controller, than model … all to make sure integration (feature) test pass. You should follow style guide for testing and read some best practices.

Integration tests usually test only hapy path. All possible edge cases should be unit tested. External api should be stubbed and repeatable.

Integration tests are generic name for any test than combine more than one unit tests and are often black-box tests, end-to-end, what user see. Acceptance testing is subset of integration testing and test that behavior is correct from customer perspective. There should not be ruby code except setup data or find specific item that we need to check if it exists on page. We should use only html page finders and matchers since we can’t see request object. Integration test should not cover special error cases, when data is nil or implementation details.

Given [role and state]
When [an event occurs]
Then [the benefit]

As a instructor
I want to be able to sign in/sign/out/reset my password

- Standard password reset via email
- Login via social buttons

or AAA:
Arange - set data - SETUP
Act - run code - EXERCISE
Assert - verify that code did what is expected - VERIFICATION
Teardown - eventual TEARDOWN
# note that multiple assets is OK only if they are related

Setup test env

You need to setup db with a command

bin/rails db:environment:set RAILS_ENV=test

or use rake db:test:prepare. I was trying to do RAILS_ENV=test rake db:drop db:create db:migrate but it did not work because of some missing tables.

Javascript test also rely on precompiled assets so you need to run rake assets:precompile or put config.assets.compile = true in conig/environments/test.rb (this could slow down). Somehow when running with headless driver than I do not need to precompile assets for those js tests (this is when I run rake). But when I run single test rspec ./spec/features/my_spec.rb I need to trigger rm -rf public/assets (do not know how they appear here) and also spring stop so than it will use fresh code (not precompiled). Also check node -v

Rspec

Vanilla rspec

rspec --init

This will generate spec/spec_helper.rb and .rspec (option --require spec_helper will automatically load spec_helper so you do not need to write require "spec_helper"). Also rspec will add spec/ folder to $LOAD_PATH

# spec/simple_test_spec.rb
RSpec.describe "simplest test" do
  it "can check equality" do
    expect(2).to eq(1+1)
  end
end

You can run simple test example with

rspec spec/simple_test_spec.rb
rspec -b # to show full backtrace in case of exceptions

When you want to stop on first failed test (and not to wait whole test suite to finish) you can use

rspec spec --fail-fast

Rspec examples are written using describe (ExampleGroup, you can nest multuple describe blocks), let statements and it blocks (Example). If you need to assert count you can use before {} (it is before(:example) and is run after let statemets) to instantinize all let objects since there are lazy (or you can use let!). Also you can use before(:context) { Location.delete_all} (this is run before let statements) to remove any test data, for example if you have fixtures, they will be loaded for every example. thoughtbot do not suggest using let, but to extract methods.

# spec/location_spec.rb
require "location"
describe Location do
  let(:latitude) { 123 }
  let(:longitude) { 321 }
  let(:location) { Location.new latitude: latitude, longitude: longitude }
  before do
    # initiate lazy let when we need to assert count
    [location]
  end
  subject { location }
  describe "#initialize" do
    it { should have_attributes latitude: latitude }
    it { should have_attributes longitude: longitude }
  end
  describe "#near" do
    context "for near location" do
      let(:near_location) do
        Location.new(latitude: latitude + 1, longitude: longitude + 1)
      end
      it { should be_near(near_location, 2) }
    end
    context "for not near location" do
      let(:not_near_location) { Location.new latitude: 1, longitude: 2 }
      it { should_not be_near(not_near_location, 2) }
    end
    it "raise exception for negative radius" do
      expect { location.near?(location, -1) }.to raise_error ArgumentError
    end
  end
end

rspec-core and style guide

  • example groups describe "..." do or context "..." do. Describe use hash #method_name and dot .class_method_name Context is alias for describe, but it is used to group examples with same state (for example context 'when not logged in or context 'when resource is not found'). Context description always start with when and always have a oposite context.
  • example is it "..." do. it description should not end with conditional like this bad example: it 'returns name if it is present' (better context 'when name is present' it 'returns name')
  • Example group is a class in which describe or context is evaluated, and it is evaluated on instance of that class
  • subject is used in group scope (describe) to define value that is returned by subject method in example (it) scope.
  • one liner it syntax Instead of it { expect(subject).to be_empty } you can use it { is_expected.to be_empty } or it { should be_empty } (deprecated). If you need to access properties of an object for one liner syntax, than use it { is_expected.to have_attribute :name }. So do not use subject inside it block.
  • let is used to memoize value so it is shared inside one example, but not between examples
  • before and after hooks are used to execute arbitrary code before and after example or context is run. You can use alias before(:each) is the same as before(:example) and before(:all) == before(:context)

DRY is accomplished with shared_context (and shared_examples) so you can share contex so do not need to repeat let and before.

RSpec.describe User do
  shared_context 'one_user' do
    let(:user) { create :user }
  end

  describe do
    include_context 'one_user'
  end
end

Testing included modules and concerns is done with shared_examples so we test with it_behaves_like "linkable" or include_examples 'linkable'

RSpec.shared_examples 'payment check_no' do
  it 'allows long check_no' do
    check_no = '356431 575017004 201705 11'
    customer_payment = create :customer_payment, check_no: check_no
    expect(customer_payment.new_record?).to be_falsy
  end
end

RSpec.describe CustomerPayment do
  describe 'validations' do
    it_behaves_like 'payment check_no'
  end
end

Also valid factory can be shared between tests

# spe/models/user_spec.rb
RSpec.describe User do
  it_behaves_like "has_valid_factory"
end

# spec/support/shared_examples.rb
# If you are using factoryBot
RSpec.shared_examples "has_valid_factory" do
  it "has a valid factory" do
    expect(create(described_class.name.underscore)).to be_valid
  end
end

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

for fixtures

RSpec.shared_examples "has_valid_fixture" do
  it "has a valid fixture" do
    items = send described_class.table_name
    expect items.map(&:valid?).all?, (items.reject(&:valid?).map { |c| (c.respond_to?(:name) ? "#{c.name} " : '') + c.errors.full_messages.to_sentence })
  end
end

Some rspec expectations built in matchers

  • expect(object).to be(expected), expect(object).to be > 3, expect(object).to eq(expected)
    • be is object identity a.equal? b (refer to the same object)
    • eq is object equivalence with type conversion a == b (same value)
  • predicate matchers for any method that begin with has_ or ends with ? you can use have_ and be_ expect(object).not_to be_empty or be_near near_location
  • type matchers expect(obj).to be_kind_of(Type) or be_a type of Type

  • expect { do_something }.to change { object.attribute }. You can also specify values expect {}.to change {session[:me]}.from(nil).to("dule") It is also possible to detect changes in two tables http://www.relishapp.com/rspec/rspec-expectations/v/3-5/docs/built-in-matchers/change-matcher

    it "should increment the counters" do
      expect { Foo.bar }.to change { Counter.count }.by(1).and \
                            change { AnotherCounter.count }.by(1)
    end
    
  • expect(object).to have_attributes name: 'duke' have_attributes to check values
  • expect(object).to have_attribute :name to check just attribute (no value)
  • expect { do_something }.to raise_error ArgumentError
  • expect(object).to match /expression/
  • expect(object).to be_a(Class)
  • expect(object).to satisfy { block }

You can use not_to for all matchers (expect raise_error).

Instead of multiple expect statements inside it, you could change it to describe and use inline it statements (pros: all assertions will be checked, cons: it could be slower because of setup and dificult to read to know how test was setup). Another solution is to use compound matchers. All matchers have alternate form (an_object_having…) so it reads better when using compose (or compound) matchers

expect(a[0]).to eq(5)
expect(a[1]).to eq(6)
# is the same as
expect(a).to match([an_object_eq_to(5), an_object_eq_to(6)])

# or

describe "build three tasks" do
  let(:tasks_string) { "My Task:2\nYour Task:3\nHis Task:1\n" }
  it do
    expect(tasks.map(&:title)).to eq(['My Task', 'Your Task', 'His Task'])
  end
  it { expect(tasks.map(&:size)).to eq([2, 3, 1]) }
end
# is the same as
describe "build three tasks" do
  let(:tasks_string) { "My Task:2\nYour Task:3\nHis Task:1\n" }
  it do
    expect(tasks).to match(
      [an_object_having_attributes(title: 'My Task', size: 2),
       an_object_having_attributes(title: 'Your Task', size: 3),
       an_object_having_attributes(title: 'His Task', size: 1)]
    )
  end
end

You can use .or and .and

expect(a).to include("a").and match(/.*1.*/)

rubydoc matchers

For pending tests, you can write empty it blocks or just add skip or prefix x like xit or xdescribe

it '...', :skip do
end

it "..." do
  skip "pending"
end

xit "skipped" do
end

For slow test you can mark with :really_slow and exclude specific tags with rspec --tag ~really_slow. Or you can add to add to spec/spec_helper.rs

# spec/spec_helper.rb
RSpec.configure do |config|
  config.filter_run_excluding(really_slow: true)
end

Note that if you run specific line bin/rspec spec/features/my_spec.rb:10 than it will not be excluded. Only if you run all or speficic file bin/rspec spec/features/my_spec.rb

If you want to run specific task then than add tag rspec --tag really_slow. If you want to run all and specific tasks than use env variable:

# spec/spec_helper.rb
RSpec.configure do |config|
  # If you really want to run then add tag `bin/rspec --tag really_slow` to run
  # only that tag, or to run all and some tags use
  # INCLUDING_TAGS=chrome_remote_selenium bin/rspec
  %i(
    really_slow chrome_remote_selenium
    headless_chrome_remote_selenium edit_hosts
  ).each do |tag|
    config.filter_run_excluding(tag) unless ENV['INCLUDING_TAGS'].to_s.include? tag.to_s
  end
end

You can exclude specific folder https://relishapp.com/rspec/rspec-core/v/3-3/docs/configuration/exclude-pattern rspec --exclude-pattern "spec/features/*" I do not know why quotes are needed here, but it works only with quotes.

rspec --profile will give you list of 10 slowest tests.

Rspec config before each example for particular group

# spec/rails_helper.rb
RSpec.configure do |config|
  config.before :each do
    DatabaseCleaner.start
  end
  config.before :each, type: lambda {|v| v != :feature } do
    allow_any_instance_of(Paperclip::Attachment).to receive(:save).and_return(true)
  end
  config.include Warden::Test::Helper, type: :features
end

Better is you include config.before only for specific tests using filter symbols https://relishapp.com/rspec/rspec-core/v/3-6/docs/hooks/filters#filtering-hooks-using-symbols

RSpec.configure do |config|
  config.before :example, :setup_xxx do
    xxx
  end
end
RSpec.describe 'invoke xxx', :setup_xxx do
  it 'in all nested examples' do
  end
end

Rspec rails

To install you need to add rspec-rails gem and generate .rspec, rails_helper.rb and spec_helper.rb.

sed -i Gemfile -e '/group :development, :test do/a  \
  gem "rspec-rails"'
bundle
rails generate rspec:install
# you should stub everything rails related so you do not need to load it
# echo "--require rails_helper" >> .rspec
# enable experimental settings, note config.profile_examples = 10 is too noisy
# sed -i spec/spec_helper.rb -e '/=begin/d'
# sed -i spec/spec_helper.rb -e '/=end/d'
git add . && git commit -m "rails generate rspec:install"

You should write require "rails_helper" at the begging of each spec, or you can add option to .rspec (this is perfomance penalty since some tests does not require rails can perform faster without this option). Once you put gem in gemfile inside development group, rspec generators will be available: rails g model posts will generate rspec instead of minitest. Also there are generators like rails g integration_test and rspec will generate spec/requests/password_resets_spec.rb

Write test file that ends _spec.rb in particular folders (so it inherits type ).

Guard

You can also use guard, just add to development group

sed -i Gemfile -e '/group :development do/a  \
  gem "guard-rspec"'
bundle
guard init rspec
git add . && git commit -m "Adding guard init rspec"

You can run guard that will run your specs.

More options for guard My favorite is failed_mode: :focus which reruns last failed test. Also you can use spring for rspec

sed -i Gemfile -e '/group :development do/a  \
  gem "spring-commands-rspec"'
bundle
bundle exec spring binstub rspec

Now you can run tests with bin/rspec which will be faster than rspec. If you are using guard, use this line

# Guardfile
guard :rspec, cmd: "bundle exec bin/rspec", failed_mode: :focus do
end

Shoulda matchers

You can use shoulda matchers

sed -i Gemfile -e '/group :development, :test do/a  \
  gem "shoulda-matchers", "~> 3.1"'
bundle
cat >> spec/rails_helper.rb << HERE_DOC
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end
HERE_DOC
bundle
git add . && git commit -m "Adding shoulda matchers"

RSpec Controller specs

controller specs can use get,post and validate response (to render_template :new, to redirect_to posts_path). Testing assings and render_template is deprecated.

subject { get "/posts" }
expect(subject).to render_template(:index)
expect(assigns(:posts)).to be([post])

assigns has been extracted to a gem. To continue using it, add gem 'rails-controller-testing' to your Gemfile.

Move all business logic and ActiveRecord calls out from controller and test only triggers to services or model methods (using mocks) and check responses. Testing routes is done separately.

Controller spec should be for each method (CRUD) in context of authorized and in context of unauthorized user (testing security), and for valid and some invalid requests.

get, delete, head, patch, post and put takes 4 params, usually used only first two. Third is session (used only if you have some multistep process that use session for continuity) and fourth is flash param (not used in controller test). (in ActionController::TestCase you can use session: keyword arg like get :show, params:{ id: 3}, session: { 'user_id': 3}, but in ActionDispatch::IntegrationTest you can not set session (no @session)) Similar xhr (for testing ajax) takes three arguments.

get :show, params: { id: @task.id }
post :create, logo: fixture_file_upload('/test/data/logo.png', 'image/png')
xhr :post, :create, params: { id: "3" }

To use fixture_file_upload in your test you need to include include ActionDispatch::TestProcess

In controller test you have access to @request, @controller and @response You can validate response.status or expect(response).to have_http_status(:success) matcher. You can use :success, :redirect, :missing, :error.

To set a host name

  • host! "my.awesome.host" for integration test available helpers
  • in controller specs use @request.host = 'my.awesome.host' (ActionController::TestCase)
  • In view specs use controller.request.host = "my.awesome.host"
  • In capybara use Capybara.default_host = "http://my.awesome.host",

When you need to create params to for testing, you can use ActionController::Parameters.new name: 'Duke'

# spec/controllers/users_controller_spec.rb
RSpec.describe UsersController do
  describe 'new' do
  end
end

Rspec Request specs

requests spec are used for full stack testing without stubbing. It is faster than system but can not use javascript… If request is not performed (get xhr) than something is different (current user is not initialized, or something). post url, params: { name: 'my name' } is used with params key To set json request use get path, format: :json. New format is patch path, params: { format: :turbo_stream } To set headers you can use third param

  get login_path, nil, { 'Authentication' => 'MyToken' }

  # in Rails 5 we need to use keyed instead positional arguments
  get '/', params: {}, headers: { 'HTTP_USER_AGENT': 'Mozilla/5.0 (compatible; Nimbostratus-Bot/v1.3.2; http://cloudsystemnetworks.com) Nimbostratus' }

You can access flash object

  post my_path, params: { name: 'd' }
  expect(flash[:notice]).to match 'd created successfully'
# spec/requests/oauth_password_flow_spec.rb
RSpec.describe "OAUTH" do
  let :email_address do
    "[email protected]"
  end
  let :password do
    "asdfasdf"
  end
  let! :user do
    FactoryBot.create :user, email_address: email_address, password: password
  end

  it "creates a token when credentials are valid" do
    # https://aaronparecki.com/oauth-2-simplified/#password
    post "/oauth/token", params: {
      grant_type: "password",
      username: email_address,
      password: password
    }
    expect(response.status).to eq 200
    expect(JSON.parse(response.body)["access_token"]).not_to be_nil
  end

  it "does not issue token when credentials are invalid" do
    post "/oauth/token", params: {
      grant_type: "password",
      username: email_address,
      password: "wrong_password"
    }
    expect(response.status).to eq 401
    expect(JSON.parse(response.body)["access_token"]).to be_nil
  end

  it 'redirect' do
    post '/'
    expect(response).to redirect_to error_path
    follow_redirect!
  end
end

expect(response.body).to include 'Logout'
# use html encoding when you have quote, so you do not need to match &#39;
expect(response.body).to include ERB::Util.html_escape "Some text with ' quote"
expect(response.body.scan(/<tr>/).size).to be 3
expect(response.body).to match /button.*Publish Package.*\/button/
# for multiline match response.body text you can use modifier m
expect(response.body).to match /dl.*dl/m

Sometimes I get error if you use sign_in user but do not perform get or post requests, ie you use empty it 'bla bla' do;end. Than it pass when specific tests are run, but fail when all test from file are run.

RSpec Router specs

router specs is not so usefull, so you should write only for some that you do not want to exists or when you have some extra logic in routing.

Usefull matchers are route_to and be_routable

# spec/routing/project_routing_spec.rb
require 'rails_helper'
RSpec.describe 'project routing', type: :routing do
  it 'routes projects' do
    expect(get: '/projects/1/edit/').to route_to(
      controller: 'project', action: 'edit', id: 'id')
  end

  it 'does not route by name' do
    expect(get: '/projects/search/duke').not_to be_routable
  end
end

RSpec Helper specs

You have access to helper object so you can call methods on it.

# spec/helpers/project_helper_spec.rb
require 'rails_helper'
RSpec.describe ProjectsHelper do
  let(:project) { Project.new name: 'Duke' }
  it "adds class if not finished" do
    allow(project).to receive(:on_schedule?).and_return(true)
    actual = helper.name_with_status(project)
    expect(actual).to have_selector('span.on_schedule', text: 'Duke')
  end
end

# app/helpers/projects_helper.rb
module ProjectsHelper
  def name_with_status(project)
    content_tag(:span, project.name, class: 'on_schedule')
  end
end

Helper tests does not go to views, so to test if it actually works write some view tests

RSpec View specs

You can set instance variables and call render (which will render outermost describe block name or you can call render 'projects/index' or render partial: 'projects/_form', locals: { admin: true }). We can stub helpers using view object like view.stub(:current_user).and_return(User.new) Output is available in rendered object. We can use capybara have_selector, have_no_selector instead of default match rspec matcher. We can use rails assert_select

# spec/views/projects/index.html.erb_spec.rb
require 'rails_helper'

RSpec.describe "projects/index" do
  let(:on_schedule) { Project.create! name: 'Dule' }
  it "show on schedule" do
    @projects = [on_schedule]
    render
    expect(rendered).to have_selector(
      "#project_#{on_schedule.id} .on-schedule",
      text: on_schedule.name,
      count: 1
    )
    assert_select ".on-schedule", count: 1
  end
end

RSpec Presenters specs

Bigger logic in view should be replaced with presenter. Ruby core SimpleDelegator delegates all messages to the object passed to the contructor. We can use test doubles and instead of rails_helpers use implicit spec_helper.

# spec/presenters/project_presenter_spec.rb
RSpec.describe ProjectPresenter do
  let(:project) { Project.new name: 'My Name' }
  let(:project_presenter) { ProjectPresenter.new project }

  it "show name with state" do
    expect(project_presenter.name_with_status).to eq("My Name on_schedule")
  end
end

# app/presenters/project_presenter.rb
class ProjectPresenter < SimpleDelegator
  def name_with_status
    "#{name} on_schedule"
  end
end

There is draper gem

RSpec Mailer specs

In controller we check if send mail is actually triggered and maybe just one line of content email.body.to_s matches title

# spec/controllers/tasks_controller_spec.rb
require 'rails_helper'

RSpec.describe TasksController, type: :controller do
  before { ActionMailer::Base.deliveries.clear }

  describe "PATCH incomplete update" do
    let(:task) { Task.create! title: "Yo!" }
    it "does not send email" do
      patch :update, id: task.id, task: { size: 3 }
      expect(ActionMailer::Base.deliveries.size).to eq(0)
    end
  end

  describe "PATCH complete update" do
    let(:task) { Task.create! title: "Yo!" }
    it "does send email" do
      patch :update, id: task.id, task: { size: 3, completed: true }
      expect(ActionMailer::Base.deliveries.size).to eq(1)
      email = ActionMailer::Base.deliveries.first
      expect(email.body.to_s).to match("Yo!")
    end
  end
end

Full content we check in mailer spec

# spec/mailers/task_mailer.rb
require "rails_helper"

RSpec.describe TaskMailer, type: :mailer do
  let(:task) { Task.new title: 'Yoo' }
  describe "task_completed_email" do
    let(:mail) { TaskMailer.task_completed_email(task) }

    it "renders the headers" do
      expect(mail.subject).to eq("Task completed email")
      expect(mail.to).to eq(["[email protected]"])
      expect(mail.from).to eq(["[email protected]"])
    end

    it "renders the body" do
      expect(mail.body.encoded).to match("Yoo")
    end
  end
end

RSpec Model specs

model specs is a thin wrapper for ActiveSupport::TestCase. Every example is in separate transaction You can use instance_double If not defined, subject is an instance of the model. Usually have following parts: attributes, relationships, validations, hooks(callbacks), scopes and other methods.

model cheatsheet

Attributes

It is good to write all column names at the top of the test so you can see what it is about

# spec/models/auction_spec.rb
RSpec.describe Auction do
  describe "attributes" do
    %w[
      user_id
      ends_at
      time_zone_id
    ].each do |attribute|
      it { is_expected.to have_attribute attribute }
    end

    # or in this one by one form. breakthrough is two. for three and more use
    # above method

    it { is_expected.to have_attribute :ends_at }
    it { is_expected.to have_attribute :time_zone_id }
  end

Relationships

  describe "relationships" do
    it { is_expected.to have_many :auction_admins }
    it { is_expected.to belong_to :user }
  end

For belongs_to validations it is advised to validate object (user not user_id) so you can use build strategy in factory bot.

Validations

You can install shoulda matchers for simple inline validations:

# Gemfile
  gem 'shoulda-matchers', '~> 3.1'

# spec/rails_helper.rb
require 'shoulda/matchers'
Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :rails
  end
end

You should check for presence for both not null and references fields, with simple line:

  descibe 'validations' do
    subject { build :sport } # we need to use factory bot since implicit subject is empty
    it { is_expected.to validate_presence_of :name }
    it { is_expected.to validate_uniqueness_of :name }
  end

For complex validation you can test validation as simply l = Location.new; expect(l).not_to be_valid but it’s better to go into details of errors message

# spec/models/location_spec.rb
require "rails_helper"
RSpec.describe Location do
  describe "validations" do
    before { subject.valid? }
    context "when latitude is missing" do
      subject { Location.new latitude: '' }
      it "should not allow blank latitude and longitude" do
        expect(subject.errors[:latitude]).to include "can't be blank"
        expect(subject.errors[:longitude]).to include "can't be blank"
      end
    end
  end
end

Validate uniqueness should be explicitly written (not shoulda matchers) with two objects: original (that exists in db) and duplicate that is going to be saved

  it "validates uniqueness of user_id scoped to auction_id" do
    original = FactoryBot.create :auction_admin
    duplicate = FactoryBot.build :auction_admin, user: original.user, auction: original.auction
    duplicate.valid?
    expect(duplicate.errors[:email]).to include "has already been taken"
  end

It is advised to write test for valid factory (so you know that your factory can be created)

  it "has a valid factory" do
    expect(FactoryBot.create(:auction)).to be_persisted
  end

  # or more general

  it 'has a valid factory' do
    # expect(create(described_class.name.underscore)).to be_persisted
    expect(build(described_class.name.underscore)).to be_valid
  end

Test enum is with

  it "has enum fulfillment_typs" do
    expect(described_class.fulfillment_types).to eq({
      "item" => 0,
      "certificate" => 1,
    })
  end

Test default values from database and in after hooks.


Test migration

You can test data migration. Do not perfom migration, come just before it

rake db:drop db:create db:migrate VERSION=20180503095528
rake db:migrate:status
You have 1 pending migration:
  20180508073700 RemoveLocationVoucherType
# Run `rake db:migrate` to update your database then try again.
# I tried to ignore with `config.active_record.migration_error = false` but it
# gives me error message. You should move or rename migration file while you run
rake db:drop db:create db:migrate && bin/rspec spec/migrations/my_migration_specc.rb

You should also rename test file so it is not run with all other, or ignore specific tag metadata.

class MyMigration < ActiveRecord::Migration
  # this is local Location class used only inside this migration
  class Location < ActiveRecord::Base
  end
  def up
    rename_column :locations, :name, :name2
    # this is needed since renamed columns will be ignored on "save"
    Location.reset_column_information
    Location.find_each do |location|
      location.name2 = 'updated in migration'
      location.save!
    end
  end
end

I tried with Migrator.migrate but nothing happens (no sql migration commands), so I simply run MyMigration.new.up

# spec/migrations/my_migration_spec.rb
load 'spec/spec_helper.rb'
load 'tmp/20180508073700_remove_location_voucher_type.rb'
# run test with this command
# rake db:drop db:create db:migrate STEP=20180503095528 && bin/rspec spec/migrations/remove_location_voucher_type_spec.rb --tag migration_specs
RSpec.describe RemoveLocationVoucherType, migration_specs: true do

  it 'up' do
    # ActiveRecord::Migrator.migrate(migrations_paths, previous_version)
    my_user = create :user
    MyMigration.new.up
    my_user.reload
    expect(my_user.name2).to be 'updated in migration'
  end
end

If there is an error than skip DatabaseCleaner

# error

# spec/support/database_cleaner.rb
RSpec.configure do |config|
  config.around(:each) do |example|
    if example.metadata[:migration_specs]
      example.run
    else
      # Start transaction
      DatabaseCleaner.cleaning do
        example.run
      end
    end
  end
end

Refactoring

Refactoring in 3 types:

  • break up complexity: boolean logic has_purchased_before?, convert local variables to methods and extract other methods from comments (part of methods)
  • combine duplication: duplication of fact (use class constant or instance method), duplication of logic (use before actions), find missing abstractions (replace some group of methods that are used together as parameters, with a new class with those instance methods, like value objects)
class User < ActiveRecord::Base
delegate :full_name, :sort_name, to: :name
def name
  Name.new(first_name, last_name)
end

class Name
  attr_accessor :first_name, :last_name
  def initialize(first_name, last_name)
    @first_name, @last_name = first_name, last_name
  end
  def full_name
    "#{first_name} #{last_name}"
  end
  def sort_name
    "#{last_name}, #{first_name}"
  end
end
  • usually do not test associations
  • testing class methods, do not create more objects than it is needed
    • when testing filter, than create two objects, one that satisfy and other does not
    it "finds completed tasks" do
      complete = Task.create!(completed_at: 1.day.ago, title: 'Completed')
      incomplete = Task.create!(completed_at: nil, title: 'Incompleted')
      expect(Task.complete.map(&:title)).to eq(['Completed'])
      # or
      expect(Task.complete).to match(
        [an_object_having_attributes(title: 'Completed')]
      )
    end
    
  • class methods could be stubbed
require 'rails_helper'
RSpec.describe User do
  context ".latest" do
    let(:user) { build :user, :published }
    before { allow(User).to receive(:latest).and_return([:user]) }
    it { expect(User.latest).to include user }
  end
end

RSpec.describe "Post #create" do
  context 'when password is invalid' do
    it 'renders the page with error' do
      user = create(:user)
      post :create, session: { email: user.email, password: 'invalid' }
      expect(response).to render_template(:new)
      expect(flash[:notice]).to match(/^Email and password do not match/)
    end
  end
end

You can stub request.remote_ip

allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return('192.168.0.1')

Also you can stub helper methods

allow_any_instance_of(ApplicationHelper).to receive(:generate_password).and_return '111000'

Oauth specs

https://github.com/elizabrock/coursewareofthefuture/blob/25372c671f73525e09927344d8f2031b6acf82d0/spec/support/features/authentication_helper.rb

Controller tests https://gist.github.com/jittuu/792715 Helper https://gist.github.com/spyou/1200365/b0bb95e5cf9144b5a9a58bb6c1fc33aee4c34e47

Respec features

Capybara is used only with feature spec Just install the gem in require it:

sed -i Gemfile -e '/group :development, :test do/a  \
  gem "capybara"\
  gem "capybara-email"\
  gem "selenium-webdriver"\
  # for save_and_open_page in browser\
  gem "launchy"\
  # to clean db when using js mode\
  gem "database_cleaner"'
bundle
sed -i spec/rails_helper.rb -e '/require .rspec.rails/a  \
require "capybara/rspec"\
require "capybara/email/rspec"'

cat > spec/support/database_cleaner.rb << HERE_DOC
RSpec.configure do |config|
  # If you're not using ActiveRecord, or you'd prefer not to run
  # each of your examples within a transaction, remove the following
  # line or assign false instead of true.
  config.use_transactional_fixtures = false

  # Clean up and initialize database before
  # running test exmaples
  config.before(:suite) do
    # Truncate database to clean up garbage from
    # interrupted or badly written examples
    DatabaseCleaner.clean_with(:truncation)

    # Seed dataase. Use it only for essential
    # to run application data.
    load "#{Rails.root}/db/seeds.rb"
  end

  config.around(:each) do |example|
    # Use really fast transaction strategy for all
    # examples except 'js: true' capybara specs
    # that is Capybara.current_driver != :rack_test
    # https://github.com/DatabaseCleaner/database_cleaner#what-strategy-is-fastest
    DatabaseCleaner.strategy = example.metadata[:js] ? :truncation : :transaction

    # Start transaction
    DatabaseCleaner.cleaning do

      # Run example
      example.run
    end

    load "#{Rails.root}/db/seeds.rb" if example.metadata[:js]

    # Clear session data
    Capybara.reset_sessions!
  end
end
HERE_DOC

sed -i spec/rails_helper.rb -e '/use_transactional_fixtures/c  \
  config.use_transactional_fixtures = false'

git add . && git commit -m "add capybara"

capybara-email is using open_email '[email protected]' to set current_email for which you can current_email.click_link

Capybara adds some aliases:

  • feature is alias for describe ..., type: :feature or context
  • background is alias for before
  • scenario is an alias for it
  • given is alias for let

To check if element exists you can use has_css?('.sm', text: 'my', wait: 0) do not wait if it does not exists (for nokogiri it is response.at('.sm:contains("my")')

Capybara example

# spec/features/search_spec.rb
require 'rails_helper'

RSpec.feature "Search recipes", js: true do
# write data inside or
  # fixtures :all  # or
  before do
    Recipe.create! name: 'Baked Potato'
  end
  scenario "successfully" do
    visit "/"
    fill_in "keywords", with: "baked"

    click_button "Search"

    expect(page).to have_content("Baked Potato")
    expect(page).to_not have_content("Garlic")
    expect(page.has_link?('Sign up')).to be true
  end
end

For /

Rspec Helpers

You an define your helpers in separate file and include it in Rspec config.

Imporant note that helpers can be used only inside examples (not example groups)

Custom matchers

RSpec::Matchers.define :be_able_to_see do |*projects|
  match do |user|
    expect(user.visible_projects).to eq(projects)
    projects.all? { |p| expect(user.can_view?(p)).to be_truthy }
    (all_projects - projects).all? { |p| expect(user.can_view?(p)).to be_falsy }
  end
end

let(:all_projects) { [project_1, project_2] }
expect(user).to be_able_to_see(project_1, project_2)
# spec/a/equal_when_sorted_by_id.rb
require 'rspec/expectations'

# When using `Timecop.freeze Date.parse('2018-06-06 10:00:00') do` than ordering
# from database could be random (since created_at are all the same) so you might
# get failed tests when comparing some scopes.
RSpec::Matchers.define :equal_when_sorted_by_id do |expected|
  match do |actual|
    actual.sort_by(&:id) == expected.sort_by(&:id)
  end
end

For flash messages in feature tests

# spec/a/flash_message_feature_helpers.rb
# instead of expect(page).to have_selector 'div', text: 'Flash message', visible: false
# you should use: expect(page).to have_flash_message 'Flash message'

require 'rspec/expectations'

RSpec::Matchers.define :have_flash_message do |expected|
  match do |page|
    expect(page).to have_selector 'div', text: expected, visible: false
  end
  failure_message do |page|
    "expected find text '#{expected}' in #{page.body}"
  end
end

Login helper

In controller tests you can use devise helpers.

RSpec.describe ProjectsController, type: :controller do
  let(:user) { FactoryBot.create :user }
  describe "authenticated user" do
    before(:example) do
      sign_in user
    end

    # Note that you can change user and it will affect current_user
    describe "has some projects" do
      before { user.projects << project }
      it "user has project" do
        expect(controller.current_user.projects.count).to eq(1)
      end
    end

Since in Rails 5, instead of controller tests, we should use integration helper and use sign_in user

class ActiveSupport::TestCase
  # provide `sign_in user`
  include Devise::Test::IntegrationHelpers
end

Alternatively, you can stub current_user.

https://github.com/plataformatec/devise/wiki/How-To:-Test-controllers-with-Rails-3-and-4-(and-RSpec)#controller-specs

Use clearance backdoor for bypassing login and simply visit my_profile_path(as: user)

In feature tests there we can’t use devise helpers but we can manually log in or use login_as warden method which is outside of rails stack

# spec/support/features/authentication_helpers.rb
module Features
  module AuthenticationHelpers
    def user_manual_sign_in(email, password)
      visit new_user_session_path
      fill_in 'Email', with: email
      fill_in 'Password', with: password
      click_button 'Sign In'
    end

    def create_logged_in_user
      user = FactoryBot.create(:user)
      login_as user
      user
    end

    def mock_facebook_auth(user = {})
      # info https://github.com/omniauth/omniauth/wiki/Integration-Testing
      auth = {
        provider: 'facebook',
        uid: user.try(:facebook_uid) || '1234567',
        info: {
          email: user.try(:email) || '[email protected]',
          name: user.try(:name) || 'Joe Bloggs',
          first_name: 'Joe',
          last_name: 'Bloggs',
          image: 'http://graph.facebook.com/1234567/picture?type=square',
          verified: true
        },
        credentials: {
          token: 'ABCDEF...', # OAuth 2.0 access_token, which you may wish to store
          expires_at: 1321747205, # when the access token expires (it always will)
          expires: true # this will always be true
        },
      }

      OmniAuth.config.test_mode = true
      OmniAuth.config.add_mock(:facebook, auth)
    end

    def mock_facebook_invalid_auth
      OmniAuth.config.test_mode = true
      OmniAuth.config.mock_auth[:facebook] = :invalid_credentials
    end

    def silence_omniauth
      previous_logger = OmniAuth.config.logger
      OmniAuth.config.logger = Logger.new("/dev/null")
      yield
    ensure
      OmniAuth.config.logger = previous_logger
    end
  end
end

RSpec.configure do |config|
  config.include Features::AuthenticationHelpers, type: :feature
end
# spec/support/devise.rb
RSpec.configure do |config|
  # this enable sign_in, sign_out devise methods in controller and view specs
  config.include Devise::Test::ControllerHelpers, type: :controller
  config.include Devise::Test::ControllerHelpers, type: :view
  # for devise <= 4.1.0
  # config.include Devise::TestHelpers
end
# configure only for features
RSpec.configure do |config|
  # login_as is warden method
  config.include Warden::Test::Helpers
end

I got error: UncaughtThrowError: uncaught throw :warden and that is because user was unconfirmed or database was empty. The best is to be by default confirmed:

  factory :user do
    sequence(:email) { |n| "email#{n}@trk.in.rs" }
    password 'asdfasdf'
    confirmed_at { Time.current }

    factory :unconfirmed_user do
      confirmed_at nil
    end
  end

Note that devise confirmation email is sent after_commit which is not happen if you are using database_cleaner. Solution is to jump to js or manually initiate sending email confirmation after create link

Mailer helper

# spec/support/mailer_helpers.rb
module MailerHelpers
  # you should call this manually for specific test
  # not for all tests like: config.before(:each) { reset_email }
  def clear_emails
    ActionMailer::Base.deliveries = []
  end

  def last_email
    fail 'please use give_me_last_mail_and_clear_mails'
    ActionMailer::Base.deliveries.last
  end

  # some usage is like:
  # mail = give_me_last_mail_and_clear_mails
  # assert_equal [email], mail.to
  # assert_match t('user_mailer.landing_signup.confirmation_text'), mail.body.decoded
  # confirmation_link = mail.body.decoded.match(
  #   /(http:.*)">#{t("confirm_email")}/
  # )[1]
  # visit confirmation_link
  def give_me_last_mail_and_clear_mails
    email = ActionMailer::Base.deliveries.last
    ActionMailer::Base.deliveries = []
    email
  end
end
RSpec.configure do |config|
  config.include(MailerHelpers)
end

Testing emails rspec is different for Devise 3 and Devise 4 (there is a problem that I can’t open regitration email in devise 4).

Pause helpers

# spec/support/pause_helpers.rb
module PauseHelpers
  # you can use byebug, but it will stop rails so you can not navigate to other
  # pages or make another requests in chrome while testing
  def pause
    $stderr.write 'Press CTRL+J or ENTER to continue'
    $stdin.gets
  end
end
RSpec.configure do |config|
  config.include PauseHelpers, type: :feature
end

If you want to show immediatelly errors while whole test suite is running you can use https://github.com/grosser/rspec-instafail

# Gemfile
group :development, :test do
  gem 'rspec-instafail', require: false
end

# .rspec
--require rspec/instafail
--format RSpec::Instafail
--format progress # to keep dots ap

Helpers inside spec file

You can add methods to your spec file, usually for integration specs using yield (click some button on some page) that can be customized for each test example

# spec/features/user_forgot_password_spec.rb
require 'rails_helper'

RSpec.feature 'User forgot password' do
  let(:email) { '[email protected]' }
  before do
    create :user, email: email
    reset_email
    Rails.cache.clear
  end

  def request_forgot_password(&block)
    visit new_user_session_path
    click_link 'Forgot your password?'

    fill_in 'Email', with: '[email protected]'
    yield if block_given?

    click_button 'Send me reset password instructions'
  end

  scenario 'with existing email' do
    request_forgot_password do
      fill_in 'Email', with: email
    end
    expect(page).to have_content 'You will receive an email with instructions about how to reset your password in a few minutes.'
    expect(last_email.to).to include email
    expect(all_emails.size).to eq 1
  end
end

Fixtures

All data is cleared between tests. Although we touch database (huge third party dependency) we call it unit test. All data is available on each tests. Rails starts db transaction at the begining of each tests and roll back at the end of test (if you do not want transaction you can disable config.use_transactional_fixtures = false)

We define it using yml file

# test/fixtures/projects.yml
my_project:
  name: My Project Name
  due_date: 2017-01-01

In tests you can access those data with projects(:my_project). Fixture loading mechanism bypasses ActiveRecord validations. You can use yaml identifier to create associations

simple_task:
  title: Simple task
  project: my_project

You can use erb to add dynamic attributes or to specify multiple entries

<% 10.times do |i| %>
task_<%= i %>:
  name: "Task <%= i %>"
<% end %>

Pros:

  • fixtures are usefull for global semi static data (like job_types)
  • very fast since all data is loaded at once. It’s better than factory bot since FactoryBot.create :task 10 times will create 10 projects (association objects) as well.

Cons:

  • fixtures are global so all edge cases will increase database data for each test
  • fixture are for specific models, so sometime it is hard to track complex setup (comment for that task for this project for that owner)
  • fixtures are distant and far away of test so you need look into it to figure out actual test setup
  • fixtures define database column so you need erb to define password hash.

    user:
      email: "[email protected]"
      encrypted_password: <%= User.new.send(:password_digest, 'password') %>
    
    # without device it is with gem bcrypt
      password_digest: <%= BCrypt::Password.create('password') %>
    

    In factory bot you can use model methods.

Factory Bot

(former Factory girl) After you install

sed -i Gemfile -e '/group :development, :test do/a  \
  gem "factory_bot_rails"'
bundle
mkdir spec/support
cat >> spec/support/factory_bot.rb << HERE_DOC
# so we do not need to prefix with FactoryBot.create :user
RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end
HERE_DOC

sed -i spec/rails_helper.rb -e '/require .spec_helper/a  \
require "support/factory_bot"'

To use factories in console you can simply use: FactoryBot.create :user

You can create factories in spec/factories.rb or spec/factories/*.rb. It is recomended to use only one file since it should be bare minimum, and should not change while the application grows (although it could get bigger). So write minimum that meets validation and use inheritance to create variations of data.

Getting started

  • factory name is used to determine the class (it has to be same or you need to use class: Project), dynamic attributes are defined with a block date_of_birth { 11.years.ago } in which you can use other attributes
# spec/factories.rb
FactoryBot.define do
  factory :project do
    name "My Project"
    full_name { "exiting #{name}" }
    due_date Date.parse("2017-01-10")
    # note that Time.zone.now is evaluated at begining of test suite so that
    # expired_at = Time.zone now - 20.mins if you test suite lasts 20.mins
    expired_at Time.zone.now
    # here is evaluated when we create :project
    expired_at { Time.zone.now }
    # also
    before :create do |project|
      project.expired_at = Time.zone.now
    end
  end
end
  • belongs_to associations attributes, you do not need to write any attributes if it match the class name, or you can specify which factory and which strategy to use when it is used. Note that you do not use _id for field name, use class name as usuall. In validation, you can validate presence for object but not id validates :user, presence: true (but not validates :user_id, presence: true)

    factory :comment do
      user
      # or
      association :user
      # if you want that user is built but not save to database
      association :user, strategy: :build
      # you can define class and additional attributes
      association :updated_by, factory: :user, name: "Admin User"
      # another form
      user.association :user, name: 'My User'
    end
    

    If you have circular dependency ie two associations that are not independent, than you can setup object in after build block (one of them is less important and another is more, which will always used as parameter and not used as generated, for example it will not happen create :location_ticket, location: location without customer it will generate customer from some other location).

    # location_ticket --------> location
    #                 --> customer --^
    factory :location_ticket do
      # location - we set in after build because of circular dependency
      customer
      after(:build) do |location_ticket|
        location_ticket.location = location_ticket.customer.location
      end
    end
    
    # if you have three dependencies than you can set both in after block.
    factory :location_package_sale do
      # location - we set in after build because of circular dependency
      # customer - we set in after build because of circular dependency
      # location_package - we set in after build because of circular dependency
      after(:build) do |location_package_sale|
        location = location_package_sale.location_package&.location
        location ||= location_package_sale.customer&.location
        unless location
          location = create :location
        end
        location_package_sale.location = location
        unless location_package_sale.customer.present?
          location_package_sale.customer = create :customer, location: location
        end
        unless location_package_sale.location_package.present?
          location_package_sale.location_package = create :location_package, location: location
        end
      end
    end
    
  • transient attributes can be used to set some configuration for dynamic attributes, that is not part of model attributes. Mostly used for has_many associations and create_list method

    factory :post do
      user
    end
    
    factory :user do
      transient do
        posts_count 5
      end
    
      # the after(:create) yields two values; the user instance itself and the
      # evaluator, which stores all values from the factory, including transient
      # attributes; `create_list`'s second argument is the number of records
      # to create and we make sure the user is associated properly to the post
      after(:create) do |user, evaluator|
        create_list(:post, evaluator.posts_count, user: user)
      end
    end
    
  • callbacks after(:create) do, after(:build), before(:create), after(:stub). Note that there is no before(:build)
    password 'asdfasdf'
    confirmed_at { Time.current }
    
    factory :unconfirmed_user do
      confirmed_at nil
    end
    
  • if you need to use existing record and not create new, you can like this
    factory :company do
    country { Country.first || create(:country) }
    created_by { User.first || create(:user) }
    end
    
  • if you need polymorphic, than use factory: :model attribute. Also you can put params in association https://robots.thoughtbot.com/aint-no-calla-back-girl
class SmsAlert < ActiveRecord::Base
  belongs_to :smsable, polymorphic: true
end
class Customer < ActiveRecord::Base
  has_many :sms_alerts, as: :smsable
end
factory :sms_alert do
  message 'a'
  factory :customer_sms_alert do
    smsable factory: :customer
  end
end
  • nested factory is usefull if you need different kind of objects. It is similar to inheritance. Note that if you have model UserWithPosts nested factory will use parent factory class User, not the UserWithPosts. You can also define as parent: :user attribute
factory :user_without_profile do
  factory :user do
    after :create do |user|
      create :profile, user: user
    end
  end
end
  • sequences are used to generate values (not AR objects) which will be incremented every time it is called during test (test sessions is whole rake test process or rails c -e test session). It is used for columns that need uniqueness like email field. Usually only one field in table can be sequence, other can use it with dynamic attributes

    # seq could be defined outside factory and reused
    sequence :username { |n| "username#{n}" }
    factory :user do
      username
      another_uniq_field { generate :username }
      sequence(:email) { |n| "user#{n}@asd.asd" }
    end
    
  • traits are used to group attributes so you can apply them to any factory. They can be used with hash also build :user, :admin, name: 'Mike'

    factory :user do
      name "Duke"
      username { name }
    
      trait :admin do
        username { "admin-#{name}" }
        is_admin true
      end
    
      # do not create profile for all users since that is not required in all test
      trait :with_profile do
        after :create do |user|
          create :user_profile, user: user
        end
      end
    end
    
  • 4 ways of using: you can pass the block to any of it, or you can override with hash attributes build(:user, name: 'My Name')
    • build(:user) it is not saved but if you have associated belongs_to :project than it will trigger create(:project) to get project_id and all its associated objects… you can try with strategy: :build but that could be a problem since associated_ids do not exists. Solution is to use build_stubbed or do not define association at all and define them in tests (code need to be testable without associations)
    • create(:user) it is saved. Use this only when need to test find/query db.
    • attributes_for(:user) get hash of attributes so you can use in params_for for example: xhr :post, users_path, user: attributes_for(:user). Note that it does not include parent object, so you need to merge references like post users_path, params: { user: attributes_for(:user).merge(company_id: company.id }, xhr: true
    • build_stubbed(:user) object with all AR attributes stubbed out (like save). It will raise an exception if they are called. Very fast since we do not create AR objects. It has rails ID and we can use associations. This is preferred way of creating data.
  • multiple records can be build_list :user, 25 or create_list :user, 25 or build_stubbed_list :user, 25

You can debug factory bot in rails console. After debugging test data will stay in database so you need to clean it manually

RAILS_ENV=test rake db:drop db:create db:migrate
rails c -e test # Better to work in test so you can drop db

# require "factory_bot" no needed if you have gem factory_bot_rails
require "./spec/factories"
FactoryBot.build :user

include FactoryBot::Syntax::Methods
build :user, name: 'Dule'

RAILS_ENV=test rake db:drop db:create db:migrate

Factory bot can’t be used as seed file as suggested thoughtbot post mainly because it is used to create new data (not to use existing) and used in a way that you do should not define all attributes.

Use fixtures for integration or complex controller tests. Use factories for unit tests.

Testing uploading files

joe.photo = File.new(File.join(Rails.root, 'spec', 'support', 'files', 'pookie.jpg'))
joe.save!
joe.photo_before_type_cast.should == "pookie.jpg"

For files you can use


Testing time and date

Using rails native helpers http://edgeapi.rubyonrails.org/classes/ActiveSupport/Testing/TimeHelpers.html Use ActiveSupport::Testing::TimeHelpers#travel (usefull when you want time to pass) and travel_to travel_back (time does not move for the duration of the test).

Most ActiveSupport methods (such as 6.days.ago) returns DateTime. If you want to compare with Date objects than you need to convert .to_date, or use .to_time or .to_datetime. The most robust way to compare is to use to_s(:db) for example 5.days.ago.to_date.to_s(:db)

When you want to set particular created_at than you can do like for any other attribute, in fixture or update method. Sometimes for updated_at you need to prevent rails callbacks with Project.record_timestamps = false; @project.updated_at = 5.days.ago; @project.save; Project.record_timestamps = true

Use gem Timecop https://github.com/travisjeffery/timecop timecoop Similar to mock which will return same value for all subsequent calls…

assert now
Timecop.freeze(Date.today + 30) do
  assert in one month
end

# fix all dates
Timecop.freeze '2020-01-01 08:00:00' do
  Time.now # will always return same value
  Time.now # will always return same value
end

# Recomended is to use block syntax since
Timecop.freeze(Date.today + 30)
Time.now # will always return same value
Time.now # will always return same value even in different test
Timecop.return

# or travel with block syntax
Timecop.travel(Date.today + 30) do
  Time.now # will show Date.today + 30 + 1s...
end

# Note that factory bot definitions are used before timecop, so if you have
factory :user do
  renewed_at: Time.zone.today
end

Timecop.freeze Date.parse('2018-01-15') do
  user = create :user
  user.renewed_at # => 2018-05-05
end

Using test doubles as mocks and stubs

https://relishapp.com/rspec/rspec-mocks A test double is fake object (double) or method (allow and receive) used in place of real when it is:

  • difficult to create it (network failure)
  • to isolate env so it tests only specific method
  • to test behavior during this test (what is called and how) and not just end results.

We can set expectation on arguments with with methods.

A TEST DOUBLE (serbian “kaskader” or “dvojnik”) is a term for any object that stands in for a real object. You can create with double method and by default they are strict: any message you have not allowed or expected will trigger an error. If you do not want that exception is raised when some other method is called than you can double(:user).as_null_object or spy(:user).

# this will raise exception for user.country, but not for user.name
user = double(:user, name: 'Duke')
# this will not raise exception for user.country
user = spy(:user)

VERYFING DOUBLE is used to mimic specific object and check if message passed to double is actually real method on real object and that arguments arity is the same.

instance_user = instance_double(User)

There are also class_double(User) and object_double(User.new).

All those have spy forms instance_spy, class_spy and object_spy giving verification that message exists without having to specify a return value.

In RSpec 3 configuration mocks.verify_partial_doubles = true will verify all partial doubles so you can not stub non existing method.

A STUB is test double that returns predetermined preconfigured value for a method call (without calling actual method on actual object). It is defined using allow().to receive().and_return(). It can also use and_raise(Exception, "message") or to provide a block to specify return value, verify arguments, perform calculation. https://relishapp.com/rspec/rspec-mocks/v/3-7/docs/configuring-responses

allow(thing).to receive(:name).and_return("Duke")
# you can define methods and return values in bulk
allow(thing).to receive(first_name: 'Duke', last_name: 'Orl')

Stubs are used to prepare test data, and mocks are used to expect some calls. A MOCK is similar to stub, but it sets expectation (expect instead allow) that method will actually be called till the end of a example. Note that POST or GET must happen after this expectation. It use expect().to receive.and_return. If it is not called, mock object triggers test failure. We can also set with which arguments it should be called https://relishapp.com/rspec/rspec-mocks/v/3-5/docs/setting-constraints/matching-arguments

expect(thing).to receive(:name).and_return("Duke")

service_double = instance_double(CreatesProject, create: true)

expect(CreatesProject).to receive(:new)
  .with(name: 'Duke', tasks_string: nil)
  .and_return(service_double)
post ...

another example

  it 'execute Charge service' do
    fake = instance_double( Charge )
    expect( Charge ).to receive(:new).with({
      object: @order,
      token: 'ToKeN',
      description: instance_of(String),
    }).and_return(fake)
    expect( fake ).to receive(:call).and_return(true)
    post :create, order_id: @order.id, stripeToken: 'ToKeN'
  end

  context 'when voucher failed to apply' do
    before do
      allow( Orders::ApplyVoucher ).to receive(:new).with(@order, 'test').and_return(double('service', call: false))
      put :apply_voucher, order_id: @order.id, code: 'test'
    end

    it 'sets failure notification' do
      expect( flash[:error] ).to eq 'Failed to apply voucher'
    end

    it 'redirects to new' do
      expect( response ).to be_redirect
      expect( response.redirect_url ).to eq new_charge_url(order_id: @order.id)
    end
  end

A SPY is similar to mock but we set expectation later using allow().to receive and expect().to have_received() (so we follow Given/When/Then structure)

allow(thing).to receive(:name).and_return("Duke")
# body of test
expect(thing).to have_received(:name)

PARTIAL DOUBLE is when you stub particular methods of real object, a FULL DOUBLE is when you use totally fake object that responds only to specified API (double) (so we do not care about behavior, only about public interface).

You can use mocks for controller tests, so you do not need to know valid and invalid params and a bug in CreatesProject will not fail this test. Usually when I use double to mock some class, than use small integration test to cover both classes (so I know that change of class API is not an issue here). It is fine that stubbed method returns a stub or double, but is not recomended that double contains other stub or doubles (double(create: true, project: double(name: 'Duke', tasks: [double(title: 'Hi')]))).

RSpec.describe ProjectsController, type: :controller do
  it "create a project" do
    fake_action = instance_double(CreatesProject, create: true)
    expect(CreatesProject).to receive(:new)
      .with(name: 'Duke', tasks_string: nil)
      .and_return(fake_action)
    post :create, params: { project: { name: 'Duke' } }
    expect(response).to redirect_to projects_path
  end
  it "goes back to the form on failure" do
    action_stub = double(create: false, project: Project.new)
    expect(CreatesProject).to receive(:new).and_return(action_stub)
    post :create, params: { project: { name: 'na' } }
    expect(response).not_to redirect_to projects_path
  end
  it "fails update gracefully" do
    sample = Project.create!(name: "Test Project")
    expect(sample).to receive(:update_attributes).and_return(false)
    allow(Project).to receive(:find).and_return(sample)
    patch :update, id: sample.id, project: {name: "Fred"}
    expect(response).to render_template(:edit)
  end
end
  • usually do not stub methods that you have not written. Extract external service to separate classes. Usually test double could still works but actual object does not receive or return reasonable value. If you mock external code which could easilly change, than your tests will still pass. One way to deal with it is to create wrappers

    class Project
      def self.create_from_controller(params)
        create(params)
      end
    end
    
    it "creates a project" do
      allow(Project).to receive(:create_from_controller).and_return(Project.new)
    end
    
  • if you need big fake objects it’s better to use stubs rather than mocks. Do not need to set expectation for current test (cover that object separatelly). We just stub external call and use some returned value. Use mock only when you need to trigger sending email.

  • if you need to change behavior of real objects, and can’t do dependency injection (process(date, validator) and stub/mock on validator, than you can use allow_any_instance_of and expect_any_instance_of:

  context 'with invalid data' do
    it 'raises Error' do
      allow_any_instance_of(Validator).to receive(:valid?).and_return(false)
      expect { processor.process('foo') }.to raise_error(DataProcessor::Error)
    end

    it 'call disconnect' do
      expect_any_instance_of(Subscriber).to receive(:delayed_disconnect)
    end
  end

There is a gem than can create stub for any activerecord model https://github.com/zeisler/active_mocker

Testing External service

  • client is our app, server is external service, adapter is between client and server, fake server returns a fake response
  • smoke test is using real server, not used often, but could be helpfull
  • integration test is using fake server
  • client unit test ends at adapter

Webmock

After installing and requiring webmock

sed -i Gemfile -e $'/group :development, :test do/a  \
  gem \'rspec-rails\'\
  gem \'webmock\''
bundle
sed -i spec/rails_helper.rb -e '/require .spec_helper/a  \
require \'support/factory_bot\''

you can not make any external request WebMock::NetConnectNotAllowedError: will be raised and information how to stub requests will be shown which you can use in your before of setup block, or in some helper like https://github.com/nebulab/cangaroo/blob/4effc172c6ee36ccf2b844e90dcc7041035d49cc/spec/support/spec_helpers.rb#L11-L30 You can also save real response in a file curl -is http://api.twitter.com/1/users/show/marnen.json > tests/responses/canned_response.json and stub_request(:get, "api.twitter.com/1/users/show/marnen.json").to_return(File.new 'canned_response.json' You can match partial query hash_including or using a block {} (do end wont work).

  • stub_request(method, uri), uri needs to be full (including query string) or use regexp
  • uri, body and headers and query params can be matched agains regexp
    stub_request(:get, '/webmock/')
    stub_request(:get, '
    `.with(body: //, headers:
    { "Content-Type": // )`
    
# spec/a/webmock_helper.rb
module WebmockHelper
  SMS_URI = /control.msg91.com/
  def stub_sms_to(mobile = "1111111111", messageRegexp = nil)
    if messageRegexp
      stub_request(:get, SMS_URI)
        .with(query: hash_including(mobiles: mobile)) { |request| request.uri.query_values['message'] =~ messageRegexp }
        .to_return(status: 200, body: "376967743076313037373133", headers: {}).times(1)
    else
      stub_request(:get, SMS_URI)
        .with(query: hash_including(mobiles: mobile))
        .to_return(status: 200, body: "376967743076313037373133", headers: {})
    end
  end

  def stub_sms_and_raise(mobile = "1111111111", error = Net::ReadTimeout)
    stub_request(:get, SMS_URI)
      .with(query: hash_including(mobiles: mobile))
      .to_raise(error)
  end
end
RSpec.configure do |config|
  config.include(WebmockHelper)
end

Last reponse is repeated infinitely (times) and you can specify number of times given response should be returned. You can set expecation on how many times request has been made.

Error like stub_request(:get, "http://127.0.0.1:9516/shutdown"). is issue with spring Also the error

WebMock::NetConnectNotAllowedError: Real HTTP connections are disabled. Unregistered request: POST http://127.0.0.1:9515/session with body '{"desiredCapabilities":{"browserName":"chrome","version":"","platform":"ANY","javascriptEnabled":true,"cssSelectorsEnabled":true,"takesScreenshot":false,"nativeEvents":false,"rotatable":false},"capabilities":{"firstMatch":[{"browserName":"chrome"}]}}' with headers {'Accept'=>'application/json', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Content-Length'=>'250', 'Content-Type'=>'application/json; charset=UTF-8', 'User-Agent'=>'selenium/3.14.1 (ruby linux)'}

Also when I run system tests

An HTTP request has been made that VCR does not know how to handle:
  GET http://127.0.0.1:9522/shutdown

  GET http://127.0.0.1:9522/session

  GET https://chromedriver.storage.googleapis.com/LATEST_RELEASE_79.0.3945

  GET https://github.com/mozilla/geckodriver/releases/latest


There is currently no cassette in use. There are a few ways

To fix you need to write initializer that call disable_net_connect!

# config/initializers/webmock.rb
if Rails.env.test?
  require 'webmock'
  WebMock.disable_net_connect!(allow_localhost: true)
end

You can not use webmock for requests in javascript.

VCR

VCR records outgoing HTTP requests. VCR is using cassetes so you do not need to manualy stub requests using curl.

# spec/support/vcr.rb
VCR.configure do |config|
  config.cassette_library_dir = "spec/vcr_cassettes"
  config.hook_into :webmock
  # config.allow_http_connections_when_no_cassette = true
  config.ignore_localhost = true

  # https://github.com/titusfortner/webdrivers/wiki/Using-with-VCR-or-WebMock
  # https://github.com/titusfortner/webdrivers/issues/109
  driver_hosts = Webdrivers::Common.subclasses.map { |driver| URI(driver.base_url).host }
  # without activesupport
  # driver_urls = (ObjectSpace.each_object(Webdrivers::Common.singleton_class).to_a - [Webdrivers::Common]).map { |driver| driver.send(:base_url) }

  driver_hosts += ['googleapis.com'] # chromedriver webdriver downloads
  driver_hosts += ['192.168.5.56'] # mikrotik test router
  config.ignore_hosts(*driver_hosts)

  # custom matcher ignore message
  config.default_cassette_options = {
    match_requests_on: [ :method,
      VCR.request_matchers.uri_without_params(:message)]
  }
end

# we can use implicit form with macros use_vcr_cassete, but that is deprecated,
# use metadata
# https://relishapp.com/vcr/vcr/v/3-0-3/docs/test-frameworks/usage-with-rspec-metadata

Latest rails uses gem 'webdrivers' https://github.com/titusfortner/webdrivers Since it automatically updates, it will be prevented by VCR You can ignore those requests https://github.com/titusfortner/webdrivers/wiki/Using-with-VCR-or-WebMock or manually trigger update with https://github.com/titusfortner/webdrivers#rake-tasks

RAILS_ENV=test rails webdrivers:chromedriver:update
RAILS_ENV=test bundle exec rake webdrivers:chromedriver:update

For error

Webdrivers::NetworkError: Net::HTTPServerException: 404 "Not Found" with https://chromedriver.storage.googleapis.com/107.0.5304.62/chromedriver_mac64_m1.zip

You should upgrade to 5.2.0 https://github.com/titusfortner/webdrivers/pull/239

For error

rails c
Loading development environment (Rails 7.0.6)
irb(main):001:0> Webdrivers::Chromedriver.update
/home/dule/.rbenv/versions/3.2.0/lib/ruby/gems/3.2.0/gems/webdrivers-5.2.0/lib/webdrivers/chromedriver.rb:83:in `rescue in latest_point_release': Unable to find latest point release version for 115.0.5790. You appear to be using a non-production version of Chrome. Please set `Webdrivers::Chromedriver.required_version = <desired driver version>` to a known chromedriver version: https://chromedriver.storage.googleapis.com/index.html (Webdrivers::VersionError)
/home/dule/.rbenv/versions/3.2.0/lib/ruby/gems/3.2.0/gems/webdrivers-5.2.0/lib/webdrivers/network.rb:19:in `get': Net::HTTPClientException: 404 "Not Found" with https://chromedriver.storage.googleapis.com/LATEST_RELEASE_115.0.5790 (Webdrivers::NetworkError)

It means you are using very recent version of chrome which is not yet supported. So you can hardcode to latest supported version https://chromedriver.storage.googleapis.com/LATEST_RELEASE for example 114.0.5735.90 so it does not autodetect based on you nightly builded chrome

# config/initializers/webdrivers.rb
Webdrivers::Chromedriver.required_version = "114.0.5735.90"

If you really need external requests you can

# spec/a/webmock.rb
require 'webmock/rspec'
WebMock.disable_net_connect!
RSpec.configure do |config|
  config.around :example, :live do |example|
    WebMock.allow_net_connect!
    VCR.turn_off!
    example.run
    VCR.turn_on!
    WebMock.disable_net_connect!
  end
end

Use cassete inside it example block

RSpec.describe "Location direct sms", js: true do
  it 'sends successfully' do
    VCR.use_cassette "sms_success_1111111111_cassette" do
      visit customer_path customer, open_chat: true
      click_button 'Send', visible: true
    end
  end
end

If you allow multiple requests (for example uploading csv with multiple users with same mobile number) than add allow_playback_repeats: true https://relishapp.com/vcr/vcr/v/3-0-3/docs/request-matching/playback-repeats

If you are not sure if sms will be sent (it is based on some other configuration) than allow_unused_http_interactions: false

Testing Rails.cache

By default in config/environments/test.rb we have config.action_controller.perform_caching = false. Note that this applies only for action_controller so any page, fragment or custom caching still works. You can disable all caching, ie caching is enabled but any write to cache is discarded with:

# config/environments/test.rb
Rails.application.configure do
  config.cache_store = :null_store
end

I you need to test some caching (if rails state depend on cache value) than you can clear (purge) cache before example

before :each do
  Rails.cache.clear
end

When not to write tests

  • when things are too hard to test, for example setting up model that external services need to use
  • too trivial tests
  • overtesting when we use same model method on severall controllers
  • exploratory coding (code that will not go to production)

Coverage

https://github.com/colszowka/simplecov

rake stats can give you LOC Code to Test ratio, which should be 1:2 - 1:3.

Tips

  • write tests that descibe behavior not implementation, so it does not change when implementation changes (completed? could be boolean true/false, or presence of completed_at),
  • only when dealing with edge cases you can look at implementation (time/dates and off-by-one error)
  • all unit tests should be (Rails 4 Test Prescriptions book):
    • Straightforward: since method should do one thing we should split tests into smaller semantically meaningful parts which are easy to understand. Do not use tricks to write short test code, since it should be simple.
    • Well defined: repeatability could be problem in case of datetime, random and third-party calls.
    • Independent: does not depend on other tests (single line of code breaks multiple tests or order of test cause failure) or external data like fixtures
    • Fast: big startup time, dependencies that create a lot of objects, database usage
    • Truthful: do not fail while code still works (in view tests we can replace exact match “Some title” with “#id.class” that rarely change). or do not pass while code has an issue (mock objects)
  • if you find yourself writting tests that already pass, that means you are writing too much code. You shoult apply “sliming”, write only code that make makes test pass
  • big method does not clearly separate individual steps, depend on side effects rher than return values.
  • first write initial state, than simple successful path than alternate successful paths and than error edge cases that break code (one by one)
  • there is a difference between integration test (when we do not use doubles) and top level unit test, when we use stubs since we only test that method is calling other methods, which will be unit tested separately. We could write unit test for those other methods first, that is bottom up approach, we need to fake data. Symetrically, top down approach is when we first write top level unit test and fake calls to other methods. You should cover edge cases in top level unit test (using doubles), and not writting a lot of integration tests which are slow and more likely to fail as it needs to call all other methods.
  • on legacy app, when code is intertwined, there are a lot of dependencies between parts of the code, it is hard to write unit test. You can start with feature tests, than write characterization test (assert(result).to eq('correct') that are used only to check old behavior, than use TDD to write new tests and code, and than refactor while characterization tests keep old behavior.
  • do not use test doubles too much when code has a lot of depencencies. It is better that test is fragile than writting/updating test double setup

Overly aggressive test doubles that set unneeded expectations are also a common cause of test fragility, so if a test fails because of an expectation on the double, it’s useful to ask whether the expectation needs to be there in the first place.

  • you do not need to write tests for part of the framework.
  • YAGNI is not used in tests
  • repeat Red-Green-Refactor cycle in small increments, because a lot of changes will have a lot of places where code could broke. Refactoring step should not need to change any tests, just code.
  • simulate network timeouts with

    # if you perform request with `res = Net::HTTP.get_response(uri)`
    expect(Net::HTTP).to receive(:get_response).and_raise(Net::OpenTimeout)
    click_button 'Make Request'
    

TODO:

Antipaterns

http://code.tutsplus.com/articles/antipatterns-basics-rails-tests--cms-26011

# spect/support/geocoder.rb
Geocoder.configure(lookup: :test)

Geocoder::Lookup::Test.set_default_stub(
  [
    {
      'latitude'     => 40.7143528,
      'longitude'    => -74.0059731,
      'address'      => 'New York, NY, USA',
      'state'        => 'New York',
      'state_code'   => 'NY',
      'country'      => 'United States',
      'country_code' => 'US',
      'city'         => 'New York',
    }
  ]
)

Acts as taggable

If you have acts_as_taggable_on :cuisines you can create with _list method: FactoryBot.create :user, cuisine_list: ['American', 'Indian'].

In fixtures you should put only what is neccesarry to create object (only validated field) so our test do not need to know about validations when they test something different. Define all neccessary stuf (like Tags) in test on the fly. Do not let your tests depend on fixtures.

Some references:

guidlines betterspecs thoughtbot

  • avoid using instance variables in tests
  • do not test private methods
  • use stubs and spies instead of mocks since it clearly separate SETUP, EXERSIZE and VERIFICATION phase.

Parallel tests

https://github.com/grosser/parallel_tests

Opensource examples with tests

Best source is real word rails applications https://github.com/eliotsykes/real-world-rails and real world rspec examples with prescriptions https://github.com/eliotsykes/rspec-rails-examples

clean https://github.com/ni3t/tweetfire https://github.com/elizabrock/coursewareofthefuture exaples for all tests

Live Coding and other video tutorials

Rspec book http://www.betterspecs.org/

  • sandy metz

  • fake sms client external test with adapter https://thoughtbot.com/blog/faking-external-services-in-tests-with-adapters Improve speed of the tests
  • show current test file name
    # spec/rails_helper.rb
    RSpec.configure do |config|
      config.before :each do |x|
        puts "In #{self.class.description} #{x.description} #{x.example.file_path}"
      end
    end
    
  • run test in seed deterministic order
    rails test --seed 12345
    

https://github.com/palkan/test-prof

TODO

https://player.fm/series/series-1401837/46-joe-ferris-test-driven-rails https://www.youtube.com/watch?v=yTkzNHF6rMs https://vimeo.com/44807822 https://www.youtube.com/watch?v=9f08KzNO4qo https://m.patrikonrails.com/how-i-test-my-rails-applications-cf150e347a6b https://robots.thoughtbot.com/headless-feature-specs-with-chrome https://building.buildkite.com/5-ways-weve-improved-flakey-test-debugging-4b3cfb9f27c8 aceptance testing easy system testings https://rlafranchi.github.io/system_tester/