shard-spectator/src/spectator/mocks/stubbable.cr

567 lines
27 KiB
Crystal

require "../dsl/reserved"
require "./formal_arguments"
require "./method_call"
require "./stub"
require "./typed_stub"
module Spectator
# Mix-in for mocks and doubles providing method stubs.
#
# Macros in this module can override existing methods.
# Stubbed methods will look for stubs to evaluate in place of their original functionality.
# The primary macro of interest is `#stub`.
# The macros are intended to be called from within the type being stubbed.
#
# Types including this module must define `#_spectator_find_stub` and `#_spectator_stubbed_name`.
# These are internal, reserved method names by Spectator, hence the `_spectator` prefix.
# These methods can't (and shouldn't) be stubbed.
module Stubbable
# Attempts to find a stub that satisfies a method call.
#
# Returns a stub that matches the method *call*
# or nil if no stubs satisfy it.
abstract def _spectator_find_stub(call : MethodCall) : Stub?
# Utility method that looks for stubs for methods with the name specified.
abstract def _spectator_stub_for_method?(method : Symbol) : Bool
# Defines a stub to change the behavior of a method.
abstract def _spectator_define_stub(stub : Stub) : Nil
# Removes a specific, previously defined stub.
abstract def _spectator_remove_stub(stub : Stub) : Nil
# Clears all previously defined stubs.
abstract def _spectator_clear_stubs : Nil
# Saves a call that was made to a stubbed method.
abstract def _spectator_record_call(call : MethodCall) : Nil
# Retrieves all previously saved calls.
abstract def _spectator_calls
# Clears all previously saved calls.
abstract def _spectator_clear_calls : Nil
# Method called when a stub isn't found.
#
# The received message is captured in *call*.
# Yield to call the original method's implementation.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_stub_fallback(call : MethodCall, &)
# Method called when a stub isn't found.
#
# The received message is captured in *call*.
# The expected return type is provided by *type*.
# Yield to call the original method's implementation.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_stub_fallback(call : MethodCall, type, &)
# Method called when a stub isn't found.
#
# This is similar to `#_spectator_stub_fallback`,
# but called when the original (un-stubbed) method isn't available.
# The received message is captured in *call*.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_abstract_stub_fallback(call : MethodCall)
# Method called when a stub isn't found.
#
# This is similar to `#_spectator_stub_fallback`,
# but called when the original (un-stubbed) method isn't available.
# The received message is captured in *call*.
# The expected return type is provided by *type*.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_abstract_stub_fallback(call : MethodCall, type)
# Utility method returning the stubbed type's name formatted for user output.
abstract def _spectator_stubbed_name : String
# Clears all previously defined calls and stubs.
def _spectator_reset : Nil
_spectator_clear_calls
_spectator_clear_stubs
end
# Redefines a method to accept stubs and provides a default response.
#
# The *method* must be a `Def`.
# That is, a normal looking method definition should follow the `default_stub` keyword.
#
# ```
# default_stub def stubbed_method
# "foobar"
# end
# ```
#
# The method cannot be abstract, as this method requires a default (fallback) response if a stub isn't provided.
#
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
# If no stub is found, then `#_spectator_stub_fallback` is called.
# The block provided to `#_spectator_stub_fallback` will invoke the default response.
# In other words, `#_spectator_stub_fallback` should yield if it's appropriate to return the default response.
private macro default_stub(method)
{% if method.is_a?(Def)
visibility = method.visibility
elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def)
visibility = method.visibility
method = method.exp
else
raise "`default_stub` requires a method definition"
end %}
{% raise "Cannot define a stub inside a method" if @def %}
{% raise "Default stub cannot be an abstract method" if method.abstract? %}
{% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %}
{{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% 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
{% original = "previous_def"
# Workaround for Crystal not propagating block with previous_def/super.
if method.accepts_block?
original += "("
if method.splat_index
method.args.each_with_index do |arg, i|
if i == method.splat_index
if arg.internal_name && arg.internal_name.size > 0
original += "*#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
elsif i > method.splat_index
original += "#{arg.name}: #{arg.internal_name}, "
else
original += "#{arg.internal_name}, "
end
end
else
method.args.each do |arg|
original += "#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
end
# If the block is captured (i.e. `&block` syntax), it must be passed along as an argument.
# Otherwise, use `yield` to forward the block.
captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0
method.block_arg.name
else
nil
end
original += "&#{captured_block}" if captured_block
original += ")"
original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block
end
original = original.id %}
{% # Reconstruct the method signature.
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
# This chunk of code must reconstruct the method signature exactly as it was originally.
# If it doesn't match, it doesn't override the method and the stubbing won't work.
%}
{{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% 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 %}
# Capture information about the call.
%call = ::Spectator::MethodCall.build(
{{method.name.symbolize}},
::NamedTuple.new(
{% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
),
{% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new(
{% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
).merge({{method.double_splat}})
)
_spectator_record_call(%call)
# Attempt to find a stub that satisfies the method call and arguments.
# Finding a suitable stub is delegated to the type including the `Stubbable` module.
if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method.
_spectator_cast_stub_value(%stub, %call, typeof({{original}}),
{{ if rt = method.return_type
if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn
:no_return
else
# Process as an enumerable type to reduce code repetition.
rt = rt.is_a?(Union) ? rt.types : [rt]
# Check if any types are nilable.
nilable = rt.any? do |t|
# These are all macro types that have the `resolve?` method.
(t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) &&
(resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil
end
if nilable
:nil
else
:raise
end
end
else
:raise
end }})
else
# Delegate missing stub behavior to concrete type.
_spectator_stub_fallback(%call, typeof({{original}})) do
# Use the default response for the method.
{{original}}
end
end
end
end
# Redefines a method to require stubs.
#
# This macro is similar to `#default_stub` but requires that a stub is defined for the method if it's called.
#
# The *method* should be a `Def`.
# That is, a normal looking method definition should follow the `stub` keyword.
#
# ```
# abstract_stub def stubbed_method
# "foobar"
# end
# ```
#
# The method being stubbed doesn't need to exist yet.
# Its body of the method passed to this macro is ignored.
# The method can be abstract.
# It should have a return type annotation, otherwise the compiled return type will probably end up as a giant union.
#
# ```
# abstract_stub abstract def stubbed_method : String
# ```
#
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
# If no stub is found, then `#_spectator_stub_fallback` or `#_spectator_abstract_stub_fallback` is called.
private macro abstract_stub(method)
{% if method.is_a?(Def)
visibility = method.visibility
elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def)
visibility = method.visibility
method = method.exp
else
raise "`abstract_stub` requires a method definition"
end %}
{% raise "Cannot define a stub inside a method" if @def %}
{% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %}
{% # The logic in this macro follows mostly the same logic from `#default_stub`.
# The main difference is that this macro cannot access the original method being stubbed.
# It might exist or it might not.
# The method could also be abstract.
# For all intents and purposes, this macro defines logic that doesn't depend on an existing method.
%}
{% unless method.abstract? %}
{{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% 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
{% original = "previous_def"
# Workaround for Crystal not propagating block with previous_def/super.
if method.accepts_block?
original += "("
if method.splat_index
method.args.each_with_index do |arg, i|
if i == method.splat_index
if arg.internal_name && arg.internal_name.size > 0
original += "*#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
elsif i > method.splat_index
original += "#{arg.name}: #{arg.internal_name}"
else
original += "#{arg.internal_name}, "
end
end
else
method.args.each do |arg|
original += "#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
end
# If the block is captured (i.e. `&block` syntax), it must be passed along as an argument.
# Otherwise, use `yield` to forward the block.
captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0
method.block_arg.name
else
nil
end
original += "&#{captured_block}" if captured_block
original += ")"
original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block
end
original = original.id %}
{% end %}
{% # Reconstruct the method signature.
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
# This chunk of code must reconstruct the method signature exactly as it was originally.
# If it doesn't match, it doesn't override the method and the stubbing won't work.
%}
{{visibility.id if visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% 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 %}
# Capture information about the call.
%call = ::Spectator::MethodCall.build(
{{method.name.symbolize}},
::NamedTuple.new(
{% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
),
{% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new(
{% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
).merge({{method.double_splat}})
)
_spectator_record_call(%call)
# Attempt to find a stub that satisfies the method call and arguments.
# Finding a suitable stub is delegated to the type including the `Stubbable` module.
if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method.
{% if rt = method.return_type %}
# Return type restriction takes priority since it can be a superset of the original implementation.
_spectator_cast_stub_value(%stub, %call, {{method.return_type}},
{{ if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn
:no_return
else
# Process as an enumerable type to reduce code repetition.
rt = rt.is_a?(Union) ? rt.types : [rt]
# Check if any types are nilable.
nilable = rt.any? do |t|
# These are all macro types that have the `resolve?` method.
(t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) &&
(resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil
end
if nilable
:nil
else
:raise
end
end }})
{% elsif !method.abstract? %}
# The method isn't abstract, infer the type it returns without calling it.
_spectator_cast_stub_value(%stub, %call, typeof({{original}}))
{% else %}
# Stubbed method is abstract and there's no return type annotation.
# The value of the stub could be returned as-is.
# This may produce a "bloated" union of all known stub types,
# and generally causes more annoying problems.
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{%call} but cannot resolve the return type. Please add a return type restriction.")
{% end %}
else
# A stub wasn't found, invoke the type-specific fallback logic.
{% if method.return_type %}
# Pass along just the return type annotation.
_spectator_abstract_stub_fallback(%call, {{method.return_type}})
{% elsif !method.abstract? %}
_spectator_abstract_stub_fallback(%call, typeof({{original}}))
{% else %}
# Stubbed method is abstract and there's no type annotation.
_spectator_abstract_stub_fallback(%call)
{% end %}
end
end
end
# Redefines a method to require stubs.
#
# The *method* can be a `Def`.
# That is, a normal looking method definition should follow the `stub` keyword.
#
# ```
# stub def stubbed_method
# "foobar"
# end
# ```
#
# If the *method* is abstract, then a stub must be provided otherwise attempts to call the method will raise `UnexpectedMessage`.
#
# ```
# stub abstract def stubbed_method
# ```
#
# A `Call` can also be specified.
# In this case all methods in the stubbed type and its ancestors that match the call's signature are stubbed.
#
# ```
# stub stubbed_method(arg)
# ```
#
# The method being stubbed doesn't need to exist yet.
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
# If no stub is found, then `#_spectator_stub_fallback` or `#_spectator_abstract_stub_fallback` is called.
macro stub(method)
{% raise "Cannot define a stub inside a method" if @def %}
{% if method.is_a?(Def) %}
{% if method.abstract? %}abstract_stub{% else %}default_stub{% end %} {{method}}
{% elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def) %}
{% if method.exp.abstract? %}abstract_stub{% else %}default_stub{% end %} {{method}}
{% elsif method.is_a?(Call) %}
{% raise "Stub on `Call` unsupported." %}
{% else %}
{% raise "Unrecognized syntax for `stub` - #{method}" %}
{% end %}
end
# Redefines all methods and ones inherited from its parents and mixins to support stubs.
private macro stub_type(type_name = @type)
{% type = type_name.resolve
definitions = [] of Nil
scope = if type == @type
:previous_def
elsif type.module?
type.name
else
:super
end.id
# Add entries for methods in the target type and its class type.
[[:self.id, type.class], [nil, type]].each do |(receiver, t)|
t.methods.each do |method|
definitions << {
type: t,
method: method,
scope: scope,
receiver: receiver,
}
end
end
# Iterate through all ancestors and add their methods.
type.ancestors.each do |ancestor|
[[:self.id, ancestor.class], [nil, ancestor]].each do |(receiver, t)|
t.methods.each do |method|
# Skip methods already found to prevent redefining them multiple times.
unless definitions.any? do |d|
m = d[:method]
m.name == method.name &&
m.args == method.args &&
m.splat_index == method.splat_index &&
m.double_splat == method.double_splat &&
m.block_arg == method.block_arg
end
definitions << {
type: t,
method: method,
scope: :super.id,
receiver: receiver,
}
end
end
end
end
definitions = definitions.reject do |definition|
name = definition[:method].name
name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.symbolize)
end %}
{% for definition in definitions %}
{% original_type = definition[:type]
method = definition[:method]
scope = definition[:scope]
receiver = definition[:receiver]
rewrite_args = method.accepts_block?
# Handle calling methods on other objects (primarily for mock modules).
if scope != :super.id && scope != :previous_def.id
if receiver == :self.id
scope = "#{scope}.#{method.name}".id
rewrite_args = true
else
scope = :super.id
end
end %}
# Redefinition of {{original_type}}{{"#".id}}{{method.name}}
{{(method.abstract? ? "abstract_stub abstract" : "default_stub").id}} {{method.visibility.id if method.visibility != :public}} def {{"#{receiver}.".id if receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% 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 %}
{% unless method.abstract? %}
{{scope}}{% if rewrite_args %}({% for arg, i in method.args %}
{% if i == method.splat_index && arg.internal_name && arg.internal_name.size > 0 %}*{{arg.internal_name}}, {% if method.double_splat %}**{{method.double_splat}}, {% end %}{% end %}
{% if method.splat_index && i > method.splat_index %}{{arg.name}}: {{arg.internal_name}}, {% end %}
{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name}}, {% end %}{% end %}
{% if !method.splat_index && method.double_splat %}**{{method.double_splat}}, {% end %}
{% captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0
method.block_arg.name
else
nil
end %}
{% if captured_block %}&{{captured_block}}{% end %}
){% if !captured_block && method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}{% end %}
end
{% end %}
{% end %}
end
# Utility macro for casting a stub (and its return value) to the correct type.
#
# *stub* is the variable holding the stub.
# *call* is the variable holding the captured method call.
# *type* is the expected type to cast the value to.
# *fail_cast* indicates the behavior used when the value returned by the stub can't be cast to *type*.
# - `:nil` - return nil.
# - `:raise` - raise a `TypeCastError`.
# - `:no_return` - raise as no value should be returned.
private macro _spectator_cast_stub_value(stub, call, type, fail_cast = :nil)
{% if fail_cast == :no_return %}
{{stub}}.call({{call}})
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a value, but it shouldn't have returned (`NoReturn`).")
{% else %}
# Get the value as-is from the stub.
# This will be compiled as a union of all known stubbed value types.
%value = {{stub}}.call({{call}})
# Attempt to cast the value to the method's return type.
# If successful, which it will be in most cases, return it.
# The caller will receive a properly typed value without unions or other side-effects.
%cast = %value.as?({{type}})
{% if fail_cast == :nil %}
%cast
{% elsif fail_cast == :raise %}
# Check if nil was returned by the stub and if its okay to return it.
if %value.nil? && {{type}}.nilable?
# Value was nil and nil is allowed to be returned.
%cast.as({{type}})
elsif %cast.nil?
# The stubbed value was something else entirely and cannot be cast to the return type.
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{ {{type}} }`.")
else
# Types match and value can be returned as cast type.
%cast
end
{% else %}
{% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %}
{% end %}
{% end %}
end
end
end