Rails Testing
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
- example groups
describe "..." do
orcontext "..." 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 examplecontext 'when not logged in
orcontext 'when resource is not found'
). Context description always start withwhen
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'
(bettercontext 'when name is present' it 'returns name'
) - Example group is a class in which
describe
orcontext
is evaluated, andit
is evaluated on instance of that class - subject
is used in group scope (
describe
) to define value that is returned bysubject
method in example (it
) scope. - one liner it
syntax
Instead of
it { expect(subject).to be_empty }
you can useit { is_expected.to be_empty }
orit { should be_empty }
(deprecated). If you need to access properties of an object for one liner syntax, than useit { is_expected.to have_attribute :name }
. So do not usesubject
insideit
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 asbefore(:example)
andbefore(: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 identitya.equal? b
(refer to the same object)eq
is object equivalence with type conversiona == b
(same value)
- predicate
matchers for any
method that begin with
has_
or ends with?
you can usehave_
andbe_
expect(object).not_to be_empty
orbe_near near_location
-
type matchers
expect(obj).to be_kind_of(Type)
orbe_a
type of Type -
expect { do_something }.to change { object.attribute }
. You can also specify valuesexpect {}.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-matcherit "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 valuesexpect(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.*/)
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 '
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.
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 fordescribe ..., type: :feature
orcontext
background
is alias forbefore
scenario
is an alias forit
given
is alias forlet
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.
- 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 blockdate_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 idvalidates :user, presence: true
(but notvalidates :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 nobefore(: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 inassociation
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 classUser
, not theUserWithPosts
. You can also define asparent: :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 orrails 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 associatedbelongs_to :project
than it will triggercreate(:project)
to getproject_id
and all its associated objects… you can try withstrategy: :build
but that could be a problem since associated_ids do not exists. Solution is to usebuild_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 likepost users_path, params: { user: attributes_for(:user).merge(company_id: company.id }, xhr: true
build_stubbed(:user)
object with all AR attributes stubbed out (likesave
). 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
orcreate_list :user, 25
orbuild_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 onvalidator
, than you can useallow_any_instance_of
andexpect_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
- https://github.com/discourse/discourse
- https://github.com/manshar/manshar rails and angular
- https://github.com/scottwillson/racing_on_rails minitests
- https://github.com/bikeindex/bike_index rspec, api, swagger
- https://github.com/mabranches/Olympic swagger, jsonapi
- http://stackoverflow.com/questions/4421174/what-are-some-good-example-open-source-ruby-projects-that-use-cucumber-and-rspec
clean https://github.com/ni3t/tweetfire https://github.com/elizabrock/coursewareofthefuture exaples for all tests
Live Coding and other video tutorials
- Advance API Phil Sturgeon list author of Building APIs your won’t hate
- gregg pollack and carlos souza minispec rails api
- Sean Devine Live code a charity auction application source code
- thoughbot workshop intro to tdd also mocking
- matt smith
- Paul Hiatt
- Railscasts
- Mocking Luca Pradovera
- Carlos Souza
Rspec book http://www.betterspecs.org/
- 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/