diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ffadb..bf4bc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added support for mock modules and types that include mocked modules. + ### Fixed - Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) - Fix method stubs used on methods that capture blocks. diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index db63dd9..05ef4c9 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -1027,4 +1027,262 @@ Spectator.describe "Mock DSL", :smoke do expect(fake.reference).to eq("reference") end end + + describe "mock module" do + module Dummy + # `extend self` cannot be used. + # The Crystal compiler doesn't report the methods as class methods when doing so. + + def self.abstract_method + :not_really_abstract + end + + def self.default_method + :original + end + + def self.args(arg) + arg + end + + def self.method1 + :original + end + + def self.reference + method1.to_s + end + end + + mock(Dummy) do + abstract_stub def self.abstract_method + :abstract + end + + stub def self.default_method + :default + end + end + + let(fake) { class_mock(Dummy) } + + it "raises on abstract stubs" do + expect { fake.abstract_method }.to raise_error(Spectator::UnexpectedMessage, /abstract_method/) + end + + it "can define default stubs" do + expect(fake.default_method).to eq(:default) + end + + it "can define new stubs" do + expect { allow(fake).to receive(:args).and_return(42) }.to change { fake.args(5) }.from(5).to(42) + end + + it "can override class method stubs" do + allow(fake).to receive(:method1).and_return(:override) + expect(fake.method1).to eq(:override) + end + + xit "can reference stubs", pending: "Default stub of module class methods always refer to original" do + allow(fake).to receive(:method1).and_return(:reference) + expect(fake.reference).to eq("reference") + end + end + + context "with a class including a mocked module" do + module Dummy + getter _spectator_invocations = [] of Symbol + + def method1 + @_spectator_invocations << :method1 + "original" + end + + def method2 : Symbol + @_spectator_invocations << :method2 + :original + end + + def method3(arg) + @_spectator_invocations << :method3 + arg + end + + def method4 : Symbol + @_spectator_invocations << :method4 + yield + end + + def method5 + @_spectator_invocations << :method5 + yield.to_i + end + + def method6 + @_spectator_invocations << :method6 + yield + end + + def method7(arg, *args, kwarg, **kwargs) + @_spectator_invocations << :method7 + {arg, args, kwarg, kwargs} + end + + def method8(arg, *args, kwarg, **kwargs) + @_spectator_invocations << :method8 + yield + {arg, args, kwarg, kwargs} + end + end + + # method1 stubbed via mock block + # method2 stubbed via keyword args + # method3 not stubbed (calls original) + # method4 stubbed via mock block (yields) + # method5 stubbed via keyword args (yields) + # method6 not stubbed (calls original and yields) + # method7 not stubbed (calls original) testing args + # method8 not stubbed (calls original and yields) testing args + mock(Dummy, method2: :stubbed, method5: 42) do + stub def method1 + "stubbed" + end + + stub def method4 : Symbol + yield + :block + end + end + + subject(fake) { mock(Dummy) } + + it "defines a subclass" do + expect(fake).to be_a(Dummy) + end + + it "defines stubs in the block" do + expect(fake.method1).to eq("stubbed") + end + + it "can stub methods defined in the block" do + stub = Spectator::ValueStub.new(:method1, "override") + expect { fake._spectator_define_stub(stub) }.to change { fake.method1 }.from("stubbed").to("override") + end + + it "defines stubs from keyword arguments" do + expect(fake.method2).to eq(:stubbed) + end + + it "can stub methods from keyword arguments" do + stub = Spectator::ValueStub.new(:method2, :override) + expect { fake._spectator_define_stub(stub) }.to change { fake.method2 }.from(:stubbed).to(:override) + end + + it "calls the original implementation for methods not provided a stub" do + expect(fake.method3(:xyz)).to eq(:xyz) + end + + it "can stub methods after declaration" do + stub = Spectator::ValueStub.new(:method3, :abc) + expect { fake._spectator_define_stub(stub) }.to change { fake.method3(:xyz) }.from(:xyz).to(:abc) + end + + it "defines stubs with yield in the block" do + expect(fake.method4 { :wrong }).to eq(:block) + end + + it "can stub methods with yield in the block" do + stub = Spectator::ValueStub.new(:method4, :override) + expect { fake._spectator_define_stub(stub) }.to change { fake.method4 { :wrong } }.from(:block).to(:override) + end + + it "defines stubs with yield from keyword arguments" do + expect(fake.method5 { :wrong }).to eq(42) + end + + it "can stub methods with yield from keyword arguments" do + stub = Spectator::ValueStub.new(:method5, 123) + expect { fake._spectator_define_stub(stub) }.to change { fake.method5 { "0" } }.from(42).to(123) + end + + it "can stub yielding methods after declaration" do + stub = Spectator::ValueStub.new(:method6, :abc) + expect { fake._spectator_define_stub(stub) }.to change { fake.method6 { :xyz } }.from(:xyz).to(:abc) + end + + it "handles arguments correctly" do + args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + aggregate_failures do + expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + end + end + + it "handles arguments correctly with stubs" do + stub1 = Spectator::ProcStub.new(:method7, args_proc) + stub2 = Spectator::ProcStub.new(:method8, args_proc) + fake._spectator_define_stub(stub1) + fake._spectator_define_stub(stub2) + args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + aggregate_failures do + expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + end + end + + it "compiles types without unions" do + aggregate_failures do + expect(fake.method1).to compile_as(String) + expect(fake.method2).to compile_as(Symbol) + expect(fake.method3(42)).to compile_as(Int32) + expect(fake.method4 { :foo }).to compile_as(Symbol) + expect(fake.method5 { "123" }).to compile_as(Int32) + expect(fake.method6 { "123" }).to compile_as(String) + end + end + + def restricted(thing : Dummy) + thing.method1 + end + + it "can be used in type restricted methods" do + expect(restricted(fake)).to eq("stubbed") + end + + it "does not call the original method when stubbed" do + fake.method1 + fake.method2 + fake.method3("foo") + fake.method4 { :foo } + fake.method5 { "42" } + fake.method6 { 42 } + fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + expect(fake._spectator_invocations).to contain_exactly(:method3, :method6, :method7, :method8) + end + + # Cannot test unexpected messages - will not compile due to missing methods. + + describe "deferred default stubs" do + mock(Dummy) + + let(fake2) do + mock(Dummy, + method1: "stubbed", + method3: 123, + method4: :xyz) + end + + it "uses the keyword arguments as stubs" do + aggregate_failures do + expect(fake2.method1).to eq("stubbed") + expect(fake2.method2).to eq(:original) + expect(fake2.method3(42)).to eq(123) + expect(fake2.method4 { :foo }).to eq(:xyz) + end + end + end + end end diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index 3c0eefe..248d8c8 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -39,6 +39,29 @@ module Spectator {% if base.id == :module.id %} {{base.id}} {{type_name.id}} include {{mocked_type.id}} + + # Mock class that includes the mocked module {{mocked_type.id}} + {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} + private class ClassIncludingMock{{type_name.id}} + include {{type_name.id}} + end + + # Returns a mock class that includes the mocked module {{mocked_type.id}}. + def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}} + # FIXME: Creating the instance normally with `.new` causing infinite recursion. + inst = ClassIncludingMock{{type_name.id}}.allocate + inst.initialize(*args, **kwargs) + inst + end + + # Returns a mock class that includes the mocked module {{mocked_type.id}}. + def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}} + # FIXME: Creating the instance normally with `.new` causing infinite recursion. + inst = ClassIncludingMock{{type_name.id}}.allocate + inst.initialize(*args, **kwargs) { |*yargs| yield *yargs } + inst + end + {% else %} {{base.id}} {{type_name.id}} < {{mocked_type.id}} {% end %} diff --git a/test.cr b/test.cr new file mode 100644 index 0000000..65cb051 --- /dev/null +++ b/test.cr @@ -0,0 +1,104 @@ +require "./src/spectator" + +module Thing + def self.original_method + :original + end + + def self.default_method + :original + end + + def self.stubbed_method(_value = 42) + :original + end + + macro finished + def self.debug + {% begin %}puts "Methods: ", {{@type.methods.map &.name.stringify}} of String{% end %} + {% begin %}puts "Class Methods: ", {{@type.class.methods.map &.name.stringify}} of String{% end %} + end + end +end + +Thing.debug + +# Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do +# stub def stubbed_method(_value = 42) +# :stubbed +# end +# end + +# Spectator.describe "Mock modules" do +# let(mock) { MockThing } + +# after { mock._spectator_clear_stubs } + +# it "overrides an existing method" do +# stub = Spectator::ValueStub.new(:original_method, :override) +# expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) +# end + +# it "doesn't affect other methods" do +# stub = Spectator::ValueStub.new(:stubbed_method, :override) +# expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } +# end + +# it "replaces an existing default stub" do +# stub = Spectator::ValueStub.new(:default_method, :override) +# expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) +# end + +# it "replaces an existing stubbed method" do +# stub = Spectator::ValueStub.new(:stubbed_method, :override) +# expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) +# end + +# def restricted(thing : Thing.class) +# thing.default_method +# end + +# describe "._spectator_clear_stubs" do +# before do +# stub = Spectator::ValueStub.new(:original_method, :override) +# mock._spectator_define_stub(stub) +# end + +# it "removes previously defined stubs" do +# expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) +# end +# end + +# describe "._spectator_calls" do +# before { mock._spectator_clear_calls } + +# # Retrieves symbolic names of methods called on a mock. +# def called_method_names(mock) +# mock._spectator_calls.map(&.method) +# end + +# it "stores calls to original methods" do +# expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) +# end + +# it "stores calls to default methods" do +# expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) +# end + +# it "stores calls to stubbed methods" do +# expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) +# end + +# it "stores multiple calls to the same stub" do +# mock.stubbed_method +# expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) +# end + +# it "stores arguments for a call" do +# mock.stubbed_method(5) +# args = Spectator::Arguments.capture(5) +# call = mock._spectator_calls.first +# expect(call.arguments).to eq(args) +# end +# end +# end