diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index 1d186c4..d636826 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -639,8 +639,6 @@ Spectator.describe "Mock DSL", :smoke do # method4 stubbed via keyword args (yields) # method5 not stubbed (calls original) mock(SemiAbstractStruct, method2: :stubbed, method4: 123) do - # NOTE: Abstract methods without a type restriction on the return value - # must be implemented with a type restriction. stub def method1 "stubbed" end @@ -741,6 +739,133 @@ Spectator.describe "Mock DSL", :smoke do end end + context "with a concrete struct" do + struct ConcreteStruct + def method1 + "original" + end + + def method2 : Symbol + :original + end + + def method3 + yield + end + + def method4 : Int32 + yield.to_i + end + + def method5 + 42 + end + end + + # method1 stubbed via mock block + # method2 stubbed via keyword args + # method3 not stubbed (calls original and yields) + # method4 stubbed via keyword args (yields) + # method5 not stubbed (calls original) + inject_mock(ConcreteStruct, method2: :stubbed, method4: 123) do + stub def method1 + "stubbed" + end + end + + subject(real) { mock(ConcreteStruct) } + + it "defines a subtype" do + expect(real).to be_a(ConcreteStruct) + end + + it "defines stubs in the block" do + expect(real.method1).to eq("stubbed") + end + + it "can stub methods defined in the block" do + stub = Spectator::ValueStub.new(:method1, "override") + expect { real._spectator_define_stub(stub) }.to change { real.method1 }.from("stubbed").to("override") + end + + it "defines stubs from keyword arguments" do + expect(real.method2).to eq(:stubbed) + end + + it "can stub methods from keyword arguments" do + stub = Spectator::ValueStub.new(:method2, :override) + expect { real._spectator_define_stub(stub) }.to change { real.method2 }.from(:stubbed).to(:override) + end + + it "calls the original method with yielding methods" do + expect(real.method3 { :block }).to eq(:block) + end + + it "can defer defining stubs with yielding methods" do + stub = Spectator::ValueStub.new(:method3, :new) + expect { real._spectator_define_stub(stub) }.to change { real.method3 { :old } }.from(:old).to(:new) + end + + it "defines stubs with yield from keyword arguments" do + expect(real.method4 { "42" }).to eq(123) + end + + it "defines stubs with yield in the block" do + stub = Spectator::ValueStub.new(:method4, 5) + expect { real._spectator_define_stub(stub) }.to change { real.method4 { "42" } }.from(123).to(5) + end + + it "calls the original method" do + expect(real.method5).to eq(42) + end + + it "can defer defining stubs" do + stub = Spectator::ValueStub.new(:method5, 123) + expect { real._spectator_define_stub(stub) }.to change { real.method5 }.from(42).to(123) + end + + it "compiles types without unions" do + aggregate_failures do + expect(real.method1).to compile_as(String) + expect(real.method2).to compile_as(Symbol) + expect(real.method3 { :foo }).to compile_as(Symbol) + expect(real.method4 { "42" }).to compile_as(Int32) + expect(real.method5).to compile_as(Int32) + end + end + + def restricted(thing : ConcreteStruct) + thing.method1 + end + + it "can be used in type restricted methods" do + expect(restricted(real)).to eq("stubbed") + end + + # Cannot test unexpected messages - will not compile due to missing methods. + + describe "deferred default stubs" do + let(real) do + mock(ConcreteStruct, + method1: "stubbed", + method2: :stubbed, + method3: :kwargs, + method4: 123, + method5: 0) + end + + it "uses the keyword arguments as stubs" do + aggregate_failures do + expect(real.method1).to eq("stubbed") + expect(real.method2).to eq(:stubbed) + expect(real.method3 { :foo }).to eq(:kwargs) + expect(real.method4 { "42" }).to eq(123) + expect(real.method5).to eq(0) + end + end + end + end + describe "scope" do class Scope def scope diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index fff640a..5a24b13 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -377,6 +377,52 @@ module Spectator::DSL {% end %} end + # Injects mock (stub) functionality into an existing type. + # + # Warning: Using this will modify the type being tested. + # This may result in different behavior between test and non-test code. + # + # This must be used instead of `def_mock` if a concrete struct is tested. + # The `mock` method is not necessary to create a type with an injected mock. + # The type can be used as it would normally instead. + # However, stub information may leak between examples. + # + # The *type* is the name of the type to inject mock functionality into. + # Initial stubbed values for methods can be provided with *value_methods*. + # + # ``` + # struct MyStruct + # def foo + # 42 + # end + # end + # + # inject_mock(MyStruct, foo: 5) + # + # specify do + # inst = MyStruct.new + # expect(inst.foo).to eq(5) + # allow(inst).to receive(:foo).and_return(123) + # expect(inst.foo).to eq(123) + # end + # ``` + macro inject_mock(type, **value_methods, &block) + {% resolved = type.resolve + base = if resolved.class? + :class + elsif resolved.struct? + :struct + else + :module + end + + # Store information about how the mock is defined and its context. + # This isn't required, but new_mock() should still find this type. + ::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, resolved.name.symbolize} %} + + ::Spectator::Mock.inject({{base}}, {{type.id}}, {{**value_methods}}) {{block}} + end + # Targets a stubbable object (such as a mock or double) for operations. # # The *stubbable* must be a `Stubbable` or `StubbedType`.