RSpec things
Table of Contents
:ID: E559724D-A7A8-438E-8042-1018DFA34AE3
# Blocks
How to test this with RSpec?
module Thing def self.call Foo.call do Bar.call end yield end end
# Stubbing
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
# Expecting
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
# Custom matchers
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' } }
# Method stubs
# and_invoke
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
# Advisory Locks
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
# Testing base classes
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
# Testing ActiveRecord concerns (need an anonymous database backed model?)
- 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
# Matchers and their aliases
See also https://rubydoc.info/github/rspec/rspec-expectations/RSpec/Matchers https://gist.github.com/JunichiIto/f603d3fbfcf99b914f86
# Upload ActiveStorage Blob
- 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
# Testing 404s in request specs
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
# Clipboard copying in system tests
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'