diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index 98f38b0..0ded983 100644 --- a/spec/spectator/mocks/double_spec.cr +++ b/spec/spectator/mocks/double_spec.cr @@ -1,7 +1,7 @@ require "../../spec_helper" Spectator.describe Spectator::Double do - subject(dbl) { Spectator::Double.new("foobar", foo: 42, bar: "baz") } + subject(dbl) { Spectator::Double({foo: Int32, bar: String}).new("foobar", foo: 42, bar: "baz") } it "responds to defined messages" do aggregate_failures do diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr new file mode 100644 index 0000000..dacc43f --- /dev/null +++ b/src/spectator/mocks/abstract_arguments.cr @@ -0,0 +1,5 @@ +module Spectator + # Untyped arguments to a method call (message). + abstract class AbstractArguments + end +end diff --git a/src/spectator/mocks/abstract_response.cr b/src/spectator/mocks/abstract_response.cr new file mode 100644 index 0000000..81c9659 --- /dev/null +++ b/src/spectator/mocks/abstract_response.cr @@ -0,0 +1,11 @@ +module Spectator + # Untyped response to a method call (message). + abstract class AbstractResponse + # Name of the method this response is for. + getter method : Symbol + + # Creates the base of the response. + def initialize(@method : Symbol) + end + end +end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index ed89947..6b531cc 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -1,10 +1,12 @@ +require "./abstract_arguments" + module Spectator # Arguments used in a method call. # # Can also be used to match arguments. # *T* must be a `Tuple` type representing the positional arguments. # *NT* must be a `NamedTuple` type representing the keyword arguments. - class Arguments(T, NT) + class Arguments(T, NT) < AbstractArguments # Positional arguments. getter args : T @@ -20,6 +22,11 @@ module Spectator new(args, kwargs) end + # Constructs an instance of empty arguments. + macro empty + {{@type.name(generic_args: false)}}.capture + end + # Constructs a string representation of the arguments. def to_s(io : IO) : Nil io << '(' diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index e65b316..d087aff 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -1,3 +1,4 @@ +require "./abstract_response" require "./unexpected_message" module Spectator @@ -5,10 +6,20 @@ module Spectator # # Handles all messages (method calls), but only responds to those configured. # Methods called that were not configured will raise `UnexpectedMessage`. - class Double(Messages) + # `NT` must be a type of `NamedTuple` that maps method names to their return types. + class Double(NT) + # Stores responses to messages (method calls). + @responses : Array(AbstractResponse) + # Creates a double with pre-configures responses. # A *name* can be provided, otherwise it is considered an anonymous double. - def initialize(@name : String? = nil, **@messages : **Messages) + def initialize(@responses : Array(AbstractResponse), @name : String? = nil) + end + + def initialize(@name : String? = nil, **methods : **NT) + @responses = methods.map do |method, value| + Response.new(method, value).as(AbstractResponse) + end end # Utility returning the double's name as a string. @@ -37,7 +48,7 @@ module Spectator {% if meth.double_splat %}**{{meth.double_splat}}, {% end %} {% 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 %} - \{% if type = Messages[{{meth.name.symbolize}}] %} + \{% if type = NT[{{meth.name.symbolize}}] %} {% if meth.return_type %} \{% if type <= {{meth.return_type}} %} # Return type appears to match configured type. @@ -70,7 +81,10 @@ module Spectator # Handle all methods but only respond to configured messages. # Raises an `UnexpectedMessage` error for non-configures messages. macro method_missing(call) - \{% if Messages.keys.includes?({{call.name.symbolize}}.id) %} + \{% if NT.keys.includes?({{call.name.symbolize}}.id) %} + # Find a suitable response. + call = MethodCall.capture({{call.name.symbolize}}, {{call.args.splat}}) + response = @responses.find &.===(call) # Return configured response. @messages[{{call.name.symbolize}}] \{% else %} diff --git a/src/spectator/mocks/method_call.cr b/src/spectator/mocks/method_call.cr new file mode 100644 index 0000000..eb13919 --- /dev/null +++ b/src/spectator/mocks/method_call.cr @@ -0,0 +1,18 @@ +require "./abstract_arguments" +require "./arguments" + +module Spectator + class MethodCall + getter method : Symbol + + getter arguments : AbstractArguments + + def initialize(@method : Symbol, @arguments : Arguments = Arguments.empty) + end + + def self.capture(method : Symbol, *args, **kwargs) + arguments = Arguments.new(args, kwargs) + new(method, arguments) + end + end +end diff --git a/src/spectator/mocks/response.cr b/src/spectator/mocks/response.cr new file mode 100644 index 0000000..0558f32 --- /dev/null +++ b/src/spectator/mocks/response.cr @@ -0,0 +1,24 @@ +require "./abstract_arguments" +require "./abstract_response" + +module Spectator + class Response(T) < AbstractResponse + # Return value. + getter value : T + + # Arguments the method must have been called with to provide this response. + # Is nil when there's no constraint - only the method name must match. + getter constraint : AbstractArguments? + + # Creates the response. + def initialize(@method : Symbol, @value : T, @constraint : Arguments? = nil) + end + + def ===(call : MethodCall) + return false if method != call.method + return true unless constraint = @constraint + + constraint === call.arguments + end + end +end