I would like to test save method:
class Note
def initialize(password)
#password = password
end
def save
encryption = Note::Encryption.new(#password)
encrypted = encryption.encrypt(serialized)
storage = Note::Storage.new
storage.write(encrypted)
end
private
def serialized
{some_data: true}
end
# .....
end
Method just delegates work to the other classes mainly, and that's the only responsibility. My first bet to testing it is just checking:
describe '#save' do
let(:encryption){ '12345' }
it 'calls encryption' do
expect_any_instance_of(Note::Encryption).to receive(:encrypt)
subject.save
end
it 'saves the file with data' do
expect_any_instance_of(Note::Storage).to receive(:write)
subject.save
end
end
I've problem with this approach as I'm little bit afraid that these tests don't test too much... Moreover test now is high coupled with the implementation not behavior. Do anyone know how to approach to this kind of methods. Worth to mention that this class would be at the top of system as wraps some resource.
I would test a bit more, because I would like to be sure about the arguments passed to that methods:
class Note
def initialize(password)
#password = password
end
def save
encrypted = Note::Encryption.new(#password).encrypt(serialized)
Note::Storage.new.write(encrypted)
end
private
def serialized
{some_data: true}
end
end
# the test
describe '#save' do
let(:password) { 'secret password' }
let(:encryted) { 'encrytped string' }
let(:storage) { double(:write => true) }
let(:note_encryption) { double(:encrypt => encrypted) }
subject(:note) { Note.new(password) }
before do
allow(Note::Encryption).to receive(:new).with(password).and_return(note_encryption)
Note::Storage.stub(:new => storage)
end
it 'encrypted the password' do
note.save
expect(Note::Encryption).to have_received(:new).with(password)
expect(note_encryption).to have_received(:encrypt).with(serialized) # mock serialized?
end
it 'stores the encryted string' do
note.save
expect(storage).to have_received(:write).with(encrypted)
end
end
If you look at the method, there are a few discrete things that it is doing:
def save
encryption = Note::Encryption.new(#password)
encrypted = encryption.encrypt(serialized) # first action
storage = Note::Storage.new
storage.write(encrypted) # second action
end
Those are the things you should test, along with validations - what happens if #password is an empty string?
Tests could look something like this:
let(:password) { 'my_pass' }
let(:note) { Note.new(password) }
let(:result) { note.save }
describe 'save' do
describe 'with a blank password' do
let(:password) { '' }
it 'fails' do
# assert_raises...
end
end
it 'encrypts the password' do
refute_same password, result #of course, this depends on what Storage.write returns...
end
it 'saves the password' do
# assert that the password is saved
end
end
Also, if I can suggest a refactor -
class Note
def initialize(password)
#password = password
end
def save
encrypted = build_encrypted_object
storage.write(encrypted)
end
private
def build_encrypted_object
encryption.encrypt(serialized)
end
def serialized
{some_data: true}
end
def encryption
Note::Encryption.new(#password)
end
def storage
Note::Storage.new
end
# .....
end
Related
I have a class, that in one situation should call :my_method, but in another situation must not call method :my_method. I would like to test both cases. Also, I would like the test to document the cases when :my_method should not be called.
Using any_instance is generally discouraged, so I would be happy to learn a nice way to replace it.
This code snippet is a reduced example on what I kind of test I would like to write.
class TestSubject
def call
call_me
end
def call_me; end
def never_mind; end
end
require 'rspec'
spec = RSpec.describe 'TestSubject' do
describe '#call' do
it 'calls #call_me' do
expect_any_instance_of(TestSubject).to receive(:call_me)
TestSubject.new.call
end
it 'does not call #never_mind' do
expect_any_instance_of(TestSubject).not_to receive(:never_mind)
TestSubject.new.call
end
end
end
spec.run # => true
It works, but uses expect_any_instance_of method, which is not recommended.
How to replace it?
I'll do somehting like that
describe TestSubject do
describe '#call' do
it 'does not call #something' do
subject = TestSubject.new
allow(subject).to receive(:something)
subject.call
expect(subject).not_to have_received(:something)
end
end
end
Hope this helped !
This is how I normally unit-test. I updated the code to support other possible questions you (or other readers) may have in the future.
class TestSubject
def call
some_call_me_value = call_me
call_you(some_call_me_value)
end
def call_me; end
def call_you(x); end
def never_mind; end
class << self
def some_class_method_a; end
def some_class_method_b(x, y); end
end
end
require 'rspec'
spec = RSpec.describe TestSubject do
context 'instance methods' do
let(:test_subject) { TestSubject.new }
describe '#call' do
let(:args) { nil }
let(:mocked_call_me_return_value) { 'somecallmevalue' }
subject { test_subject.call(*args) }
before do
allow(test_subject).to receive(:call_me) do
mocked_call_me_return_value
end
end
it 'calls #call_me' do
expect(test_subject).to receive(:call_me).once
subject
end
it 'calls #call_you with call_me value as the argument' do
expect(test_subject).to receive(:call_you).once.with(mocked_call_me_return_value)
subject
end
it 'does not call #never_mind' do
expect(test_subject).to_not receive(:never_mind)
subject
end
it 'calls in order' do
expect(test_subject).to receive(:call_me).once.ordered
expect(test_subject).to receive(:call_you).once.ordered
subject
end
end
describe '#call_me' do
let(:args) { nil }
subject { test_subject.call_me(*args) }
# it ...
end
describe '#call_you' do
let(:args) { nil }
subject { test_subject.call_you(*args) }
shared_examples_for 'shared #call_you behaviours' do
it 'calls your phone number'
it 'creates a Conversation record'
end
# just an example of argument-dependent behaviour spec
context 'when argument is true' do
let(:args) { [true] }
it 'does something magical'
it_behaves_like 'shared #call_you behaviours'
end
# just an example of argument-dependent behaviour spec
context 'when argument is false' do
let(:args) { [false] }
it 'does something explosive'
it_behaves_like 'shared #call_you behaviours'
end
end
end
context 'class methods' do
let(:args) { nil }
describe '#some_class_method_a' do
let(:args) { nil }
subject { TestSubject.some_class_method_a(*args) }
# it ...
end
describe '#some_class_method_b' do
let(:args) { [1, 2] }
subject { TestSubject.some_class_method_b(*args) }
# it ...
end
end
end
spec.run # => true
Do not test if some method was called or wasn't.
This will tight your tests to the implementation details and will force you to change tests every time you refactor(change implementation details without changing the behaviour) your class under test.
Instead test against return value or changed application state.
It is difficult come up with the example, you didn't provide enough context about the class under the test.
class CreateEntity
def initialize(name)
#name = name
end
def call
if company_name?(#name)
create_company
else
create_person
end
end
def create_person
Person.create!(:name => #name)
end
def create_company
Company.create!(:name => #name)
end
end
# tests
RSpec.describe CreateEntity do
let(:create) { CreateEntity.new(name).call }
describe '#call' do
context 'when person name is given' do
let(:name) { 'Firstname Lastname' }
it 'creates a person' do
expect { create }.to change { Person.count }.by(1)
end
it 'do not create a company' do
expect { create }.not_to change { Company.count }
end
end
context 'when company name is given' do
let(:name) { 'Name & Sons Ltd' }
it 'creates a company' do
expect { create }.to change { Company.count }.by(1)
end
it 'do not create a person' do
expect { create }.not_to change { Person.count }
end
end
end
end
With tests above I would be able to change how CreateEntity.call method implemented without changing tests as far as behaviour remain same.
Background: I've got an after_action callback in my controller, which takes the string address, processes it and stores longitude and latitude in corresponding fields. I want to test this.
This SO question, as well as this article only consider update methods, but at least, they are quite clear, because I've already got an object to work with.
So my question is - how to find this newly created record? This SO question led me to this code:
require 'rails_helper'
RSpec.describe Admin::Settings::GeneralSettingsController, type: :controller do
context "POST methods" do
describe "#edit and #create" do
it "encodes and stores lang/lot correctly" do
post :create, general_setting: FactoryGirl.attributes_for(:general_setting)
expect(assigns(:general_setting).long).to eq(37.568021)
# expect(general_setting.long).to eq(37.568021)
# expect(general_setting.lat).to eq(55.805553)
end
end
end
end
But using the code in the answer, I get this error:
Failure/Error: expect(assigns(:general_setting).long).to eq(37.568021)
NoMethodError:
undefined method `long' for nil:NilClass
Update #1:
This is my new controller spec code:
RSpec.describe Admin::Settings::GeneralSettingsController, type: :controller do
context 'POST methods' do
before(:each) do
allow(subject).to receive(:set_long_lat)
end
describe 'post create' do
before(:each) do
post :create, params: { general_setting: FactoryGirl.attributes_for(:general_setting) }
end
it "saves the record with valid attributes" do
expect{subject}.to change{GeneralSetting.count}.by(1)
end
it 'calls :set_long_lat' do
expect(subject).to have_received(:set_long_lat)
end
end
end
describe '#set_long_lat' do
# spec for method
end
end
Update #2:
Here is my controller code:
class Admin::Settings::GeneralSettingsController < AdminController
include CrudConcern
before_action :find_general_setting, only: [:edit, :destroy, :update, :set_long_lat]
after_action :set_long_lat
def index
#general_settings = GeneralSetting.all
end
def new
#general_setting = GeneralSetting.new
# Билдим для того, что бы было видно сразу одно поле и пользователь не должен
# кликать на "добавить телефон"
#general_setting.phones.build
#general_setting.opening_hours.build
end
def edit
# Тоже самое, что и с нью - если телефонов нет вообще, то показываем одно пустое поле
if #general_setting.phones.blank?
#general_setting.phones.build
end
if #general_setting.opening_hours.blank?
#general_setting.opening_hours.build
end
end
def create
#general_setting = GeneralSetting.new(general_setting_params)
create_helper(#general_setting, "edit_admin_settings_general_setting_path")
end
def destroy
destroy_helper(#general_setting, "admin_settings_general_settings_path")
end
def update
# debug
# #general_setting.update(language: create_hash(params[:general_setting][:language]))
#general_setting.language = create_hash(params[:general_setting][:language])
update_helper(#general_setting, "edit_admin_settings_general_setting_path", general_setting_params)
end
private
def set_long_lat
geocoder = Geocoder.new
data = geocoder.encode!(#general_setting.address)
#general_setting.update!(long: data[0], lat: data[1])
end
def find_general_setting
#general_setting = GeneralSetting.find(params[:id])
end
def general_setting_params
params.require(:general_setting).permit(GeneralSetting.attribute_names.map(&:to_sym).push(
phones_attributes: [:id, :value, :_destroy, :general_setting_id ]).push(
opening_hours_attributes: [:id, :title, :value, :_destroy, :deneral_setting_id]) )
end
def create_hash(params)
language_hash = Hash.new
params.each do |param|
language_hash[param.to_sym] = param.to_sym
end
return language_hash
end
end
(If it helps - I've got a lot of similar crud-actions, that is why I've put them all in a concern controller)
module CrudConcern
extend ActiveSupport::Concern
include Language
included do
helper_method :create_helper, :update_helper, :destroy_helper, :get_locales
end
def get_locales
#remaining_locales = Language.get_remaining_locales
end
def create_helper(object, path)
if object.save!
respond_to do |format|
format.html {
redirect_to send(path, object)
flash[:primary] = "Well done!"
}
end
else
render :new
flash[:danger] = "Something not quite right"
end
#remaining_locales = Language.get_remaining_locales
end
def update_helper(object, path, params)
if object.update!(params)
respond_to do |format|
format.html {
redirect_to send(path, object)
flash[:primary] = "Well done!"
}
end
else
render :edit
flash[:danger] = "Something's not quite right"
end
end
def destroy_helper(object, path)
if object.destroy
respond_to do |format|
format.html {
redirect_to send(path)
flash[:primary] = "Well done"
}
end
else
render :index
flash[:danger] = "Something's not quite right"
end
end
end
Update #3
It's not the ideal solution, but, somehow, controller tests just won't work. I've moved my callback into the model and updated my general_setting_spec test.
class GeneralSetting < ApplicationRecord
after_save :set_long_lat
validates :url, presence: true
private
def set_long_lat
geocoder = Geocoder.new
data = geocoder.encode(self.address)
self.update_column(:long, data[0])
self.update_column(:lat, data[1])
end
end
My tests now:
RSpec.describe GeneralSetting, type: :model do
let (:regular) { FactoryGirl.build(:general_setting) }
describe "checking other validations" do
it "is invalid with no url" do
expect{
invalid.save
}.not_to change(GeneralSetting, :count)
end
it 'autofills the longitude' do
expect{ regular.save }.to change{ regular.long }.from(nil).to(37.568021)
end
it 'autofills the latitude' do
expect{ regular.save }.to change{ regular.lat }.from(nil).to(55.805078)
end
end
end
I would test expectation that controller calls method specified in after_action and make a separate test for that method.
Something like:
context 'POST methods' do
before(:each) do
allow(subject).to receive(:method_from_callback)
end
describe 'post create' do
before(:each) do
post :create, params: { general_setting: attributes_for(:general_setting) }
end
it 'calls :method_from_callback' do
expect(subject).to have_received(:method_from_callback)
end
end
end
describe '#method_from_callback' do
# spec for method
end
Be sure to use your method name instead of :method_from_callback pay attention that I've used rspec 3.5 syntax (wrapped request request parameters into params).
I have the following controller concern that is used for authentication:
module ValidateEventRequest
extend ActiveSupport::Concern
def event_request_verified?(request)
sha256 = OpenSSL::Digest::SHA256.new
secret = app_client_id
body = request.body.read
signature = OpenSSL::HMAC.hexdigest(sha256, secret, body)
([signature] & [request.headers['X-Webhook-Signature'], request.headers['X-Api-Signature']]).present?
end
private
def app_client_id
ENV['APP_CLIENT_ID']
end
end
So far I have the following Rspec Test setup to hit this:
RSpec.describe ValidateEventRequest, type: :concern do
let!(:current_secret) { SecureRandom.hex }
describe '#event_request_verified?' do
it 'validates X-Webhook-Signature' do
# TBD
end
it 'validates X-Api-Signature' do
# TBD
end
end
end
I started out with stubbing the request, then mocking and stubbing, and now I am down to scrapping what I have and seeking assistance. 100% coverage is important to me and I am looking for some pointers on how to structure tests that cover this 100%.
object_double is handy for testing concerns:
require 'rails_helper'
describe MyClass do
subject { object_double(Class.new).tap {|c| c.extend MyClass} }
it "extends the subject" do
expect(subject.respond_to?(:some_method_in_my_class)).to be true
# ...
Then you can test subject like any other class. Of course you need to pass in the appropriate arguments when testing methods, which may mean creating additional mocks -- in your case a request object.
Here is how I solved this issue, and I am open to ideas:
RSpec.describe ValidateApiRequest, type: :concern do
let!(:auth_secret) { ENV['APP_CLIENT_ID'] }
let!(:auth_sha256) { OpenSSL::Digest::SHA256.new }
let!(:auth_body) { 'TESTME' }
let(:object) { FakeController.new }
before(:each) do
allow(described_class).to receive(:secret).and_return(auth_secret)
class FakeController < ApplicationController
include ValidateApiRequest
end
end
after(:each) do
Object.send :remove_const, :FakeController
end
describe '#event_request_verified?' do
context 'X-Api-Signature' do
it 'pass' do
request = OpenStruct.new(headers: { 'X-Api-Signature' => OpenSSL::HMAC.hexdigest(auth_sha256, auth_secret, auth_body) }, raw_post: auth_body)
expect(object.event_request_verified?(request)).to be_truthy
end
it 'fail' do
request = OpenStruct.new(headers: { 'X-Api-Signature' => OpenSSL::HMAC.hexdigest(auth_sha256, 'not-the-same', auth_body) }, raw_post: auth_body)
expect(object.event_request_verified?(request)).to be_falsey
end
end
context 'X-Webhook-Signature' do
it 'pass' do
request = OpenStruct.new(headers: { 'X-Webhook-Signature' => OpenSSL::HMAC.hexdigest(auth_sha256, auth_secret, auth_body) }, raw_post: auth_body)
expect(object.event_request_verified?(request)).to be_truthy
end
it 'fail' do
request = OpenStruct.new(headers: { 'X-Webhook-Signature' => OpenSSL::HMAC.hexdigest(auth_sha256, 'not-the-same', auth_body) }, raw_post: auth_body)
expect(object.event_request_verified?(request)).to be_falsey
end
end
end
end
I've got a before_destroy callback that looks like this:
class Component < ActiveRecord::Base
has_many :documents, through: :publications
def document_check
if documents.exists?
errors[:documents] << 'cannot exist'
return true
else
return false
end
end
The test looks like this:
describe '#document_check' do
let(:document) { create(:document) }
let(:component) { create(:component) }
context 'with documents' do
before do
document.components << component
end
specify { expect(component.errors).to include(:document, 'cannot exist') }
specify { expect(component.document_check).to eq true }
end
context 'without documents' do
before do
document.components = []
end
specify { expect(component.document_check).to eq false }
end
end
I want it to raise the error if a component is in a document, but I can't seem to be able to write it correctly. The second test passes, the first doesn't:
Diff:
## -1,2 +1,2 ##
-[:document, "cannot exist"]
+[]
What am I doing wrong?
How is document_check being invoked? If manually (as you're 2nd tests seem to suggest) then you also need to invoke it for the first specify.
That is:
specify { component.document_check; expect(component.errors).to include(:document, 'cannot exist') }
That's horrible syntax, but you need to invoke the method before you can check the errors on it.
Here's the callback:
def document_check
return unless documents.present?
errors.add(:article, 'in use cannot be deleted')
false
end
And here's the passing test for it.
describe '#document_check' do
let(:subject) { create(:component) }
let(:document) { create(:document) }
let(:count) { Component.size }
before do
document.components << subject
subject.send :document_check
end
context 'with documents raises error' do
specify do
expect(subject.errors[:article]).to be_present
end
end
context 'with documents raises correct error' do
specify do
expect(subject.errors[:article]).to include(
'in use cannot be deleted')
end
end
context 'with documents prevents deletion' do
specify do
expect { subject.destroy }.to_not change(Component, :count)
end
end
end
Took ages but it's worth it.
module Asterisk
class Client
include HTTParty
base_uri 'https://asterisk.dev/'
def initialize(session_key = nil)
#session_key = session_key
end
def get_session_key(login, password)
self.class.put('/api/auth', query: { login: login, password: password })
end
def get_contacts
self.class.get("/api/#{#session_key}/contacts")
end
def get_contact(id)
self.class.get("/api/#{#session_key}/contact/#{id}")
end
def create_contact
self.class.put("/api/#{#session_key}/create")
end
def logout
self.class.delete("/api/#{#session_key}/logout")
end
end
end
Right now it works like below
session_key = Asterisk::Client.new.get_session_key('login', 'pass')
client = Asterisk::Client.new(session_key)
client.get_contacts
I would like to get and set session key using singleton. How to do that?
module Asterisk
class Client
include HTTParty
include Singleton
base_uri 'https://asterisk.dev/'
attr_reader :last_session_update
private
def session_key
if !#session_key || session_refresh_needed?
#session_key = set_session_key
#last_session_update = Time.now
else
#session_key
end
end
def set_session_key
self.class.put('/api/auth', query: { login: login, password: password })
end
def password
#the way you get pass
end
def login
#the way you get login (ENV...)
end
def session_refresh_needed?
return true unless last_session_update
( Time.now - last_session_update) > 30.minute
end
end
end
It includes your issue with 30 minutes refresh.
Now call Asterisk::Client.instance