diff --git a/README.md b/README.md index 7617a01..1585016 100644 --- a/README.md +++ b/README.md @@ -311,11 +311,11 @@ Items not marked as completed may have partial implementations. - [ ] Compound - `and`, `or` - [ ] Mocks and Doubles - [ ] Mocks (Stub real types) - `mock TYPE { }` - - [ ] Doubles (Stand-ins for real types) - `double NAME { }` - - [ ] Method stubs - `allow().to receive()`, `allow().to receive().and_return()` + - [X] Doubles (Stand-ins for real types) - `double NAME { }` + - [X] Method stubs - `allow().to receive()`, `allow().to receive().and_return()` - [ ] Spies - `expect().to receive()` - - [ ] Message expectations - `expect().to receive().at_least()` - - [ ] Argument expectations - `expect().to receive().with()` + - [X] Message expectations - `expect().to receive().at_least()` + - [X] Argument expectations - `expect().to receive().with()` - [ ] Message ordering - `expect().to receive().ordered` - [ ] Null doubles - [ ] Runner @@ -326,11 +326,12 @@ Items not marked as completed may have partial implementations. - [X] Dry run - for validation and checking formatted output - [X] Config block in `spec_helper.cr` - [X] Config file - `.spectator` -- [X] Reporter and formatting +- [ ] Reporter and formatting - [X] RSpec/Crystal Spec default - [X] JSON - [X] JUnit - [X] TAP + - [ ] HTML ### How it Works (in a nutshell) diff --git a/src/spectator/anything.cr b/src/spectator/anything.cr new file mode 100644 index 0000000..511a024 --- /dev/null +++ b/src/spectator/anything.cr @@ -0,0 +1,15 @@ +module Spectator + struct Anything + def ==(other) + true + end + + def ===(other) + true + end + + def =~(other) + true + end + end +end diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index 5c55a06..0e779c3 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -683,6 +683,11 @@ module Spectator expect {{block}}.to raise_error({{type}}, {{message}}) end + macro have_received(method) + %test_value = ::Spectator::TestValue.new(({{method.id.symbolize}}), {{method.id.stringify}}) + ::Spectator::Matchers::ReceiveMatcher.new(%test_value) + end + # Used to create predicate matchers. # Any missing method that starts with 'be_' or 'have_' will be handled. # All other method names will be ignored and raise a compile-time error. diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr new file mode 100644 index 0000000..07d3c00 --- /dev/null +++ b/src/spectator/dsl/mocks.cr @@ -0,0 +1,173 @@ +require "../mocks" + +module Spectator::DSL + macro double(name = "Anonymous", **stubs, &block) + {% if name.is_a?(StringLiteral) %} + anonymous_double({{name}}, {{stubs.double_splat}}) + {% else %} + {% + safe_name = name.id.symbolize.gsub(/\W/, "_").id + type_name = "Double#{safe_name}".id + %} + + {% if block.is_a?(Nop) %} + create_double({{type_name}}, {{name}}, {{stubs.double_splat}}) + {% else %} + define_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}} + {% end %} + {% end %} + end + + macro create_double(type_name, name, **stubs) + {% type_name.resolve? || raise("Could not find a double labeled #{name}") %} + + {{type_name}}.new.tap do |%double| + {% for name, value in stubs %} + allow(%double).to receive({{name.id}}).and_return({{value}}) + {% end %} + end + end + + macro define_double(type_name, name, **stubs, &block) + {% begin %} + {% if (name.is_a?(Path) || name.is_a?(Generic)) && (resolved = name.resolve?) %} + verify_double({{name}}) + class {{type_name}} < ::Spectator::Mocks::VerifyingDouble(::{{resolved.id}}) + {% else %} + class {{type_name}} < ::Spectator::Mocks::Double + def initialize(null = false) + super({{name.id.stringify}}, null) + end + {% end %} + + def as_null_object + {{type_name}}.new(true) + end + + # TODO: Do something with **stubs? + + {{block.body}} + end + {% end %} + end + + def anonymous_double(name = "Anonymous", **stubs) + Mocks::AnonymousDouble.new(name, stubs) + end + + macro null_double(name, **stubs, &block) + {% if name.is_a?(StringLiteral) %} + anonymous_null_double({{name}}, {{stubs.double_splat}}) + {% else %} + {% + safe_name = name.id.symbolize.gsub(/\W/, "_").id + type_name = "Double#{safe_name}".id + %} + + {% if block.is_a?(Nop) %} + create_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) + {% else %} + define_null_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}} + {% end %} + {% end %} + end + + macro create_null_double(type_name, name, **stubs) + {% type_name.resolve? || raise("Could not find a double labeled #{name}") %} + + {{type_name}}.new(true).tap do |%double| + {% for name, value in stubs %} + allow(%double).to receive({{name.id}}).and_return({{value}}) + {% end %} + end + end + + macro define_null_double(type_name, name, **stubs, &block) + class {{type_name}} < ::Spectator::Mocks::Double + def initialize(null = true) + super({{name.id.stringify}}, null) + end + + def as_null_object + {{type_name}}.new(true) + end + + # TODO: Do something with **stubs? + + {{block.body}} + end + end + + def anonymous_null_double(name = "Anonymous", **stubs) + AnonymousNullDouble.new(name, stubs) + end + + macro mock(name, &block) + {% resolved = name.resolve + type = if resolved < Reference + :class + elsif resolved < Value + :struct + else + :module + end %} + {% begin %} + {{type.id}} ::{{resolved.id}} + include ::Spectator::Mocks::Stubs + + {{block.body}} + end + {% end %} + end + + macro verify_double(name, &block) + {% resolved = name.resolve + type = if resolved < Reference + :class + elsif resolved < Value + :struct + else + :module + end %} + {% begin %} + {{type.id}} ::{{resolved.id}} + include ::Spectator::Mocks::Reflection + + macro finished + _spectator_reflect + end + end + {% end %} + end + + def allow(thing) + Mocks::Allow.new(thing) + end + + def allow_any_instance_of(type : T.class) forall T + Mocks::AllowAnyInstance(T).new + end + + macro expect_any_instance_of(type, _source_file = __FILE__, _source_line = __LINE__) + %source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) + ::Spectator::Mocks::ExpectAnyInstance({{type}}).new(%source) + end + + macro receive(method_name, _source_file = __FILE__, _source_line = __LINE__, &block) + %source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) + {% if block.is_a?(Nop) %} + ::Spectator::Mocks::NilMethodStub.new({{method_name.id.symbolize}}, %source) + {% else %} + ::Spectator::Mocks::ProcMethodStub.create({{method_name.id.symbolize}}, %source) { {{block.body}} } + {% end %} + end + + macro receive_messages(_source_file = __FILE__, _source_line = __LINE__, **stubs) + %source = ::Spectator::Source.new({{_source_file}}, {{_source_line}}) + %stubs = [] of ::Spectator::Mocks::MethodStub + {% for name, value in stubs %} + %stubs << ::Spectator::Mocks::ValueMethodStub.new({{name.id.symbolize}}, %source, {{value}}) + {% end %} + %stubs + end +end diff --git a/src/spectator/dsl/values.cr b/src/spectator/dsl/values.cr index 194e853..ea9fdf5 100644 --- a/src/spectator/dsl/values.cr +++ b/src/spectator/dsl/values.cr @@ -1,17 +1,17 @@ module Spectator module DSL macro let(name, &block) - def %value - {{block.body}} - end - @%wrapper : ::Spectator::ValueWrapper? + def {{name.id}} + {{block.body}} + end + def {{name.id}} if (wrapper = @%wrapper) - wrapper.as(::Spectator::TypedValueWrapper(typeof(%value))).value + wrapper.as(::Spectator::TypedValueWrapper(typeof(previous_def))).value else - %value.tap do |value| + previous_def.tap do |value| @%wrapper = ::Spectator::TypedValueWrapper.new(value) end end diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index e5d35f8..ae8a5a8 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -35,6 +35,10 @@ module Spectator @example_count = children.sum(&.example_count) end + def double(id, sample_values) + @doubles[id].build(sample_values) + end + getter context def initialize(@context : TestContext) diff --git a/src/spectator/expectations/expectation_partial.cr b/src/spectator/expectations/expectation_partial.cr index 8aa3709..b7a602d 100644 --- a/src/spectator/expectations/expectation_partial.cr +++ b/src/spectator/expectations/expectation_partial.cr @@ -24,6 +24,17 @@ module Spectator::Expectations report(match_data) end + def to(stub : Mocks::MethodStub) : Nil + Harness.current.mocks.expect(@actual.value, stub) + value = TestValue.new(stub.name, stub.to_s) + matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?) + to_eventually(matcher) + end + + def to(stubs : Enumerable(Mocks::MethodStub)) : Nil + stubs.each { |stub| to(stub) } + end + # Asserts that some criteria defined by the matcher is not satisfied. # This is effectively the opposite of `#to`. def to_not(matcher) : Nil @@ -31,6 +42,16 @@ module Spectator::Expectations report(match_data) end + def to_not(stub : Mocks::MethodStub) : Nil + value = TestValue.new(stub.name, stub.to_s) + matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?) + to_never(matcher) + end + + def to_not(stubs : Enumerable(Mocks::MethodStub)) : Nil + stubs.each { |stub| to_not(stub) } + end + # ditto @[AlwaysInline] def not_to(matcher) : Nil diff --git a/src/spectator/formatting/comment.cr b/src/spectator/formatting/comment.cr index cbe6919..6a42685 100644 --- a/src/spectator/formatting/comment.cr +++ b/src/spectator/formatting/comment.cr @@ -13,7 +13,7 @@ module Spectator::Formatting end # Creates a colorized version of the comment. - def self.color(text : T) forall T + def self.color(text) Color.comment(new(text)) end end diff --git a/src/spectator/harness.cr b/src/spectator/harness.cr index caa43a0..33ed2f0 100644 --- a/src/spectator/harness.cr +++ b/src/spectator/harness.cr @@ -1,3 +1,5 @@ +require "./mocks/registry" + module Spectator # Helper class that acts as a gateway between example code and the test framework. # Every example must be invoked by passing it to `#run`. @@ -34,6 +36,8 @@ module Spectator # Retrieves the current running example. getter example : Example + getter mocks : Mocks::Registry + # Retrieves the group for the current running example. def group example.group @@ -66,6 +70,7 @@ module Spectator # The example the harness is for should be passed in. private def initialize(@example) @reporter = Expectations::ExpectationReporter.new + @mocks = Mocks::Registry.new(@example.group.context) @deferred = Deque(->).new end end diff --git a/src/spectator/includes.cr b/src/spectator/includes.cr index c27fc49..4425f99 100644 --- a/src/spectator/includes.cr +++ b/src/spectator/includes.cr @@ -29,6 +29,8 @@ require "./example_group" require "./nested_example_group" require "./root_example_group" +require "./mocks" + require "./config" require "./config_builder" require "./config_source" diff --git a/src/spectator/matchers/all_matcher.cr b/src/spectator/matchers/all_matcher.cr index f413fbd..bf6231d 100644 --- a/src/spectator/matchers/all_matcher.cr +++ b/src/spectator/matchers/all_matcher.cr @@ -26,7 +26,7 @@ module Spectator::Matchers match_data = matcher.match(element) break match_data unless match_data.matched? end - found ? found : SuccessfulMatchData.new + found || SuccessfulMatchData.new end # Negated matching for this matcher is not supported. diff --git a/src/spectator/matchers/change_from_matcher.cr b/src/spectator/matchers/change_from_matcher.cr index cafb51d..fa3504b 100644 --- a/src/spectator/matchers/change_from_matcher.cr +++ b/src/spectator/matchers/change_from_matcher.cr @@ -63,12 +63,12 @@ module Spectator::Matchers end # Specifies what the resulting value of the expression must be. - def to(value : T) forall T + def to(value) ChangeExactMatcher.new(@expression, @expected, value) end # Specifies what the resulting value of the expression should change by. - def by(amount : T) forall T + def by(amount) ChangeExactMatcher.new(@expression, @expected, @expected + value) end diff --git a/src/spectator/matchers/change_matcher.cr b/src/spectator/matchers/change_matcher.cr index 2b016d5..5de2415 100644 --- a/src/spectator/matchers/change_matcher.cr +++ b/src/spectator/matchers/change_matcher.cr @@ -49,27 +49,27 @@ module Spectator::Matchers end # Specifies what the initial value of the expression must be. - def from(value : T) forall T + def from(value) ChangeFromMatcher.new(@expression, value) end # Specifies what the resulting value of the expression must be. - def to(value : T) forall T + def to(value) ChangeToMatcher.new(@expression, value) end # Specifies that t he resulting value must be some amount different. - def by(amount : T) forall T + def by(amount) ChangeRelativeMatcher.new(@expression, "by #{amount}") { |before, after| amount == after - before } end # Specifies that the resulting value must be at least some amount different. - def by_at_least(minimum : T) forall T + def by_at_least(minimum) ChangeRelativeMatcher.new(@expression, "by at least #{minimum}") { |before, after| minimum <= after - before } end # Specifies that the resulting value must be at most some amount different. - def by_at_most(maximum : T) forall T + def by_at_most(maximum) ChangeRelativeMatcher.new(@expression, "by at most #{maximum}") { |before, after| maximum >= after - before } end diff --git a/src/spectator/matchers/change_to_matcher.cr b/src/spectator/matchers/change_to_matcher.cr index a05587a..e29d23b 100644 --- a/src/spectator/matchers/change_to_matcher.cr +++ b/src/spectator/matchers/change_to_matcher.cr @@ -57,12 +57,12 @@ module Spectator::Matchers end # Specifies what the initial value of the expression must be. - def from(value : T) forall T + def from(value) ChangeExactMatcher.new(@expression, value, @expected) end # Specifies how much the initial value should change by. - def by(amount : T) forall T + def by(amount) ChangeExactMatcher.new(@expression, @expected - amount, @expected) end diff --git a/src/spectator/matchers/failed_match_data.cr b/src/spectator/matchers/failed_match_data.cr index 3e0857a..205b31c 100644 --- a/src/spectator/matchers/failed_match_data.cr +++ b/src/spectator/matchers/failed_match_data.cr @@ -14,6 +14,10 @@ module Spectator::Matchers # Additional information from the match that can be used to debug the problem. getter values : Array(Tuple(Symbol, String)) + # Creates the match data. + def initialize(@failure_message, @values) + end + # Creates the match data. def initialize(@failure_message, **values) @values = values.to_a diff --git a/src/spectator/matchers/receive_matcher.cr b/src/spectator/matchers/receive_matcher.cr new file mode 100644 index 0000000..9a96f82 --- /dev/null +++ b/src/spectator/matchers/receive_matcher.cr @@ -0,0 +1,126 @@ +require "../mocks" +require "./standard_matcher" + +module Spectator::Matchers + struct ReceiveMatcher < StandardMatcher + alias Range = ::Range(Int32, Int32) | ::Range(Nil, Int32) | ::Range(Int32, Nil) + + def initialize(@expected : TestExpression(Symbol), @args : Mocks::Arguments? = nil, @range : Range? = nil) + end + + def description : String + range = @range + "received message #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "At least once"} with #{@args || "any arguments"}" + end + + def match?(actual : TestExpression(T)) : Bool forall T + calls = Harness.current.mocks.calls_for(actual.value, @expected.value) + calls.select! { |call| @args === call.args } if @args + if (range = @range) + range.includes?(calls.size) + else + !calls.empty? + end + end + + def failure_message(actual : TestExpression(T)) : String forall T + range = @range + "#{actual.label} did not receive #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "at least once"} with #{@args || "any arguments"}" + end + + def failure_message_when_negated(actual) : String + range = @range + "#{actual.label} received #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "at least once"} with #{@args || "any arguments"}" + end + + def values(actual : TestExpression(T)) forall T + calls = Harness.current.mocks.calls_for(actual.value, @expected.value) + calls.select! { |call| @args === call.args } if @args + range = @range + { + expected: "#{range ? "#{humanize_range(range)} time(s)" : "At least once"} with #{@args || "any arguments"}", + received: "#{calls.size} time(s)", + } + end + + def negated_values(actual : TestExpression(T)) forall T + calls = Harness.current.mocks.calls_for(actual.value, @expected.value) + calls.select! { |call| @args === call.args } if @args + range = @range + { + expected: "#{range ? "Not #{humanize_range(range)} time(s)" : "Never"} with #{@args || "any arguments"}", + received: "#{calls.size} time(s)", + } + end + + def with(*args, **opts) + args = Mocks::GenericArguments.new(args, opts) + ReceiveMatcher.new(@expected, args, @range) + end + + def once + ReceiveMatcher.new(@expected, @args, (1..1)) + end + + def twice + ReceiveMatcher.new(@expected, @args, (2..2)) + end + + def exactly(count) + Count.new(@expected, @args, (count..count)) + end + + def at_least(count) + Count.new(@expected, @args, (count..)) + end + + def at_most(count) + Count.new(@expected, @args, (..count)) + end + + def at_least_once + at_least(1).times + end + + def at_least_twice + at_least(2).times + end + + def at_most_once + at_most(1).times + end + + def at_most_twice + at_most(2).times + end + + def humanize_range(range : Range) + if (min = range.begin) + if (max = range.end) + if min == max + min + else + "#{min} to #{max}" + end + else + "At least #{min}" + end + else + if (max = range.end) + "At most #{max}" + else + raise "Unexpected endless range" + end + end + end + + private struct Count + def initialize(@expected : TestExpression(Symbol), @args : Mocks::Arguments?, @range : Range) + end + + def times + ReceiveMatcher.new(@expected, @args, @range) + end + end + end +end diff --git a/src/spectator/matchers/receive_type_matcher.cr b/src/spectator/matchers/receive_type_matcher.cr new file mode 100644 index 0000000..c716f05 --- /dev/null +++ b/src/spectator/matchers/receive_type_matcher.cr @@ -0,0 +1,126 @@ +require "../mocks" +require "./standard_matcher" + +module Spectator::Matchers + struct ReceiveTypeMatcher < StandardMatcher + alias Range = ::Range(Int32, Int32) | ::Range(Nil, Int32) | ::Range(Int32, Nil) + + def initialize(@expected : TestExpression(Symbol), @args : Mocks::Arguments? = nil, @range : Range? = nil) + end + + def description : String + range = @range + "received message #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "At least once"} with #{@args || "any arguments"}" + end + + def match?(actual : TestExpression(T)) : Bool forall T + calls = Harness.current.mocks.calls_for_type(actual.value, @expected.value) + calls.select! { |call| @args === call.args } if @args + if (range = @range) + range.includes?(calls.size) + else + !calls.empty? + end + end + + def failure_message(actual : TestExpression(T)) : String forall T + range = @range + "#{actual.label} did not receive #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "at least once"} with #{@args || "any arguments"}" + end + + def failure_message_when_negated(actual : TestExpression(T)) : String forall T + range = @range + "#{actual.label} received #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "at least once"} with #{@args || "any arguments"}" + end + + def values(actual : TestExpression(T)) forall T + calls = Harness.current.mocks.calls_for_type(T, @expected.value) + calls.select! { |call| @args === call.args } if @args + range = @range + { + expected: "#{range ? "#{humanize_range(range)} time(s)" : "At least once"} with #{@args || "any arguments"}", + received: "#{calls.size} time(s)", + } + end + + def negated_values(actual : TestExpression(T)) forall T + calls = Harness.current.mocks.calls_for_type(T, @expected.value) + calls.select! { |call| @args === call.args } if @args + range = @range + { + expected: "#{range ? "Not #{humanize_range(range)} time(s)" : "Never"} with #{@args || "any arguments"}", + received: "#{calls.size} time(s)", + } + end + + def with(*args, **opts) + args = Mocks::GenericArguments.new(args, opts) + ReceiveTypeMatcher.new(@expected, args, @range) + end + + def once + ReceiveTypeMatcher.new(@expected, @args, (1..1)) + end + + def twice + ReceiveTypeMatcher.new(@expected, @args, (2..2)) + end + + def exactly(count) + Count.new(@expected, @args, (count..count)) + end + + def at_least(count) + Count.new(@expected, @args, (count..)) + end + + def at_most(count) + Count.new(@expected, @args, (..count)) + end + + def at_least_once + at_least(1).times + end + + def at_least_twice + at_least(2).times + end + + def at_most_once + at_most(1).times + end + + def at_most_twice + at_most(2).times + end + + def humanize_range(range : Range) + if (min = range.begin) + if (max = range.end) + if min == max + min + else + "#{min} to #{max}" + end + else + "At least #{min}" + end + else + if (max = range.end) + "At most #{max}" + else + raise "Unexpected endless range" + end + end + end + + private struct Count + def initialize(@expected : TestExpression(Symbol), @args : Mocks::Arguments?, @range : Range) + end + + def times + ReceiveTypeMatcher.new(@expected, @args, @range) + end + end + end +end diff --git a/src/spectator/matchers/standard_matcher.cr b/src/spectator/matchers/standard_matcher.cr index 414e491..2a01ab3 100644 --- a/src/spectator/matchers/standard_matcher.cr +++ b/src/spectator/matchers/standard_matcher.cr @@ -27,7 +27,7 @@ module Spectator::Matchers if match?(actual) SuccessfulMatchData.new else - FailedMatchData.new(failure_message(actual), **values(actual)) + FailedMatchData.new(failure_message(actual), values(actual).to_a) end end @@ -42,7 +42,7 @@ module Spectator::Matchers if does_not_match?(actual) SuccessfulMatchData.new else - FailedMatchData.new(failure_message_when_negated(actual), **negated_values(actual)) + FailedMatchData.new(failure_message_when_negated(actual), negated_values(actual).to_a) end end @@ -66,7 +66,7 @@ module Spectator::Matchers # The message should typically only contain the test expression labels. # Actual values should be returned by `#values`. private def failure_message_when_negated(actual : TestExpression(T)) : String forall T - {% raise "Negation with #{@type.name} is not supported." %} + raise "Negation with #{self.class} is not supported." end # Checks whether the matcher is satisifed with the expression given to it. diff --git a/src/spectator/matchers/truthy_matcher.cr b/src/spectator/matchers/truthy_matcher.cr index df21d8c..bc26a6e 100644 --- a/src/spectator/matchers/truthy_matcher.cr +++ b/src/spectator/matchers/truthy_matcher.cr @@ -27,7 +27,7 @@ module Spectator::Matchers # ``` # expect(0).to be < 1 # ``` - def <(value : ExpectedType) forall ExpectedType + def <(value) expected = TestValue.new(value) LessThanMatcher.new(expected) end @@ -37,7 +37,7 @@ module Spectator::Matchers # ``` # expect(0).to be <= 1 # ``` - def <=(value : ExpectedType) forall ExpectedType + def <=(value) expected = TestValue.new(value) LessThanEqualMatcher.new(expected) end @@ -47,7 +47,7 @@ module Spectator::Matchers # ``` # expect(2).to be > 1 # ``` - def >(value : ExpectedType) forall ExpectedType + def >(value) expected = TestValue.new(value) GreaterThanMatcher.new(expected) end @@ -57,7 +57,7 @@ module Spectator::Matchers # ``` # expect(2).to be >= 1 # ``` - def >=(value : ExpectedType) forall ExpectedType + def >=(value) expected = TestValue.new(value) GreaterThanEqualMatcher.new(expected) end @@ -67,7 +67,7 @@ module Spectator::Matchers # ``` # expect(0).to be == 0 # ``` - def ==(value : ExpectedType) forall ExpectedType + def ==(value) expected = TestValue.new(value) EqualityMatcher.new(expected) end @@ -77,7 +77,7 @@ module Spectator::Matchers # ``` # expect(0).to be != 1 # ``` - def !=(value : ExpectedType) forall ExpectedType + def !=(value) expected = TestValue.new(value) InequalityMatcher.new(expected) end @@ -114,7 +114,7 @@ module Spectator::Matchers { expected: @truthy ? "Not false or nil" : "false or nil", actual: actual.value.inspect, - truthy: !!actual.value.inspect, + truthy: (!!actual.value).inspect, } end @@ -124,7 +124,7 @@ module Spectator::Matchers { expected: @truthy ? "false or nil" : "Not false or nil", actual: actual.value.inspect, - truthy: !!actual.value.inspect, + truthy: (!!actual.value).inspect, } end diff --git a/src/spectator/mocks.cr b/src/spectator/mocks.cr new file mode 100644 index 0000000..d36eeff --- /dev/null +++ b/src/spectator/mocks.cr @@ -0,0 +1,13 @@ +require "./mocks/*" + +module Spectator + # Functionality for mocking existing types. + module Mocks + def self.run(context : TestContext) + Registry.prepare(context) + yield + ensure + Registry.reset + end + end +end diff --git a/src/spectator/mocks/allow.cr b/src/spectator/mocks/allow.cr new file mode 100644 index 0000000..fb6b86f --- /dev/null +++ b/src/spectator/mocks/allow.cr @@ -0,0 +1,18 @@ +require "./registry" + +module Spectator::Mocks + struct Allow(T) + def initialize(@mock : T) + end + + def to(stub : MethodStub) : Nil + Harness.current.mocks.add_stub(@mock, stub) + end + + def to(stubs : Enumerable(MethodStub)) : Nil + stubs.each do |stub| + Harness.current.mocks.add_stub(@mock, stub) + end + end + end +end diff --git a/src/spectator/mocks/allow_any_instance.cr b/src/spectator/mocks/allow_any_instance.cr new file mode 100644 index 0000000..b652940 --- /dev/null +++ b/src/spectator/mocks/allow_any_instance.cr @@ -0,0 +1,15 @@ +require "./registry" + +module Spectator::Mocks + struct AllowAnyInstance(T) + def to(stub : MethodStub) : Nil + Harness.current.mocks.add_type_stub(T, stub) + end + + def to(stubs : Enumerable(MethodStub)) : Nil + stubs.each do |stub| + Harness.current.mocks.add_type_stub(T, stub) + end + end + end +end diff --git a/src/spectator/mocks/anonymous_double.cr b/src/spectator/mocks/anonymous_double.cr new file mode 100644 index 0000000..439901a --- /dev/null +++ b/src/spectator/mocks/anonymous_double.cr @@ -0,0 +1,26 @@ +module Spectator::Mocks + class AnonymousDouble(T) + def initialize(@name : String, @values : T) + end + + def as_null_object + AnonymousNullDouble.new(@name, @values) + end + + macro method_missing(call) + args = ::Spectator::Mocks::GenericArguments.create({{call.args.splat}}) + call = ::Spectator::Mocks::GenericMethodCall.new({{call.name.symbolize}}, args) + ::Spectator::Harness.current.mocks.record_call(self, call) + if (stub = ::Spectator::Harness.current.mocks.find_stub(self, call)) + stub.call!(args) do + @values.fetch({{call.name.symbolize}}) { raise "Consistency error - method stubbed with no implementation"; nil } + end + else + @values.fetch({{call.name.symbolize}}) do + return nil if ::Spectator::Harness.current.mocks.expected?(self, call) + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{call.name}}") + end + end + end + end +end diff --git a/src/spectator/mocks/anonymous_null_double.cr b/src/spectator/mocks/anonymous_null_double.cr new file mode 100644 index 0000000..8fa338c --- /dev/null +++ b/src/spectator/mocks/anonymous_null_double.cr @@ -0,0 +1,17 @@ +module Spectator::Mocks + class AnonymousNullDouble(T) + def initialize(@name : String, @values : T) + end + + macro method_missing(call) + args = ::Spectator::Mocks::GenericArguments.create({{call.args.splat}}) + call = ::Spectator::Mocks::GenericMethodCall.new({{call.name.symbolize}}, args) + ::Spectator::Harness.current.mocks.record_call(self, call) + if (stub = ::Spectator::Harness.current.mocks.find_stub(self, call)) + stub.call!(args) { @values.fetch({{call.name.symbolize}}) { self } } + else + @values.fetch({{call.name.symbolize}}) { self } + end + end + end +end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr new file mode 100644 index 0000000..e909235 --- /dev/null +++ b/src/spectator/mocks/arguments.cr @@ -0,0 +1,4 @@ +module Spectator::Mocks + abstract class Arguments + end +end diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr new file mode 100644 index 0000000..de218ba --- /dev/null +++ b/src/spectator/mocks/double.cr @@ -0,0 +1,98 @@ +require "./generic_method_call" +require "./generic_method_stub" +require "./unexpected_message_error" + +module Spectator::Mocks + abstract class Double + def initialize(@spectator_double_name : String, @null = false) + end + + private macro stub(definition, &block) + {% + name = nil + params = nil + args = nil + body = nil + if definition.is_a?(Call) # stub foo { :bar } + named = false + name = definition.name.id + params = definition.args + args = params.map do |p| + n = p.is_a?(TypeDeclaration) ? p.var : p.id + r = named ? "#{n}: #{n}".id : n + named = true if n.starts_with?('*') + r + end + body = definition.block.is_a?(Nop) ? block : definition.block + elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol + name = definition.var + params = [] of MacroId + args = [] of MacroId + body = block + else + raise "Unrecognized stub format" + end + %} + + def {{name}}({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + %args = ::Spectator::Mocks::GenericArguments.create({{args.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + ::Spectator::Harness.current.mocks.record_call(self, %call) + if (%stub = ::Spectator::Harness.current.mocks.find_stub(self, %call)) + %stub.call!(%args) { %method({{args.splat}}) } + else + %method({{args.splat}}) + end + end + + def {{name}}({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + %args = ::Spectator::Mocks::GenericArguments.create({{args.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + ::Spectator::Harness.current.mocks.record_call(self, %call) + if (%stub = ::Spectator::Harness.current.mocks.find_stub(self, %call)) + %stub.call!(%args) { %method({{args.splat}}) { |*%ya| yield *%ya } } + else + %method({{args.splat}}) do |*%yield_args| + yield *%yield_args + end + end + end + + def %method({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + {% if body && !body.is_a?(Nop) %} + {{body.body}} + {% else %} + %args = ::Spectator::Mocks::GenericArguments.create({{params.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + unless ::Spectator::Harness.current.mocks.expected?(self, %call) + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{name}}") + end + + # This code shouldn't be reached, but makes the compiler happy to have a matching return type. + {% if definition.is_a?(TypeDeclaration) %} + %x = uninitialized {{definition.type}} + {% else %} + nil + {% end %} + {% end %} + end + end + + macro method_missing(call) + args = ::Spectator::Mocks::GenericArguments.create({{call.args.splat}}) + call = ::Spectator::Mocks::GenericMethodCall.new({{call.name.symbolize}}, args) + ::Spectator::Harness.current.mocks.record_call(self, call) + + return self if @null + return self if ::Spectator::Harness.current.mocks.expected?(self, call) + + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{call.name}}") + end + + def to_s(io) + io << "Double(" + io << @spectator_double_name + io << ')' + end + end +end diff --git a/src/spectator/mocks/exception_method_stub.cr b/src/spectator/mocks/exception_method_stub.cr new file mode 100644 index 0000000..96342bc --- /dev/null +++ b/src/spectator/mocks/exception_method_stub.cr @@ -0,0 +1,14 @@ +require "./generic_arguments" +require "./generic_method_stub" + +module Spectator::Mocks + class ExceptionMethodStub(ExceptionType) < GenericMethodStub(Nil) + def initialize(name, source, @exception : ExceptionType, args = nil) + super(name, source, args) + end + + def call(_args : GenericArguments(T, NT), &_original : -> RT) forall T, NT, RT + raise @exception + end + end +end diff --git a/src/spectator/mocks/expect_any_instance.cr b/src/spectator/mocks/expect_any_instance.cr new file mode 100644 index 0000000..c51c0e8 --- /dev/null +++ b/src/spectator/mocks/expect_any_instance.cr @@ -0,0 +1,23 @@ +require "./registry" + +module Spectator::Mocks + struct ExpectAnyInstance(T) + def initialize(@source : Source) + end + + def to(stub : MethodStub) : Nil + actual = TestValue.new(T) + Harness.current.mocks.expect(T, stub.name) + value = TestValue.new(stub.name, stub.to_s) + matcher = Matchers::ReceiveTypeMatcher.new(value, stub.arguments?) + partial = Expectations::ExpectationPartial.new(actual, @source) + partial.to_eventually(matcher) + end + + def to(stubs : Enumerable(MethodStub)) : Nil + stubs.each do |stub| + to(stub) + end + end + end +end diff --git a/src/spectator/mocks/generic_arguments.cr b/src/spectator/mocks/generic_arguments.cr new file mode 100644 index 0000000..18d4193 --- /dev/null +++ b/src/spectator/mocks/generic_arguments.cr @@ -0,0 +1,42 @@ +require "./arguments" + +module Spectator::Mocks + class GenericArguments(T, NT) < Arguments + protected getter args + protected getter opts + + def initialize(@args : T, @opts : NT) + end + + def self.create(*args, **opts) + GenericArguments.new(args, opts) + end + + def pass_to(dispatcher) + dispatcher.call(*@args, **@opts) + end + + def ===(other) : Bool + return false unless @args === other.args + return false unless @opts.size === other.opts.size + + @opts.keys.all? do |key| + other.opts.has_key?(key) && @opts[key] === other.opts[key] + end + end + + def to_s(io) + @args.each_with_index do |arg, i| + arg.inspect(io) + io << ", " if i < @args.size - 1 + end + io << ", " unless @args.empty? || @opts.empty? + @opts.each_with_index do |key, value, i| + io << key + io << ": " + value.inspect(io) + io << ", " if i < @opts.size - 1 + end + end + end +end diff --git a/src/spectator/mocks/generic_method_call.cr b/src/spectator/mocks/generic_method_call.cr new file mode 100644 index 0000000..64f8999 --- /dev/null +++ b/src/spectator/mocks/generic_method_call.cr @@ -0,0 +1,19 @@ +require "./generic_arguments" +require "./method_call" + +module Spectator::Mocks + class GenericMethodCall(T, NT) < MethodCall + getter args + + def initialize(name : Symbol, @args : GenericArguments(T, NT)) + super(name) + end + + def to_s(io) + super + io << '(' + io << @args + io << ')' + end + end +end diff --git a/src/spectator/mocks/generic_method_stub.cr b/src/spectator/mocks/generic_method_stub.cr new file mode 100644 index 0000000..c0ee38f --- /dev/null +++ b/src/spectator/mocks/generic_method_stub.cr @@ -0,0 +1,31 @@ +require "./arguments" +require "./generic_arguments" +require "./method_call" +require "./method_stub" + +module Spectator::Mocks + abstract class GenericMethodStub(ReturnType) < MethodStub + getter! arguments : Arguments + + def initialize(name, source, @args : Arguments? = nil) + super(name, source) + end + + def callable?(call : GenericMethodCall(T, NT)) : Bool forall T, NT + super && (!@args || @args === call.args) + end + + def to_s(io) + super(io) + if @args + io << '(' + io << @args + io << ')' + end + io << " : " + io << ReturnType + io << " at " + io << @source + end + end +end diff --git a/src/spectator/mocks/method_call.cr b/src/spectator/mocks/method_call.cr new file mode 100644 index 0000000..00c6d0c --- /dev/null +++ b/src/spectator/mocks/method_call.cr @@ -0,0 +1,13 @@ +module Spectator::Mocks + abstract class MethodCall + getter name : Symbol + + def initialize(@name : Symbol) + end + + def to_s(io) + io << '#' + io << @name + end + end +end diff --git a/src/spectator/mocks/method_stub.cr b/src/spectator/mocks/method_stub.cr new file mode 100644 index 0000000..245bff3 --- /dev/null +++ b/src/spectator/mocks/method_stub.cr @@ -0,0 +1,33 @@ +require "../source" +require "./generic_method_call" + +module Spectator::Mocks + abstract class MethodStub + getter name : Symbol + + getter source : Source + + def initialize(@name, @source) + end + + def callable?(call : GenericMethodCall(T, NT)) : Bool forall T, NT + @name == call.name + end + + abstract def call(args : GenericArguments(T, NT), &original : -> RT) forall T, NT, RT + + def call!(args : GenericArguments(T, NT), &original : -> RT) : RT forall T, NT, RT + value = call(args, &original) + if value.is_a?(RT) + value.as(RT) + else + raise TypeCastError.new("The return type of stub #{self} doesn't match the expected type #{RT}") + end + end + + def to_s(io) + io << '#' + io << @name + end + end +end diff --git a/src/spectator/mocks/multi_value_method_stub.cr b/src/spectator/mocks/multi_value_method_stub.cr new file mode 100644 index 0000000..c623a4c --- /dev/null +++ b/src/spectator/mocks/multi_value_method_stub.cr @@ -0,0 +1,19 @@ +require "./generic_arguments" +require "./generic_method_stub" + +module Spectator::Mocks + class MultiValueMethodStub(ReturnType) < GenericMethodStub(ReturnType) + @index = 0 + + def initialize(name, source, @values : ReturnType, args = nil) + super(name, source, args) + raise ArgumentError.new("Values must have at least one item") if @values.size < 1 + end + + def call(_args : GenericArguments(T, NT), &_original : -> RT) forall T, NT, RT + value = @values[@index] + @index += 1 if @index < @values.size - 1 + value + end + end +end diff --git a/src/spectator/mocks/nil_method_stub.cr b/src/spectator/mocks/nil_method_stub.cr new file mode 100644 index 0000000..592d3b6 --- /dev/null +++ b/src/spectator/mocks/nil_method_stub.cr @@ -0,0 +1,48 @@ +require "./generic_arguments" +require "./generic_method_stub" +require "./value_method_stub" + +module Spectator::Mocks + class NilMethodStub < GenericMethodStub(Nil) + def call(_args : GenericArguments(T, NT), &_original : -> RT) forall T, NT, RT + nil + end + + def and_return + self + end + + def and_return(value) + ValueMethodStub.new(@name, @source, value, @args) + end + + def and_return(*values) + MultiValueMethodStub.new(@name, @source, values.to_a, @args) + end + + def and_raise(exception_type : Exception.class) + ExceptionMethodStub.new(@name, @source, exception_type.new, @args) + end + + def and_raise(exception : Exception) + ExceptionMethodStub.new(@name, @source, exception, @args) + end + + def and_raise(message : String) + ExceptionMethodStub.new(@name, @source, Exception.new(message), @args) + end + + def and_raise(exception_type : Exception.class, *args) forall T + ExceptionMethodStub.new(@name, @source, exception_type.new(*args), @args) + end + + def with(*args : *T, **opts : **NT) forall T, NT + args = GenericArguments.new(args, opts) + NilMethodStub.new(@name, @source, args) + end + + def and_call_original + OriginalMethodStub.new(@name, @source, @args) + end + end +end diff --git a/src/spectator/mocks/original_method_stub.cr b/src/spectator/mocks/original_method_stub.cr new file mode 100644 index 0000000..2097eb6 --- /dev/null +++ b/src/spectator/mocks/original_method_stub.cr @@ -0,0 +1,10 @@ +require "./generic_arguments" +require "./generic_method_stub" + +module Spectator::Mocks + class OriginalMethodStub < GenericMethodStub(Nil) + def call(_args : GenericArguments(T, NT), &_original : -> RT) forall T, NT, RT + yield + end + end +end diff --git a/src/spectator/mocks/proc_method_stub.cr b/src/spectator/mocks/proc_method_stub.cr new file mode 100644 index 0000000..1d55db3 --- /dev/null +++ b/src/spectator/mocks/proc_method_stub.cr @@ -0,0 +1,18 @@ +require "./arguments" +require "./generic_method_stub" + +module Spectator::Mocks + class ProcMethodStub(ReturnType) < GenericMethodStub(ReturnType) + def initialize(name, source, @proc : -> ReturnType, args = nil) + super(name, source, args) + end + + def self.create(name, source, args = nil, &block : -> T) forall T + ProcMethodStub.new(name, source, block, args) + end + + def call(_args : GenericArguments(T, NT), &_original : -> RT) forall T, NT, RT + @proc.call + end + end +end diff --git a/src/spectator/mocks/reflection.cr b/src/spectator/mocks/reflection.cr new file mode 100644 index 0000000..43f20f2 --- /dev/null +++ b/src/spectator/mocks/reflection.cr @@ -0,0 +1,21 @@ +require "../anything" + +module Spectator::Mocks + module Reflection + private macro _spectator_reflect + {% for meth in @type.methods %} + %source = ::Spectator::Source.new({{meth.filename}}, {{meth.line_number}}) + %args = ::Spectator::Mocks::GenericArguments.create( + {% for arg, i in meth.args %} + {% if meth.splat_index && i == meth.splat_index %} + *{{arg.restriction || "::Spectator::Anything.new".id}}{% if i < meth.args.size %},{% end %} + {% else %} + {{arg.restriction || "::Spectator::Anything.new".id}}{% if i < meth.args.size %},{% end %} + {% end %} + {% end %} + ) + ::Spectator::Mocks::TypeRegistry.add({{@type.id.stringify}}, {{meth.name.symbolize}}, %source, %args) + {% end %} + end + end +end diff --git a/src/spectator/mocks/registry.cr b/src/spectator/mocks/registry.cr new file mode 100644 index 0000000..a946ae4 --- /dev/null +++ b/src/spectator/mocks/registry.cr @@ -0,0 +1,107 @@ +module Spectator::Mocks + class Registry + alias Key = Tuple(String, UInt64) + + private struct Entry + getter stubs = Deque(MethodStub).new + getter calls = Deque(MethodCall).new + getter expected = Set(MethodStub).new + end + + @all_instances = {} of String => Entry + @entries = {} of Key => Entry + + def initialize(context : TestContext) + current_context = context + while current_context + current_context.stubs.each do |k, v| + stubs = if @all_instances.has_key?(k) + @all_instances[k].stubs + else + entry = Entry.new + @all_instances[k] = entry + entry.stubs + end + stubs.concat(v) + end + current_context = current_context.parent? + end + end + + def reset : Nil + @entries.clear + end + + def add_stub(object, stub : MethodStub) : Nil + # Stubs are added in reverse order, + # so that later-defined stubs override previously defined ones. + fetch_instance(object).stubs.unshift(stub) + end + + def add_type_stub(type, stub : MethodStub) : Nil + fetch_type(type).stubs.unshift(stub) + end + + def stubbed?(object, method_name : Symbol) : Bool + fetch_instance(object).stubs.any? { |stub| stub.name == method_name } || + fetch_type(object.class).stubs.any? { |stub| stub.name == method_name } + end + + def find_stub(object, call : GenericMethodCall(T, NT)) forall T, NT + fetch_instance(object).stubs.find(&.callable?(call)) || + fetch_type(object.class).stubs.find(&.callable?(call)) + end + + def record_call(object, call : MethodCall) : Nil + fetch_instance(object).calls << call + fetch_type(object.class).calls << call + end + + def calls_for(object, method_name : Symbol) + fetch_instance(object).calls.select { |call| call.name == method_name } + end + + def calls_for_type(type : T.class, method_name : Symbol) forall T + fetch_type(type).calls.select { |call| call.name == method_name } + end + + def expected?(object, call : GenericMethodCall(T, NT)) : Bool forall T, NT + fetch_instance(object).expected.any?(&.callable?(call)) || + fetch_type(object.class).expected.any?(&.callable?(call)) + end + + def expect(object, stub : MethodStub) : Nil + fetch_instance(object).expected.add(stub) + end + + def expect(type : T.class, stub : MethodStub) : Nil forall T + fetch_type(type).expected.add(stub) + end + + private def fetch_instance(object) + key = unique_key(object) + if @entries.has_key?(key) + @entries[key] + else + @entries[key] = Entry.new + end + end + + private def fetch_type(type) + key = type.name + if @all_instances.has_key?(key) + @all_instances[key] + else + @all_instances[key] = Entry.new + end + end + + private def unique_key(reference : Reference) + {reference.class.name, reference.object_id} + end + + private def unique_key(value : Value) + {value.class.name, value.hash} + end + end +end diff --git a/src/spectator/mocks/stubs.cr b/src/spectator/mocks/stubs.cr new file mode 100644 index 0000000..089e637 --- /dev/null +++ b/src/spectator/mocks/stubs.cr @@ -0,0 +1,80 @@ +module Spectator::Mocks + module Stubs + private macro stub(definition, _file = __FILE__, _line = __LINE__, &block) + {% + receiver = nil + name = nil + params = nil + args = nil + body = nil + if definition.is_a?(Call) # stub foo { :bar } + receiver = definition.receiver.id + named = false + name = definition.name.id + params = definition.args + args = params.map do |p| + n = p.is_a?(TypeDeclaration) ? p.var : p.id + r = named ? "#{n}: #{n}".id : n + named = true if n.starts_with?('*') + r + end + body = definition.block.is_a?(Nop) ? block : definition.block + elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol + name = definition.var + params = [] of MacroId + args = [] of MacroId + body = block + else + raise "Unrecognized stub format" + end + + original = if @type.methods.find { |m| m.name.id == name } + :previous_def + else + :super + end.id + receiver = if receiver == :self.id + original = :previous_def.id + "self." + else + "" + end.id + %} + + {% if body && !body.is_a?(Nop) %} + %source = ::Spectator::Source.new({{_file}}, {{_line}}) + %proc = ->{ + {{body.body}} + } + %ds = ::Spectator::Mocks::ProcMethodStub.new({{name.symbolize}}, %source, %proc) + ::Spectator::SpecBuilder.add_default_stub({{@type.name}}, %ds) + {% end %} + + def {{receiver}}{{name}}({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + if (%harness = ::Spectator::Harness.current?) + %args = ::Spectator::Mocks::GenericArguments.create({{args.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + %harness.mocks.record_call(self, %call) + if (%stub = %harness.mocks.find_stub(self, %call)) + return %stub.call!(%args) { {{original}}({{args.splat}}) } + end + end + {{original}}({{args.splat}}) + end + + def {{receiver}}{{name}}({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + if (%harness = ::Spectator::Harness.current?) + %args = ::Spectator::Mocks::GenericArguments.create({{args.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + %harness.mocks.record_call(self, %call) + if (%stub = %harness.mocks.find_stub(self, %call)) + return %stub.call!(%args) { {{original}}({{args.splat}}) { |*%ya| yield *%ya } } + end + end + {{original}}({{args.splat}}) do |*%yield_args| + yield *%yield_args + end + end + end + end +end diff --git a/src/spectator/mocks/type_registry.cr b/src/spectator/mocks/type_registry.cr new file mode 100644 index 0000000..c3cb0b6 --- /dev/null +++ b/src/spectator/mocks/type_registry.cr @@ -0,0 +1,25 @@ +module Spectator::Mocks + module TypeRegistry + extend self + + alias Key = Tuple(String, Symbol) + + @@entries = {} of Key => Deque(MethodStub) + + def add(type_name : String, method_name : Symbol, source : Source, args : Arguments) : Nil + key = {type_name, method_name} + list = if @@entries.has_key?(key) + @@entries[key] + else + @@entries[key] = Deque(MethodStub).new + end + list << NilMethodStub.new(method_name, source, args) + end + + def exists?(type_name : String, call : GenericMethodCall(T, NT)) : Bool forall T, NT + key = {type_name, call.name} + list = @@entries.fetch(key) { return false } + list.any?(&.callable?(call)) + end + end +end diff --git a/src/spectator/mocks/unexpected_message_error.cr b/src/spectator/mocks/unexpected_message_error.cr new file mode 100644 index 0000000..9207846 --- /dev/null +++ b/src/spectator/mocks/unexpected_message_error.cr @@ -0,0 +1,4 @@ +module Spectator::Mocks + class UnexpectedMessageError < Exception + end +end diff --git a/src/spectator/mocks/value_method_stub.cr b/src/spectator/mocks/value_method_stub.cr new file mode 100644 index 0000000..43bfcaa --- /dev/null +++ b/src/spectator/mocks/value_method_stub.cr @@ -0,0 +1,14 @@ +require "./generic_arguments" +require "./generic_method_stub" + +module Spectator::Mocks + class ValueMethodStub(ReturnType) < GenericMethodStub(ReturnType) + def initialize(name, source, @value : ReturnType, args = nil) + super(name, source, args) + end + + def call(_args : GenericArguments(T, NT), &_original : -> RT) forall T, NT, RT + @value + end + end +end diff --git a/src/spectator/mocks/verifying_double.cr b/src/spectator/mocks/verifying_double.cr new file mode 100644 index 0000000..3f451cb --- /dev/null +++ b/src/spectator/mocks/verifying_double.cr @@ -0,0 +1,108 @@ +module Spectator::Mocks + abstract class VerifyingDouble(T) + def initialize(@null = false) + end + + private macro stub(definition, &block) + {% + name = nil + params = nil + args = nil + body = nil + if definition.is_a?(Call) # stub foo { :bar } + named = false + name = definition.name.id + params = definition.args + args = params.map do |p| + n = p.is_a?(TypeDeclaration) ? p.var : p.id + r = named ? "#{n}: #{n}".id : n + named = true if n.starts_with?('*') + r + end + body = definition.block.is_a?(Nop) ? block : definition.block + elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol + name = definition.var + params = [] of MacroId + args = [] of MacroId + body = block + else + raise "Unrecognized stub format" + end + %} + + def {{name}}({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + %args = ::Spectator::Mocks::GenericArguments.create({{args.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + ::Spectator::Harness.current.mocks.record_call(self, %call) + + unless ::Spectator::Mocks::TypeRegistry.exists?(T.to_s, %call) + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{name}} - #{T} does not respond to #{%call}") + end + + if (%stub = ::Spectator::Harness.current.mocks.find_stub(self, %call)) + %stub.call!(%args) { %method({{args.splat}}) } + else + %method({{args.splat}}) + end + end + + def {{name}}({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + %args = ::Spectator::Mocks::GenericArguments.create({{args.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + ::Spectator::Harness.current.mocks.record_call(self, %call) + + unless ::Spectator::Mocks::TypeRegistry.exists?(T.to_s, %call) + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{name}} - #{T} does not respond to #{%call}") + end + + if (%stub = ::Spectator::Harness.current.mocks.find_stub(self, %call)) + %stub.call!(%args) { %method({{args.splat}}) { |*%ya| yield *%ya } } + else + %method({{args.splat}}) do |*%yield_args| + yield *%yield_args + end + end + end + + def %method({{params.splat}}){% if definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %} + {% if body && !body.is_a?(Nop) %} + {{body.body}} + {% else %} + %args = ::Spectator::Mocks::GenericArguments.create({{params.splat}}) + %call = ::Spectator::Mocks::GenericMethodCall.new({{name.symbolize}}, %args) + unless ::Spectator::Harness.current.mocks.expected?(self, %call) + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{name}}") + end + + # This code shouldn't be reached, but makes the compiler happy to have a matching return type. + {% if definition.is_a?(TypeDeclaration) %} + %x = uninitialized {{definition.type}} + {% else %} + nil + {% end %} + {% end %} + end + end + + macro method_missing(call) + args = ::Spectator::Mocks::GenericArguments.create({{call.args.splat}}) + call = ::Spectator::Mocks::GenericMethodCall.new({{call.name.symbolize}}, args) + ::Spectator::Harness.current.mocks.record_call(self, call) + + unless ::Spectator::Mocks::TypeRegistry.exists?(T.to_s, call) + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{call.name}} - #{T} does not respond to #{call}") + end + + return self if @null + return self if ::Spectator::Harness.current.mocks.expected?(self, call) + + raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{call.name}}") + end + + def to_s(io) + io << "Double(" + io << T + io << ')' + end + end +end diff --git a/src/spectator/runnable_example.cr b/src/spectator/runnable_example.cr index 9bbbbed..02086d0 100644 --- a/src/spectator/runnable_example.cr +++ b/src/spectator/runnable_example.cr @@ -20,7 +20,7 @@ module Spectator context.run_before_hooks(self) run_example(result) context.run_after_hooks(self) - run_deferred(result) + run_deferred(result) unless result.error end end diff --git a/src/spectator/spec_builder.cr b/src/spectator/spec_builder.cr index e8ec95e..9e199c2 100644 --- a/src/spectator/spec_builder.cr +++ b/src/spectator/spec_builder.cr @@ -96,6 +96,10 @@ module Spectator @@stack.current.add_post_condition(block) end + def add_default_stub(*args) : Nil + @@stack.current.add_default_stub(*args) + end + # Builds the entire spec and returns it as a test suite. # This should be called only once after the entire spec has been defined. protected def build(filter : ExampleFilter) : TestSuite diff --git a/src/spectator/spec_builder/example_group_builder.cr b/src/spectator/spec_builder/example_group_builder.cr index f9ed5bf..950f99d 100644 --- a/src/spectator/spec_builder/example_group_builder.cr +++ b/src/spectator/spec_builder/example_group_builder.cr @@ -14,6 +14,7 @@ module Spectator::SpecBuilder @around_each_hooks = Deque(::SpectatorTest, Proc(Nil) ->).new @pre_conditions = Deque(TestMetaMethod).new @post_conditions = Deque(TestMetaMethod).new + @default_stubs = {} of String => Deque(Mocks::MethodStub) def add_child(child : Child) @children << child @@ -47,6 +48,12 @@ module Spectator::SpecBuilder @post_conditions << hook end + def add_default_stub(type : T.class, stub : Mocks::MethodStub) forall T + key = type.name + @default_stubs[key] = Deque(Mocks::MethodStub).new unless @default_stubs.has_key?(key) + @default_stubs[key].unshift(stub) + end + private def build_hooks ExampleHooks.new( @before_all_hooks.to_a, diff --git a/src/spectator/spec_builder/nested_example_group_builder.cr b/src/spectator/spec_builder/nested_example_group_builder.cr index dc08cff..6d7bb77 100644 --- a/src/spectator/spec_builder/nested_example_group_builder.cr +++ b/src/spectator/spec_builder/nested_example_group_builder.cr @@ -7,7 +7,7 @@ module Spectator::SpecBuilder end def build(parent_group) - context = TestContext.new(parent_group.context, build_hooks, build_conditions, parent_group.context.values) + context = TestContext.new(parent_group.context, build_hooks, build_conditions, parent_group.context.values, @default_stubs) NestedExampleGroup.new(@description, @source, parent_group, context).tap do |group| group.children = children.map do |child| child.build(group).as(ExampleComponent) diff --git a/src/spectator/spec_builder/root_example_group_builder.cr b/src/spectator/spec_builder/root_example_group_builder.cr index 02f4914..3dbbcf7 100644 --- a/src/spectator/spec_builder/root_example_group_builder.cr +++ b/src/spectator/spec_builder/root_example_group_builder.cr @@ -4,7 +4,7 @@ require "./example_group_builder" module Spectator::SpecBuilder class RootExampleGroupBuilder < ExampleGroupBuilder def build - context = TestContext.new(nil, build_hooks, build_conditions, TestValues.empty) + context = TestContext.new(nil, build_hooks, build_conditions, TestValues.empty, {} of String => Deque(Mocks::MethodStub)) RootExampleGroup.new(context).tap do |group| group.children = children.map do |child| child.build(group).as(ExampleComponent) diff --git a/src/spectator/spec_builder/sample_example_group_builder.cr b/src/spectator/spec_builder/sample_example_group_builder.cr index eacd596..955ac7a 100644 --- a/src/spectator/spec_builder/sample_example_group_builder.cr +++ b/src/spectator/spec_builder/sample_example_group_builder.cr @@ -9,7 +9,7 @@ module Spectator::SpecBuilder def build(parent_group) values = parent_group.context.values collection = @collection_builder.call(values) - context = TestContext.new(parent_group.context, build_hooks, build_conditions, values) + context = TestContext.new(parent_group.context, build_hooks, build_conditions, values, @default_stubs) NestedExampleGroup.new(@description, @source, parent_group, context).tap do |group| group.children = collection.map do |element| build_sub_group(group, element).as(ExampleComponent) @@ -19,7 +19,7 @@ module Spectator::SpecBuilder private def build_sub_group(parent_group, element) values = parent_group.context.values.add(@id, @description.to_s, element) - context = TestContext.new(parent_group.context, ExampleHooks.empty, ExampleConditions.empty, values) + context = TestContext.new(parent_group.context, ExampleHooks.empty, ExampleConditions.empty, values, {} of String => Deque(Mocks::MethodStub)) NestedExampleGroup.new("#{@label} = #{element.inspect}", @source, parent_group, context).tap do |group| group.children = children.map do |child| child.build(group).as(ExampleComponent) diff --git a/src/spectator/test_context.cr b/src/spectator/test_context.cr index 8d16fee..bf2c612 100644 --- a/src/spectator/test_context.cr +++ b/src/spectator/test_context.cr @@ -3,9 +3,17 @@ require "./test_values" module Spectator class TestContext + getter! parent + getter values - def initialize(@parent : TestContext?, @hooks : ExampleHooks, @conditions : ExampleConditions, @values : TestValues) + getter stubs : Hash(String, Deque(Mocks::MethodStub)) + + def initialize(@parent : TestContext?, + @hooks : ExampleHooks, + @conditions : ExampleConditions, + @values : TestValues, + @stubs : Hash(String, Deque(Mocks::MethodStub))) @before_all_hooks_run = false @after_all_hooks_run = false end diff --git a/src/spectator/test_values.cr b/src/spectator/test_values.cr index d07da71..8503ca8 100644 --- a/src/spectator/test_values.cr +++ b/src/spectator/test_values.cr @@ -18,8 +18,8 @@ module Spectator # Adds a new value by duplicating the current set and adding to it. # The new sample values with the additional value is returned. # The original set of sample values is not modified. - def add(id : Symbol, name : String, value : T) : TestValues forall T - wrapper = TypedValueWrapper(T).new(value) + def add(id : Symbol, name : String, value) : TestValues + wrapper = TypedValueWrapper.new(value) TestValues.new(@values.merge({ id => Entry.new(name, wrapper), }))