2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

DEV: Monkey patch a from_described_class helper in RSpec::Mocks (#34021)

This change adds a new `from_described_class` helper to `RSpec::Mocks`, allowing stubs to only be used when called from the described class in a given spec.

The practical application of this helper is to handle flaky specs, where other code can call the mocks before the test target can.
This commit is contained in:
Gary Pendergast 2025-08-05 12:03:42 +10:00 committed by GitHub
parent 9562465329
commit 0802135037
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 142 additions and 2 deletions

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true

# Upstream feature request: https://github.com/rspec/rspec/issues/231

module RSpec
module Mocks
class MessageExpectation
# Only apply the stub if called from the described class
#
# @return [MessageExpectation] self, to support chaining
# @example
# allow(Process).to receive(:clock_gettime).from_described_class.and_return(123.45)
def from_described_class
# Mark the method double so it knows to check caller context
@method_double.from_described_class_only = true

self
end
end

module MethodDoubleExtensions
attr_accessor :from_described_class_only

# Override proxy_method_invoked to check caller context before processing expectations
def proxy_method_invoked(obj, *args, &block)
# If this method has from_described_class_only expectations, check the caller
if @from_described_class_only && !should_apply_stub?
return original_implementation_callable.call(*args, &block)
end

# Process normally through RSpec's expectation/stub system
super
end
ruby2_keywords :proxy_method_invoked if respond_to?(:ruby2_keywords, true)

private

def should_apply_stub?
return false unless defined?(RSpec.current_example.metadata)

# Find the real caller location, ignoring RSpec internals
actual_caller =
caller_locations.find do |location|
path = location.path
!path.include?("rspec-mocks") && !path.include?("rspec-core") &&
!path.end_with?("/freedom_patches/rspec_mocks_from_described_class.rb")
end
return false unless actual_caller

check_if_in_described_class(actual_caller, RSpec.current_example.metadata[:described_class])
end

def check_if_in_described_class(caller_location, described_class)
lines = File.readlines(caller_location.path)
line_idx = caller_location.lineno - 1

# Look backwards to find the enclosing class
while line_idx >= 0
line = lines[line_idx]

# Found a class definition
return $1 == described_class.name.split("::").last if line =~ /^\s*class\s+(\w+)/

# Stop at test boundaries
break if line =~ /^(module|describe|context|it)\s+/

line_idx -= 1
end

false
end
end

class MethodDouble
prepend MethodDoubleExtensions
end
end
end

View file

@ -144,7 +144,7 @@ RSpec.describe DiscourseAutomation::Stat do
context "with block form" do
it "measures the execution time and records it" do
# Mock Process.clock_gettime to return controlled values
allow(Process).to receive(:clock_gettime).and_return(10.0, 10.75)
allow(Process).to receive(:clock_gettime).from_described_class.and_return(10.0, 10.75)

result = DiscourseAutomation::Stat.log(automation_id) { "test result" }

@ -160,7 +160,7 @@ RSpec.describe DiscourseAutomation::Stat do

context "when an error occurs" do
it "yields the correct error and records it" do
allow(Process).to receive(:clock_gettime).and_return(10.0, 10.75)
allow(Process).to receive(:clock_gettime).from_described_class.and_return(10.0, 10.75)

expect { DiscourseAutomation::Stat.log(automation_id) { raise } }.to raise_error(
RuntimeError,

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true

RSpec.describe "RSpec Mocks from_described_class" do
class TestClass
def self.clock_time
Time.now.to_f
end

def instance_method
self.class.clock_time
end
end

module SomeModule
class OtherClass
def call_test_class
TestClass.clock_time
end
end
end

describe TestClass do
describe ".clock_time" do
it "stubs the method when called from described class" do
allow(TestClass).to receive(:clock_time).from_described_class.and_return(999.0)

# Call from within TestClass (via instance method)
instance = described_class.new
expect(instance.instance_method).to eq(999.0)
end

it "does not stub the method when called from another class" do
allow(TestClass).to receive(:clock_time).from_described_class.and_return(999.0)

# Call from OtherClass should use real method
other = SomeModule::OtherClass.new
result = other.call_test_class
expect(result).not_to eq(999.0)
expect(result).to be_a(Float)
end

it "allows chaining with other expectations" do
allow(TestClass).to receive(:clock_time).from_described_class.twice.and_return(1.0, 2.0)

instance = described_class.new
expect(instance.instance_method).to eq(1.0)
expect(instance.instance_method).to eq(2.0)
expect { instance.instance_method }.to raise_error(RSpec::Mocks::MockExpectationError)
end
end
end

describe SomeModule::OtherClass do
it "stubs the method when called from described class" do
allow(TestClass).to receive(:clock_time).from_described_class.and_return(777.0)

instance = described_class.new
result = instance.call_test_class
expect(result).to eq(777.0)
end
end
end