mirror of
https://gitea.invidious.io/iv-org/shard-spectator.git
synced 2024-08-15 00:53:35 +00:00
Some rework of stubs and doubles
This commit is contained in:
parent
7c8db07eda
commit
76c1d6a096
6 changed files with 183 additions and 84 deletions
15
spec/spectator/mocks/double_dsl_spec.cr
Normal file
15
spec/spectator/mocks/double_dsl_spec.cr
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
\{% if type = NT[{{meth.name.symbolize}}] %}
|
||||
{% if meth.return_type %}
|
||||
\{% if type <= {{meth.return_type}} %}
|
||||
# Return type appears to match configured type.
|
||||
%call = MethodCall.new({{meth.name.symbolize}}, %args)
|
||||
|
||||
# Find a suitable stub.
|
||||
stub = @stubs.find &.===(call)
|
||||
|
||||
if stub
|
||||
if %stub = @stubs.find &.===(%call)
|
||||
# Return configured response.
|
||||
stub.as(ValueStub(\{{type}})).value
|
||||
%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}")
|
||||
raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{meth.name}} (masking ancestor) with #{%args}")
|
||||
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 %}
|
||||
# Response not configured for this method/message.
|
||||
raise UnexpectedMessage.new("#{_spectator_double_name} received unexpected message :{{meth.name}} (masking ancestor) with #{arguments}")
|
||||
\{% 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
|
||||
|
|
48
src/spectator/mocks/stubable.cr
Normal file
48
src/spectator/mocks/stubable.cr
Normal 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
|
Loading…
Reference in a new issue