From 793184716477a4c451456823325449ecac7b2b6b Mon Sep 17 00:00:00 2001 From: Michael Miller Date: Sat, 12 Mar 2022 15:43:12 -0700 Subject: [PATCH] Cleanup and docs --- src/spectator/mocks/double.cr | 42 ++++++++++++++---- src/spectator/mocks/stubable.cr | 68 +++++++++++++++++++++++++++++ src/spectator/mocks/stubbed_name.cr | 9 ++++ 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 src/spectator/mocks/stubbed_name.cr diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index edd9094..1fd564f 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -2,26 +2,45 @@ require "./arguments" require "./method_call" require "./stub" require "./stubable" +require "./stubbed_name" require "./unexpected_message" require "./value_stub" module Spectator - # Defines the name of a double or mock. - # - # When present on a stubbed type, this annotation indicates its name in output such as exceptions. - # Must have one argument - the name of the double or mock. - # This can be a symbol, string literal, or type name. - annotation StubbedName; 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`. + # Doubles should be defined with the `#define` macro. + # + # Use `#_spectator_define_stub` to override behavior of a method in the double. + # Only methods defined in the double's type can have stubs. + # New methods are not defines when a stub is added that doesn't have a matching method name. abstract class Double include Stubable Log = Spectator::Log.for(self) + # Defines a test double type. + # + # The *type_name* is the name to give the class. + # Instances of the double can be named by providing a *name*. + # This can be a symbol, string, or even a type. + # See `StubbedName` for details. + # + # After the names, a collection of key-value pairs can be given to quickly define methods. + # Each key is the method name, and the corresponding value is the value returned by the method. + # These methods accept any arguments. + # Additionally, these methods can be overridden later with stubs. + # + # Lastly, a block can be provided to define additional methods and stubs. + # The block is evaluated in the context of the double's class. + # + # ``` + # Double.define(SomeDouble, meth1: 42, meth2: "foobar") do + # stub abstract def meth3 : Symbol + # end + # ``` macro define(type_name, name = nil, **value_methods, &block) {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} class {{type_name.id}} < {{@type.name}} @@ -34,21 +53,26 @@ module Spectator end end + # Creates the double. + # + # An initial set of *stubs* can be provided. def initialize(@stubs : Array(Stub) = [] of Stub) end + # Defines a stub to change the behavior of a method in this double. + # + # NOTE: Defining a stub for a method not defined in the double's type has no effect. protected def _spectator_define_stub(stub : Stub) : Nil @stubs.unshift(stub) end - private def _spectator_find_stub(call) : Stub? + private def _spectator_find_stub(call : MethodCall) : Stub? Log.debug { "Finding stub for #{call}" } stub = @stubs.find &.===(call) Log.debug { stub ? "Found stub #{stub} for #{call}" : "Did not find stub for #{call}" } stub end - # Utility returning the double's name as a string. private def _spectator_stubbed_name : String {% if anno = @type.annotation(StubbedName) %} "#" diff --git a/src/spectator/mocks/stubable.cr b/src/spectator/mocks/stubable.cr index 97368c0..b059fca 100644 --- a/src/spectator/mocks/stubable.cr +++ b/src/spectator/mocks/stubable.cr @@ -1,5 +1,49 @@ 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 Stubable + # 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 returning the mock or double's name as a string. + abstract def _spectator_stubbed_name : String + + # Redefines a method to accept stubs. + # + # The *method* should be a `Def`. + # That is, a normal looking method definition should follow the `stub` keyword. + # + # ``` + # stub def stubbed_method + # "foobar" + # end + # ``` + # + # The method being stubbed must already exist in the type, parent, or included/extend module. + # If it doesn't exist, and a new stubable method is being added, use `#inject_stub` instead. + # The original's method is called if there are no applicable stubs for the invocation. + # The body of the method passed to this macro is ignored. + # + # The method can be abstract. + # If an abstract method is invoked that doesn't have a stub, an `UnexpectedMessage` error is raised. + # The abstract method should have a return type annotation, otherwise the compiled return type will probably end up as a giant union. + # + # ``` + # stub abstract def stubbed_method : String + # ``` + # + # Stubbed methods will call `#_spectator_find_stub` with the method call information. 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) %} @@ -40,6 +84,29 @@ module Spectator end end + # Redefines a method to require stubs. + # + # This macro is similar to `#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. private macro abstract_stub(method) {% raise "abstract_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) %} @@ -72,6 +139,7 @@ module Spectator end end + # Utility for defining a stubbed method and a fallback. private macro inject_stub(method) {{method}} stub {{method}} diff --git a/src/spectator/mocks/stubbed_name.cr b/src/spectator/mocks/stubbed_name.cr new file mode 100644 index 0000000..df3cfc6 --- /dev/null +++ b/src/spectator/mocks/stubbed_name.cr @@ -0,0 +1,9 @@ +module Spectator + # Defines the name of a double or mock. + # + # When present on a stubbed type, this annotation indicates its name in output such as exceptions. + # Must have one argument - the name of the double or mock. + # This can be a symbol, string literal, or type name. + annotation StubbedName + end +end