Alex's Slip-box

These are my org-mode notes in sort of Zettelkasten style

RSpec things

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

  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

# 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'

Search Results