diff --git a/spec/spectator/mocks/double_dsl_spec.cr b/spec/spectator/mocks/double_dsl_spec.cr new file mode 100644 index 0000000..eaf8684 --- /dev/null +++ b/spec/spectator/mocks/double_dsl_spec.cr @@ -0,0 +1,15 @@ +require "../../spec_helper" + +Spectator.describe "Double DSL" do + double(:foo, foo: 42, bar: "baz") do + end + + let(dbl) { double(:foo) } + + specify do + expect(dbl.foo).to eq(42) + expect(dbl.bar).to eq("baz") + expect(dbl.foo).to compile_as(Int32) + expect(dbl.bar).to compile_as(String) + end +end diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index 75519c9..3f1992a 100644 --- a/spec/spectator/mocks/double_spec.cr +++ b/spec/spectator/mocks/double_spec.cr @@ -1,7 +1,9 @@ require "../../spec_helper" Spectator.describe Spectator::Double do - subject(dbl) { Spectator::Double({foo: Int32, bar: String}).new("dbl-name", foo: 42, bar: "baz") } + Spectator::Double.define(TestDouble, foo: 42, bar: "baz") + + subject(dbl) { TestDouble.new("dbl-name") } it "responds to defined messages" do aggregate_failures do @@ -22,8 +24,17 @@ Spectator.describe Spectator::Double do expect { dbl.baz(123, "qux", field: :value) }.to raise_error(Spectator::UnexpectedMessage, /\(123, "qux", field: :value\)/) end + it "has a non-union return type" do + aggregate_failures do + expect(dbl.foo).to compile_as(Int32) + expect(dbl.bar).to compile_as(String) + end + end + context "without a double name" do - subject(dbl) { Spectator::Double.new(foo: 42) } + Spectator::Double.define(TestDouble, foo: 42) + + subject(dbl) { TestDouble.new } it "reports as anonymous" do expect { dbl.baz }.to raise_error(/anonymous/i) @@ -85,6 +96,10 @@ Spectator.describe Spectator::Double do expect(dbl.same?(nil)).to eq(false) end end + + it "has a non-union return type" do + expect(dbl.inspect).to compile_as(String) + end end context "without common object methods" do @@ -149,6 +164,10 @@ Spectator.describe Spectator::Double do expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) end + it "has a non-union return type" do + expect(dbl.foo("foobar")).to compile_as(String) + end + context "with common object methods" do let(stub) { Spectator::ValueStub.new(:"same?", true, arguments).as(Spectator::Stub) } subject(dbl) { Spectator::Double({"same?": Bool}).new([stub]) } @@ -164,6 +183,10 @@ Spectator.describe Spectator::Double do it "raises an error when argument count doesn't match" do expect { dbl.same? }.to raise_error(Spectator::UnexpectedMessage) end + + it "has a non-union return type" do + expect(dbl.same?("foobar")).to compile_as(Bool) + end end end end diff --git a/src/spectator/dsl.cr b/src/spectator/dsl.cr index e250767..d739a45 100644 --- a/src/spectator/dsl.cr +++ b/src/spectator/dsl.cr @@ -9,6 +9,6 @@ module Spectator module DSL # Keywords that cannot be used in specs using the DSL. # These are either problematic or reserved for internal use. - RESERVED_KEYWORDS = %i[initialize _spectator_double_name] + RESERVED_KEYWORDS = %i[initialize _spectator_double_name _spectator_find_stub] end end diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index 29f9400..cb850a2 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -3,5 +3,59 @@ require "../mocks" module Spectator::DSL # Methods and macros for mocks and doubles. module Mocks + # All defined double types. + # Each tuple consists of the double name, defined context (example group), + # and double type name relative to its context. + DOUBLES = [] of {Symbol, Symbol, Symbol} + + macro double(name, **value_methods, &block) + {% name_symbol = name.id.symbolize + context_type_name = @type.name(generic_args: false).symbolize %} + + {% if @def %} + {% # Find tuples with the same name. + found_tuples = ::Spectator::DSL::Mocks::DOUBLES.select { |tuple| tuple[0] == name_symbol } + + # Split the current context's type namespace into parts. + type_parts = @type.name(generic_args: false).split("::") + + # Find tuples in the same context or a parent of where the double was defined. + # This is done by comparing each part of their namespaces. + found_tuples = found_tuples.select do |tuple| + # Split the namespace of the context the double was defined in. + context_parts = tuple[1].id.split("::") + + # Compare namespace parts between the context the double was defined in and this context. + # This logic below is effectively comparing array elements, but with methods supported by macros. + matches = context_parts.map_with_index { |part, i| part == type_parts[i] } + matches.all? { |b| b } + end + + # Sort the results by the number of namespace parts. + # The last result will be the double type defined closest to the current context's type. + found_tuples = found_tuples.sort_by do |tuple| + tuple[1].id.split("::").size + end + found_tuple = found_tuples.last + raise "Undefined double type '#{name}'" unless found_tuple + + # Store the type name used to define the underlying double. + double_type_name = found_tuple[2].id %} + + {{double_type_name}}.new + {% else %} + {% # Construct a unique type name for the double by using the number of defined doubles. + index = ::Spectator::DSL::Mocks::DOUBLES.size + double_type_name = "Double#{index}".id.symbolize + + # Store information about how the double is defined and its context. + # This is important for constructing an instance of the double later. + ::Spectator::DSL::Mocks::DOUBLES << {name_symbol, context_type_name, double_type_name} %} + + ::Spectator::Double.define({{double_type_name}}, {{value_methods.double_splat}}) do + {{block.body}} + end + {% end %} + end end end diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index b7edfd9..9ff7336 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -1,43 +1,47 @@ require "./unexpected_message" require "./stub" +require "./stubable" require "./value_stub" +require "./arguments" +require "./method_call" module Spectator + annotation DoubleName; end + # Stands in for an object for testing that a SUT calls expected methods. # # Handles all messages (method calls), but only responds to those configured. # Methods called that were not configured will raise `UnexpectedMessage`. - # `NT` must be a type of `NamedTuple` that maps method names to their return types. - class Double(NT) - # Stores responses to messages (method calls). - @stubs : Array(Stub) + abstract class Double + include Stubable - # Creates a double with pre-configures responses. - # A *name* can be provided, otherwise it is considered an anonymous double. - def initialize(@stubs : Array(Stub), @name : String? = nil) + macro define(type_name, name = nil, **value_methods, &block) + {% if name %}@[DoubleName({{name}})]{% end %} + class {{type_name.id}} < {{@type.name}} + {% for key, value in value_methods %} + stub def {{key.id}} + {{value}} + end + {% end %} + {% if block %}{{block.body}}{% end %} + end + {% debug %} end - def initialize(@name : String? = nil, **methods : **NT) - @stubs = {% if NT.keys.empty? %} - [] of Stub - {% else %} - {% begin %} - [ - {% for key in NT.keys %} - ValueStub.new({{key.symbolize}}, methods[{{key.symbolize}}]).as(Stub), - {% end %} - ] - {% end %} - {% end %} + # Stores responses to messages (method calls). + @stubs = [] of Stub + + private def _spectator_find_stub(call) : Stub? + @stubs.find &.===(call) end # Utility returning the double's name as a string. private def _spectator_double_name : String - if name = @name - "#" - else + {% if anno = @type.annotation(DoubleName) %} + "#" - end + {% end %} end # Redefines all methods on a type to conditionally respond to messages. @@ -58,51 +62,20 @@ module Spectator {% if meth.block_arg %}&{{meth.block_arg}}{% elsif meth.accepts_block? %}&{% end %} ){% if meth.return_type %} : {{meth.return_type}}{% end %}{% if !meth.free_vars.empty? %} forall {{meth.free_vars.splat}}{% end %} # Capture call information. - arguments = Arguments.capture( + %args = Arguments.capture( {{meth.args.map(&.internal_name).splat}}{% if !meth.args.empty? %}, {% end %} {% if meth.double_splat %}**{{meth.double_splat}}, {% end %} ) - call = MethodCall.new({{meth.name.symbolize}}, arguments) + %call = MethodCall.new({{meth.name.symbolize}}, %args) - \{% if type = NT[{{meth.name.symbolize}}] %} - {% if meth.return_type %} - \{% if type <= {{meth.return_type}} %} - # Return type appears to match configured type. - - # Find a suitable stub. - stub = @stubs.find &.===(call) - - if stub - # Return configured response. - stub.as(ValueStub(\{{type}})).value - else - # Response not configured for this method/message. - raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{meth.name}} (masking ancestor) with #{arguments}") - end - \{% else %} - # Return type doesn't match configured type. - # Can't return the configured response as the type mismatches (won't compile). - # Raise at runtime to provide additional information. - raise "Type mismatch {{meth.name}} : {{meth.return_type}}" - \{% end %} - {% else %} - # No return type restriction, return configured response. - - # Find a suitable stub. - stub = @stubs.find &.===(call) - - if stub - # Return configured response. - stub.as(ValueStub(\{{type}})).value - else - # Response not configured for this method/message. - raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{meth.name}} (masking ancestor) with #{arguments}") - end - {% end %} - \{% else %} + # Find a suitable stub. + if %stub = @stubs.find &.===(%call) + # Return configured response. + %stub.value + else # Response not configured for this method/message. - raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{meth.name}} (masking ancestor) with #{arguments}") - \{% end %} + raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{meth.name}} (masking ancestor) with #{%args}") + end end {% end %} {% end %} @@ -116,24 +89,10 @@ module Spectator # Handle all methods but only respond to configured messages. # Raises an `UnexpectedMessage` error for non-configures messages. macro method_missing(call) - arguments = Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{call.named_args.splat}}{% end %}) - call = MethodCall.new({{call.name.symbolize}}, arguments) - - \{% if type = NT[{{call.name.symbolize}}] %} - # Find a suitable stub. - stub = @stubs.find &.===(call) - - if stub - # Return configured response. - stub.as(ValueStub(\{{type}})).value - else - # Response not configured for this method/message. - raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{call.name}} with #{arguments}") - end - \{% else %} - # Response not configured for this method/message. - raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{call.name}} with #{arguments}") - \{% end %} + arguments = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{call.named_args.splat}}{% end %}) + call = ::Spectator::MethodCall.new({{call.name.symbolize}}, arguments) + raise ::Spectator::UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{call.name}} with #{arguments}") + {% debug %} end end end diff --git a/src/spectator/mocks/stubable.cr b/src/spectator/mocks/stubable.cr new file mode 100644 index 0000000..54abf5b --- /dev/null +++ b/src/spectator/mocks/stubable.cr @@ -0,0 +1,48 @@ +module Spectator + module Stubable + private macro stub(method) + {% raise "stub requires a method definition" if !method.is_a?(Def) %} + {% raise "Cannot stub method with reserved keyword as name - #{method.name}" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %} + + {% unless method.abstract? %} + {% if method.visibility != :public %}{{method.visibility.id}}{% end %} def {{method.receiver}}{{method.name}}( + {{method.args.splat(",")}} + {% if method.double_splat %}**{{method.double_splat}}, {% end %} + {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} + ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} + {{method.body}} + end + {% end %} + + {% if method.visibility != :public %}{{method.visibility.id}}{% end %} def {{method.receiver}}{{method.name}}( + {{method.args.splat(",")}} + {% if method.double_splat %}**{{method.double_splat}}, {% end %} + {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} + ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} + %args = ::Spectator::Arguments.capture( + {{method.args.map(&.internal_name).splat(",")}} + {% if method.double_splat %}**{{method.double_splat}}{% end %} + ) + %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) + + if %stub = _spectator_find_stub(%call) + {% if !method.abstract? %} + %stub.as(::Spectator::ValueStub(typeof(previous_def))).value + {% elsif method.return_type %} + %stub.as(::Spectator::ValueStub({{method.return_type}})).value + {% else %} + %stub.value + {% end %} + else + {% if method.abstract? %} + # Response not configured for this method/message. + raise ::Spectator::UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{method.name}} with #{%args}") + {% else %} + previous_def + {% end %} + end + end + {% debug %} + end + end +end