Some rework of stubs and doubles

This commit is contained in:
Michael Miller 2022-03-12 09:39:32 -07:00
parent 7c8db07eda
commit 76c1d6a096
No known key found for this signature in database
GPG key ID: AC78B32D30CE34A2
6 changed files with 183 additions and 84 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
"#<Double #{name.inspect}>"
else
{% if anno = @type.annotation(DoubleName) %}
"#<Double {{anno[0]}}"
{% else %}
"#<Double Anonymous>"
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

View file

@ -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