Skip to content

Latest commit

 

History

History
273 lines (227 loc) · 8.4 KB

20220812121410-rspec_and_blocks.org

File metadata and controls

273 lines (227 loc) · 8.4 KB

RSpec things

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?)

  1. 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
  2. Create a table and insert some records if needed
  3. Make sure the model sets the table_name
  4. 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

  1. Open a file
  2. 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'