How to test this with RSpec?
module Thing
def self.call
Foo.call do
Bar.call
end
yield
end
end
When stubbing Foo.call
, in order for block that contains Bar.call
to be
called, we need to use and_yield
. It can take arguments. See also
https://www.rubydoc.info/gems/rspec-mocks/RSpec%2FMocks%2FMessageExpectation:and_yield
RSpec.describe Thing do
before do
allow(Foo).to receive(:call).and_yield
allow(Bar).to receive(:call)
end
describe '.call' do
it 'calls Foo' do
described_class.call
expect(Foo).to have_received(:call)
end
it 'calls Bar' do
described_class.call
expect(Bar).to have_received(:call)
end
end
end
We can expect the subject under test to yield control by passing a block to
expect with an argument. The argument itself is a block, captured (&b
) and
passed to the method call. See also
https://rubydoc.info/github/rspec/rspec-expectations/RSpec%2FMatchers:yield_with_args
it 'yields control to a block' do
expect do |b|
described_class.call(&b)
end.to yield_control
end
You can pass blocks to custom matchers, but we need to use the block_arg
method. In this example, the captured block is forwarded to the
assert_turbo_stream
method.
🚨 Must use curly braces for blocks passed to matchers
not only will do/end
not work, it will silently fail.
RSpec::Matchers.define :have_turbo_stream do |action:, target: nil, targets: nil, count: 1|
match do |_actual|
assert_turbo_stream(action:, target:, targets:, count:, &block_arg).present?
end
end
it { is_expected.to have_turbo_stream(action: 'foo') { assert_select 'div.bar' } }
Can be used to stub multiple calls to the same method, which could raise and retry see also https://www.rubydoc.info/github/rspec/rspec-mocks/RSpec%2FMocks%2FMessageExpectation:and_invoke
See also Database Locks
Example code to test:
def call
result = MyModel.with_advisory_lock_result('my_lock', timeout_seconds: 0) do
# do things sensitive to competing consumers
end
raise MyError unless result.lock_was_acquired?
end
Test the lock creation and behavior with lock cannot be acquired:
describe '#call' do
it 'creates an advisory lock' do
allow(MyModel).to receive(:with_advisory_lock_result).and_call_original
described_class.new.call
expect(MyModel).to have_received(:with_advisory_lock_result).with('my_lock', timeout_seconds: 0)
end
context 'when an advisory lock cannot be acquired' do
it 'raises an error' do
locking_thread = Thread.new do
MyModel.with_advisory_lock('my_lock') do
sleep 3 # retain lock for enough time to perform expection below
end
end
sleep 0.5 # Allow time for the Thread to be created and lock acquired before the main thread does
expect { described_class.call }.to raise_error(described_class::MyError)
locking_thread.kill # Dispose of the thread after expectation (no need to wait any longer)
end
end
end
You can test them by themselves or subclass them with a dummy class
RSpec.describe Thing::Base do
let(:dummy_thing) do
Class.new(described_class)
end
before do
stub_const('DummyThing', dummy_thing)
end
describe '#method_that_should_be_implemented' do
subject do
DummyThing.new.method_that_should_be_implemented
end
it { is_expected.to raise_error 'DummyThing must implement the method method_that_should_be_implemented'}
end
end
- Create an anonymous class that inherits from
ApplicationRecord
See also https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyConstantDeclaration for guidelines on creating anonymous classes - Create a table and insert some records if needed
- Make sure the model sets the
table_name
- In the test, now you can instantiate
model_class.new
and test the concerns behviour proxied through the model_class obj
RSpec.describe MyConcern do
let(:model_class) do
Class.new(ApplicationRecord) do
self.table_name = 'mock_table'
extend MyConcern
end
end
before :all do
ActiveRecord::Base.connection.execute(<<~SQL)
CREATE TABLE mock_table (
id serial PRIMARY KEY,
label varchar
);
INSERT INTO mock_table (label)
VALUES ('Foo'), ('Bar');
SQL
end
after :all do
ActiveRecord::Base.connection.drop_table :mock_types
end
# ... specs here
end
See also https://rubydoc.info/github/rspec/rspec-expectations/RSpec/Matchers https://gist.github.com/JunichiIto/f603d3fbfcf99b914f86
- Open a file
- Use create_and_upload!
let(:io) { File.open Rails.root.join('spec/fixtures/files/image.png') }
let(:blob) { ActiveStorage::Blob.create_and_upload!(io:, filename: 'image.png') }
In config/storage.yml
the adapter is probably test and looks something like
this
test:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
So, after the test suite, you probably want to clean some shit up.
# spec/rails_helper.rb
config.after(:suite) do
FileUtils.rm_rf(ActiveStorage::Blob.service.root)
end
In test and development (eg, local requests) 404s will show as an exception page (ie, redirect response) for ActiveRecord::RecordNotFound. If you want to disable this and get an actual 404 as it would be in prod, here’s one way.
RSpec.shared_context 'with disable consider all requests local' do
before do
method = Rails.application.method(:env_config)
allow(Rails.application).to receive(:env_config).with(no_args) do
method.call.merge(
'action_dispatch.show_exceptions' => :all,
'action_dispatch.show_detailed_exceptions' => false,
'consider_all_requests_local' => false
)
end
end
end
Let’s say you have a JS feature that tests if the browser supports clipboard copying before showing a copy to clipboard button:
if ('clipboard' in navigator ) {
// show the copy button
}
NOTE: this example uses Cuprite/Ferrum gems
If for some reason, the clipboard isn’t available in the test environment browser, it can be mocked:
See also https://github.com/rubycdp/ferrum?tab=readme-ov-file#evaluate_asyncexpression-wait_time-args
c.before(:example, type: :system) do
page.driver.browser.evaluate_on_new_document(<<~JS)
const clipboard = {
writeText: text => new Promise(resolve => this.text = text),
readText: () => new Promise(resolve => resolve(this.text))
}
Object.defineProperty(navigator, 'clipboard', { value: clipboard } )
JS
end
This in the spec, you can retrieve the clipboard text the was copied
See also https://github.com/rubycdp/ferrum?tab=readme-ov-file#evaluate_asyncexpression-wait_time-args
text = page.driver.browser.evaluate_async(%(arguments[0](navigator.clipboard.readText())), 1) # this is some werid ass js syntax
expect(text).to eq 'foo'