Merge branch 'mock-redesign' into 'master'
Overhaul mock system See merge request arctic-fox/spectator!35
This commit is contained in:
commit
b65f53f105
|
@ -41,6 +41,16 @@ spec rspec:
|
|||
script:
|
||||
- crystal spec --error-on-warnings --junit_output=. spec/rspec/
|
||||
|
||||
spec dsl:
|
||||
extends: spec
|
||||
script:
|
||||
- crystal spec --error-on-warnings --junit_output=. spec/spectator/dsl/
|
||||
|
||||
spec mocks:
|
||||
extends: spec
|
||||
script:
|
||||
- crystal spec --error-on-warnings --junit_output=. spec/spectator/mocks/
|
||||
|
||||
format:
|
||||
script:
|
||||
- shards
|
||||
|
@ -56,7 +66,7 @@ nightly:
|
|||
allow_failure: true
|
||||
script:
|
||||
- shards --ignore-crystal-version
|
||||
- crystal spec --error-on-warnings --junit_output=. --tag smoke
|
||||
- crystal spec --error-on-warnings --junit_output=. --tag smoke spec/spectator/dsl/
|
||||
- crystal tool format --check
|
||||
artifacts:
|
||||
when: always
|
||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -5,6 +5,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
- Overhauled mock and double system. [#63](https://gitlab.com/arctic-fox/spectator/-/issues/63)
|
||||
- Testing if `exit` is called no longer is done with stubs and catching the `Spectator::SystemExit` exception should be caught. [#29](https://github.com/icy-arctic-fox/spectator/issues/29)
|
||||
- Adjust evaluation order of `change` matcher expressions.
|
||||
|
||||
### Removed
|
||||
- Removed support for stubbing top-level methods (such as `exit`).
|
||||
|
||||
## [0.10.6] - 2022-07-07
|
||||
### Fixed
|
||||
- Fixed compiler warnings generated by positional arguments with different names.
|
||||
|
||||
### Changed
|
||||
- Forward example procsy `to_s` to underlying example. [#70](https://gitlab.com/arctic-fox/spectator/-/issues/70)
|
||||
|
||||
|
@ -375,7 +387,8 @@ This has been changed so that it compiles and raises an error at runtime with a
|
|||
First version ready for public use.
|
||||
|
||||
|
||||
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...master
|
||||
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...master
|
||||
[0.10.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...v0.10.6
|
||||
[0.10.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.4...v0.10.5
|
||||
[0.10.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.3...v0.10.4
|
||||
[0.10.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.2...v0.10.3
|
||||
|
|
39
README.md
39
README.md
|
@ -255,19 +255,40 @@ end
|
|||
Spectator supports an extensive mocking feature set via two types - mocks and doubles.
|
||||
Mocks are used to override behavior in existing types.
|
||||
Doubles are objects that stand-in when there are no type restrictions.
|
||||
Stubs can be defined on both that control how methods behave.
|
||||
Stubs can be defined on both which control how methods behave.
|
||||
|
||||
```crystal
|
||||
double :my_double do
|
||||
stub foo : Int32
|
||||
stub bar(arg) { arg.to_s }
|
||||
abstract class Interface
|
||||
abstract def invoke(thing) : String
|
||||
end
|
||||
|
||||
it "does a thing" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:foo).and_return(42)
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.bar(42)).to eq("42")
|
||||
# Type being tested.
|
||||
class Driver
|
||||
def do_something(interface : Interface, thing)
|
||||
interface.invoke(thing)
|
||||
end
|
||||
end
|
||||
|
||||
Spectator.describe Driver do
|
||||
# Define a mock for Interface.
|
||||
mock Interface
|
||||
|
||||
# Define a double that the interface will use.
|
||||
double(:my_double, foo: 42)
|
||||
|
||||
it "does a thing" do
|
||||
# Create an instance of the mock interface.
|
||||
interface = mock(Interface)
|
||||
# Indicate that `#invoke` should return "test" when called.
|
||||
allow(interface).to receive(:invoke).and_return("test")
|
||||
|
||||
# Create an instance of the double.
|
||||
dbl = double(:my_double)
|
||||
# Call the mock method.
|
||||
subject.do_something(interface, dbl)
|
||||
# Verify everything went okay.
|
||||
expect(interface).to have_received(:invoke).with(thing)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
name: spectator
|
||||
version: 0.10.6
|
||||
version: 0.11.0-alpha
|
||||
description: |
|
||||
A feature-rich spec testing framework for Crystal with similarities to RSpec.
|
||||
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
class Phonebook
|
||||
def find(name)
|
||||
# Some expensive lookup call.
|
||||
"+18005554321"
|
||||
end
|
||||
end
|
||||
|
||||
class Resolver
|
||||
def initialize(@phonebook : Phonebook)
|
||||
end
|
||||
|
||||
def find(name)
|
||||
@phonebook.find(name)
|
||||
end
|
||||
end
|
||||
|
||||
Spectator.describe Resolver do
|
||||
mock Phonebook do
|
||||
stub find(name)
|
||||
end
|
||||
|
||||
describe "#find" do
|
||||
it "can find number" do
|
||||
pb = Phonebook.new
|
||||
allow(pb).to receive(find).and_return("+18005551234")
|
||||
resolver = Resolver.new(pb)
|
||||
expect(resolver.find("Bob")).to eq("+18005551234")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,40 +0,0 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe "Doubles" do
|
||||
double :my_double do
|
||||
stub answer { 42 }
|
||||
end
|
||||
|
||||
specify "the answer to everything" do
|
||||
dbl = double(:my_double)
|
||||
expect(dbl.answer).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
class MyType
|
||||
def answer
|
||||
123
|
||||
end
|
||||
end
|
||||
|
||||
Spectator.describe "Mocks" do
|
||||
mock MyType do
|
||||
stub answer { 42 }
|
||||
end
|
||||
|
||||
specify "the answer to everything" do
|
||||
m = MyType.new
|
||||
expect(m.answer).to eq(42)
|
||||
end
|
||||
end
|
||||
Spectator.describe "Mocks and doubles" do
|
||||
double :my_double do
|
||||
stub answer : Int32 # Return type required, otherwise nil is assumed.
|
||||
end
|
||||
|
||||
specify "the answer to everything" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:answer).and_return(42)
|
||||
expect(dbl.answer).to eq(42)
|
||||
end
|
||||
end
|
|
@ -1,166 +0,0 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe "Stubs" do
|
||||
context "Implementing a Stub" do
|
||||
double :my_double do
|
||||
stub answer : Int32
|
||||
stub do_something
|
||||
end
|
||||
|
||||
it "knows the answer" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:answer).and_return(42)
|
||||
expect(dbl.answer).to eq(42)
|
||||
end
|
||||
|
||||
it "does something" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:do_something)
|
||||
expect(dbl.do_something).to be_nil
|
||||
end
|
||||
|
||||
context "and_return" do
|
||||
double :my_double do
|
||||
stub to_s : String
|
||||
stub do_something
|
||||
end
|
||||
|
||||
it "stringifies" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:to_s).and_return("foobar")
|
||||
expect(dbl.to_s).to eq("foobar")
|
||||
end
|
||||
|
||||
it "returns gibberish" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:to_s).and_return("foo", "bar", "baz")
|
||||
expect(dbl.to_s).to eq("foo")
|
||||
expect(dbl.to_s).to eq("bar")
|
||||
expect(dbl.to_s).to eq("baz")
|
||||
expect(dbl.to_s).to eq("baz")
|
||||
end
|
||||
|
||||
it "returns nil" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:do_something).and_return
|
||||
expect(dbl.do_something).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "and_raise" do
|
||||
double :my_double do
|
||||
stub oops
|
||||
end
|
||||
|
||||
it "raises an error" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:oops).and_raise(DivisionByZeroError.new)
|
||||
expect { dbl.oops }.to raise_error(DivisionByZeroError)
|
||||
end
|
||||
it "raises an error" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:oops).and_raise("Something broke")
|
||||
expect { dbl.oops }.to raise_error(/Something broke/)
|
||||
end
|
||||
it "raises an error" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:oops).and_raise(ArgumentError, "Size must be > 0")
|
||||
expect { dbl.oops }.to raise_error(ArgumentError, /Size/)
|
||||
end
|
||||
end
|
||||
|
||||
context "and_call_original" do
|
||||
class MyType
|
||||
def foo
|
||||
"foo"
|
||||
end
|
||||
end
|
||||
|
||||
mock MyType do
|
||||
stub foo
|
||||
end
|
||||
|
||||
it "calls the original" do
|
||||
instance = MyType.new
|
||||
allow(instance).to receive(:foo).and_call_original
|
||||
expect(instance.foo).to eq("foo")
|
||||
end
|
||||
end
|
||||
|
||||
context "Short-hand for Multiple Stubs" do
|
||||
double :my_double do
|
||||
stub method_a : Symbol
|
||||
stub method_b : Int32
|
||||
stub method_c : String
|
||||
end
|
||||
|
||||
it "does something" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive_messages(method_a: :foo, method_b: 42, method_c: "foobar")
|
||||
expect(dbl.method_a).to eq(:foo)
|
||||
expect(dbl.method_b).to eq(42)
|
||||
expect(dbl.method_c).to eq("foobar")
|
||||
end
|
||||
end
|
||||
|
||||
context "Custom Implementation" do
|
||||
double :my_double do
|
||||
stub foo : String
|
||||
end
|
||||
|
||||
it "does something" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:foo) { "foo" }
|
||||
expect(dbl.foo).to eq("foo")
|
||||
end
|
||||
end
|
||||
|
||||
context "Arguments" do
|
||||
double :my_double do
|
||||
stub add(a, b) { a + b }
|
||||
stub do_something(arg) { arg } # Return the argument by default.
|
||||
end
|
||||
|
||||
it "adds two numbers" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:add).and_return(7)
|
||||
expect(dbl.add(1, 2)).to eq(7)
|
||||
end
|
||||
|
||||
it "does basic matching" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:do_something).with(1).and_return(42)
|
||||
allow(dbl).to receive(:do_something).with(2).and_return(22)
|
||||
expect(dbl.do_something(1)).to eq(42)
|
||||
expect(dbl.do_something(2)).to eq(22)
|
||||
end
|
||||
|
||||
it "can call the original" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:do_something).with(1).and_return(42)
|
||||
allow(dbl).to receive(:do_something).with(2).and_call_original
|
||||
expect(dbl.do_something(1)).to eq(42)
|
||||
expect(dbl.do_something(2)).to eq(2)
|
||||
end
|
||||
|
||||
it "falls back to the default" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:do_something).and_return(22)
|
||||
allow(dbl).to receive(:do_something).with(1).and_return(42)
|
||||
expect(dbl.do_something(1)).to eq(42)
|
||||
expect(dbl.do_something(2)).to eq(22)
|
||||
expect(dbl.do_something(3)).to eq(22)
|
||||
end
|
||||
|
||||
it "does advanced matching" do
|
||||
dbl = double(:my_double)
|
||||
allow(dbl).to receive(:do_something).with(Int32).and_return(42)
|
||||
allow(dbl).to receive(:do_something).with(String).and_return("foobar")
|
||||
allow(dbl).to receive(:do_something).with(/hi/).and_return("hello there")
|
||||
expect(dbl.do_something(1)).to eq(42)
|
||||
expect(dbl.do_something("foo")).to eq("foobar")
|
||||
expect(dbl.do_something("hi there")).to eq("hello there")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,34 @@
|
|||
require "../spec_helper"
|
||||
|
||||
private abstract class Interface
|
||||
abstract def invoke(thing) : String
|
||||
end
|
||||
|
||||
# Type being tested.
|
||||
private class Driver
|
||||
def do_something(interface : Interface, thing)
|
||||
interface.invoke(thing)
|
||||
end
|
||||
end
|
||||
|
||||
Spectator.describe Driver do
|
||||
# Define a mock for Interface.
|
||||
mock Interface
|
||||
|
||||
# Define a double that the interface will use.
|
||||
double(:my_double, foo: 42)
|
||||
|
||||
it "does a thing" do
|
||||
# Create an instance of the mock interface.
|
||||
interface = mock(Interface)
|
||||
# Indicate that `#invoke` should return "test" when called.
|
||||
allow(interface).to receive(:invoke).and_return("test")
|
||||
|
||||
# Create an instance of the double.
|
||||
dbl = double(:my_double)
|
||||
# Call the mock method.
|
||||
subject.do_something(interface, dbl)
|
||||
# Verify everything went okay.
|
||||
expect(interface).to have_received(:invoke).with(dbl)
|
||||
end
|
||||
end
|
|
@ -7,12 +7,10 @@ Spectator.describe "GitHub Issue #28" do
|
|||
end
|
||||
end
|
||||
|
||||
mock Test do
|
||||
stub foo
|
||||
end
|
||||
mock Test
|
||||
|
||||
it "matches method stubs with no_args" do
|
||||
test = Test.new
|
||||
test = mock(Test)
|
||||
expect(test).to receive(:foo).with(no_args).and_return(42)
|
||||
test.foo
|
||||
end
|
||||
|
|
|
@ -7,14 +7,9 @@ Spectator.describe "GitHub Issue #29" do
|
|||
end
|
||||
end
|
||||
|
||||
mock SomeClass do
|
||||
stub exit(code)
|
||||
end
|
||||
|
||||
describe SomeClass do
|
||||
it "captures exit" do
|
||||
expect(subject).to receive(:exit).with(0)
|
||||
subject.goodbye
|
||||
expect { subject.goodbye }.to raise_error(Spectator::SystemExit)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -25,16 +20,10 @@ Spectator.describe "GitHub Issue #29" do
|
|||
end
|
||||
end
|
||||
|
||||
mock Foo do
|
||||
stub self.exit(code)
|
||||
end
|
||||
|
||||
subject { Foo }
|
||||
|
||||
it "must capture exit" do
|
||||
expect(subject).to receive(:exit).with(0)
|
||||
|
||||
subject.test
|
||||
expect { subject.test }.to raise_error(Spectator::SystemExit)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,10 +21,7 @@ Spectator.describe "GitHub Issue #32" do
|
|||
let(test_instance) { test_class.new }
|
||||
|
||||
describe "something else" do
|
||||
mock TestFoo::TestClass do
|
||||
stub self.new
|
||||
stub test
|
||||
end
|
||||
inject_mock TestFoo::TestClass
|
||||
|
||||
it "must test when new is called" do
|
||||
expect(test_class).to receive(:new).with(no_args).and_return(test_instance)
|
||||
|
|
|
@ -10,11 +10,11 @@ Spectator.describe "GitHub Issue #33" do
|
|||
end
|
||||
end
|
||||
|
||||
mock Test do
|
||||
stub method2
|
||||
end
|
||||
mock Test
|
||||
|
||||
describe Test do
|
||||
subject { mock(Test) }
|
||||
|
||||
describe "#method1" do
|
||||
it do
|
||||
expect(subject).to receive(:method2)
|
||||
|
|
|
@ -25,13 +25,11 @@ class Sdk < SdkInterface
|
|||
end
|
||||
|
||||
Spectator.describe Example do
|
||||
mock Sdk do
|
||||
stub register_hook(name, &block)
|
||||
end
|
||||
mock Sdk
|
||||
|
||||
describe "#configure" do
|
||||
it "registers a block on configure" do
|
||||
sdk = Sdk.new
|
||||
sdk = mock(Sdk)
|
||||
example_class = Example.new(sdk)
|
||||
allow(sdk).to receive(register_hook())
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ Spectator.describe "eq matcher" do
|
|||
expect(42).to eq(42)
|
||||
end
|
||||
|
||||
it "is false for inequal values" do
|
||||
it "is false for unequal values" do
|
||||
expect(42).to_not eq(24)
|
||||
end
|
||||
|
||||
|
@ -21,12 +21,14 @@ Spectator.describe "eq matcher" do
|
|||
end
|
||||
|
||||
double(:fake) do
|
||||
stub instance.==(other) { true }
|
||||
stub def ==(other)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
it "uses the == operator" do
|
||||
dbl = double(:fake)
|
||||
expect(42).to eq(dbl)
|
||||
expect(dbl).to have_received(:==).with(42).once
|
||||
expect(dbl).to have_received(:==).with(42)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,306 @@
|
|||
require "../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::Matchers::ReceiveMatcher do
|
||||
let(stub) { Spectator::NullStub.new(:test_method) }
|
||||
subject(matcher) { described_class.new(stub) }
|
||||
|
||||
let(args) { Spectator::Arguments.capture(1, "test", Symbol, foo: /bar/) }
|
||||
let(args_stub) { Spectator::NullStub.new(:test_method, args) }
|
||||
let(args_matcher) { described_class.new(args_stub) }
|
||||
|
||||
let(no_args_stub) { Spectator::NullStub.new(:test_method, Spectator::Arguments.none) }
|
||||
let(no_args_matcher) { described_class.new(no_args_stub) }
|
||||
|
||||
double(:dbl, test_method: nil, irrelevant: nil)
|
||||
let(dbl) { double(:dbl) }
|
||||
let(actual) { Spectator::Value.new(dbl, "dbl") }
|
||||
|
||||
def successful_match
|
||||
Spectator::Matchers::SuccessfulMatchData
|
||||
end
|
||||
|
||||
def failed_match
|
||||
Spectator::Matchers::FailedMatchData
|
||||
end
|
||||
|
||||
describe "#description" do
|
||||
subject { matcher.description }
|
||||
|
||||
it "includes the method name" do
|
||||
is_expected.to contain("test_method")
|
||||
end
|
||||
|
||||
context "without an argument constraint" do
|
||||
it "mentions it accepts any arguments" do
|
||||
is_expected.to contain("any args")
|
||||
end
|
||||
end
|
||||
|
||||
context "with no arguments" do
|
||||
let(matcher) { no_args_matcher }
|
||||
|
||||
it "mentions there are none" do
|
||||
is_expected.to contain("no args")
|
||||
end
|
||||
end
|
||||
|
||||
context "with arguments" do
|
||||
let(matcher) { args_matcher }
|
||||
|
||||
it "lists the arguments" do
|
||||
is_expected.to contain("1, \"test\", Symbol, foo: #{/bar/.inspect}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#with" do
|
||||
subject { matcher.with(1, 2, 3, bar: /baz/) }
|
||||
|
||||
it "applies a constraint on arguments" do
|
||||
dbl.test_method
|
||||
expect(&.match(actual)).to be_a(failed_match)
|
||||
dbl.test_method(1, 2, 3, bar: "foobarbaz")
|
||||
expect(&.match(actual)).to be_a(successful_match)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#match" do
|
||||
subject(match_data) { matcher.match(actual) }
|
||||
|
||||
post_condition { expect(match_data.description).to contain("dbl received #test_method") }
|
||||
|
||||
let(failure_message) { match_data.as(Spectator::Matchers::FailedMatchData).failure_message }
|
||||
|
||||
context "with no argument constraint" do
|
||||
post_condition { expect(&.description).to contain("(any args)") }
|
||||
|
||||
it "matches with no arguments" do
|
||||
dbl.test_method
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "matches with any arguments" do
|
||||
dbl.test_method("foo")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "doesn't match with no calls" do
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(any args)")
|
||||
end
|
||||
|
||||
it "doesn't match with different calls" do
|
||||
dbl.irrelevant("foo")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(any args)")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a \"no arguments\" constraint" do
|
||||
let(matcher) { no_args_matcher }
|
||||
|
||||
post_condition { expect(&.description).to contain("(no args)") }
|
||||
|
||||
it "matches with no arguments" do
|
||||
dbl.test_method
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "doesn't match with arguments" do
|
||||
dbl.test_method("foo")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(no args)")
|
||||
end
|
||||
|
||||
it "doesn't match with no calls" do
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(no args)")
|
||||
end
|
||||
|
||||
it "doesn't match with different calls" do
|
||||
dbl.irrelevant("foo")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(no args)")
|
||||
end
|
||||
end
|
||||
|
||||
context "with an arguments constraint" do
|
||||
let(matcher) { args_matcher }
|
||||
|
||||
post_condition { expect(&.description).to contain("(1, \"test\", Symbol, foo: #{/bar/.inspect})") }
|
||||
|
||||
it "doesn't match with no arguments" do
|
||||
dbl.test_method
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(1, \"test\", Symbol, foo: #{/bar/.inspect})")
|
||||
end
|
||||
|
||||
it "matches with matching arguments" do
|
||||
dbl.test_method(1, "test", :xyz, foo: "foobarbaz")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "doesn't match with differing arguments" do
|
||||
dbl.test_method(1, "wrong", 42, foo: "wrong")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(1, \"test\", Symbol, foo: #{/bar/.inspect})")
|
||||
end
|
||||
|
||||
it "doesn't match with no calls" do
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(1, \"test\", Symbol, foo: #{/bar/.inspect})")
|
||||
end
|
||||
|
||||
it "doesn't match with different calls" do
|
||||
dbl.irrelevant("foo")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl did not receive #test_method(1, \"test\", Symbol, foo: #{/bar/.inspect})")
|
||||
end
|
||||
end
|
||||
|
||||
describe "the match data values" do
|
||||
let(matcher) { args_matcher }
|
||||
subject(values) { match_data.as(Spectator::Matchers::FailedMatchData).values }
|
||||
|
||||
pre_condition { expect(match_data).to be_a(failed_match) }
|
||||
|
||||
before_each do
|
||||
dbl.test_method
|
||||
dbl.test_method(1, "wrong", :xyz, foo: "foobarbaz")
|
||||
dbl.irrelevant("foo")
|
||||
end
|
||||
|
||||
it "has the expected call listed" do
|
||||
is_expected.to contain({:expected, "#test_method(1, \"test\", Symbol, foo: #{/bar/.inspect})"})
|
||||
end
|
||||
|
||||
it "has the list of called methods" do
|
||||
is_expected.to contain({
|
||||
:actual,
|
||||
<<-SIGNATURES
|
||||
#test_method(no args)
|
||||
#test_method(1, "wrong", :xyz, foo: "foobarbaz")
|
||||
#irrelevant("foo")
|
||||
SIGNATURES
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#negated_match" do
|
||||
subject(match_data) { matcher.negated_match(actual) }
|
||||
|
||||
post_condition { expect(match_data.description).to contain("dbl did not receive #test_method") }
|
||||
|
||||
let(failure_message) { match_data.as(Spectator::Matchers::FailedMatchData).failure_message }
|
||||
|
||||
context "with no argument constraint" do
|
||||
post_condition { expect(&.description).to contain("(any args)") }
|
||||
|
||||
it "doesn't match with no arguments" do
|
||||
dbl.test_method
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl received #test_method(any args)")
|
||||
end
|
||||
|
||||
it "doesn't match with any arguments" do
|
||||
dbl.test_method("foo")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl received #test_method(any args)")
|
||||
end
|
||||
|
||||
it "matches with no calls" do
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "matches with different calls" do
|
||||
dbl.irrelevant("foo")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a \"no arguments\" constraint" do
|
||||
let(matcher) { no_args_matcher }
|
||||
|
||||
post_condition { expect(&.description).to contain("(no args)") }
|
||||
|
||||
it "doesn't match with no arguments" do
|
||||
dbl.test_method
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl received #test_method(no args)")
|
||||
end
|
||||
|
||||
it "matches with arguments" do
|
||||
dbl.test_method("foo")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "matches with no calls" do
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "matches with different calls" do
|
||||
dbl.irrelevant("foo")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an arguments constraint" do
|
||||
let(matcher) { args_matcher }
|
||||
|
||||
post_condition { expect(&.description).to contain("(1, \"test\", Symbol, foo: #{/bar/.inspect})") }
|
||||
|
||||
it "matches with no arguments" do
|
||||
dbl.test_method
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "doesn't match with matching arguments" do
|
||||
dbl.test_method(1, "test", :xyz, foo: "foobarbaz")
|
||||
is_expected.to be_a(failed_match)
|
||||
expect(failure_message).to eq("dbl received #test_method(1, \"test\", Symbol, foo: #{/bar/.inspect})")
|
||||
end
|
||||
|
||||
it "matches with differing arguments" do
|
||||
dbl.test_method(1, "wrong", 42, foo: "wrong")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "matches with no calls" do
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
|
||||
it "matches with different calls" do
|
||||
dbl.irrelevant("foo")
|
||||
is_expected.to be_a(successful_match)
|
||||
end
|
||||
end
|
||||
|
||||
describe "the match data values" do
|
||||
subject(values) { match_data.as(Spectator::Matchers::FailedMatchData).values }
|
||||
|
||||
pre_condition { expect(match_data).to be_a(failed_match) }
|
||||
|
||||
before_each do
|
||||
dbl.test_method
|
||||
dbl.test_method(1, "test", :xyz, foo: "foobarbaz")
|
||||
dbl.irrelevant("foo")
|
||||
end
|
||||
|
||||
it "has the expected call listed" do
|
||||
is_expected.to contain({:expected, "Not #{stub}"})
|
||||
end
|
||||
|
||||
it "has the list of called methods" do
|
||||
is_expected.to contain({
|
||||
:actual,
|
||||
<<-SIGNATURES
|
||||
#test_method(no args)
|
||||
#test_method(1, "test", :xyz, foo: "foobarbaz")
|
||||
#irrelevant("foo")
|
||||
SIGNATURES
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -34,16 +34,14 @@ Spectator.describe "Explicit Subject" do
|
|||
|
||||
subject { @@element_list.pop }
|
||||
|
||||
skip "is memoized across calls (i.e. the block is invoked once)",
|
||||
reason: "RSpec calls the \"actual\" block after the \"change block\"." do
|
||||
it "is memoized across calls (i.e. the block is invoked once)" do
|
||||
expect do
|
||||
3.times { subject }
|
||||
end.to change { @@element_list }.from([1, 2, 3]).to([1, 2])
|
||||
expect(subject).to eq(3)
|
||||
end
|
||||
|
||||
skip "is not memoized across examples",
|
||||
reason: "RSpec calls the \"actual\" block after the \"change block\"." do
|
||||
it "is not memoized across examples" do
|
||||
expect { subject }.to change { @@element_list }.from([1, 2]).to([1])
|
||||
expect(subject).to eq(2)
|
||||
end
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
require "json" # Needed to test masking Object#to_json in doubles.
|
||||
require "yaml" # Needed to test masking Object#to_yaml in doubles.
|
||||
require "../src/spectator"
|
||||
require "../src/spectator/should"
|
||||
require "./helpers/**"
|
||||
|
|
|
@ -0,0 +1,385 @@
|
|||
require "../../../spec_helper"
|
||||
|
||||
Spectator.describe "Double DSL", :smoke do
|
||||
context "specifying methods as keyword args" do
|
||||
double(:test, foo: "foobar", bar: 42)
|
||||
subject(dbl) { double(:test) }
|
||||
|
||||
it "defines a double with methods" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq("foobar")
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "compiles types without unions" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to compile_as(String)
|
||||
expect(dbl.bar).to compile_as(Int32)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an unexpected message" do
|
||||
it "raises an error" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
|
||||
it "reports the double name" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /:test/)
|
||||
end
|
||||
|
||||
it "reports the arguments" do
|
||||
expect { dbl.baz(:xyz, 123, a: "XYZ") }.to raise_error(Spectator::UnexpectedMessage, /\(:xyz, 123, a: "XYZ"\)/)
|
||||
end
|
||||
end
|
||||
|
||||
context "blocks" do
|
||||
it "supports blocks" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq("foobar")
|
||||
expect(dbl.bar { nil }).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "supports blocks and has non-union return types" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to compile_as(String)
|
||||
expect(dbl.bar { nil }).to compile_as(Int32)
|
||||
end
|
||||
end
|
||||
|
||||
it "fails on undefined messages" do
|
||||
expect do
|
||||
dbl.baz { nil }
|
||||
end.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "block with stubs" do
|
||||
context "one method" do
|
||||
double(:test2) do
|
||||
stub def foo
|
||||
"one method"
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test2) }
|
||||
|
||||
it "defines a double with methods" do
|
||||
expect(dbl.foo).to eq("one method")
|
||||
end
|
||||
|
||||
it "compiles types without unions" do
|
||||
expect(dbl.foo).to compile_as(String)
|
||||
end
|
||||
end
|
||||
|
||||
context "two methods" do
|
||||
double(:test3) do
|
||||
stub def foo
|
||||
"two methods"
|
||||
end
|
||||
|
||||
stub def bar
|
||||
42
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test3) }
|
||||
|
||||
it "defines a double with methods" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq("two methods")
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "compiles types without unions" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to compile_as(String)
|
||||
expect(dbl.bar).to compile_as(Int32)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "empty block" do
|
||||
double(:test4) do
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test4) }
|
||||
|
||||
it "defines a double" do
|
||||
expect(dbl).to be_a(Spectator::Double)
|
||||
end
|
||||
end
|
||||
|
||||
context "stub-less method" do
|
||||
double(:test5) do
|
||||
def foo
|
||||
"no stub"
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test5) }
|
||||
|
||||
it "defines a double with methods" do
|
||||
expect(dbl.foo).to eq("no stub")
|
||||
end
|
||||
end
|
||||
|
||||
context "mixing keyword arguments" do
|
||||
double(:test6, foo: "kwargs", bar: 42) do
|
||||
stub def foo
|
||||
"block"
|
||||
end
|
||||
|
||||
stub def baz
|
||||
"block"
|
||||
end
|
||||
|
||||
stub def baz(value)
|
||||
"block2"
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test6) }
|
||||
|
||||
it "overrides the keyword arguments with the block methods" do
|
||||
expect(dbl.foo).to eq("block")
|
||||
end
|
||||
|
||||
it "falls back to the keyword argument value for mismatched arguments" do
|
||||
expect(dbl.foo(42)).to eq("kwargs")
|
||||
end
|
||||
|
||||
it "can call methods defined only by keyword arguments" do
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
|
||||
it "can call methods defined only by the block" do
|
||||
expect(dbl.baz).to eq("block")
|
||||
end
|
||||
|
||||
it "can call methods defined by the block with different signatures" do
|
||||
expect(dbl.baz(42)).to eq("block2")
|
||||
end
|
||||
end
|
||||
|
||||
context "methods accepting blocks" do
|
||||
double(:test7) do
|
||||
stub def foo
|
||||
yield
|
||||
end
|
||||
|
||||
stub def bar(& : Int32 -> String)
|
||||
yield 42
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test7) }
|
||||
|
||||
it "defines the method and yields" do
|
||||
expect(dbl.foo { :xyz }).to eq(:xyz)
|
||||
end
|
||||
|
||||
it "matches methods with block argument type restrictions" do
|
||||
expect(dbl.bar &.to_s).to eq("42")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "double naming" do
|
||||
double(:Name, type: :symbol)
|
||||
|
||||
it "accepts a symbolic double name" do
|
||||
dbl = double(:Name)
|
||||
expect(dbl.type).to eq(:symbol)
|
||||
end
|
||||
|
||||
it "accepts a string double name" do
|
||||
dbl = double("Name")
|
||||
expect(dbl.type).to eq(:symbol)
|
||||
end
|
||||
|
||||
it "accepts a constant double name" do
|
||||
dbl = double(Name)
|
||||
expect(dbl.type).to eq(:symbol)
|
||||
end
|
||||
end
|
||||
|
||||
describe "predefined method stubs" do
|
||||
double(:test8, foo: 42)
|
||||
|
||||
let(dbl) { double(:test8, foo: 7) }
|
||||
|
||||
it "overrides the original value" do
|
||||
expect(dbl.foo).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
describe "scope" do
|
||||
double(:outer, scope: :outer)
|
||||
double(:scope, scope: :outer)
|
||||
|
||||
it "finds a double in the same scope" do
|
||||
dbl = double(:outer)
|
||||
expect(dbl.scope).to eq(:outer)
|
||||
end
|
||||
|
||||
it "uses an identically named double from the same scope" do
|
||||
dbl = double(:scope)
|
||||
expect(dbl.scope).to eq(:outer)
|
||||
end
|
||||
|
||||
context "inner1" do
|
||||
double(:inner, scope: :inner1)
|
||||
double(:scope, scope: :inner1)
|
||||
|
||||
it "finds a double in the same scope" do
|
||||
dbl = double(:inner)
|
||||
expect(dbl.scope).to eq(:inner1)
|
||||
end
|
||||
|
||||
it "uses an identically named double from the same scope" do
|
||||
dbl = double(:scope)
|
||||
expect(dbl.scope).to eq(:inner1)
|
||||
end
|
||||
|
||||
context "nested" do
|
||||
it "finds a double from a parent scope" do
|
||||
aggregate_failures do
|
||||
dbl = double(:inner)
|
||||
expect(dbl.scope).to eq(:inner1)
|
||||
dbl = double(:outer)
|
||||
expect(dbl.scope).to eq(:outer)
|
||||
end
|
||||
end
|
||||
|
||||
it "uses the inner-most identically named double" do
|
||||
dbl = double(:inner)
|
||||
expect(dbl.scope).to eq(:inner1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "inner2" do
|
||||
double(:inner, scope: :inner2)
|
||||
double(:scope, scope: :inner2)
|
||||
|
||||
it "finds a double in the same scope" do
|
||||
dbl = double(:inner)
|
||||
expect(dbl.scope).to eq(:inner2)
|
||||
end
|
||||
|
||||
it "uses an identically named double from the same scope" do
|
||||
dbl = double(:scope)
|
||||
expect(dbl.scope).to eq(:inner2)
|
||||
end
|
||||
|
||||
context "nested" do
|
||||
it "finds a double from a parent scope" do
|
||||
aggregate_failures do
|
||||
dbl = double(:inner)
|
||||
expect(dbl.scope).to eq(:inner2)
|
||||
dbl = double(:outer)
|
||||
expect(dbl.scope).to eq(:outer)
|
||||
end
|
||||
end
|
||||
|
||||
it "uses the inner-most identically named double" do
|
||||
dbl = double(:inner)
|
||||
expect(dbl.scope).to eq(:inner2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "context" do
|
||||
double(:context_double, predefined: :predefined, override: :predefined) do
|
||||
stub abstract def memoize : Symbol
|
||||
|
||||
stub def inline : Symbol
|
||||
:inline # Memoized values can't be used here.
|
||||
end
|
||||
|
||||
stub def reference : String
|
||||
memoize.to_s
|
||||
end
|
||||
end
|
||||
|
||||
let(memoize) { :memoize }
|
||||
let(override) { :override }
|
||||
let(dbl) { double(:context_double, override: override) }
|
||||
|
||||
before_each { allow(dbl).to receive(:memoize).and_return(memoize) }
|
||||
|
||||
it "doesn't change predefined values" do
|
||||
expect(dbl.predefined).to eq(:predefined)
|
||||
end
|
||||
|
||||
it "can use memoized values for overrides" do
|
||||
expect(dbl.override).to eq(:override)
|
||||
end
|
||||
|
||||
it "can use memoized values for stubs" do
|
||||
expect(dbl.memoize).to eq(:memoize)
|
||||
end
|
||||
|
||||
it "can override inline stubs" do
|
||||
expect { allow(dbl).to receive(:inline).and_return(override) }.to change { dbl.inline }.from(:inline).to(:override)
|
||||
end
|
||||
|
||||
it "can reference memoized values with indirection" do
|
||||
expect { allow(dbl).to receive(:memoize).and_return(override) }.to change { dbl.reference }.from("memoize").to("override")
|
||||
end
|
||||
end
|
||||
|
||||
describe "class doubles" do
|
||||
double(:class_double) do
|
||||
abstract_stub def self.abstract_method
|
||||
:abstract
|
||||
end
|
||||
|
||||
stub def self.default_method
|
||||
:default
|
||||
end
|
||||
|
||||
stub def self.args(arg)
|
||||
arg
|
||||
end
|
||||
|
||||
stub def self.method1
|
||||
:method1
|
||||
end
|
||||
|
||||
stub def self.reference
|
||||
method1.to_s
|
||||
end
|
||||
end
|
||||
|
||||
let(dbl) { class_double(:class_double) }
|
||||
|
||||
it "raises on abstract stubs" do
|
||||
expect { dbl.abstract_method }.to raise_error(Spectator::UnexpectedMessage, /abstract_method/)
|
||||
end
|
||||
|
||||
it "can define default stubs" do
|
||||
expect(dbl.default_method).to eq(:default)
|
||||
end
|
||||
|
||||
it "can define new stubs" do
|
||||
expect { allow(dbl).to receive(:args).and_return(42) }.to change { dbl.args(5) }.from(5).to(42)
|
||||
end
|
||||
|
||||
it "can override class method stubs" do
|
||||
allow(dbl).to receive(:method1).and_return(:override)
|
||||
expect(dbl.method1).to eq(:override)
|
||||
end
|
||||
|
||||
it "can reference stubs" do
|
||||
allow(dbl).to receive(:method1).and_return(:reference)
|
||||
expect(dbl.reference).to eq("reference")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,202 @@
|
|||
require "../../../spec_helper"
|
||||
|
||||
Spectator.describe "Deferred stub expectation DSL" do
|
||||
context "with a double" do
|
||||
double(:dbl) do
|
||||
# Ensure the original is never called.
|
||||
stub abstract def foo : Nil
|
||||
stub abstract def foo(arg) : Nil
|
||||
stub abstract def value : Int32
|
||||
end
|
||||
|
||||
let(dbl) { double(:dbl) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
expect(dbl).to receive(:foo)
|
||||
dbl.foo
|
||||
end
|
||||
|
||||
it "returns the correct value" do
|
||||
expect(dbl).to receive(:value).and_return(42)
|
||||
expect(dbl.value).to eq(42)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(dbl).to_not receive(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
expect(dbl).to receive(:foo).with(:bar)
|
||||
dbl.foo(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(dbl).to_not receive(:foo).with(:bar)
|
||||
dbl.foo
|
||||
end
|
||||
|
||||
it "matches when a message without arguments isn't received" do
|
||||
expect(dbl).to_not receive(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
expect(dbl).to_not receive(:foo).with(:baz)
|
||||
dbl.foo(:bar)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class double" do
|
||||
double(:dbl) do
|
||||
# Ensure the original is never called.
|
||||
abstract_stub def self.foo : Nil
|
||||
end
|
||||
|
||||
abstract_stub def self.foo(arg) : Nil
|
||||
end
|
||||
|
||||
abstract_stub def self.value : Int32
|
||||
42
|
||||
end
|
||||
end
|
||||
|
||||
let(dbl) { class_double(:dbl) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
expect(dbl).to receive(:foo)
|
||||
dbl.foo
|
||||
end
|
||||
|
||||
it "returns the correct value" do
|
||||
expect(dbl).to receive(:value).and_return(42)
|
||||
expect(dbl.value).to eq(42)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(dbl).to_not receive(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
expect(dbl).to receive(:foo).with(:bar)
|
||||
dbl.foo(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(dbl).to_not receive(:foo).with(:bar)
|
||||
dbl.foo
|
||||
end
|
||||
|
||||
it "matches when a message without arguments isn't received" do
|
||||
expect(dbl).to_not receive(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
expect(dbl).to_not receive(:foo).with(:baz)
|
||||
dbl.foo(:bar)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a mock" do
|
||||
abstract class MyClass
|
||||
abstract def foo : Int32
|
||||
abstract def foo(arg) : Int32
|
||||
end
|
||||
|
||||
mock(MyClass)
|
||||
|
||||
let(fake) { mock(MyClass) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
expect(fake).to receive(:foo).and_return(42)
|
||||
fake.foo(:bar)
|
||||
end
|
||||
|
||||
it "returns the correct value" do
|
||||
expect(fake).to receive(:foo).and_return(42)
|
||||
expect(fake.foo).to eq(42)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(fake).to_not receive(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
expect(fake).to receive(:foo).with(:bar).and_return(42)
|
||||
fake.foo(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(fake).to_not receive(:foo).with(:bar).and_return(42)
|
||||
fake.foo
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(fake).to_not receive(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
expect(fake).to_not receive(:foo).with(:baz).and_return(42)
|
||||
fake.foo(:bar)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class mock" do
|
||||
class MyClass
|
||||
def self.foo : Int32
|
||||
42
|
||||
end
|
||||
|
||||
def self.foo(arg) : Int32
|
||||
42
|
||||
end
|
||||
end
|
||||
|
||||
mock(MyClass)
|
||||
|
||||
let(fake) { class_mock(MyClass) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
expect(fake).to receive(:foo).and_return(42)
|
||||
fake.foo(:bar)
|
||||
end
|
||||
|
||||
it "returns the correct value" do
|
||||
expect(fake).to receive(:foo).and_return(42)
|
||||
expect(fake.foo).to eq(42)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(fake).to_not receive(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
expect(fake).to receive(:foo).with(:bar).and_return(42)
|
||||
fake.foo(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(fake).to_not receive(:foo).with(:bar).and_return(42)
|
||||
fake.foo
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(fake).to_not receive(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
expect(fake).to_not receive(:foo).with(:baz).and_return(42)
|
||||
fake.foo(:bar)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,159 @@
|
|||
require "../../../spec_helper"
|
||||
|
||||
Spectator.describe "Stubbable receiver DSL" do
|
||||
context "with a double" do
|
||||
double(:dbl, foo: 42)
|
||||
|
||||
let(dbl) { double(:dbl) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
dbl.foo
|
||||
expect(dbl).to have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(dbl).to_not have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
dbl.foo(:bar)
|
||||
expect(dbl).to have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
dbl.foo
|
||||
expect(dbl).to_not have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments isn't received" do
|
||||
expect(dbl).to_not have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
dbl.foo(:bar)
|
||||
expect(dbl).to_not have_received(:foo).with(:baz)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class double" do
|
||||
double(:dbl) do
|
||||
stub def self.foo
|
||||
42
|
||||
end
|
||||
|
||||
stub def self.foo(arg)
|
||||
42
|
||||
end
|
||||
end
|
||||
|
||||
let(dbl) { class_double(:dbl) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
dbl.foo
|
||||
expect(dbl).to have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(dbl).to_not have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
dbl.foo(:bar)
|
||||
expect(dbl).to have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
dbl.foo
|
||||
expect(dbl).to_not have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments isn't received" do
|
||||
expect(dbl).to_not have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
dbl.foo(:bar)
|
||||
expect(dbl).to_not have_received(:foo).with(:baz)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a mock" do
|
||||
abstract class MyClass
|
||||
abstract def foo(arg) : Int32
|
||||
end
|
||||
|
||||
mock(MyClass, foo: 42)
|
||||
|
||||
let(fake) { mock(MyClass) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
fake.foo(:bar)
|
||||
expect(fake).to have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(fake).to_not have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
fake.foo(:bar)
|
||||
expect(fake).to have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(fake).to_not have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
fake.foo(:bar)
|
||||
expect(fake).to_not have_received(:foo).with(:baz)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class mock" do
|
||||
class MyClass
|
||||
def self.foo(arg) : Int32
|
||||
42
|
||||
end
|
||||
end
|
||||
|
||||
mock(MyClass)
|
||||
|
||||
let(fake) { class_mock(MyClass) }
|
||||
|
||||
# Ensure invocations don't leak between examples.
|
||||
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
|
||||
|
||||
it "matches when a message is received" do
|
||||
fake.foo(:bar)
|
||||
expect(fake).to have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message isn't received" do
|
||||
expect(fake).to_not have_received(:foo)
|
||||
end
|
||||
|
||||
it "matches when a message is received with matching arguments" do
|
||||
fake.foo(:bar)
|
||||
expect(fake).to have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message without arguments is received" do
|
||||
expect(fake).to_not have_received(:foo).with(:bar)
|
||||
end
|
||||
|
||||
it "matches when a message with arguments isn't received" do
|
||||
fake.foo(:bar)
|
||||
expect(fake).to_not have_received(:foo).with(:baz)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,83 @@
|
|||
require "../../../spec_helper"
|
||||
|
||||
Spectator.describe "Lazy double DSL" do
|
||||
context "specifying methods as keyword args" do
|
||||
subject(dbl) { double(:test, foo: "foobar", bar: 42) }
|
||||
|
||||
it "defines a double with methods" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq("foobar")
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an unexpected message" do
|
||||
it "raises an error" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
|
||||
it "reports the double name" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /:test/)
|
||||
end
|
||||
|
||||
it "reports the arguments" do
|
||||
expect { dbl.baz(:xyz, 123, a: "XYZ") }.to raise_error(Spectator::UnexpectedMessage, /\(:xyz, 123, a: "XYZ"\)/)
|
||||
end
|
||||
end
|
||||
|
||||
context "blocks" do
|
||||
it "supports blocks" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq("foobar")
|
||||
expect(dbl.bar { nil }).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "fails on undefined messages" do
|
||||
expect do
|
||||
dbl.baz { nil }
|
||||
end.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "double naming" do
|
||||
it "accepts a symbolic double name" do
|
||||
dbl = double(:name)
|
||||
expect { dbl.oops }.to raise_error(Spectator::UnexpectedMessage, /:name/)
|
||||
end
|
||||
|
||||
it "accepts a string double name" do
|
||||
dbl = double("Name")
|
||||
expect { dbl.oops }.to raise_error(Spectator::UnexpectedMessage, /"Name"/)
|
||||
end
|
||||
|
||||
it "accepts no name" do
|
||||
dbl = double
|
||||
expect { dbl.oops }.to raise_error(Spectator::UnexpectedMessage, /anonymous/i)
|
||||
end
|
||||
|
||||
it "accepts no name and predefined responses" do
|
||||
dbl = double(foo: 42)
|
||||
expect(dbl.foo).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
describe "context" do
|
||||
let(memoize) { :memoize }
|
||||
let(override) { :override }
|
||||
let(dbl) { double(predefined: :predefined, memoize: memoize) }
|
||||
|
||||
it "doesn't change predefined values" do
|
||||
expect(dbl.predefined).to eq(:predefined)
|
||||
end
|
||||
|
||||
it "can use memoized values for stubs" do
|
||||
expect(dbl.memoize).to eq(:memoize)
|
||||
end
|
||||
|
||||
it "can stub methods with memoized values" do
|
||||
expect { allow(dbl).to receive(:memoize).and_return(override) }.to change { dbl.memoize }.from(:memoize).to(:override)
|
||||
end
|
||||
end
|
||||
end
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,189 @@
|
|||
require "../../../spec_helper"
|
||||
|
||||
Spectator.describe "Null double DSL" do
|
||||
context "specifying methods as keyword args" do
|
||||
double(:test, foo: "foobar", bar: 42)
|
||||
subject(dbl) { double(:test).as_null_object }
|
||||
|
||||
it "defines a double with methods" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq("foobar")
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "compiles types without unions" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to compile_as(String)
|
||||
expect(dbl.bar).to compile_as(Int32)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns self for unexpected messages" do
|
||||
expect(dbl.baz).to be(dbl)
|
||||
end
|
||||
|
||||
context "blocks" do
|
||||
it "supports blocks" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq("foobar")
|
||||
expect(dbl.bar { nil }).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "supports blocks and has non-union return types" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to compile_as(String)
|
||||
expect(dbl.bar { nil }).to compile_as(Int32)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns self on undefined messages" do
|
||||
expect(dbl.baz { nil }).to be(dbl)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "block with stubs" do
|
||||
context "one method" do
|
||||
double(:test2) do
|
||||
stub def foo
|
||||
"one method"
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test2).as_null_object }
|
||||
|
||||
it "defines a double with methods" do
|
||||
expect(dbl.foo).to eq("one method")
|
||||
end
|
||||
|
||||
it "compiles types without unions" do
|
||||
expect(dbl.foo).to compile_as(String)
|
||||
end
|
||||
end
|
||||
|
||||
context "two methods" do
|
||||
double(:test3) do
|
||||
stub def foo
|
||||
"two methods"
|
||||
end
|
||||
|
||||
stub def bar
|
||||
42
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test3).as_null_object }
|
||||
|
||||
it "defines a double with methods" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq("two methods")
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "compiles types without unions" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to compile_as(String)
|
||||
expect(dbl.bar).to compile_as(Int32)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "empty block" do
|
||||
double(:test4) do
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test4).as_null_object }
|
||||
|
||||
it "defines a double" do
|
||||
expect(dbl).to be_a(Spectator::Double)
|
||||
end
|
||||
end
|
||||
|
||||
context "stub-less method" do
|
||||
double(:test5) do
|
||||
def foo
|
||||
"no stub"
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test5).as_null_object }
|
||||
|
||||
it "defines a double with methods" do
|
||||
expect(dbl.foo).to eq("no stub")
|
||||
end
|
||||
end
|
||||
|
||||
context "mixing keyword arguments" do
|
||||
double(:test6, foo: "kwargs", bar: 42) do
|
||||
stub def foo
|
||||
"block"
|
||||
end
|
||||
|
||||
stub def baz
|
||||
"block"
|
||||
end
|
||||
|
||||
stub def baz(value)
|
||||
"block2"
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test6).as_null_object }
|
||||
|
||||
it "overrides the keyword arguments with the block methods" do
|
||||
expect(dbl.foo).to eq("block")
|
||||
end
|
||||
|
||||
it "falls back to the keyword argument value for mismatched arguments" do
|
||||
expect(dbl.foo(42)).to eq("kwargs")
|
||||
end
|
||||
|
||||
it "can call methods defined only by keyword arguments" do
|
||||
expect(dbl.bar).to eq(42)
|
||||
end
|
||||
|
||||
it "can call methods defined only by the block" do
|
||||
expect(dbl.baz).to eq("block")
|
||||
end
|
||||
|
||||
it "can call methods defined by the block with different signatures" do
|
||||
expect(dbl.baz(42)).to eq("block2")
|
||||
end
|
||||
end
|
||||
|
||||
context "methods accepting blocks" do
|
||||
double(:test7) do
|
||||
stub def foo
|
||||
yield
|
||||
end
|
||||
|
||||
stub def bar(& : Int32 -> String)
|
||||
yield 42
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { double(:test7).as_null_object }
|
||||
|
||||
it "defines the method and yields" do
|
||||
expect(dbl.foo { :xyz }).to eq(:xyz)
|
||||
end
|
||||
|
||||
it "matches methods with block argument type restrictions" do
|
||||
expect(dbl.bar &.to_s).to eq("42")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "predefined method stubs" do
|
||||
double(:test8, foo: 42)
|
||||
|
||||
let(dbl) { double(:test8, foo: 7).as_null_object }
|
||||
|
||||
it "overrides the original value" do
|
||||
expect(dbl.foo).to eq(7)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,116 @@
|
|||
require "../../../spec_helper"
|
||||
|
||||
Spectator.describe "Stub DSL", :smoke do
|
||||
double(:foobar, foo: 42, bar: "baz") do
|
||||
stub abstract def other : String
|
||||
stub abstract def null : Nil
|
||||
end
|
||||
|
||||
let(dbl) { double(:foobar) }
|
||||
|
||||
it "overrides default stubs" do
|
||||
allow(dbl).to receive(:foo).and_return(123)
|
||||
expect(dbl.foo).to eq(123)
|
||||
end
|
||||
|
||||
it "overrides abstract stubs" do
|
||||
allow(dbl).to receive(:other).and_return("test")
|
||||
expect(dbl.other).to eq("test")
|
||||
end
|
||||
|
||||
it "returns nil by default" do
|
||||
allow(dbl).to receive(:null)
|
||||
expect(dbl.null).to be_nil
|
||||
end
|
||||
|
||||
it "raises on cast errors" do
|
||||
allow(dbl).to receive(:foo).and_return(:xyz)
|
||||
expect { dbl.foo }.to raise_error(TypeCastError, /Int32/)
|
||||
end
|
||||
|
||||
describe "#receive" do
|
||||
it "returns the value from the block" do
|
||||
allow(dbl).to receive(:foo) { 5 }
|
||||
expect(dbl.foo).to eq(5)
|
||||
end
|
||||
|
||||
it "accepts and calls block" do
|
||||
count = 0
|
||||
allow(dbl).to receive(:foo) { count += 1 }
|
||||
expect { dbl.foo }.to change { count }.from(0).to(1)
|
||||
end
|
||||
|
||||
it "passes the arguments to the block" do
|
||||
captured = nil.as(Spectator::AbstractArguments?)
|
||||
allow(dbl).to receive(:foo) { |a| captured = a; 0 }
|
||||
dbl.foo(3, 5, 7, bar: "baz")
|
||||
args = Spectator::Arguments.capture(3, 5, 7, bar: "baz")
|
||||
expect(captured).to eq(args)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#with" do
|
||||
context Spectator::MultiValueStub do
|
||||
it "applies the stub with matching arguments" do
|
||||
allow(dbl).to receive(:foo).and_return(1, 2, 3).with(Int32, bar: /baz/)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo(3, bar: "foobarbaz")).to eq(1)
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.foo(5, bar: "barbaz")).to eq(2)
|
||||
expect(dbl.foo(7, bar: "foobaz")).to eq(3)
|
||||
expect(dbl.foo(11, bar: "baz")).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context Spectator::NullStub do
|
||||
it "applies the stub with matching arguments" do
|
||||
allow(dbl).to receive(:foo).with(Int32, bar: /baz/).and_return(1)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo(3, bar: "foobarbaz")).to eq(1)
|
||||
expect(dbl.foo).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
it "changes to a proc stub" do
|
||||
called = 0
|
||||
allow(dbl).to receive(:foo).with(Int32, bar: /baz/) { called += 1 }
|
||||
aggregate_failures do
|
||||
expect { dbl.foo(3, bar: "foobarbaz") }.to change { called }.from(0).to(1)
|
||||
expect(dbl.foo(5, bar: "baz")).to eq(2)
|
||||
expect(dbl.foo).to eq(42)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context Spectator::ValueStub do
|
||||
it "applies the stub with matching arguments" do
|
||||
allow(dbl).to receive(:foo).and_return(1).with(Int32, bar: /baz/)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo(3, bar: "foobarbaz")).to eq(1)
|
||||
expect(dbl.foo).to eq(42)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#no_args" do
|
||||
it "defines a stub with a no arguments constraint" do
|
||||
allow(dbl).to receive(:foo).with(no_args).and_return(5)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(5)
|
||||
expect(dbl.foo(0)).to eq(42)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#any_args" do
|
||||
it "defines a stub with no arguments constraint" do
|
||||
allow(dbl).to receive(:foo).with(any_args).and_return(5)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(5)
|
||||
expect(dbl.foo(0)).to eq(5)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::Allow do
|
||||
let(dbl) { Spectator::LazyDouble.new(foo: 42) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 123) }
|
||||
subject(alw) { Spectator::Allow.new(dbl) }
|
||||
|
||||
describe "#to" do
|
||||
it "applies a stub" do
|
||||
expect { alw.to(stub) }.to change { dbl.foo }.from(42).to(123)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,190 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::Arguments do
|
||||
subject(arguments) do
|
||||
Spectator::Arguments.new(
|
||||
args: {42, "foo"},
|
||||
kwargs: {bar: "baz", qux: 123}
|
||||
)
|
||||
end
|
||||
|
||||
it "stores the arguments" do
|
||||
expect(arguments.args).to eq({42, "foo"})
|
||||
end
|
||||
|
||||
it "stores the keyword arguments" do
|
||||
expect(arguments.kwargs).to eq({bar: "baz", qux: 123})
|
||||
end
|
||||
|
||||
describe ".capture" do
|
||||
subject { Spectator::Arguments.capture(42, "foo", bar: "baz", qux: 123) }
|
||||
|
||||
it "stores the arguments and keyword arguments" do
|
||||
is_expected.to have_attributes(args: {42, "foo"}, kwargs: {bar: "baz", qux: 123})
|
||||
end
|
||||
end
|
||||
|
||||
describe "#[]" do
|
||||
context "with an index" do
|
||||
it "returns a positional argument" do
|
||||
aggregate_failures do
|
||||
expect(arguments[0]).to eq(42)
|
||||
expect(arguments[1]).to eq("foo")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with a symbol" do
|
||||
it "returns a named argument" do
|
||||
aggregate_failures do
|
||||
expect(arguments[:bar]).to eq("baz")
|
||||
expect(arguments[:qux]).to eq(123)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#to_s" do
|
||||
subject { arguments.to_s }
|
||||
|
||||
it "formats the arguments" do
|
||||
is_expected.to eq("(42, \"foo\", bar: \"baz\", qux: 123)")
|
||||
end
|
||||
|
||||
context "when empty" do
|
||||
let(arguments) { Spectator::Arguments.none }
|
||||
|
||||
it "returns (no args)" do
|
||||
is_expected.to eq("(no args)")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#==" do
|
||||
subject { arguments == other }
|
||||
|
||||
context "with equal arguments" do
|
||||
let(other) { arguments }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with different arguments" do
|
||||
let(other) do
|
||||
Spectator::Arguments.new(
|
||||
args: {123, :foo, "bar"},
|
||||
kwargs: {opt: "foobar"}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with the same kwargs in a different order" do
|
||||
let(other) do
|
||||
Spectator::Arguments.new(
|
||||
args: arguments.args,
|
||||
kwargs: {qux: 123, bar: "baz"}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a missing kwarg" do
|
||||
let(other) do
|
||||
Spectator::Arguments.new(
|
||||
args: arguments.args,
|
||||
kwargs: {bar: "baz"}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#===" do
|
||||
subject { pattern === arguments }
|
||||
|
||||
context "with equal arguments" do
|
||||
let(pattern) { arguments }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with different arguments" do
|
||||
let(pattern) do
|
||||
Spectator::Arguments.new(
|
||||
args: {123, :foo, "bar"},
|
||||
kwargs: {opt: "foobar"}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with the same kwargs in a different order" do
|
||||
let(pattern) do
|
||||
Spectator::Arguments.new(
|
||||
args: arguments.args,
|
||||
kwargs: {qux: 123, bar: "baz"}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a missing kwarg" do
|
||||
let(pattern) do
|
||||
Spectator::Arguments.new(
|
||||
args: arguments.args,
|
||||
kwargs: {bar: "baz"}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with matching types and regex" do
|
||||
let(pattern) do
|
||||
Spectator::Arguments.new(
|
||||
args: {Int32, /foo/},
|
||||
kwargs: {bar: String, qux: 123}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with different types and regex" do
|
||||
let(pattern) do
|
||||
Spectator::Arguments.new(
|
||||
args: {Symbol, /bar/},
|
||||
kwargs: {bar: String, qux: 42}
|
||||
)
|
||||
end
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,461 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::Double do
|
||||
Spectator::Double.define(EmptyDouble)
|
||||
Spectator::Double.define(FooBarDouble, "dbl-name", foo: 42, bar: "baz")
|
||||
|
||||
# The subject `dbl` must be carefully used in sub-contexts, otherwise it pollutes parent scopes.
|
||||
# This changes the type of `dbl` to `Double+`, which produces a union of methods and their return types.
|
||||
context "plain double" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
|
||||
it "responds to defined messages" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.bar).to eq("baz")
|
||||
end
|
||||
end
|
||||
|
||||
it "fails on undefined messages" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
|
||||
it "reports the name in errors" do
|
||||
expect { dbl.baz }.to raise_error(/"dbl-name"/)
|
||||
end
|
||||
|
||||
it "reports arguments" 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
|
||||
|
||||
it "uses nil for undefined messages" do
|
||||
expect { dbl.baz }.to compile_as(Nil)
|
||||
end
|
||||
|
||||
context "blocks" do
|
||||
it "supports blocks" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq(42)
|
||||
expect(dbl.bar { nil }).to eq("baz")
|
||||
end
|
||||
end
|
||||
|
||||
it "supports blocks and has non-union return types" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to compile_as(Int32)
|
||||
expect(dbl.bar { nil }).to compile_as(String)
|
||||
end
|
||||
end
|
||||
|
||||
it "fails on undefined messages" do
|
||||
expect do
|
||||
dbl.baz { nil }
|
||||
end.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "without a double name" do
|
||||
Spectator::Double.define(NamelessDouble, foo: 42)
|
||||
|
||||
subject(dbl) { NamelessDouble.new }
|
||||
|
||||
it "reports as anonymous" do
|
||||
expect { dbl.baz }.to raise_error(/anonymous/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "with abstract stubs and return type annotations" do
|
||||
Spectator::Double.define(TestDouble) do
|
||||
abstract_stub abstract def foo(value) : String
|
||||
end
|
||||
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([stub]) }
|
||||
|
||||
it "enforces the return type" do
|
||||
expect(dbl.foo("foobar")).to compile_as(String)
|
||||
end
|
||||
|
||||
it "raises on non-matching arguments" do
|
||||
expect { dbl.foo("bar") }.to raise_error(Spectator::UnexpectedMessage, /foo/)
|
||||
end
|
||||
|
||||
it "raises on non-matching stub" do
|
||||
stub = Spectator::ValueStub.new(:foo, 42, arguments).as(Spectator::Stub)
|
||||
dbl._spectator_define_stub(stub)
|
||||
expect { dbl.foo("foobar") }.to raise_error(TypeCastError, /String/)
|
||||
end
|
||||
end
|
||||
|
||||
context "with nillable return type annotations" do
|
||||
Spectator::Double.define(TestDouble) do
|
||||
abstract_stub abstract def foo : String?
|
||||
abstract_stub abstract def bar : Nil
|
||||
end
|
||||
|
||||
let(foo_stub) { Spectator::ValueStub.new(:foo, nil).as(Spectator::Stub) }
|
||||
let(bar_stub) { Spectator::ValueStub.new(:bar, nil).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([foo_stub, bar_stub]) }
|
||||
|
||||
it "doesn't raise on nil" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to be_nil
|
||||
expect(dbl.bar).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with common object methods" do
|
||||
subject(dbl) do
|
||||
EmptyDouble.new([
|
||||
Spectator::ValueStub.new(:"!=", false),
|
||||
Spectator::ValueStub.new(:"!~", false),
|
||||
Spectator::ValueStub.new(:"==", true),
|
||||
Spectator::ValueStub.new(:"===", true),
|
||||
Spectator::ValueStub.new(:"=~", nil),
|
||||
Spectator::ValueStub.new(:class, EmptyDouble),
|
||||
Spectator::ValueStub.new(:dup, EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:"in?", true),
|
||||
Spectator::ValueStub.new(:inspect, "inspect"),
|
||||
Spectator::ValueStub.new(:itself, EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:"not_nil!", EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:pretty_inspect, "pretty_inspect"),
|
||||
Spectator::ValueStub.new(:tap, EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:to_json, "to_json"),
|
||||
Spectator::ValueStub.new(:to_pretty_json, "to_pretty_json"),
|
||||
Spectator::ValueStub.new(:to_s, "to_s"),
|
||||
Spectator::ValueStub.new(:to_yaml, "to_yaml"),
|
||||
Spectator::ValueStub.new(:try, nil),
|
||||
Spectator::ValueStub.new(:object_id, 42_u64),
|
||||
Spectator::ValueStub.new(:"same?", true),
|
||||
] of Spectator::Stub)
|
||||
end
|
||||
|
||||
it "responds with defined messages" do
|
||||
aggregate_failures do
|
||||
expect(dbl.!=(42)).to be_false
|
||||
expect(dbl.!~(42)).to be_false
|
||||
expect(dbl.==(42)).to be_true
|
||||
expect(dbl.===(42)).to be_true
|
||||
expect(dbl.=~(42)).to be_nil
|
||||
expect(dbl.class).to eq(EmptyDouble)
|
||||
expect(dbl.dup).to be_a(EmptyDouble)
|
||||
expect(dbl.in?([42])).to eq(true)
|
||||
expect(dbl.in?(1, 2, 3)).to eq(true)
|
||||
expect(dbl.inspect).to eq("inspect")
|
||||
expect(dbl.itself).to be_a(EmptyDouble)
|
||||
expect(dbl.not_nil!).to be_a(EmptyDouble)
|
||||
expect(dbl.pretty_inspect).to eq("pretty_inspect")
|
||||
expect(dbl.tap { nil }).to be_a(EmptyDouble)
|
||||
expect(dbl.to_json).to eq("to_json")
|
||||
expect(dbl.to_pretty_json).to eq("to_pretty_json")
|
||||
expect(dbl.to_s).to eq("to_s")
|
||||
expect(dbl.to_yaml).to eq("to_yaml")
|
||||
expect(dbl.try { nil }).to be_nil
|
||||
expect(dbl.object_id).to eq(42_u64)
|
||||
expect(dbl.same?(dbl)).to be_true
|
||||
expect(dbl.same?(nil)).to be_true
|
||||
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
|
||||
subject(dbl) { EmptyDouble.new }
|
||||
|
||||
it "returns original implementation with undefined messages" do
|
||||
io = IO::Memory.new
|
||||
pp = PrettyPrint.new(io)
|
||||
hasher = Crystal::Hasher.new
|
||||
aggregate_failures do
|
||||
expect(dbl.!=(42)).to be_true
|
||||
expect(dbl.!~(42)).to be_true
|
||||
expect(dbl.==(42)).to be_false
|
||||
expect(dbl.===(42)).to be_false
|
||||
expect(dbl.=~(42)).to be_nil
|
||||
expect(dbl.class).to eq(EmptyDouble)
|
||||
expect(dbl.dup).to be_a(EmptyDouble)
|
||||
expect(dbl.hash(hasher)).to be_a(Crystal::Hasher)
|
||||
expect(dbl.hash).to be_a(UInt64)
|
||||
expect(dbl.in?([42])).to be_false
|
||||
expect(dbl.in?(1, 2, 3)).to be_false
|
||||
expect(dbl.inspect).to contain("EmptyDouble")
|
||||
expect(dbl.itself).to be(dbl)
|
||||
expect(dbl.not_nil!).to be(dbl)
|
||||
expect(dbl.pretty_inspect).to contain("EmptyDouble")
|
||||
expect(dbl.pretty_print(pp)).to be_nil
|
||||
expect(dbl.tap { nil }).to be(dbl)
|
||||
expect(dbl.to_s).to contain("EmptyDouble")
|
||||
expect(dbl.to_s(io)).to be_nil
|
||||
expect(dbl.try { nil }).to be_nil
|
||||
expect(dbl.object_id).to be_a(UInt64)
|
||||
expect(dbl.same?(dbl)).to be_true
|
||||
expect(dbl.same?(nil)).to be_false
|
||||
end
|
||||
end
|
||||
|
||||
it "reports arguments when they don't match" do
|
||||
expect { dbl.same?(123, :xyz) }.to raise_error(Spectator::UnexpectedMessage, /\(123, :xyz\)/)
|
||||
end
|
||||
end
|
||||
|
||||
context "with arguments constraints" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
|
||||
context "without common object methods" do
|
||||
Spectator::Double.define(TestDouble) do
|
||||
abstract_stub abstract def foo(value) : String
|
||||
abstract_stub abstract def foo(value, & : -> _) : String
|
||||
end
|
||||
|
||||
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([stub]) }
|
||||
|
||||
it "returns the response when constraint satisfied" do
|
||||
expect(dbl.foo("foobar")).to eq("bar")
|
||||
end
|
||||
|
||||
it "raises an error when constraint unsatisfied" do
|
||||
expect { dbl.foo("baz") }.to raise_error(Spectator::UnexpectedMessage)
|
||||
end
|
||||
|
||||
it "raises an error when argument count doesn't match" 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
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
expect(dbl.foo("foobar") { nil }).to eq("bar")
|
||||
end
|
||||
end
|
||||
|
||||
context "with common object methods" do
|
||||
Spectator::Double.define(TestDouble) do
|
||||
stub abstract def same?(other : Reference) : Bool
|
||||
end
|
||||
|
||||
let(stub) { Spectator::ValueStub.new(:"same?", true, arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([stub]) }
|
||||
|
||||
it "returns the response when constraint satisfied" do
|
||||
expect(dbl.same?("foobar")).to eq(true)
|
||||
end
|
||||
|
||||
it "raises an error when constraint unsatisfied" do
|
||||
expect { dbl.same?("baz") }.to raise_error(Spectator::UnexpectedMessage)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
context "class method stubs" do
|
||||
Spectator::Double.define(ClassDouble) do
|
||||
stub def self.foo
|
||||
:stub
|
||||
end
|
||||
|
||||
stub def self.bar(arg)
|
||||
arg
|
||||
end
|
||||
|
||||
stub def self.baz(arg)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { ClassDouble }
|
||||
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
|
||||
|
||||
after_each { dbl._spectator_clear_stubs }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { dbl._spectator_define_stub(foo_stub) }.to_not change { dbl.bar(42) }
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
dbl._spectator_define_stub(foo_stub)
|
||||
stub = Spectator::ValueStub.new(:foo, :replacement)
|
||||
expect { dbl._spectator_define_stub(stub) }.to change { dbl.foo }.to(:replacement)
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
stub1 = Spectator::ValueStub.new(:bar, :fallback)
|
||||
stub2 = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
dbl._spectator_define_stub(stub1)
|
||||
dbl._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(dbl.bar(:wrong)).to eq(:fallback)
|
||||
expect(dbl.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
stub = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
dbl._spectator_define_stub(stub)
|
||||
aggregate_failures do
|
||||
expect(dbl.bar(:original)).to eq(:original)
|
||||
expect(dbl.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
stub1 = Spectator::ValueStub.new(:baz, 1)
|
||||
stub2 = Spectator::ValueStub.new(:baz, 2, Spectator::Arguments.capture(3))
|
||||
dbl._spectator_define_stub(stub1)
|
||||
dbl._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(dbl.baz(5) { 42 }).to eq(1)
|
||||
expect(dbl.baz(3) { 42 }).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "._spectator_clear_stubs" do
|
||||
before_each { dbl._spectator_define_stub(foo_stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub)
|
||||
end
|
||||
end
|
||||
|
||||
describe "._spectator_calls" do
|
||||
before_each { dbl._spectator_clear_calls }
|
||||
|
||||
# Retrieves symbolic names of methods called on a double.
|
||||
def called_method_names(dbl)
|
||||
dbl._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
dbl.foo
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
dbl.bar(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = dbl._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_define_stub" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
let(stub3) { Spectator::ValueStub.new(:foo, 3) }
|
||||
let(stub5) { Spectator::ValueStub.new(:foo, 5) }
|
||||
let(stub7) { Spectator::ValueStub.new(:foo, 7, Spectator::Arguments.capture(:lucky)) }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { dbl._spectator_define_stub(stub3) }.to change { dbl.foo }.from(42).to(3)
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
dbl._spectator_define_stub(stub3)
|
||||
expect { dbl._spectator_define_stub(stub5) }.to change { dbl.foo }.from(3).to(5)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { dbl._spectator_define_stub(stub5) }.to_not change { dbl.bar }
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
dbl._spectator_define_stub(stub5)
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(5)
|
||||
expect(dbl.foo(:lucky)).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.foo(:lucky)).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
dbl._spectator_define_stub(stub5)
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq(5)
|
||||
expect(dbl.foo(:lucky) { nil }).to eq(7)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_clear_stubs" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 5) }
|
||||
|
||||
before_each { dbl._spectator_define_stub(stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_calls" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 5) }
|
||||
|
||||
before_each { dbl._spectator_define_stub(stub) }
|
||||
|
||||
# Retrieves symbolic names of methods called on a double.
|
||||
def called_method_names(dbl)
|
||||
dbl._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
dbl.foo
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores calls to non-stubbed methods" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
expect(called_method_names(dbl)).to eq(%i[baz])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
dbl.foo(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = dbl._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,166 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::ExceptionStub do
|
||||
let(method_call) { Spectator::MethodCall.capture(:foo) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(exception) { RuntimeError.new("Test exception") }
|
||||
subject(stub) { described_class.new(:foo, exception, location: location) }
|
||||
|
||||
it "stores the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
|
||||
it "raises the specified exception" do
|
||||
expect { stub.call(method_call) }.to raise_error(RuntimeError, "Test exception")
|
||||
end
|
||||
|
||||
context Spectator::StubModifiers do
|
||||
describe "#and_return(value)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ExceptionStub.new(:foo, exception, arguments, location) }
|
||||
subject(stub) { original.and_return(123) }
|
||||
|
||||
it "produces a stub that returns a value" do
|
||||
expect(stub.call(method_call)).to eq(123)
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_return(*values)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ExceptionStub.new(:foo, exception, arguments, location) }
|
||||
subject(stub) { original.and_return(3, 2, 1, 0) }
|
||||
|
||||
it "produces a stub that returns values" do
|
||||
values = Array.new(5) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 2, 1, 0, 0])
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_raise" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ExceptionStub.new(:foo, exception, arguments, location) }
|
||||
let(new_exception) { ArgumentError.new("Test argument error") }
|
||||
subject(stub) { original.and_raise(new_exception) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
|
||||
context "with a class and message" do
|
||||
subject(stub) { original.and_raise(ArgumentError, "Test argument error") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a message" do
|
||||
subject(stub) { original.and_raise("Test exception") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(Exception, "Test exception")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class" do
|
||||
subject(stub) { original.and_raise(ArgumentError) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#===" do
|
||||
subject { stub === call }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with a constraint" do
|
||||
let(constraint) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 42, constraint) }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
|
||||
context "with a non-matching arguments" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "baz") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,304 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::LazyDouble do
|
||||
context "plain double" do
|
||||
subject(dbl) { Spectator::LazyDouble.new("dbl-name", foo: 42, bar: "baz") }
|
||||
|
||||
it "responds to defined messages" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.bar).to eq("baz")
|
||||
end
|
||||
end
|
||||
|
||||
it "fails on undefined messages" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
|
||||
it "reports the name in errors" do
|
||||
expect { dbl.baz }.to raise_error(/"dbl-name"/)
|
||||
end
|
||||
|
||||
it "reports arguments" do
|
||||
expect { dbl.baz(123, "qux", field: :value) }.to raise_error(Spectator::UnexpectedMessage, /\(123, "qux", field: :value\)/)
|
||||
end
|
||||
|
||||
context "blocks" do
|
||||
it "supports blocks" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq(42)
|
||||
expect(dbl.bar { nil }).to eq("baz")
|
||||
end
|
||||
end
|
||||
|
||||
it "fails on undefined messages" do
|
||||
expect do
|
||||
dbl.baz { nil }
|
||||
end.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "without a double name" do
|
||||
subject(dbl) { Spectator::LazyDouble.new }
|
||||
|
||||
it "reports as anonymous" do
|
||||
expect { dbl.baz }.to raise_error(/anonymous/i)
|
||||
end
|
||||
end
|
||||
|
||||
context "with nillable values" do
|
||||
subject(dbl) { Spectator::LazyDouble.new(foo: nil.as(String?), bar: nil) }
|
||||
|
||||
it "doesn't raise on nil" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to be_nil
|
||||
expect(dbl.bar).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with common object methods" do
|
||||
let(dup) { double(:dup) }
|
||||
|
||||
subject(dbl) do
|
||||
Spectator::LazyDouble.new(nil, [
|
||||
Spectator::ValueStub.new(:"!=", false),
|
||||
Spectator::ValueStub.new(:"!~", false),
|
||||
Spectator::ValueStub.new(:"==", true),
|
||||
Spectator::ValueStub.new(:"===", true),
|
||||
Spectator::ValueStub.new(:"=~", nil),
|
||||
Spectator::ValueStub.new(:dup, dup),
|
||||
Spectator::ValueStub.new(:hash, 42_u64),
|
||||
Spectator::ValueStub.new(:"in?", true),
|
||||
Spectator::ValueStub.new(:inspect, "inspect"),
|
||||
Spectator::ValueStub.new(:itself, dup),
|
||||
Spectator::ValueStub.new(:"not_nil!", dup),
|
||||
Spectator::ValueStub.new(:pretty_inspect, "pretty_inspect"),
|
||||
Spectator::ValueStub.new(:tap, dup),
|
||||
Spectator::ValueStub.new(:to_json, "to_json"),
|
||||
Spectator::ValueStub.new(:to_pretty_json, "to_pretty_json"),
|
||||
Spectator::ValueStub.new(:to_s, "to_s"),
|
||||
Spectator::ValueStub.new(:to_yaml, "to_yaml"),
|
||||
Spectator::ValueStub.new(:try, nil),
|
||||
Spectator::ValueStub.new(:object_id, 42_u64),
|
||||
Spectator::ValueStub.new(:"same?", true),
|
||||
] of Spectator::Stub)
|
||||
end
|
||||
|
||||
it "responds with defined messages" do
|
||||
aggregate_failures do
|
||||
expect(dbl.!=(42)).to eq(false)
|
||||
expect(dbl.!~(42)).to eq(false)
|
||||
expect(dbl.==(42)).to eq(true)
|
||||
expect(dbl.===(42)).to eq(true)
|
||||
expect(dbl.=~(42)).to be_nil
|
||||
expect(dbl.dup).to be(dup)
|
||||
expect(dbl.hash).to eq(42_u64)
|
||||
expect(dbl.in?([42])).to eq(true)
|
||||
expect(dbl.in?(1, 2, 3)).to eq(true)
|
||||
expect(dbl.inspect).to eq("inspect")
|
||||
expect(dbl.itself).to be(dup)
|
||||
expect(dbl.not_nil!).to be(dup)
|
||||
expect(dbl.pretty_inspect).to eq("pretty_inspect")
|
||||
expect(dbl.tap { nil }).to be(dup)
|
||||
expect(dbl.to_json).to eq("to_json")
|
||||
expect(dbl.to_pretty_json).to eq("to_pretty_json")
|
||||
expect(dbl.to_s).to eq("to_s")
|
||||
expect(dbl.to_yaml).to eq("to_yaml")
|
||||
expect(dbl.try { nil }).to be_nil
|
||||
expect(dbl.object_id).to eq(42_u64)
|
||||
expect(dbl.same?(dbl)).to eq(true)
|
||||
expect(dbl.same?(nil)).to eq(true)
|
||||
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
|
||||
subject(dbl) { Spectator::LazyDouble.new }
|
||||
|
||||
it "returns the original value" do
|
||||
io = IO::Memory.new
|
||||
aggregate_failures do
|
||||
expect(dbl.!=(42)).to be_true
|
||||
expect(dbl.!~(42)).to be_true
|
||||
expect(dbl.==(42)).to be_false
|
||||
expect(dbl.===(42)).to be_false
|
||||
expect(dbl.=~(42)).to be_nil
|
||||
expect(dbl.class).to be_lt(Spectator::LazyDouble)
|
||||
expect(dbl.in?([42])).to be_false
|
||||
expect(dbl.in?(1, 2, 3)).to be_false
|
||||
expect(dbl.itself).to be(dbl)
|
||||
expect(dbl.not_nil!).to be(dbl)
|
||||
expect(dbl.tap { nil }).to be(dbl)
|
||||
expect(dbl.to_s(io)).to be_nil
|
||||
expect(dbl.try { nil }).to be_nil
|
||||
expect(dbl.same?(dbl)).to be_true
|
||||
expect(dbl.same?(nil)).to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with arguments constraints" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
|
||||
context "without common object methods" do
|
||||
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { Spectator::LazyDouble.new(nil, [stub], foo: "fallback") }
|
||||
|
||||
it "returns the response when constraint satisfied" do
|
||||
expect(dbl.foo("foobar")).to eq("bar")
|
||||
end
|
||||
|
||||
it "returns the fallback value when constraint unsatisfied" do
|
||||
expect { dbl.foo("baz") }.to eq("fallback")
|
||||
end
|
||||
|
||||
it "returns the fallback value when argument count doesn't match" do
|
||||
expect { dbl.foo }.to eq("fallback")
|
||||
end
|
||||
end
|
||||
|
||||
context "with common object methods" do
|
||||
let(stub) { Spectator::ValueStub.new(:"same?", true, arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { Spectator::LazyDouble.new(nil, [stub]) }
|
||||
|
||||
it "returns the response when constraint satisfied" do
|
||||
expect(dbl.same?("foobar")).to eq(true)
|
||||
end
|
||||
|
||||
it "raises an error when constraint unsatisfied" do
|
||||
expect { dbl.same?("baz") }.to raise_error(Spectator::UnexpectedMessage)
|
||||
end
|
||||
|
||||
it "raises an error when argument count doesn't match" do
|
||||
expect { dbl.same? }.to raise_error(Spectator::UnexpectedMessage)
|
||||
end
|
||||
|
||||
context "with a fallback defined" do
|
||||
subject(dbl) { Spectator::LazyDouble.new(nil, [stub], "same?": true) }
|
||||
|
||||
it "returns the fallback when constraint unsatisfied" do
|
||||
expect(dbl.same?("baz")).to be_true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_define_stub" do
|
||||
subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") }
|
||||
let(stub3) { Spectator::ValueStub.new(:foo, 3) }
|
||||
let(stub5) { Spectator::ValueStub.new(:foo, 5) }
|
||||
let(stub7) { Spectator::ValueStub.new(:foo, 7, Spectator::Arguments.capture(:lucky)) }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { dbl._spectator_define_stub(stub3) }.to change { dbl.foo }.from(42).to(3)
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
dbl._spectator_define_stub(stub3)
|
||||
expect { dbl._spectator_define_stub(stub5) }.to change { dbl.foo }.from(3).to(5)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { dbl._spectator_define_stub(stub5) }.to_not change { dbl.bar }
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
dbl._spectator_define_stub(stub5)
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(5)
|
||||
expect(dbl.foo(:lucky)).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.foo(:lucky)).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
dbl._spectator_define_stub(stub5)
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq(5)
|
||||
expect(dbl.foo(:lucky) { nil }).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
context "with previously undefined methods" do
|
||||
it "can stub methods" do
|
||||
stub = Spectator::ValueStub.new(:baz, :xyz)
|
||||
dbl._spectator_define_stub(stub)
|
||||
expect(dbl.baz).to eq(:xyz)
|
||||
end
|
||||
|
||||
it "uses a stub only if an argument constraint is met" do
|
||||
stub = Spectator::ValueStub.new(:baz, :xyz, Spectator::Arguments.capture(:right))
|
||||
dbl._spectator_define_stub(stub)
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_clear_stubs" do
|
||||
subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 5) }
|
||||
|
||||
before_each { dbl._spectator_define_stub(stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42)
|
||||
end
|
||||
|
||||
it "raises on methods without an implementation" do
|
||||
stub = Spectator::ValueStub.new(:baz, :xyz)
|
||||
dbl._spectator_define_stub(stub)
|
||||
expect(dbl.baz).to eq(:xyz)
|
||||
|
||||
dbl._spectator_clear_stubs
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_calls" do
|
||||
subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 5) }
|
||||
|
||||
before_each { dbl._spectator_define_stub(stub) }
|
||||
|
||||
# Retrieves symbolic names of methods called on a double.
|
||||
def called_method_names(dbl)
|
||||
dbl._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
dbl.foo
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores calls to non-stubbed methods" do
|
||||
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
|
||||
expect(called_method_names(dbl)).to eq(%i[baz])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
dbl.foo(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = dbl._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,26 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::MethodCall do
|
||||
let(arguments) { Spectator::Arguments.capture(42, "foobar", foo: :bar) }
|
||||
subject(call) { Spectator::MethodCall.new(:foo, arguments) }
|
||||
|
||||
it "stores the method name" do
|
||||
expect(&.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores arguments" do
|
||||
expect(&.arguments).to eq(arguments)
|
||||
end
|
||||
|
||||
describe ".capture" do
|
||||
subject(call) { Spectator::MethodCall.capture(:foo, 42, "foobar", foo: :bar) }
|
||||
|
||||
it "stores the method name" do
|
||||
expect(&.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores arguments" do
|
||||
expect(&.arguments).to eq(arguments)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,723 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::Mock do
|
||||
let(stub1) { Spectator::ValueStub.new(:method1, 777) }
|
||||
let(stub2) { Spectator::ValueStub.new(:method2, :override) }
|
||||
let(stub3) { Spectator::ValueStub.new(:method3, "stubbed") }
|
||||
|
||||
# Retrieves symbolic names of methods called on a mock.
|
||||
def called_method_names(mock)
|
||||
mock._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
describe "#define_subtype" do
|
||||
context "with a concrete class" do
|
||||
class Thing
|
||||
getter _spectator_invocations = [] of Symbol
|
||||
|
||||
def method1
|
||||
@_spectator_invocations << :method1
|
||||
42
|
||||
end
|
||||
|
||||
def method2
|
||||
@_spectator_invocations << :method2
|
||||
:original
|
||||
end
|
||||
|
||||
def method3
|
||||
@_spectator_invocations << :method3
|
||||
"original"
|
||||
end
|
||||
end
|
||||
|
||||
Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method1: 123) do
|
||||
stub def method2
|
||||
:stubbed
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { MockThing.new }
|
||||
|
||||
it "defines a subclass of the mocked type" do
|
||||
expect(mock).to be_a(Thing)
|
||||
end
|
||||
|
||||
it "overrides responses from methods with keyword arguments" do
|
||||
expect(mock.method1).to eq(123)
|
||||
end
|
||||
|
||||
it "overrides responses from methods defined in the block" do
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
end
|
||||
|
||||
it "allows methods to be stubbed" do
|
||||
aggregate_failures do
|
||||
expect { mock._spectator_define_stub(stub1) }.to change { mock.method1 }.to(777)
|
||||
expect { mock._spectator_define_stub(stub2) }.to change { mock.method2 }.to(:override)
|
||||
expect { mock._spectator_define_stub(stub3) }.to change { mock.method3 }.from("original").to("stubbed")
|
||||
end
|
||||
end
|
||||
|
||||
it "can clear stubs" do
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
mock._spectator_define_stub(stub3)
|
||||
|
||||
mock._spectator_clear_stubs
|
||||
aggregate_failures do
|
||||
expect(mock.method1).to eq(123)
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
expect(mock.method3).to eq("original")
|
||||
end
|
||||
end
|
||||
|
||||
it "sets the mock name" do
|
||||
args = Spectator::Arguments.capture("foo")
|
||||
stub = Spectator::ValueStub.new(:method3, 0, args)
|
||||
mock._spectator_define_stub(stub)
|
||||
expect { mock.method3 }.to raise_error(Spectator::UnexpectedMessage, /mock_name/), "Raised error doesn't contain the mocked name."
|
||||
end
|
||||
|
||||
it "records invoked stubs" do
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[]).to(%i[method2])
|
||||
expect { mock.method1 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method1])
|
||||
expect { mock.method3 }.to change { called_method_names(mock) }.from(%i[method2 method1]).to(%i[method2 method1 method3])
|
||||
end
|
||||
|
||||
it "records multiple invocations of the same stub" do
|
||||
mock.method2
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method2])
|
||||
end
|
||||
|
||||
def restricted(thing : Thing)
|
||||
thing.method1
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(123)
|
||||
end
|
||||
|
||||
it "does not call the original method when stubbed" do
|
||||
mock.method1
|
||||
mock.method2
|
||||
mock.method3
|
||||
expect(mock._spectator_invocations).to contain_exactly(:method3)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an abstract class" do
|
||||
abstract class Thing
|
||||
getter _spectator_invocations = [] of Symbol
|
||||
|
||||
abstract def method1
|
||||
|
||||
abstract def method2 : Symbol
|
||||
|
||||
def method3
|
||||
@_spectator_invocations << :method3
|
||||
"original"
|
||||
end
|
||||
|
||||
abstract def method4
|
||||
end
|
||||
|
||||
Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method2: :stubbed) do
|
||||
stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent.
|
||||
123
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { MockThing.new }
|
||||
|
||||
it "defines a subclass of the mocked type" do
|
||||
expect(mock).to be_a(Thing)
|
||||
end
|
||||
|
||||
it "overrides responses from methods defined in the block" do
|
||||
expect(mock.method1).to eq(123)
|
||||
end
|
||||
|
||||
it "overrides responses from methods with keyword arguments" do
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
end
|
||||
|
||||
it "allows methods to be stubbed" do
|
||||
aggregate_failures do
|
||||
expect { mock._spectator_define_stub(stub1) }.to change { mock.method1 }.to(777)
|
||||
expect { mock._spectator_define_stub(stub2) }.to change { mock.method2 }.to(:override)
|
||||
expect { mock._spectator_define_stub(stub3) }.to change { mock.method3 }.from("original").to("stubbed")
|
||||
end
|
||||
end
|
||||
|
||||
it "can clear stubs" do
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
mock._spectator_define_stub(stub3)
|
||||
|
||||
mock._spectator_clear_stubs
|
||||
aggregate_failures do
|
||||
expect(mock.method1).to eq(123)
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
expect(mock.method3).to eq("original")
|
||||
end
|
||||
end
|
||||
|
||||
it "raises when calling an abstract method that isn't stubbed" do
|
||||
expect { mock.method4 }.to raise_error(Spectator::UnexpectedMessage, /method4/)
|
||||
end
|
||||
|
||||
it "sets the mock name" do
|
||||
args = Spectator::Arguments.capture("foo")
|
||||
stub = Spectator::ValueStub.new(:method3, 0, args)
|
||||
mock._spectator_define_stub(stub)
|
||||
expect { mock.method3 }.to raise_error(Spectator::UnexpectedMessage, /mock_name/), "Raised error doesn't contain the mocked name."
|
||||
end
|
||||
|
||||
it "records invoked stubs" do
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[]).to(%i[method2])
|
||||
expect { mock.method1 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method1])
|
||||
expect { mock.method3 }.to change { called_method_names(mock) }.from(%i[method2 method1]).to(%i[method2 method1 method3])
|
||||
end
|
||||
|
||||
it "records multiple invocations of the same stub" do
|
||||
mock.method2
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method2])
|
||||
end
|
||||
|
||||
def restricted(thing : Thing)
|
||||
thing.method1
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(123)
|
||||
end
|
||||
|
||||
it "does not call the original method when stubbed" do
|
||||
mock.method1
|
||||
mock.method2
|
||||
mock.method3
|
||||
expect(mock._spectator_invocations).to contain_exactly(:method3)
|
||||
end
|
||||
end
|
||||
|
||||
context "with an abstract struct" do
|
||||
abstract struct Thing
|
||||
getter _spectator_invocations = [] of Symbol
|
||||
|
||||
abstract def method1
|
||||
|
||||
abstract def method2 : Symbol
|
||||
|
||||
def method3
|
||||
@_spectator_invocations << :method3
|
||||
"original"
|
||||
end
|
||||
|
||||
abstract def method4
|
||||
end
|
||||
|
||||
Spectator::Mock.define_subtype(:struct, Thing, MockThing, :mock_name, method2: :stubbed) do
|
||||
stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent.
|
||||
123
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { MockThing.new }
|
||||
|
||||
it "defines a sub-type of the mocked type" do
|
||||
expect(mock).to be_a(Thing)
|
||||
end
|
||||
|
||||
it "overrides responses from methods defined in the block" do
|
||||
expect(mock.method1).to eq(123)
|
||||
end
|
||||
|
||||
it "overrides responses from methods with keyword arguments" do
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
end
|
||||
|
||||
it "allows methods to be stubbed" do
|
||||
mock = self.mock # FIXME: Workaround for passing by value messing with stubs.
|
||||
aggregate_failures do
|
||||
expect { mock._spectator_define_stub(stub1) }.to change { mock.method1 }.to(777)
|
||||
expect { mock._spectator_define_stub(stub2) }.to change { mock.method2 }.to(:override)
|
||||
expect { mock._spectator_define_stub(stub3) }.to change { mock.method3 }.from("original").to("stubbed")
|
||||
end
|
||||
end
|
||||
|
||||
it "can clear stubs" do
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
mock._spectator_define_stub(stub3)
|
||||
|
||||
mock._spectator_clear_stubs
|
||||
aggregate_failures do
|
||||
expect(mock.method1).to eq(123)
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
expect(mock.method3).to eq("original")
|
||||
end
|
||||
end
|
||||
|
||||
it "raises when calling an abstract method that isn't stubbed" do
|
||||
expect { mock.method4 }.to raise_error(Spectator::UnexpectedMessage, /method4/)
|
||||
end
|
||||
|
||||
it "sets the mock name" do
|
||||
mock = self.mock # FIXME: Workaround for passing by value messing with stubs.
|
||||
args = Spectator::Arguments.capture("foo")
|
||||
stub = Spectator::ValueStub.new(:method3, 0, args)
|
||||
mock._spectator_define_stub(stub)
|
||||
expect { mock.method3 }.to raise_error(Spectator::UnexpectedMessage, /mock_name/), "Raised error doesn't contain the mocked name."
|
||||
end
|
||||
|
||||
def restricted(thing : Thing)
|
||||
thing.method1
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(123)
|
||||
end
|
||||
|
||||
it "does not call the original method when stubbed" do
|
||||
mock = self.mock # FIXME: Workaround for passing by value messing with stubs.
|
||||
mock.method1
|
||||
mock.method2
|
||||
mock.method3
|
||||
expect(mock._spectator_invocations).to contain_exactly(:method3)
|
||||
end
|
||||
end
|
||||
|
||||
context "class method stubs" do
|
||||
class Thing
|
||||
def self.foo
|
||||
:original
|
||||
end
|
||||
|
||||
def self.bar(arg)
|
||||
arg
|
||||
end
|
||||
|
||||
def self.baz(arg)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
Spectator::Mock.define_subtype(:class, Thing, MockThing) do
|
||||
stub def self.foo
|
||||
:stub
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { MockThing }
|
||||
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
|
||||
|
||||
after_each { mock._spectator_clear_stubs }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { mock._spectator_define_stub(foo_stub) }.to_not change { mock.bar(42) }
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
mock._spectator_define_stub(foo_stub)
|
||||
stub = Spectator::ValueStub.new(:foo, :replacement)
|
||||
expect { mock._spectator_define_stub(stub) }.to change { mock.foo }.to(:replacement)
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
stub1 = Spectator::ValueStub.new(:bar, :fallback)
|
||||
stub2 = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(mock.bar(:wrong)).to eq(:fallback)
|
||||
expect(mock.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
stub = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
mock._spectator_define_stub(stub)
|
||||
aggregate_failures do
|
||||
expect(mock.bar(:original)).to eq(:original)
|
||||
expect(mock.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
stub1 = Spectator::ValueStub.new(:baz, 1)
|
||||
stub2 = Spectator::ValueStub.new(:baz, 2, Spectator::Arguments.capture(3))
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(mock.baz(5) { 42 }).to eq(1)
|
||||
expect(mock.baz(3) { 42 }).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
def restricted(thing : Thing.class)
|
||||
thing.foo
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(:stub)
|
||||
end
|
||||
|
||||
describe "._spectator_clear_stubs" do
|
||||
before_each { mock._spectator_define_stub(foo_stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub)
|
||||
end
|
||||
end
|
||||
|
||||
describe "._spectator_calls" do
|
||||
before_each { mock._spectator_clear_calls }
|
||||
|
||||
# Retrieves symbolic names of methods called on a mock.
|
||||
def called_method_names(mock)
|
||||
mock._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { mock.foo }.to change { called_method_names(mock) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
mock.foo
|
||||
expect { mock.foo }.to change { called_method_names(mock) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
mock.bar(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = mock._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#inject" do
|
||||
context "with a class" do
|
||||
class MockedClass
|
||||
getter _spectator_invocations = [] of Symbol
|
||||
|
||||
getter method1 do
|
||||
@_spectator_invocations << :method1
|
||||
42
|
||||
end
|
||||
|
||||
def method2
|
||||
@_spectator_invocations << :method2
|
||||
:original
|
||||
end
|
||||
|
||||
def method3
|
||||
@_spectator_invocations << :method3
|
||||
"original"
|
||||
end
|
||||
|
||||
def instance_variables
|
||||
{% begin %}[{{@type.instance_vars.map(&.name.symbolize).splat}}]{% end %}
|
||||
end
|
||||
end
|
||||
|
||||
Spectator::Mock.inject(:class, MockedClass, :mock_name, method1: 123) do
|
||||
stub def method2
|
||||
:stubbed
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { MockedClass.new }
|
||||
|
||||
# Necessary to clear stubs to prevent leakages between tests.
|
||||
after_each { mock._spectator_clear_stubs }
|
||||
|
||||
it "overrides responses from methods with keyword arguments" do
|
||||
expect(mock.method1).to eq(123)
|
||||
end
|
||||
|
||||
it "overrides responses from methods defined in the block" do
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
end
|
||||
|
||||
it "allows methods to be stubbed" do
|
||||
aggregate_failures do
|
||||
expect { mock._spectator_define_stub(stub1) }.to change { mock.method1 }.to(777)
|
||||
expect { mock._spectator_define_stub(stub2) }.to change { mock.method2 }.to(:override)
|
||||
expect { mock._spectator_define_stub(stub3) }.to change { mock.method3 }.from("original").to("stubbed")
|
||||
end
|
||||
end
|
||||
|
||||
it "can clear stubs" do
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
mock._spectator_define_stub(stub3)
|
||||
|
||||
mock._spectator_clear_stubs
|
||||
aggregate_failures do
|
||||
expect(mock.method1).to eq(123)
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
expect(mock.method3).to eq("original")
|
||||
end
|
||||
end
|
||||
|
||||
it "doesn't change the size of an instance" do
|
||||
size = sizeof(Int64) + sizeof(Int32?) + sizeof(Array(Symbol)) # TypeID + Int32? + _spectator_invocations
|
||||
expect(instance_sizeof(MockedClass)).to eq(size)
|
||||
end
|
||||
|
||||
it "doesn't affect instance variables" do
|
||||
expect(mock.instance_variables).to contain_exactly(:method1, :_spectator_invocations)
|
||||
end
|
||||
|
||||
it "sets the mock name" do
|
||||
args = Spectator::Arguments.capture("foo")
|
||||
stub = Spectator::ValueStub.new(:method3, 0, args)
|
||||
mock._spectator_define_stub(stub)
|
||||
expect { mock.method3 }.to raise_error(Spectator::UnexpectedMessage, /mock_name/), "Raised error doesn't contain the mocked name."
|
||||
end
|
||||
|
||||
it "records invoked stubs" do
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[]).to(%i[method2])
|
||||
expect { mock.method1 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method1])
|
||||
expect { mock.method3 }.to change { called_method_names(mock) }.from(%i[method2 method1]).to(%i[method2 method1 method3])
|
||||
end
|
||||
|
||||
it "records multiple invocations of the same stub" do
|
||||
mock.method2
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method2])
|
||||
end
|
||||
|
||||
def restricted(thing : MockedClass)
|
||||
thing.method1
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(123)
|
||||
end
|
||||
|
||||
it "does not call the original method when stubbed" do
|
||||
mock.method1
|
||||
mock.method2
|
||||
mock.method3
|
||||
expect(mock._spectator_invocations).to contain_exactly(:method3)
|
||||
end
|
||||
end
|
||||
|
||||
context "with a struct" do
|
||||
struct MockedStruct
|
||||
# Using a class variable instead of an instance variable to prevent mutability problems with stub lookup.
|
||||
class_getter _spectator_invocations = [] of Symbol
|
||||
|
||||
@method1 = 42
|
||||
|
||||
def method1
|
||||
@@_spectator_invocations << :method1
|
||||
@method1
|
||||
end
|
||||
|
||||
def method2
|
||||
@@_spectator_invocations << :method2
|
||||
:original
|
||||
end
|
||||
|
||||
def method3
|
||||
@@_spectator_invocations << :method3
|
||||
"original"
|
||||
end
|
||||
|
||||
def instance_variables
|
||||
{% begin %}[{{@type.instance_vars.map(&.name.symbolize).splat}}]{% end %}
|
||||
end
|
||||
end
|
||||
|
||||
Spectator::Mock.inject(:struct, MockedStruct, :mock_name, method1: 123) do
|
||||
stub def method2
|
||||
:stubbed
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { MockedStruct.new }
|
||||
|
||||
# Necessary to clear stubs to prevent leakages between tests.
|
||||
after_each { mock._spectator_clear_stubs }
|
||||
after_each { MockedStruct._spectator_invocations.clear }
|
||||
|
||||
it "overrides responses from methods with keyword arguments" do
|
||||
expect(mock.method1).to eq(123)
|
||||
end
|
||||
|
||||
it "overrides responses from methods defined in the block" do
|
||||
expect(mock.method2).to eq(:stubbed)
|
||||
end
|
||||
|
||||
it "allows methods to be stubbed" do
|
||||
aggregate_failures do
|
||||
expect { mock._spectator_define_stub(stub1) }.to change { mock.method1 }.to(777)
|
||||
expect { mock._spectator_define_stub(stub2) }.to change { mock.method2 }.to(:override)
|
||||
expect { mock._spectator_define_stub(stub3) }.to change { mock.method3 }.from("original").to("stubbed")
|
||||
end
|
||||
end
|
||||
|
||||
it "doesn't change the size of an instance" do
|
||||
expect(sizeof(MockedStruct)).to eq(sizeof(Int32))
|
||||
end
|
||||
|
||||
it "doesn't affect instance variables" do
|
||||
expect(mock.instance_variables).to contain_exactly(:method1)
|
||||
end
|
||||
|
||||
it "sets the mock name" do
|
||||
args = Spectator::Arguments.capture("foo")
|
||||
stub = Spectator::ValueStub.new(:method3, 0, args)
|
||||
mock._spectator_define_stub(stub)
|
||||
expect { mock.method3 }.to raise_error(Spectator::UnexpectedMessage, /mock_name/), "Raised error doesn't contain the mocked name."
|
||||
end
|
||||
|
||||
it "records invoked stubs" do
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[]).to(%i[method2])
|
||||
expect { mock.method1 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method1])
|
||||
expect { mock.method3 }.to change { called_method_names(mock) }.from(%i[method2 method1]).to(%i[method2 method1 method3])
|
||||
end
|
||||
|
||||
it "records multiple invocations of the same stub" do
|
||||
mock.method2
|
||||
expect { mock.method2 }.to change { called_method_names(mock) }.from(%i[method2]).to(%i[method2 method2])
|
||||
end
|
||||
|
||||
def restricted(thing : MockedStruct)
|
||||
thing.method1
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(123)
|
||||
end
|
||||
|
||||
it "does not call the original method when stubbed" do
|
||||
mock.method1
|
||||
mock.method2
|
||||
mock.method3
|
||||
expect(MockedStruct._spectator_invocations).to contain_exactly(:method3)
|
||||
end
|
||||
end
|
||||
|
||||
context "class method stubs" do
|
||||
class Thing
|
||||
def self.foo
|
||||
:original
|
||||
end
|
||||
|
||||
def self.bar(arg)
|
||||
arg
|
||||
end
|
||||
|
||||
def self.baz(arg)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
Spectator::Mock.inject(:class, Thing) do
|
||||
stub def self.foo
|
||||
:stub
|
||||
end
|
||||
end
|
||||
|
||||
let(mock) { Thing }
|
||||
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
|
||||
|
||||
after_each { mock._spectator_clear_stubs }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { mock._spectator_define_stub(foo_stub) }.to_not change { mock.bar(42) }
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
mock._spectator_define_stub(foo_stub)
|
||||
stub = Spectator::ValueStub.new(:foo, :replacement)
|
||||
expect { mock._spectator_define_stub(stub) }.to change { mock.foo }.to(:replacement)
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
stub1 = Spectator::ValueStub.new(:bar, :fallback)
|
||||
stub2 = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(mock.bar(:wrong)).to eq(:fallback)
|
||||
expect(mock.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
stub = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
mock._spectator_define_stub(stub)
|
||||
aggregate_failures do
|
||||
expect(mock.bar(:original)).to eq(:original)
|
||||
expect(mock.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
stub1 = Spectator::ValueStub.new(:baz, 1)
|
||||
stub2 = Spectator::ValueStub.new(:baz, 2, Spectator::Arguments.capture(3))
|
||||
mock._spectator_define_stub(stub1)
|
||||
mock._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(mock.baz(5) { 42 }).to eq(1)
|
||||
expect(mock.baz(3) { 42 }).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
def restricted(thing : Thing.class)
|
||||
thing.foo
|
||||
end
|
||||
|
||||
it "can be used in type restricted methods" do
|
||||
expect(restricted(mock)).to eq(:stub)
|
||||
end
|
||||
|
||||
describe "._spectator_clear_stubs" do
|
||||
before_each { mock._spectator_define_stub(foo_stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub)
|
||||
end
|
||||
end
|
||||
|
||||
describe "._spectator_calls" do
|
||||
before_each { mock._spectator_clear_calls }
|
||||
|
||||
# Retrieves symbolic names of methods called on a mock.
|
||||
def called_method_names(mock)
|
||||
mock._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { mock.foo }.to change { called_method_names(mock) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
mock.foo
|
||||
expect { mock.foo }.to change { called_method_names(mock) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
mock.bar(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = mock._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,173 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::MultiValueStub do
|
||||
let(method_call) { Spectator::MethodCall.capture(:foo) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
subject(stub) { described_class.new(:foo, [3, 5, 7], location: location) }
|
||||
|
||||
it "stores the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
|
||||
describe "#call" do
|
||||
it "returns the values in order" do
|
||||
values = Array.new(3) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 5, 7])
|
||||
end
|
||||
|
||||
it "returns the final value after exhausting other values" do
|
||||
values = Array.new(5) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 5, 7, 7, 7])
|
||||
end
|
||||
end
|
||||
|
||||
context Spectator::StubModifiers do
|
||||
describe "#and_return(value)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::MultiValueStub.new(:foo, [3, 5, 7], arguments, location) }
|
||||
subject(stub) { original.and_return(123) }
|
||||
|
||||
it "produces a stub that returns a value" do
|
||||
expect(stub.call(method_call)).to eq(123)
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_return(*values)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::MultiValueStub.new(:foo, [3, 5, 7], arguments, location) }
|
||||
subject(stub) { original.and_return(3, 2, 1, 0) }
|
||||
|
||||
it "produces a stub that returns values" do
|
||||
values = Array.new(5) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 2, 1, 0, 0])
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_raise" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::MultiValueStub.new(:foo, [3, 5, 7], arguments, location) }
|
||||
let(new_exception) { ArgumentError.new("Test argument error") }
|
||||
subject(stub) { original.and_raise(new_exception) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
|
||||
context "with a class and message" do
|
||||
subject(stub) { original.and_raise(ArgumentError, "Test argument error") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a message" do
|
||||
subject(stub) { original.and_raise("Test exception") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(Exception, "Test exception")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class" do
|
||||
subject(stub) { original.and_raise(ArgumentError) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#===" do
|
||||
subject { stub === call }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with a constraint" do
|
||||
let(constraint) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 42, constraint) }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
|
||||
context "with a non-matching arguments" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "baz") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,430 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::NullDouble do
|
||||
Spectator::NullDouble.define(EmptyDouble)
|
||||
Spectator::NullDouble.define(FooBarDouble, "dbl-name", foo: 42, bar: "baz")
|
||||
|
||||
# The subject `dbl` must be carefully used in sub-contexts, otherwise it pollutes parent scopes.
|
||||
# This changes the type of `dbl` to `Double`, which produces a union of methods and their return types.
|
||||
context "plain double" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
|
||||
it "responds to defined messages" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.bar).to eq("baz")
|
||||
end
|
||||
end
|
||||
|
||||
it "returns self on undefined messages" do
|
||||
expect(dbl.baz).to be(dbl)
|
||||
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 "blocks" do
|
||||
it "supports blocks" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq(42)
|
||||
expect(dbl.bar { nil }).to eq("baz")
|
||||
end
|
||||
end
|
||||
|
||||
it "supports blocks and has non-union return types" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to compile_as(Int32)
|
||||
expect(dbl.bar { nil }).to compile_as(String)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns self on undefined messages" do
|
||||
expect(dbl.baz { nil }).to be(dbl)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with abstract stubs and return type annotations" do
|
||||
Spectator::NullDouble.define(TestDouble2) do
|
||||
abstract_stub abstract def foo(value) : String
|
||||
end
|
||||
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble2.new([stub]) }
|
||||
|
||||
it "enforces the return type" do
|
||||
expect(dbl.foo("foobar")).to compile_as(String)
|
||||
end
|
||||
|
||||
it "raises on non-matching arguments" do
|
||||
expect { dbl.foo("bar") }.to raise_error(Spectator::UnexpectedMessage, /foo/)
|
||||
end
|
||||
|
||||
it "raises on non-matching stub" do
|
||||
stub = Spectator::ValueStub.new(:foo, 42, arguments).as(Spectator::Stub)
|
||||
dbl._spectator_define_stub(stub)
|
||||
expect { dbl.foo("foobar") }.to raise_error(TypeCastError, /String/)
|
||||
end
|
||||
end
|
||||
|
||||
context "with nillable return type annotations" do
|
||||
Spectator::NullDouble.define(TestDouble) do
|
||||
abstract_stub abstract def foo : String?
|
||||
abstract_stub abstract def bar : Nil
|
||||
end
|
||||
|
||||
let(foo_stub) { Spectator::ValueStub.new(:foo, nil).as(Spectator::Stub) }
|
||||
let(bar_stub) { Spectator::ValueStub.new(:bar, nil).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([foo_stub, bar_stub]) }
|
||||
|
||||
it "doesn't raise on nil" do
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to be_nil
|
||||
expect(dbl.bar).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with common object methods" do
|
||||
subject(dbl) do
|
||||
EmptyDouble.new([
|
||||
Spectator::ValueStub.new(:"!=", false),
|
||||
Spectator::ValueStub.new(:"!~", false),
|
||||
Spectator::ValueStub.new(:"==", true),
|
||||
Spectator::ValueStub.new(:"===", true),
|
||||
Spectator::ValueStub.new(:"=~", nil),
|
||||
Spectator::ValueStub.new(:class, EmptyDouble),
|
||||
Spectator::ValueStub.new(:dup, EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:"in?", true),
|
||||
Spectator::ValueStub.new(:inspect, "inspect"),
|
||||
Spectator::ValueStub.new(:itself, EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:"not_nil!", EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:pretty_inspect, "pretty_inspect"),
|
||||
Spectator::ValueStub.new(:tap, EmptyDouble.new),
|
||||
Spectator::ValueStub.new(:to_json, "to_json"),
|
||||
Spectator::ValueStub.new(:to_pretty_json, "to_pretty_json"),
|
||||
Spectator::ValueStub.new(:to_s, "to_s"),
|
||||
Spectator::ValueStub.new(:to_yaml, "to_yaml"),
|
||||
Spectator::ValueStub.new(:try, nil),
|
||||
Spectator::ValueStub.new(:object_id, 42_u64),
|
||||
Spectator::ValueStub.new(:"same?", true),
|
||||
] of Spectator::Stub)
|
||||
end
|
||||
|
||||
it "responds with defined messages" do
|
||||
aggregate_failures do
|
||||
expect(dbl.!=(42)).to be_false
|
||||
expect(dbl.!~(42)).to be_false
|
||||
expect(dbl.==(42)).to be_true
|
||||
expect(dbl.===(42)).to be_true
|
||||
expect(dbl.=~(42)).to be_nil
|
||||
expect(dbl.class).to eq(EmptyDouble)
|
||||
expect(dbl.dup).to be_a(EmptyDouble)
|
||||
expect(dbl.in?([42])).to eq(true)
|
||||
expect(dbl.in?(1, 2, 3)).to eq(true)
|
||||
expect(dbl.inspect).to eq("inspect")
|
||||
expect(dbl.itself).to be_a(EmptyDouble)
|
||||
expect(dbl.not_nil!).to be_a(EmptyDouble)
|
||||
expect(dbl.pretty_inspect).to eq("pretty_inspect")
|
||||
expect(dbl.tap { nil }).to be_a(EmptyDouble)
|
||||
expect(dbl.to_json).to eq("to_json")
|
||||
expect(dbl.to_pretty_json).to eq("to_pretty_json")
|
||||
expect(dbl.to_s).to eq("to_s")
|
||||
expect(dbl.to_yaml).to eq("to_yaml")
|
||||
expect(dbl.try { nil }).to be_nil
|
||||
expect(dbl.object_id).to eq(42_u64)
|
||||
expect(dbl.same?(dbl)).to be_true
|
||||
expect(dbl.same?(nil)).to be_true
|
||||
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
|
||||
subject(dbl) { EmptyDouble.new }
|
||||
|
||||
it "returns original implementation with undefined messages" do
|
||||
hasher = Crystal::Hasher.new
|
||||
aggregate_failures do
|
||||
expect(dbl.!=(42)).to be_true
|
||||
expect(dbl.!~(42)).to be_true
|
||||
expect(dbl.==(42)).to be_false
|
||||
expect(dbl.===(42)).to be_false
|
||||
expect(dbl.=~(42)).to be_nil
|
||||
expect(dbl.class).to eq(EmptyDouble)
|
||||
expect(dbl.dup).to be_a(EmptyDouble)
|
||||
expect(dbl.hash(hasher)).to be_a(Crystal::Hasher)
|
||||
expect(dbl.hash).to be_a(UInt64)
|
||||
expect(dbl.in?([42])).to be_false
|
||||
expect(dbl.in?(1, 2, 3)).to be_false
|
||||
expect(dbl.inspect).to contain("EmptyDouble")
|
||||
expect(dbl.itself).to be(dbl)
|
||||
expect(dbl.not_nil!).to be(dbl)
|
||||
expect(dbl.pretty_inspect).to contain("EmptyDouble")
|
||||
expect(dbl.tap { nil }).to be(dbl)
|
||||
expect(dbl.to_s).to contain("EmptyDouble")
|
||||
expect(dbl.try { nil }).to be_nil
|
||||
expect(dbl.object_id).to be_a(UInt64)
|
||||
expect(dbl.same?(dbl)).to be_true
|
||||
expect(dbl.same?(nil)).to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with arguments constraints" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
|
||||
context "without common object methods" do
|
||||
Spectator::NullDouble.define(TestDouble) do
|
||||
abstract_stub abstract def foo(value) : String
|
||||
abstract_stub abstract def foo(value, & : -> _) : String
|
||||
end
|
||||
|
||||
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([stub]) }
|
||||
|
||||
it "returns the response when constraint satisfied" do
|
||||
expect(dbl.foo("foobar")).to eq("bar")
|
||||
end
|
||||
|
||||
it "raises self when constraint unsatisfied" do
|
||||
expect { dbl.foo("baz") }.to raise_error(Spectator::UnexpectedMessage, /foo/)
|
||||
end
|
||||
|
||||
it "raises self when argument count doesn't match" do
|
||||
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/)
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
expect(dbl.foo("foobar") { nil }).to eq("bar")
|
||||
end
|
||||
end
|
||||
|
||||
context "with common object methods" do
|
||||
Spectator::NullDouble.define(TestDouble) do
|
||||
stub abstract def hash(hasher) : Crystal::Hasher
|
||||
end
|
||||
|
||||
let(hasher) { Crystal::Hasher.new }
|
||||
let(stub) { Spectator::ValueStub.new(:hash, hasher, arguments).as(Spectator::Stub) }
|
||||
subject(dbl) { TestDouble.new([stub]) }
|
||||
|
||||
it "returns the response when constraint satisfied" do
|
||||
expect(dbl.hash("foobar")).to be(hasher)
|
||||
end
|
||||
|
||||
it "raises when constraint unsatisfied" do
|
||||
expect { dbl.hash("baz") }.to raise_error(Spectator::UnexpectedMessage, /hash/)
|
||||
end
|
||||
|
||||
it "raises when argument count doesn't match" do
|
||||
expect { dbl.hash }.to raise_error(Spectator::UnexpectedMessage, /hash/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "class method stubs" do
|
||||
Spectator::NullDouble.define(ClassDouble) do
|
||||
stub def self.foo
|
||||
:stub
|
||||
end
|
||||
|
||||
stub def self.bar(arg)
|
||||
arg
|
||||
end
|
||||
|
||||
stub def self.baz(arg)
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
subject(dbl) { ClassDouble }
|
||||
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
|
||||
|
||||
after_each { dbl._spectator_clear_stubs }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { dbl._spectator_define_stub(foo_stub) }.to_not change { dbl.bar(42) }
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
dbl._spectator_define_stub(foo_stub)
|
||||
stub = Spectator::ValueStub.new(:foo, :replacement)
|
||||
expect { dbl._spectator_define_stub(stub) }.to change { dbl.foo }.to(:replacement)
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
stub1 = Spectator::ValueStub.new(:bar, :fallback)
|
||||
stub2 = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
dbl._spectator_define_stub(stub1)
|
||||
dbl._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(dbl.bar(:wrong)).to eq(:fallback)
|
||||
expect(dbl.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
stub = Spectator::ValueStub.new(:bar, :override, Spectator::Arguments.capture(:match))
|
||||
dbl._spectator_define_stub(stub)
|
||||
aggregate_failures do
|
||||
expect(dbl.bar(:original)).to eq(:original)
|
||||
expect(dbl.bar(:match)).to eq(:override)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
stub1 = Spectator::ValueStub.new(:baz, 1)
|
||||
stub2 = Spectator::ValueStub.new(:baz, 2, Spectator::Arguments.capture(3))
|
||||
dbl._spectator_define_stub(stub1)
|
||||
dbl._spectator_define_stub(stub2)
|
||||
aggregate_failures do
|
||||
expect(dbl.baz(5) { 42 }).to eq(1)
|
||||
expect(dbl.baz(3) { 42 }).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "._spectator_clear_stubs" do
|
||||
before_each { dbl._spectator_define_stub(foo_stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub)
|
||||
end
|
||||
end
|
||||
|
||||
describe "._spectator_calls" do
|
||||
before_each { dbl._spectator_clear_calls }
|
||||
|
||||
# Retrieves symbolic names of methods called on a double.
|
||||
def called_method_names(dbl)
|
||||
dbl._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
dbl.foo
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
dbl.bar(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = dbl._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_define_stub" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
let(stub3) { Spectator::ValueStub.new(:foo, 3) }
|
||||
let(stub5) { Spectator::ValueStub.new(:foo, 5) }
|
||||
let(stub7) { Spectator::ValueStub.new(:foo, 7, Spectator::Arguments.capture(:lucky)) }
|
||||
|
||||
it "overrides an existing method" do
|
||||
expect { dbl._spectator_define_stub(stub3) }.to change { dbl.foo }.from(42).to(3)
|
||||
end
|
||||
|
||||
it "replaces an existing stub" do
|
||||
dbl._spectator_define_stub(stub3)
|
||||
expect { dbl._spectator_define_stub(stub5) }.to change { dbl.foo }.from(3).to(5)
|
||||
end
|
||||
|
||||
it "doesn't affect other methods" do
|
||||
expect { dbl._spectator_define_stub(stub5) }.to_not change { dbl.bar }
|
||||
end
|
||||
|
||||
it "picks the correct stub based on arguments" do
|
||||
dbl._spectator_define_stub(stub5)
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(5)
|
||||
expect(dbl.foo(:lucky)).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "only uses a stub if an argument constraint is met" do
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo).to eq(42)
|
||||
expect(dbl.foo(:lucky)).to eq(7)
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores the block argument if not in the constraint" do
|
||||
dbl._spectator_define_stub(stub5)
|
||||
dbl._spectator_define_stub(stub7)
|
||||
aggregate_failures do
|
||||
expect(dbl.foo { nil }).to eq(5)
|
||||
expect(dbl.foo(:lucky) { nil }).to eq(7)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_clear_stubs" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 5) }
|
||||
|
||||
before_each { dbl._spectator_define_stub(stub) }
|
||||
|
||||
it "removes previously defined stubs" do
|
||||
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42)
|
||||
end
|
||||
|
||||
it "defaults to returning itself for methods with no implementation" do
|
||||
stub = Spectator::ValueStub.new(:baz, :xyz)
|
||||
dbl._spectator_define_stub(stub)
|
||||
expect(dbl.baz).to eq(:xyz)
|
||||
|
||||
dbl._spectator_clear_stubs
|
||||
expect(dbl.baz).to be(dbl)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#_spectator_calls" do
|
||||
subject(dbl) { FooBarDouble.new }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 5) }
|
||||
|
||||
before_each { dbl._spectator_define_stub(stub) }
|
||||
|
||||
# Retrieves symbolic names of methods called on a double.
|
||||
def called_method_names(dbl)
|
||||
dbl._spectator_calls.map(&.method)
|
||||
end
|
||||
|
||||
it "stores calls to stubbed methods" do
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[]).to(%i[foo])
|
||||
end
|
||||
|
||||
it "stores multiple calls to the same stub" do
|
||||
dbl.foo
|
||||
expect { dbl.foo }.to change { called_method_names(dbl) }.from(%i[foo]).to(%i[foo foo])
|
||||
end
|
||||
|
||||
it "stores calls to non-stubbed methods" do
|
||||
expect { dbl.baz }.to change { called_method_names(dbl) }.from(%i[]).to(%i[baz])
|
||||
end
|
||||
|
||||
it "stores arguments for a call" do
|
||||
dbl.foo(42)
|
||||
args = Spectator::Arguments.capture(42)
|
||||
call = dbl._spectator_calls.first
|
||||
expect(call.arguments).to eq(args)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,165 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::NullStub do
|
||||
let(method_call) { Spectator::MethodCall.capture(:foo) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
subject(stub) { described_class.new(:foo, location: location) }
|
||||
|
||||
it "stores the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
|
||||
it "returns nil" do
|
||||
expect(stub.call(method_call)).to be_nil
|
||||
end
|
||||
|
||||
context Spectator::StubModifiers do
|
||||
describe "#and_return(value)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::NullStub.new(:foo, arguments, location) }
|
||||
subject(stub) { original.and_return(42) }
|
||||
|
||||
it "produces a stub that returns a value" do
|
||||
expect(stub.call(method_call)).to eq(42)
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_return(*values)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::NullStub.new(:foo, arguments, location) }
|
||||
subject(stub) { original.and_return(3, 2, 1, 0) }
|
||||
|
||||
it "produces a stub that returns values" do
|
||||
values = Array.new(5) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 2, 1, 0, 0])
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_raise" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::NullStub.new(:foo, arguments, location) }
|
||||
let(new_exception) { ArgumentError.new("Test argument error") }
|
||||
subject(stub) { original.and_raise(new_exception) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
|
||||
context "with a class and message" do
|
||||
subject(stub) { original.and_raise(ArgumentError, "Test argument error") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a message" do
|
||||
subject(stub) { original.and_raise("Test exception") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(Exception, "Test exception")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class" do
|
||||
subject(stub) { original.and_raise(ArgumentError) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#===" do
|
||||
subject { stub === call }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with a constraint" do
|
||||
let(constraint) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { described_class.new(:foo, constraint) }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
|
||||
context "with a non-matching arguments" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "baz") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,182 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::ProcStub do
|
||||
let(method_call) { Spectator::MethodCall.capture(:foo) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(proc) { Proc(Spectator::AbstractArguments, Int32).new { @call_count += 1 } }
|
||||
subject(stub) { described_class.new(:foo, proc, location: location) }
|
||||
|
||||
@call_count = 0
|
||||
|
||||
it "stores the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
|
||||
it "calls the proc" do
|
||||
expect(stub.call(method_call)).to eq(1)
|
||||
end
|
||||
|
||||
it "calls the proc for each invocation" do
|
||||
stub.call(method_call)
|
||||
expect { stub.call(method_call) }.to change { @call_count }.from(1).to(2)
|
||||
end
|
||||
|
||||
it "passed the original arguments" do
|
||||
proc = Proc(Spectator::AbstractArguments, Spectator::AbstractArguments).new { |a| a }
|
||||
stub = described_class.new(:foo, proc)
|
||||
args = Spectator::Arguments.capture(42, bar: "baz")
|
||||
call = Spectator::MethodCall.new(:foo, args)
|
||||
captured = stub.call(call)
|
||||
expect(captured).to eq(args)
|
||||
end
|
||||
|
||||
context Spectator::StubModifiers do
|
||||
describe "#and_return(value)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ProcStub.new(:foo, proc, arguments, location) }
|
||||
subject(stub) { original.and_return(123) }
|
||||
|
||||
it "produces a stub that returns a value" do
|
||||
expect(stub.call(method_call)).to eq(123)
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_return(*values)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ProcStub.new(:foo, proc, arguments, location) }
|
||||
subject(stub) { original.and_return(3, 2, 1, 0) }
|
||||
|
||||
it "produces a stub that returns values" do
|
||||
values = Array.new(5) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 2, 1, 0, 0])
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_raise" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ProcStub.new(:foo, proc, arguments, location) }
|
||||
let(new_exception) { ArgumentError.new("Test argument error") }
|
||||
subject(stub) { original.and_raise(new_exception) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
|
||||
context "with a class and message" do
|
||||
subject(stub) { original.and_raise(ArgumentError, "Test argument error") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a message" do
|
||||
subject(stub) { original.and_raise("Test exception") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(Exception, "Test exception")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class" do
|
||||
subject(stub) { original.and_raise(ArgumentError) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#===" do
|
||||
subject { stub === call }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with a constraint" do
|
||||
let(constraint) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 42, constraint) }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
|
||||
context "with a non-matching arguments" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "baz") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,93 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::ReferenceMockRegistry do
|
||||
subject(registry) { described_class.new }
|
||||
let(obj) { "foobar" }
|
||||
let(stub) { Spectator::ValueStub.new(:test, 42) }
|
||||
let(stubs) { [stub] of Spectator::Stub }
|
||||
let(no_stubs) { [] of Spectator::Stub }
|
||||
let(call) { Spectator::MethodCall.capture(:method2, 5) }
|
||||
let(calls) { [call] }
|
||||
let(no_calls) { [] of Spectator::MethodCall }
|
||||
|
||||
it "initially has no stubs" do
|
||||
expect(registry[obj].stubs).to be_empty
|
||||
end
|
||||
|
||||
it "initially has no calls" do
|
||||
expect(registry[obj].calls).to be_empty
|
||||
end
|
||||
|
||||
it "stores stubs for an object" do
|
||||
expect { registry[obj].stubs << stub }.to change { registry[obj].stubs }.from(no_stubs).to(stubs)
|
||||
end
|
||||
|
||||
it "stores calls for an object" do
|
||||
expect { registry[obj].calls << call }.to change { registry[obj].calls }.from(no_calls).to(calls)
|
||||
end
|
||||
|
||||
it "isolates stubs between different objects" do
|
||||
obj1 = "foo"
|
||||
obj2 = "bar"
|
||||
registry[obj2].stubs << Spectator::ValueStub.new(:obj2, 42)
|
||||
expect { registry[obj1].stubs << stub }.to_not change { registry[obj2].stubs }
|
||||
end
|
||||
|
||||
it "isolates calls between different objects" do
|
||||
obj1 = "foo"
|
||||
obj2 = "bar"
|
||||
registry[obj2].calls << Spectator::MethodCall.capture(:method1, 42)
|
||||
expect { registry[obj1].calls << call }.to_not change { registry[obj2].calls }
|
||||
end
|
||||
|
||||
describe "#fetch" do
|
||||
it "retrieves existing stubs" do
|
||||
registry[obj].stubs << stub
|
||||
expect(registry.fetch(obj) { no_stubs }.stubs).to eq(stubs)
|
||||
end
|
||||
|
||||
it "stores stubs on the first retrieval" do
|
||||
expect(registry.fetch(obj) { stubs }.stubs).to eq(stubs)
|
||||
end
|
||||
|
||||
it "isolates stubs between different objects" do
|
||||
obj1 = "foo"
|
||||
obj2 = "bar"
|
||||
registry[obj2].stubs << Spectator::ValueStub.new(:obj2, 42)
|
||||
expect { registry.fetch(obj1) { no_stubs }.stubs }.to_not change { registry[obj2].stubs }
|
||||
end
|
||||
|
||||
it "isolates calls between different objects" do
|
||||
obj1 = "foo"
|
||||
obj2 = "bar"
|
||||
registry[obj2].calls << Spectator::MethodCall.capture(:method1, 42)
|
||||
expect { registry.fetch(obj1) { no_stubs }.calls }.to_not change { registry[obj2].calls }
|
||||
end
|
||||
end
|
||||
|
||||
describe "#delete" do
|
||||
it "clears stubs for an object" do
|
||||
registry[obj].stubs << stub
|
||||
expect { registry.delete(obj) }.to change { registry[obj].stubs }.from(stubs).to(no_stubs)
|
||||
end
|
||||
|
||||
it "doesn't clear initial stubs provided with #fetch" do
|
||||
registry[obj].stubs << Spectator::ValueStub.new(:stub2, 42)
|
||||
expect { registry.delete(obj) }.to change { registry.fetch(obj) { stubs }.stubs }.to(stubs)
|
||||
end
|
||||
|
||||
it "isolates stubs between different objects" do
|
||||
obj1 = "foo"
|
||||
obj2 = "bar"
|
||||
registry[obj2].stubs << Spectator::ValueStub.new(:obj2, 42)
|
||||
expect { registry.delete(obj1) }.to_not change { registry[obj2].stubs }
|
||||
end
|
||||
|
||||
it "isolates calls between different objects" do
|
||||
obj1 = "foo"
|
||||
obj2 = "bar"
|
||||
registry[obj2].calls << Spectator::MethodCall.capture(:method1, 42)
|
||||
expect { registry.delete(obj1) }.to_not change { registry[obj2].calls }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,93 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::ValueMockRegistry do
|
||||
subject(registry) { Spectator::ValueMockRegistry(Int32).new }
|
||||
let(obj) { 42 }
|
||||
let(stub) { Spectator::ValueStub.new(:test, 5) }
|
||||
let(stubs) { [stub] of Spectator::Stub }
|
||||
let(no_stubs) { [] of Spectator::Stub }
|
||||
let(call) { Spectator::MethodCall.capture(:method2, 5) }
|
||||
let(calls) { [call] }
|
||||
let(no_calls) { [] of Spectator::MethodCall }
|
||||
|
||||
it "initially has no stubs" do
|
||||
expect(registry[obj].stubs).to be_empty
|
||||
end
|
||||
|
||||
it "initially has no calls" do
|
||||
expect(registry[obj].calls).to be_empty
|
||||
end
|
||||
|
||||
it "stores stubs for an object" do
|
||||
expect { registry[obj].stubs << stub }.to change { registry[obj].stubs }.from(no_stubs).to(stubs)
|
||||
end
|
||||
|
||||
it "stores calls for an object" do
|
||||
expect { registry[obj].calls << call }.to change { registry[obj].calls }.from(no_calls).to(calls)
|
||||
end
|
||||
|
||||
it "isolates stubs between different objects" do
|
||||
obj1 = 1
|
||||
obj2 = 2
|
||||
registry[obj2].stubs << Spectator::ValueStub.new(:obj2, 42)
|
||||
expect { registry[obj1].stubs << stub }.to_not change { registry[obj2].stubs }
|
||||
end
|
||||
|
||||
it "isolates calls between different objects" do
|
||||
obj1 = 1
|
||||
obj2 = 2
|
||||
registry[obj2].calls << Spectator::MethodCall.capture(:method1, 42)
|
||||
expect { registry[obj1].calls << call }.to_not change { registry[obj2].calls }
|
||||
end
|
||||
|
||||
describe "#fetch" do
|
||||
it "retrieves existing stubs" do
|
||||
registry[obj].stubs << stub
|
||||
expect(registry.fetch(obj) { no_stubs }.stubs).to eq(stubs)
|
||||
end
|
||||
|
||||
it "stores stubs on the first retrieval" do
|
||||
expect(registry.fetch(obj) { stubs }.stubs).to eq(stubs)
|
||||
end
|
||||
|
||||
it "isolates stubs between different objects" do
|
||||
obj1 = 1
|
||||
obj2 = 2
|
||||
registry[obj2].stubs << Spectator::ValueStub.new(:obj2, 42)
|
||||
expect { registry.fetch(obj1) { no_stubs }.stubs }.to_not change { registry[obj2].stubs }
|
||||
end
|
||||
|
||||
it "isolates calls between different objects" do
|
||||
obj1 = 1
|
||||
obj2 = 2
|
||||
registry[obj2].calls << Spectator::MethodCall.capture(:method1, 42)
|
||||
expect { registry.fetch(obj1) { no_stubs }.calls }.to_not change { registry[obj2].calls }
|
||||
end
|
||||
end
|
||||
|
||||
describe "#delete" do
|
||||
it "clears stubs for an object" do
|
||||
registry[obj].stubs << stub
|
||||
expect { registry.delete(obj) }.to change { registry[obj].stubs }.from(stubs).to(no_stubs)
|
||||
end
|
||||
|
||||
it "doesn't clear initial stubs provided with #fetch" do
|
||||
registry[obj].stubs << Spectator::ValueStub.new(:stub2, 42)
|
||||
expect { registry.delete(obj) }.to change { registry.fetch(obj) { stubs }.stubs }.to(stubs)
|
||||
end
|
||||
|
||||
it "isolates stubs between different objects" do
|
||||
obj1 = 1
|
||||
obj2 = 2
|
||||
registry[obj2].stubs << Spectator::ValueStub.new(:obj2, 42)
|
||||
expect { registry.delete(obj1) }.to_not change { registry[obj2].stubs }
|
||||
end
|
||||
|
||||
it "isolates calls between different objects" do
|
||||
obj1 = 1
|
||||
obj2 = 2
|
||||
registry[obj2].calls << Spectator::MethodCall.capture(:method1, 42)
|
||||
expect { registry.delete(obj1) }.to_not change { registry[obj2].calls }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,165 @@
|
|||
require "../../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::ValueStub do
|
||||
let(method_call) { Spectator::MethodCall.capture(:foo) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
subject(stub) { described_class.new(:foo, 42, location: location) }
|
||||
|
||||
it "stores the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "stores the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
|
||||
it "stores the return value" do
|
||||
expect(stub.call(method_call)).to eq(42)
|
||||
end
|
||||
|
||||
context Spectator::StubModifiers do
|
||||
describe "#and_return(value)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ValueStub.new(:foo, 42, arguments, location) }
|
||||
subject(stub) { original.and_return(123) }
|
||||
|
||||
it "produces a stub that returns a value" do
|
||||
expect(stub.call(method_call)).to eq(123)
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_return(*values)" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ValueStub.new(:foo, 42, arguments, location) }
|
||||
subject(stub) { original.and_return(3, 2, 1, 0) }
|
||||
|
||||
it "produces a stub that returns values" do
|
||||
values = Array.new(5) { stub.call(method_call) }
|
||||
expect(values).to eq([3, 2, 1, 0, 0])
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#and_raise" do
|
||||
let(arguments) { Spectator::Arguments.capture(/foo/) }
|
||||
let(location) { Spectator::Location.new(__FILE__, __LINE__) }
|
||||
let(original) { Spectator::ValueStub.new(:foo, 42, arguments, location) }
|
||||
let(new_exception) { ArgumentError.new("Test argument error") }
|
||||
subject(stub) { original.and_raise(new_exception) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
|
||||
context "with a class and message" do
|
||||
subject(stub) { original.and_raise(ArgumentError, "Test argument error") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError, "Test argument error")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a message" do
|
||||
subject(stub) { original.and_raise("Test exception") }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(Exception, "Test exception")
|
||||
end
|
||||
end
|
||||
|
||||
context "with a class" do
|
||||
subject(stub) { original.and_raise(ArgumentError) }
|
||||
|
||||
it "produces a stub that raises" do
|
||||
expect { stub.call(method_call) }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
it "retains the method name" do
|
||||
expect(stub.method).to eq(:foo)
|
||||
end
|
||||
|
||||
it "retains the arguments constraint" do
|
||||
expect(stub.constraint).to eq(arguments)
|
||||
end
|
||||
|
||||
it "retains the location" do
|
||||
expect(stub.location).to eq(location)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#===" do
|
||||
subject { stub === call }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
|
||||
context "with a constraint" do
|
||||
let(constraint) { Spectator::Arguments.capture(/foo/) }
|
||||
let(stub) { Spectator::ValueStub.new(:foo, 42, constraint) }
|
||||
|
||||
context "with a matching method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "foobar") }
|
||||
|
||||
it "returns true" do
|
||||
is_expected.to be_true
|
||||
end
|
||||
|
||||
context "with a non-matching arguments" do
|
||||
let(call) { Spectator::MethodCall.capture(:foo, "baz") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "with a different method name" do
|
||||
let(call) { Spectator::MethodCall.capture(:bar, "foobar") }
|
||||
|
||||
it "returns false" do
|
||||
is_expected.to be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
require "../spec_helper"
|
||||
|
||||
Spectator.describe Spectator::SystemExit do
|
||||
it "is raised when an attempt is made to exit the application" do
|
||||
expect { exit }.to raise_error(described_class)
|
||||
end
|
||||
|
||||
it "has the status code passed to an exit call" do
|
||||
exit 5
|
||||
rescue error : Spectator::SystemExit
|
||||
expect(error.status).to eq(5)
|
||||
end
|
||||
end
|
|
@ -7,8 +7,5 @@ module Spectator
|
|||
# This also helps keep error traces small.
|
||||
# Documentation only useful for debugging is included in generated code.
|
||||
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]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -821,6 +821,12 @@ module Spectator::DSL
|
|||
expect {{block}}.to raise_error({{type}}, {{message}})
|
||||
end
|
||||
|
||||
# Indicates that a mock or double (stubbable type) should receive a message (have a method called).
|
||||
# The *method* is the name of the method expected to be called.
|
||||
#
|
||||
# ```
|
||||
# expect(dbl).to have_received(:foo)
|
||||
# ```
|
||||
macro have_received(method)
|
||||
%value = ::Spectator::Value.new(({{method.id.symbolize}}), {{method.id.stringify}})
|
||||
::Spectator::Matchers::ReceiveMatcher.new(%value)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
require "../lazy_wrapper"
|
||||
require "./reserved"
|
||||
|
||||
module Spectator::DSL
|
||||
# DSL methods for defining test values (subjects).
|
||||
|
@ -12,7 +13,7 @@ module Spectator::DSL
|
|||
{% raise "Missing block for 'let'" unless block %}
|
||||
{% raise "Expected zero or one arguments for 'let', but got #{block.args.size}" if block.args.size > 1 %}
|
||||
{% raise "Cannot use 'let' inside of an example block" if @def %}
|
||||
{% raise "Cannot use '#{name.id}' for 'let'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
{% raise "Cannot use '#{name.id}' for 'let'" if name.id.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
|
||||
@%value = ::Spectator::LazyWrapper.new
|
||||
|
||||
|
@ -34,7 +35,7 @@ module Spectator::DSL
|
|||
{% raise "Missing block for 'let!'" unless block %}
|
||||
{% raise "Expected zero or one arguments for 'let!', but got #{block.args.size}" if block.args.size > 1 %}
|
||||
{% raise "Cannot use 'let!' inside of an example block" if @def %}
|
||||
{% raise "Cannot use '#{name.id}' for 'let!'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
{% raise "Cannot use '#{name.id}' for 'let!'" if name.id.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
|
||||
let({{name}}) {{block}}
|
||||
before_each { {{name.id}} }
|
||||
|
@ -61,7 +62,7 @@ module Spectator::DSL
|
|||
{% raise "Missing block for 'subject'" unless block %}
|
||||
{% raise "Expected zero or one arguments for 'subject', but got #{block.args.size}" if block.args.size > 1 %}
|
||||
{% raise "Cannot use 'subject' inside of an example block" if @def %}
|
||||
{% raise "Cannot use '#{name.id}' for 'subject'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
{% raise "Cannot use '#{name.id}' for 'subject'" if name.id.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
|
||||
let({{name.id}}) {{block}}
|
||||
|
||||
|
@ -93,7 +94,7 @@ module Spectator::DSL
|
|||
{% raise "Missing block for 'subject'" unless block %}
|
||||
{% raise "Expected zero or one arguments for 'subject!', but got #{block.args.size}" if block.args.size > 1 %}
|
||||
{% raise "Cannot use 'subject!' inside of an example block" if @def %}
|
||||
{% raise "Cannot use '#{name.id}' for 'subject!'" if ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
{% raise "Cannot use '#{name.id}' for 'subject!'" if name.id.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.id.symbolize) %}
|
||||
|
||||
let!({{name.id}}) {{block}}
|
||||
|
||||
|
|
|
@ -1,183 +1,496 @@
|
|||
require "../mocks"
|
||||
|
||||
module Spectator::DSL
|
||||
# Methods and macros for mocks and doubles.
|
||||
module Mocks
|
||||
macro double(name = "Anonymous", **stubs, &block)
|
||||
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %}
|
||||
anonymous_double({{name}}, {{stubs.double_splat}})
|
||||
{% else %}
|
||||
{%
|
||||
safe_name = name.id.symbolize.gsub(/\W/, "_").id
|
||||
type_name = "Double#{safe_name}".id
|
||||
%}
|
||||
# All defined double and mock types.
|
||||
# Each tuple consists of the double name or mocked type,
|
||||
# defined context (example group), and double type name relative to its context.
|
||||
TYPES = [] of {Symbol, Symbol, Symbol}
|
||||
|
||||
{% if block %}
|
||||
define_double({{type_name}}, {{name}}, {{stubs.double_splat}}) {{block}}
|
||||
{% else %}
|
||||
create_double({{type_name}}, {{name}}, {{stubs.double_splat}})
|
||||
{% end %}
|
||||
# Defines a new double type.
|
||||
#
|
||||
# This must be called from outside of a method (where classes can be defined).
|
||||
# The *name* is the identifier used to reference the double, like when instantiating it.
|
||||
# Simple stubbed methods returning a value can be defined by *value_methods*.
|
||||
# More complex methods and stubs can be defined in a block passed to this macro.
|
||||
#
|
||||
# ```
|
||||
# def_double(:dbl, foo: 42, bar: "baz") do
|
||||
# stub abstract def deferred : String
|
||||
# end
|
||||
# ```
|
||||
private macro def_double(name, **value_methods, &block)
|
||||
{% # Construct a unique type name for the double by using the number of defined doubles.
|
||||
index = ::Spectator::DSL::Mocks::TYPES.size
|
||||
double_type_name = "Double#{index}".id
|
||||
null_double_type_name = "NullDouble#{index}".id
|
||||
|
||||
# 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::TYPES << {name.id.symbolize, @type.name(generic_args: false).symbolize, double_type_name.symbolize} %}
|
||||
|
||||
# Define the plain double type.
|
||||
::Spectator::Double.define({{double_type_name}}, {{name}}, {{**value_methods}}) do
|
||||
# Returns a new double that responds to undefined methods with itself.
|
||||
# See: `NullDouble`
|
||||
def as_null_object
|
||||
{{null_double_type_name}}.new(@stubs)
|
||||
end
|
||||
|
||||
{% if block %}{{block.body}}{% end %}
|
||||
end
|
||||
|
||||
{% begin %}
|
||||
# Define a matching null double type.
|
||||
::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{**value_methods}}) {{block}}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro create_double(type_name, name, **stubs)
|
||||
{% if type_name.resolve? %}
|
||||
{{type_name}}.new.tap do |%double|
|
||||
{% for name, value in stubs %}
|
||||
allow(%double).to receive({{name.id}}).and_return({{value}})
|
||||
# Instantiates a double.
|
||||
#
|
||||
# The *name* is an optional identifier for the double.
|
||||
# If *name* was previously used to define a double (with `#def_double`),
|
||||
# then this macro returns a new instance of that previously defined double type.
|
||||
# Otherwise, a `LazyDouble` is created and returned.
|
||||
#
|
||||
# Initial stubbed values for methods can be provided with *value_methods*.
|
||||
#
|
||||
# ```
|
||||
# def_double(:dbl, foo: 42)
|
||||
#
|
||||
# specify do
|
||||
# dbl = new_double(:dbl, foo: 7)
|
||||
# expect(dbl.foo).to eq(7)
|
||||
# lazy = new_double(:lazy, foo: 123)
|
||||
# expect(lazy.foo).to eq(123)
|
||||
# end
|
||||
# ```
|
||||
private macro new_double(name = nil, **value_methods)
|
||||
{% # Find tuples with the same name.
|
||||
found_tuples = ::Spectator::DSL::Mocks::TYPES.select { |tuple| tuple[0] == name.id.symbolize }
|
||||
|
||||
# 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 %}
|
||||
|
||||
begin
|
||||
%double = {% if found_tuple %}
|
||||
{{found_tuple[2].id}}.new({{**value_methods}})
|
||||
{% else %}
|
||||
::Spectator::LazyDouble.new({{name}}, {{**value_methods}})
|
||||
{% end %}
|
||||
::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset })
|
||||
%double
|
||||
end
|
||||
end
|
||||
|
||||
# Instantiates a class double.
|
||||
#
|
||||
# The *name* is an optional identifier for the double.
|
||||
# If *name* was previously used to define a double (with `#def_double`),
|
||||
# then this macro returns a previously defined double class.
|
||||
# Otherwise, `LazyDouble` is created and returned.
|
||||
#
|
||||
# ```
|
||||
# def_double(:dbl) do
|
||||
# stub def self.foo
|
||||
# 42
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# specify do
|
||||
# dbl = class_double(:dbl)
|
||||
# expect(dbl.foo).to eq(42)
|
||||
# allow(dbl).to receive(:foo).and_return(123)
|
||||
# expect(dbl.foo).to eq(123)
|
||||
# end
|
||||
# ```
|
||||
private macro class_double(name = nil)
|
||||
{% # Find tuples with the same name.
|
||||
found_tuples = ::Spectator::DSL::Mocks::TYPES.select { |tuple| tuple[0] == name.id.symbolize }
|
||||
|
||||
# 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 %}
|
||||
|
||||
begin
|
||||
%double = {% if found_tuple %}
|
||||
{{found_tuple[2].id}}
|
||||
{% else %}
|
||||
::Spectator::LazyDouble
|
||||
{% end %}
|
||||
::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset })
|
||||
%double
|
||||
end
|
||||
end
|
||||
|
||||
# Defines or instantiates a double.
|
||||
#
|
||||
# When used inside of a method, instantiates a new double.
|
||||
# See `#new_double`.
|
||||
#
|
||||
# When used outside of a method, defines a new double.
|
||||
# See `#def_double`.
|
||||
macro double(name, **value_methods, &block)
|
||||
{% begin %}
|
||||
{% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{**value_methods}}) {{block}}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
# Instantiates a new double with predefined responses.
|
||||
#
|
||||
# This constructs a `LazyDouble`.
|
||||
#
|
||||
# ```
|
||||
# dbl = double(foo: 42)
|
||||
# expect(dbl.foo).to eq(42)
|
||||
# ```
|
||||
macro double(**value_methods)
|
||||
::Spectator::LazyDouble.new({{**value_methods}})
|
||||
end
|
||||
|
||||
# Defines a new mock type.
|
||||
#
|
||||
# This must be called from outside of a method (where classes can be defined).
|
||||
# *type* is the type being mocked.
|
||||
# The *name* is an optional identifier used in debug output.
|
||||
# Simple stubbed methods returning a value can be defined by *value_methods*.
|
||||
# More complex methods and stubs can be defined in a block passed to this macro.
|
||||
#
|
||||
# ```
|
||||
# abstract class MyClass
|
||||
# def foo
|
||||
# 42
|
||||
# end
|
||||
#
|
||||
# def bar
|
||||
# Time.utc
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# def_mock(MyClass, foo: 5) do
|
||||
# stub def bar
|
||||
# Time.utc(2022, 7, 10)
|
||||
# end
|
||||
# end
|
||||
# ```
|
||||
private macro def_mock(type, name = nil, **value_methods, &block)
|
||||
{% # Construct a unique type name for the mock by using the number of defined types.
|
||||
index = ::Spectator::DSL::Mocks::TYPES.size
|
||||
mock_type_name = "Mock#{index}".id
|
||||
|
||||
# Store information about how the mock is defined and its context.
|
||||
# This is important for constructing an instance of the mock later.
|
||||
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, mock_type_name.symbolize}
|
||||
|
||||
resolved = type.resolve
|
||||
base = if resolved.class?
|
||||
:class
|
||||
elsif resolved.struct?
|
||||
:struct
|
||||
else
|
||||
:module
|
||||
end %}
|
||||
|
||||
::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}}
|
||||
end
|
||||
|
||||
# Instantiates a mock.
|
||||
#
|
||||
# *type* is the type being mocked.
|
||||
#
|
||||
# Initial stubbed values for methods can be provided with *value_methods*.
|
||||
#
|
||||
# ```
|
||||
# abstract class MyClass
|
||||
# def foo
|
||||
# 42
|
||||
# end
|
||||
#
|
||||
# def bar
|
||||
# Time.utc
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# def_mock(MyClass, foo: 5) do
|
||||
# stub def bar
|
||||
# Time.utc(2022, 7, 10)
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# specify do
|
||||
# dbl = new_mock(MyClass, foo: 7)
|
||||
# expect(dbl.foo).to eq(7)
|
||||
# expect(dbl.bar).to eq(Time.utc(2022, 7, 10))
|
||||
# end
|
||||
# ```
|
||||
private macro new_mock(type, **value_methods)
|
||||
{% # Find tuples with the same name.
|
||||
found_tuples = ::Spectator::DSL::Mocks::TYPES.select { |tuple| tuple[0] == type.id.symbolize }
|
||||
|
||||
# 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 mock 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 %}
|
||||
|
||||
{% if found_tuple %}
|
||||
{{found_tuple[2].id}}.new.tap do |%mock|
|
||||
{% for key, value in value_methods %}
|
||||
%stub{key} = ::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}})
|
||||
%mock._spectator_define_stub(%stub{key})
|
||||
{% end %}
|
||||
::Spectator::Harness.current?.try(&.cleanup { %mock._spectator_reset })
|
||||
end
|
||||
{% elsif @def %}
|
||||
anonymous_double({{name ? name.stringify : "Anonymous"}}, {{stubs.double_splat}})
|
||||
{% else %}
|
||||
{% raise "Block required for double definition" %}
|
||||
{% raise "Type `#{type.id}` must be previously mocked before attempting to instantiate." %}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro define_double(type_name, name, **stubs, &block)
|
||||
# Defines or instantiates a mock.
|
||||
#
|
||||
# When used inside of a method, instantiates a new mock.
|
||||
# See `#new_mock`.
|
||||
#
|
||||
# When used outside of a method, defines a new mock.
|
||||
# See `#def_mock`.
|
||||
macro mock(type, **value_methods, &block)
|
||||
{% raise "First argument of `mock` must be a type name, not #{type}" unless type.is_a?(Path) || type.is_a?(Generic) || type.is_a?(Union) || type.is_a?(Metaclass) || type.is_a?(TypeNode) %}
|
||||
{% 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
|
||||
{% if @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{**value_methods}}) {{block}}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def anonymous_double(name = "Anonymous", **stubs)
|
||||
::Spectator::Mocks::AnonymousDouble.new(name, stubs)
|
||||
end
|
||||
# Instantiates a class mock.
|
||||
#
|
||||
# *type* is the type being mocked.
|
||||
#
|
||||
# Initial stubbed values for methods can be provided with *value_methods*.
|
||||
#
|
||||
# ```
|
||||
# class MyClass
|
||||
# def self.foo
|
||||
# 42
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# def_mock(MyClass)
|
||||
#
|
||||
# specify do
|
||||
# mock = class_mock(MyClass, foo: 5)
|
||||
# expect(dbl.foo).to eq(5)
|
||||
# allow(dbl).to receive(:foo).and_return(123)
|
||||
# expect(dbl.foo).to eq(123)
|
||||
# end
|
||||
# ```
|
||||
private macro class_mock(type, **value_methods)
|
||||
{% # Find tuples with the same name.
|
||||
found_tuples = ::Spectator::DSL::Mocks::TYPES.select { |tuple| tuple[0] == type.id.symbolize }
|
||||
|
||||
macro null_double(name, **stubs, &block)
|
||||
{% if name.is_a?(StringLiteral) || name.is_a?(StringInterpolation) %}
|
||||
anonymous_null_double({{name}}, {{stubs.double_splat}})
|
||||
# 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 mock 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 %}
|
||||
|
||||
{% if found_tuple %}
|
||||
begin
|
||||
%mock = {{found_tuple[2].id}}
|
||||
{% for key, value in value_methods %}
|
||||
%stub{key} = ::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}})
|
||||
%mock._spectator_define_stub(%stub{key})
|
||||
{% end %}
|
||||
::Spectator::Harness.current?.try(&.cleanup { %mock._spectator_reset })
|
||||
%mock
|
||||
end
|
||||
{% 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 %}
|
||||
{% raise "Type `#{type.id}` must be previously mocked before attempting to instantiate." %}
|
||||
{% 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)
|
||||
::Spectator::Mocks::AnonymousNullDouble.new(name, stubs)
|
||||
end
|
||||
|
||||
macro mock(name, &block)
|
||||
{% resolved = name.resolve
|
||||
type = if resolved < Reference
|
||||
# Injects mock (stub) functionality into an existing type.
|
||||
#
|
||||
# Warning: Using this will modify the type being tested.
|
||||
# This may result in different behavior between test and non-test code.
|
||||
#
|
||||
# This must be used instead of `def_mock` if a concrete struct is tested.
|
||||
# The `mock` method is not necessary to create a type with an injected mock.
|
||||
# The type can be used as it would normally instead.
|
||||
# However, stub information may leak between examples.
|
||||
#
|
||||
# The *type* is the name of the type to inject mock functionality into.
|
||||
# Initial stubbed values for methods can be provided with *value_methods*.
|
||||
#
|
||||
# ```
|
||||
# struct MyStruct
|
||||
# def foo
|
||||
# 42
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# inject_mock(MyStruct, foo: 5)
|
||||
#
|
||||
# specify do
|
||||
# inst = MyStruct.new
|
||||
# expect(inst.foo).to eq(5)
|
||||
# allow(inst).to receive(:foo).and_return(123)
|
||||
# expect(inst.foo).to eq(123)
|
||||
# end
|
||||
# ```
|
||||
macro inject_mock(type, **value_methods, &block)
|
||||
{% resolved = type.resolve
|
||||
base = if resolved.class?
|
||||
:class
|
||||
elsif resolved < Value
|
||||
elsif resolved.struct?
|
||||
:struct
|
||||
else
|
||||
:module
|
||||
end %}
|
||||
{% begin %}
|
||||
{{type.id}} ::{{resolved.id}}
|
||||
include ::Spectator::Mocks::Stubs
|
||||
end
|
||||
|
||||
{{block.body}}
|
||||
end
|
||||
{% end %}
|
||||
# Store information about how the mock is defined and its context.
|
||||
# This isn't required, but new_mock() should still find this type.
|
||||
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, resolved.name.symbolize} %}
|
||||
|
||||
::Spectator::Mock.inject({{base}}, {{type.id}}, {{**value_methods}}) {{block}}
|
||||
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 %}
|
||||
# Targets a stubbable object (such as a mock or double) for operations.
|
||||
#
|
||||
# The *stubbable* must be a `Stubbable` or `StubbedType`.
|
||||
# This method is expected to be followed up with `.to receive()`.
|
||||
#
|
||||
# ```
|
||||
# dbl = dbl(:foobar)
|
||||
# allow(dbl).to receive(:foo).and_return(42)
|
||||
# ```
|
||||
def allow(stubbable : Stubbable | StubbedType)
|
||||
::Spectator::Allow.new(stubbable)
|
||||
end
|
||||
|
||||
def allow(thing)
|
||||
::Spectator::Mocks::Allow.new(thing)
|
||||
# Helper method producing a compilation error when attempting to stub a non-stubbable object.
|
||||
#
|
||||
# Triggered in cases like this:
|
||||
# ```
|
||||
# allow(42).to receive(:to_s).and_return("123")
|
||||
# ```
|
||||
def allow(stubbable)
|
||||
{% raise "Target of `allow()` must be stubbable (mock or double)." %}
|
||||
end
|
||||
|
||||
def allow_any_instance_of(type : T.class) forall T
|
||||
::Spectator::Mocks::AllowAnyInstance(T).new
|
||||
end
|
||||
|
||||
macro expect_any_instance_of(type, _source_file = __FILE__, _source_line = __LINE__)
|
||||
%location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
|
||||
::Spectator::Mocks::ExpectAnyInstance({{type}}).new(%location)
|
||||
end
|
||||
|
||||
macro receive(method_name, _source_file = __FILE__, _source_line = __LINE__, &block)
|
||||
%location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
|
||||
# Begins the creation of a stub.
|
||||
#
|
||||
# The *method* is the name of the method being stubbed.
|
||||
# It should not define any parameters, it should be just the method name as a literal symbol or string.
|
||||
#
|
||||
# Alone, this method returns a `NullStub`, which allows a stubbable object to return nil from a method.
|
||||
# This macro is typically followed up with a method like `and_return` to change the stub's behavior.
|
||||
#
|
||||
# ```
|
||||
# dbl = dbl(:foobar)
|
||||
# allow(dbl).to receive(:foo)
|
||||
# expect(dbl.foo).to be_nil
|
||||
#
|
||||
# allow(dbl).to receive(:foo).and_return(42)
|
||||
# expect(dbl.foo).to eq(42)
|
||||
# ```
|
||||
#
|
||||
# A block can be provided to be run every time the stub is invoked.
|
||||
# The value returned by the block is returned by the stubbed method.
|
||||
#
|
||||
# ```
|
||||
# dbl = dbl(:foobar)
|
||||
# allow(dbl).to receive(:foo) { 42 }
|
||||
# expect(dbl.foo).to eq(42)
|
||||
# ```
|
||||
macro receive(method, *, _file = __FILE__, _line = __LINE__, &block)
|
||||
{% if block %}
|
||||
::Spectator::Mocks::ProcMethodStub.create({{method_name.id.symbolize}}, %location) { {{block.body}} }
|
||||
%proc = ->(%args : ::Spectator::AbstractArguments) {
|
||||
{% if !block.args.empty? %}{{*block.args}} = %args {% end %}
|
||||
{{block.body}}
|
||||
}
|
||||
::Spectator::ProcStub.new({{method.id.symbolize}}, %proc, location: ::Spectator::Location.new({{_file}}, {{_line}}))
|
||||
{% else %}
|
||||
::Spectator::Mocks::NilMethodStub.new({{method_name.id.symbolize}}, %location)
|
||||
::Spectator::NullStub.new({{method.id.symbolize}}, location: ::Spectator::Location.new({{_file}}, {{_line}}))
|
||||
{% end %}
|
||||
end
|
||||
|
||||
macro receive_messages(_source_file = __FILE__, _source_line = __LINE__, **stubs)
|
||||
%location = ::Spectator::Location.new({{_source_file}}, {{_source_line}})
|
||||
%stubs = [] of ::Spectator::Mocks::MethodStub
|
||||
{% for name, value in stubs %}
|
||||
%stubs << ::Spectator::Mocks::ValueMethodStub.new({{name.id.symbolize}}, %location, {{value}})
|
||||
{% end %}
|
||||
%stubs
|
||||
end
|
||||
|
||||
# Returns empty arguments.
|
||||
def no_args
|
||||
::Spectator::Mocks::NoArguments.new
|
||||
::Spectator::Arguments.none
|
||||
end
|
||||
|
||||
# Indicates any arguments can be used (no constraint).
|
||||
def any_args
|
||||
::Spectator::Arguments.any
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
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 finalize class allocate]
|
||||
end
|
||||
end
|
|
@ -99,6 +99,14 @@ module Spectator
|
|||
def initialize(@expression : Expression(T), @location : Location)
|
||||
end
|
||||
|
||||
# Asserts that a method is called some point before the example completes.
|
||||
@[AlwaysInline]
|
||||
def to(stub : Stub, message = nil) : Nil
|
||||
{% raise "The syntax `expect(...).to receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
|
||||
|
||||
to_eventually(stub, message)
|
||||
end
|
||||
|
||||
# Asserts that some criteria defined by the matcher is satisfied.
|
||||
# Allows a custom message to be used.
|
||||
def to(matcher, message = nil) : Nil
|
||||
|
@ -106,15 +114,18 @@ module Spectator
|
|||
report(match_data, message)
|
||||
end
|
||||
|
||||
def to(stub : Mocks::MethodStub) : Nil
|
||||
Harness.current.mocks.expect(@expression.value, stub)
|
||||
value = Value.new(stub.name, stub.to_s)
|
||||
matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?)
|
||||
to_eventually(matcher)
|
||||
# Asserts that a method is not called before the example completes.
|
||||
@[AlwaysInline]
|
||||
def to_not(stub : Stub, message = nil) : Nil
|
||||
{% raise "The syntax `expect(...).to_not receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
|
||||
|
||||
to_never(stub, message)
|
||||
end
|
||||
|
||||
def to(stubs : Enumerable(Mocks::MethodStub)) : Nil
|
||||
stubs.each { |stub| to(stub) }
|
||||
# :ditto:
|
||||
@[AlwaysInline]
|
||||
def not_to(stub : Stub, message = nil) : Nil
|
||||
to_not(stub, message)
|
||||
end
|
||||
|
||||
# Asserts that some criteria defined by the matcher is not satisfied.
|
||||
|
@ -131,22 +142,27 @@ module Spectator
|
|||
to_not(matcher, message)
|
||||
end
|
||||
|
||||
def to_not(stub : Mocks::MethodStub) : Nil
|
||||
value = Value.new(stub.name, stub.to_s)
|
||||
matcher = Matchers::ReceiveMatcher.new(value, stub.arguments?)
|
||||
to_never(matcher)
|
||||
end
|
||||
# Asserts that a method is called some point before the example completes.
|
||||
def to_eventually(stub : Stub, message = nil) : Nil
|
||||
{% raise "The syntax `expect(...).to_eventually receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
|
||||
|
||||
def not_to(stub : Mocks::MethodStub) : Nil
|
||||
to_not(stub)
|
||||
end
|
||||
stubbable = @expression.value
|
||||
unless stubbable._spectator_stub_for_method?(stub.method)
|
||||
# Add stub without an argument constraint.
|
||||
# Avoids confusing logic like this:
|
||||
# ```
|
||||
# expect(dbl).to receive(:foo).with(:bar)
|
||||
# dbl.foo(:baz)
|
||||
# ```
|
||||
# Notice that `#foo` is called, but with different arguments.
|
||||
# Normally this would raise an error, but that should be prevented.
|
||||
unconstrained_stub = stub.with(Arguments.any)
|
||||
stubbable._spectator_define_stub(unconstrained_stub)
|
||||
end
|
||||
|
||||
def to_not(stubs : Enumerable(Mocks::MethodStub)) : Nil
|
||||
stubs.each { |stub| to_not(stub) }
|
||||
end
|
||||
|
||||
def not_to(stubs : Enumerable(Mocks::MethodStub)) : Nil
|
||||
to_not(stubs)
|
||||
stubbable._spectator_define_stub(stub)
|
||||
matcher = Matchers::ReceiveMatcher.new(stub)
|
||||
to_eventually(matcher, message)
|
||||
end
|
||||
|
||||
# Asserts that some criteria defined by the matcher is eventually satisfied.
|
||||
|
@ -156,12 +172,33 @@ module Spectator
|
|||
Harness.current.defer { to(matcher, message) }
|
||||
end
|
||||
|
||||
def to_eventually(stub : Mocks::MethodStub) : Nil
|
||||
to(stub)
|
||||
# Asserts that a method is not called before the example completes.
|
||||
def to_never(stub : Stub, message = nil) : Nil
|
||||
{% raise "The syntax `expect(...).to_never receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
|
||||
|
||||
stubbable = @expression.value
|
||||
unless stubbable._spectator_stub_for_method?(stub.method)
|
||||
# Add stub without an argument constraint.
|
||||
# Avoids confusing logic like this:
|
||||
# ```
|
||||
# expect(dbl).to receive(:foo).with(:bar)
|
||||
# dbl.foo(:baz)
|
||||
# ```
|
||||
# Notice that `#foo` is called, but with different arguments.
|
||||
# Normally this would raise an error, but that should be prevented.
|
||||
unconstrained_stub = stub.with(Arguments.any)
|
||||
stubbable._spectator_define_stub(unconstrained_stub)
|
||||
end
|
||||
|
||||
stubbable._spectator_define_stub(stub)
|
||||
matcher = Matchers::ReceiveMatcher.new(stub)
|
||||
to_never(matcher, message)
|
||||
end
|
||||
|
||||
def to_eventually(stubs : Enumerable(Mocks::MethodStub)) : Nil
|
||||
to(stub)
|
||||
# :ditto:
|
||||
@[AlwaysInline]
|
||||
def never_to(stub : Stub, message = nil) : Nil
|
||||
to_never(stub, message)
|
||||
end
|
||||
|
||||
# Asserts that some criteria defined by the matcher is never satisfied.
|
||||
|
@ -177,14 +214,6 @@ module Spectator
|
|||
to_never(matcher, message)
|
||||
end
|
||||
|
||||
def to_never(stub : Mocks::MethodStub) : Nil
|
||||
to_not(stub)
|
||||
end
|
||||
|
||||
def to_never(stub : Enumerable(Mocks::MethodStub)) : Nil
|
||||
to_not(stub)
|
||||
end
|
||||
|
||||
# Reports an expectation to the current harness.
|
||||
private def report(match_data : Matchers::MatchData, message : String? | Proc(String) = nil)
|
||||
expectation = Expectation.new(match_data, @location, message)
|
||||
|
|
|
@ -3,7 +3,6 @@ require "./example_failed"
|
|||
require "./example_pending"
|
||||
require "./expectation"
|
||||
require "./expectation_failed"
|
||||
require "./mocks"
|
||||
require "./multiple_expectations_failed"
|
||||
require "./pass_result"
|
||||
require "./result"
|
||||
|
@ -39,8 +38,6 @@ module Spectator
|
|||
# Retrieves the harness for the current running example.
|
||||
class_getter! current : self
|
||||
|
||||
getter mocks = Mocks::Registry.new
|
||||
|
||||
# Wraps an example with a harness and runs test code.
|
||||
# A block provided to this method is considered to be the test code.
|
||||
# The value of `.current` is set to the harness for the duration of the test.
|
||||
|
@ -67,6 +64,7 @@ module Spectator
|
|||
end
|
||||
|
||||
@deferred = Deque(->).new
|
||||
@cleanup = Deque(->).new
|
||||
@expectations = [] of Expectation
|
||||
@aggregate : Array(Expectation)? = nil
|
||||
|
||||
|
@ -75,6 +73,7 @@ module Spectator
|
|||
def run : Result
|
||||
elapsed, error = capture { yield }
|
||||
elapsed2, error2 = capture { run_deferred }
|
||||
run_cleanup
|
||||
translate(elapsed + elapsed2, error || error2)
|
||||
end
|
||||
|
||||
|
@ -100,6 +99,13 @@ module Spectator
|
|||
@deferred << block
|
||||
end
|
||||
|
||||
# Stores a block of code to be executed at cleanup.
|
||||
# Cleanup is run after everything else, even deferred blocks.
|
||||
# Each cleanup step is wrapped in error handling so that one failure doesn't block the next ones.
|
||||
def cleanup(&block) : Nil
|
||||
@cleanup << block
|
||||
end
|
||||
|
||||
def aggregate_failures(label = nil)
|
||||
previous = @aggregate
|
||||
@aggregate = aggregate = [] of Expectation
|
||||
|
@ -134,7 +140,6 @@ module Spectator
|
|||
elapsed = Time.measure do
|
||||
error = catch { yield }
|
||||
end
|
||||
error = nil if error.is_a?(SystemExit) && mocks.exit_handled?
|
||||
{elapsed, error}
|
||||
end
|
||||
|
||||
|
@ -172,5 +177,16 @@ module Spectator
|
|||
Log.debug { "Running deferred operations" }
|
||||
@deferred.each(&.call)
|
||||
end
|
||||
|
||||
# Invokes all cleanup callbacks.
|
||||
# Each callback is wrapped with error handling.
|
||||
private def run_cleanup
|
||||
Log.debug { "Running cleanup" }
|
||||
@cleanup.each do |callback|
|
||||
callback.call
|
||||
rescue e
|
||||
Log.error(exception: e) { "Encountered error during cleanup" }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -51,6 +51,7 @@ require "./runner_events"
|
|||
require "./runner"
|
||||
require "./spec_builder"
|
||||
require "./spec"
|
||||
require "./system_exit"
|
||||
require "./tag_node_filter"
|
||||
require "./test_context"
|
||||
require "./value"
|
||||
|
|
|
@ -27,26 +27,32 @@ module Spectator::Matchers
|
|||
|
||||
# Actually performs the test against the expression.
|
||||
def match(actual : Expression(T)) : MatchData forall T
|
||||
before, after = change(actual)
|
||||
before = expression.value
|
||||
before_inspect = before.inspect
|
||||
|
||||
if expected_before == before
|
||||
if before == after
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} did not change #{expression.label}",
|
||||
before: before.inspect,
|
||||
after: after.inspect
|
||||
)
|
||||
elsif expected_after == after
|
||||
actual.value # Trigger block that might cause a change.
|
||||
after = expression.value
|
||||
after_inspect = after.inspect
|
||||
|
||||
if expected_after == after
|
||||
SuccessfulMatchData.new(match_data_description(actual))
|
||||
elsif before == after
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} did not change #{expression.label}",
|
||||
before: before_inspect,
|
||||
after: after_inspect
|
||||
)
|
||||
else
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} did not change #{expression.label} to #{expected_after.inspect}",
|
||||
before: before.inspect,
|
||||
after: after.inspect,
|
||||
before: before_inspect,
|
||||
after: after_inspect,
|
||||
expected: expected_after.inspect
|
||||
)
|
||||
end
|
||||
else
|
||||
FailedMatchData.new(match_data_description(actual), "#{expression.label} was not initially #{expected_before.inspect}",
|
||||
expected: expected_before.inspect,
|
||||
actual: before.inspect,
|
||||
actual: before_inspect,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -54,12 +60,18 @@ module Spectator::Matchers
|
|||
# Performs the test against the expression, but inverted.
|
||||
# A successful match with `#match` should normally fail for this method, and vice-versa.
|
||||
def negated_match(actual : Expression(T)) : MatchData forall T
|
||||
before, after = change(actual)
|
||||
before = expression.value
|
||||
before_inspect = before.inspect
|
||||
|
||||
if expected_before == before
|
||||
actual.value # Trigger block that might cause a change.
|
||||
after = expression.value
|
||||
after_inspect = after.inspect
|
||||
|
||||
if expected_after == after
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} changed #{expression.label} from #{expected_before.inspect} to #{expected_after.inspect}",
|
||||
before: before.inspect,
|
||||
after: after.inspect
|
||||
before: before_inspect,
|
||||
after: after_inspect
|
||||
)
|
||||
else
|
||||
SuccessfulMatchData.new(match_data_description(actual))
|
||||
|
@ -67,18 +79,9 @@ module Spectator::Matchers
|
|||
else
|
||||
FailedMatchData.new(match_data_description(actual), "#{expression.label} was not initially #{expected_before.inspect}",
|
||||
expected: expected_before.inspect,
|
||||
actual: before.inspect,
|
||||
actual: before_inspect,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Performs the change and reports the before and after values.
|
||||
private def change(actual)
|
||||
before = expression.value # Retrieve the expression's initial value.
|
||||
actual.value # Invoke action that might change the expression's value.
|
||||
after = expression.value # Retrieve the expression's value again.
|
||||
|
||||
{before, after}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,16 +25,24 @@ module Spectator::Matchers
|
|||
|
||||
# Actually performs the test against the expression.
|
||||
def match(actual : Expression(T)) : MatchData forall T
|
||||
before, after = change(actual)
|
||||
before = expression.value
|
||||
before_inspect = before.inspect
|
||||
|
||||
if expected != before
|
||||
FailedMatchData.new(match_data_description(actual), "#{expression.label} was not initially #{expected}",
|
||||
return FailedMatchData.new(match_data_description(actual), "#{expression.label} was not initially #{expected}",
|
||||
expected: expected.inspect,
|
||||
actual: before.inspect,
|
||||
actual: before_inspect,
|
||||
)
|
||||
elsif before == after
|
||||
end
|
||||
|
||||
actual.value # Trigger block that might change the expression.
|
||||
after = expression.value
|
||||
after_inspect = after.inspect
|
||||
|
||||
if expected == after
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} did not change #{expression.label} from #{expected}",
|
||||
before: before.inspect,
|
||||
after: after.inspect,
|
||||
before: before_inspect,
|
||||
after: after_inspect,
|
||||
expected: "Not #{expected.inspect}"
|
||||
)
|
||||
else
|
||||
|
@ -45,18 +53,26 @@ module Spectator::Matchers
|
|||
# Performs the test against the expression, but inverted.
|
||||
# A successful match with `#match` should normally fail for this method, and vice-versa.
|
||||
def negated_match(actual : Expression(T)) : MatchData forall T
|
||||
before, after = change(actual)
|
||||
before = expression.value
|
||||
before_inspect = before.inspect
|
||||
|
||||
if expected != before
|
||||
FailedMatchData.new(match_data_description(actual), "#{expression.label} was not initially #{expected}",
|
||||
return FailedMatchData.new(match_data_description(actual), "#{expression.label} was not initially #{expected}",
|
||||
expected: expected.inspect,
|
||||
actual: before.inspect
|
||||
actual: before_inspect
|
||||
)
|
||||
elsif before == after
|
||||
end
|
||||
|
||||
actual.value # Trigger block that might change the expression.
|
||||
after = expression.value
|
||||
after_inspect = after.inspect
|
||||
|
||||
if expected == after
|
||||
SuccessfulMatchData.new(match_data_description(actual))
|
||||
else
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} changed #{expression.label} from #{expected}",
|
||||
before: before.inspect,
|
||||
after: after.inspect,
|
||||
before: before_inspect,
|
||||
after: after_inspect,
|
||||
expected: expected.inspect
|
||||
)
|
||||
end
|
||||
|
@ -71,14 +87,5 @@ module Spectator::Matchers
|
|||
def by(amount)
|
||||
ChangeExactMatcher.new(@expression, @expected, @expected + value)
|
||||
end
|
||||
|
||||
# Performs the change and reports the before and after values.
|
||||
private def change(actual)
|
||||
before = expression.value # Retrieve the expression's initial value.
|
||||
actual.value # Invoke action that might change the expression's value.
|
||||
after = expression.value # Retrieve the expression's value again.
|
||||
|
||||
{before, after}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,19 +25,26 @@ module Spectator::Matchers
|
|||
|
||||
# Actually performs the test against the expression.
|
||||
def match(actual : Expression(T)) : MatchData forall T
|
||||
before, after = change(actual)
|
||||
if before == after
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} did not change #{expression.label}",
|
||||
before: before.inspect,
|
||||
after: after.inspect,
|
||||
expected: expected.inspect
|
||||
before = expression.value
|
||||
before_inspect = before.inspect
|
||||
|
||||
if expected == before
|
||||
return FailedMatchData.new(match_data_description(actual), "#{expression.label} was already #{expected}",
|
||||
before: before_inspect,
|
||||
expected: "Not #{expected.inspect}"
|
||||
)
|
||||
elsif expected == after
|
||||
end
|
||||
|
||||
actual.value # Trigger block that could change the expression.
|
||||
after = expression.value
|
||||
after_inspect = after.inspect
|
||||
|
||||
if expected == after
|
||||
SuccessfulMatchData.new(match_data_description(actual))
|
||||
else
|
||||
FailedMatchData.new(match_data_description(actual), "#{actual.label} did not change #{expression.label} to #{expected}",
|
||||
before: before.inspect,
|
||||
after: after.inspect,
|
||||
before: before_inspect,
|
||||
after: after_inspect,
|
||||
expected: expected.inspect
|
||||
)
|
||||
end
|
||||
|
@ -65,14 +72,5 @@ module Spectator::Matchers
|
|||
def by(amount)
|
||||
ChangeExactMatcher.new(@expression, @expected - amount, @expected)
|
||||
end
|
||||
|
||||
# Performs the change and reports the before and after values.
|
||||
private def change(actual)
|
||||
before = expression.value # Retrieve the expression's initial value.
|
||||
actual.value # Invoke action that might change the expression's value.
|
||||
after = expression.value # Retrieve the expression's value again.
|
||||
|
||||
{before, after}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,126 +1,77 @@
|
|||
require "../mocks"
|
||||
require "./standard_matcher"
|
||||
require "../mocks/stub"
|
||||
require "../mocks/stubbable"
|
||||
require "../mocks/stubbed_type"
|
||||
require "./matcher"
|
||||
|
||||
module Spectator::Matchers
|
||||
struct ReceiveMatcher < StandardMatcher
|
||||
alias Range = ::Range(Int32, Int32) | ::Range(Nil, Int32) | ::Range(Int32, Nil)
|
||||
|
||||
def initialize(@expected : Expression(Symbol), @args : Mocks::Arguments? = nil, @range : Range? = nil)
|
||||
# Matcher that inspects stubbable objects for method calls.
|
||||
struct ReceiveMatcher < Matcher
|
||||
# Creates the matcher for expecting a method call matching a stub.
|
||||
def initialize(@stub : Stub)
|
||||
end
|
||||
|
||||
# Creates the matcher for expecting a method call with any arguments.
|
||||
# *expected* is an expression evaluating to the method name as a symbol.
|
||||
def initialize(expected : Expression(Symbol))
|
||||
stub = NullStub.new(expected.value).as(Stub)
|
||||
initialize(stub)
|
||||
end
|
||||
|
||||
# Returns a new matcher with an argument constraint.
|
||||
def with(*args, **kwargs) : self
|
||||
stub = @stub.with(*args, **kwargs)
|
||||
self.class.new(stub)
|
||||
end
|
||||
|
||||
# Short text about the matcher's purpose.
|
||||
def description : String
|
||||
range = @range
|
||||
"received message #{@expected.label} #{range ? "#{humanize_range(range)} time(s)" : "At least once"} with #{@args || "any arguments"}"
|
||||
"received #{@stub}"
|
||||
end
|
||||
|
||||
def match?(actual : Expression(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)
|
||||
# Actually performs the test against the expression (value or block).
|
||||
def match(actual : Expression(Stubbable) | Expression(StubbedType)) : MatchData
|
||||
stubbed = actual.value
|
||||
if stubbed._spectator_calls.any? { |call| @stub === call }
|
||||
SuccessfulMatchData.new("#{actual.label} received #{@stub}")
|
||||
else
|
||||
!calls.empty?
|
||||
FailedMatchData.new("#{actual.label} received #{@stub}", "#{actual.label} did not receive #{@stub}", values(actual).to_a)
|
||||
end
|
||||
end
|
||||
|
||||
def failure_message(actual : Expression(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"}"
|
||||
# Actually performs the test against the expression (value or block).
|
||||
def match(actual : Expression(T)) : MatchData forall T
|
||||
{% raise "Value being checked with `have_received` must be stubbable (mock or double)." %}
|
||||
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"}"
|
||||
# Performs the test against the expression (value or block), but inverted.
|
||||
def negated_match(actual : Expression(Stubbable) | Expression(StubbedType)) : MatchData
|
||||
stubbed = actual.value
|
||||
if stubbed._spectator_calls.any? { |call| @stub === call }
|
||||
FailedMatchData.new("#{actual.label} did not receive #{@stub}", "#{actual.label} received #{@stub}", negated_values(actual).to_a)
|
||||
else
|
||||
SuccessfulMatchData.new("#{actual.label} did not receive #{@stub}")
|
||||
end
|
||||
end
|
||||
|
||||
def values(actual : Expression(T)) forall T
|
||||
calls = Harness.current.mocks.calls_for(actual.value, @expected.value)
|
||||
calls.select! { |call| @args === call.args } if @args
|
||||
range = @range
|
||||
# Performs the test against the expression (value or block), but inverted.
|
||||
def negated_match(actual : Expression(T)) : MatchData forall T
|
||||
{% raise "Value being checked with `have_received` must be stubbable (mock or double)." %}
|
||||
end
|
||||
|
||||
# Additional information about the match failure.
|
||||
private def values(actual : Expression(T)) forall T
|
||||
{
|
||||
expected: "#{range ? "#{humanize_range(range)} time(s)" : "At least once"} with #{@args || "any arguments"}",
|
||||
received: "#{calls.size} time(s)",
|
||||
expected: @stub.to_s,
|
||||
actual: actual.value._spectator_calls.join("\n"),
|
||||
}
|
||||
end
|
||||
|
||||
def negated_values(actual : Expression(T)) forall T
|
||||
calls = Harness.current.mocks.calls_for(actual.value, @expected.value)
|
||||
calls.select! { |call| @args === call.args } if @args
|
||||
range = @range
|
||||
# Additional information about the match failure when negated.
|
||||
private def negated_values(actual : Expression(T)) forall T
|
||||
{
|
||||
expected: "#{range ? "Not #{humanize_range(range)} time(s)" : "Never"} with #{@args || "any arguments"}",
|
||||
received: "#{calls.size} time(s)",
|
||||
expected: "Not #{@stub}",
|
||||
actual: actual.value._spectator_calls.join("\n"),
|
||||
}
|
||||
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 : Expression(Symbol), @args : Mocks::Arguments?, @range : Range)
|
||||
end
|
||||
|
||||
def times
|
||||
ReceiveMatcher.new(@expected, @args, @range)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,126 +0,0 @@
|
|||
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 : Expression(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 : Expression(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 : Expression(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 : Expression(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 : Expression(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 : Expression(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 : Expression(Symbol), @args : Mocks::Arguments?, @range : Range)
|
||||
end
|
||||
|
||||
def times
|
||||
ReceiveTypeMatcher.new(@expected, @args, @range)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,24 +1,7 @@
|
|||
require "./mocks/*"
|
||||
require "./system_exit"
|
||||
|
||||
module Spectator
|
||||
# Functionality for mocking existing types.
|
||||
module Mocks
|
||||
def self.run(context : TestContext)
|
||||
Registry.prepare(context)
|
||||
yield
|
||||
ensure
|
||||
Registry.reset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add default stub to `exit` method.
|
||||
# This captures *most* (technically not all) attempts to exit the process.
|
||||
# This stub only takes effect in example code.
|
||||
# It intercepts `exit` calls and raises `Spectator::SystemExit` to prevent killing the test.
|
||||
class ::Process
|
||||
include ::Spectator::Mocks::Stubs
|
||||
|
||||
stub self.exit(code) { raise ::Spectator::SystemExit.new }
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
module Spectator
|
||||
# Untyped arguments to a method call (message).
|
||||
abstract class AbstractArguments
|
||||
end
|
||||
end
|
|
@ -1,18 +1,26 @@
|
|||
require "./registry"
|
||||
require "./stub"
|
||||
require "./stubbable"
|
||||
require "./stubbed_type"
|
||||
|
||||
module Spectator::Mocks
|
||||
module Spectator
|
||||
# Targets a stubbable object.
|
||||
#
|
||||
# This type is effectively part of the mock DSL.
|
||||
# It is primarily used in the mock DSL to provide this syntax:
|
||||
# ```
|
||||
# allow(dbl).to
|
||||
# ```
|
||||
struct Allow(T)
|
||||
def initialize(@mock : T)
|
||||
# Creates the stub target.
|
||||
#
|
||||
# The *target* must be a kind of `Stubbable` or `StubbedType`.
|
||||
def initialize(@target : T)
|
||||
{% raise "Target of `allow` must be stubbable (a mock or double)." unless T < Stubbable || T < StubbedType %}
|
||||
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
|
||||
# Applies a stub to the targeted stubbable object.
|
||||
def to(stub : Stub) : Nil
|
||||
@target._spectator_define_stub(stub)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
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
|
|
@ -1,26 +0,0 @@
|
|||
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::MethodCall.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
|
|
@ -1,17 +0,0 @@
|
|||
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::MethodCall.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
|
|
@ -1,4 +1,91 @@
|
|||
module Spectator::Mocks
|
||||
abstract class Arguments
|
||||
require "./abstract_arguments"
|
||||
|
||||
module Spectator
|
||||
# Arguments used in a method call.
|
||||
#
|
||||
# Can also be used to match arguments.
|
||||
# *T* must be a `Tuple` type representing the positional arguments.
|
||||
# *NT* must be a `NamedTuple` type representing the keyword arguments.
|
||||
class Arguments(T, NT) < AbstractArguments
|
||||
# Positional arguments.
|
||||
getter args : T
|
||||
|
||||
# Keyword arguments.
|
||||
getter kwargs : NT
|
||||
|
||||
# Creates arguments used in a method call.
|
||||
def initialize(@args : T, @kwargs : NT)
|
||||
end
|
||||
|
||||
# Constructs an instance from literal arguments.
|
||||
def self.capture(*args, **kwargs) : AbstractArguments
|
||||
new(args, kwargs).as(AbstractArguments)
|
||||
end
|
||||
|
||||
# Instance of empty arguments.
|
||||
class_getter none : AbstractArguments = capture
|
||||
|
||||
# Returns unconstrained arguments.
|
||||
def self.any : AbstractArguments?
|
||||
nil.as(AbstractArguments?)
|
||||
end
|
||||
|
||||
# Returns the positional argument at the specified index.
|
||||
def [](index : Int)
|
||||
@args[index]
|
||||
end
|
||||
|
||||
# Returns the specified named argument.
|
||||
def [](arg : Symbol)
|
||||
@kwargs[arg]
|
||||
end
|
||||
|
||||
# Constructs a string representation of the arguments.
|
||||
def to_s(io : IO) : Nil
|
||||
return io << "(no args)" if args.empty? && kwargs.empty?
|
||||
|
||||
io << '('
|
||||
|
||||
# Add the positional arguments.
|
||||
args.each_with_index do |arg, i|
|
||||
io << ", " if i > 0
|
||||
arg.inspect(io)
|
||||
end
|
||||
|
||||
# Add the keyword arguments.
|
||||
size = args.size + kwargs.size
|
||||
kwargs.each_with_index(args.size) do |k, v, i|
|
||||
io << ", " if 0 < i < size
|
||||
io << k << ": "
|
||||
v.inspect(io)
|
||||
end
|
||||
|
||||
io << ')'
|
||||
end
|
||||
|
||||
# Checks if this set of arguments and another are equal.
|
||||
def ==(other : Arguments)
|
||||
args == other.args && kwargs == other.kwargs
|
||||
end
|
||||
|
||||
# Checks if another set of arguments matches this set of arguments.
|
||||
def ===(other : Arguments)
|
||||
args === other.args && named_tuples_match?(kwargs, other.kwargs)
|
||||
end
|
||||
|
||||
# Checks if two named tuples match.
|
||||
#
|
||||
# Uses case equality (`===`) on every key-value pair.
|
||||
# NamedTuple doesn't have a `===` operator, even though Tuple does.
|
||||
private def named_tuples_match?(a : NamedTuple, b : NamedTuple)
|
||||
return false if a.size != b.size
|
||||
|
||||
a.each do |k, v|
|
||||
return false unless b.has_key?(k)
|
||||
return false unless v === b[k]
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,130 +1,191 @@
|
|||
require "./generic_method_stub"
|
||||
require "./arguments"
|
||||
require "./method_call"
|
||||
require "./unexpected_message_error"
|
||||
require "./stub"
|
||||
require "./stubbable"
|
||||
require "./stubbed_name"
|
||||
require "./stubbed_type"
|
||||
require "./unexpected_message"
|
||||
require "./value_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
module Spectator
|
||||
# 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
|
||||
def initialize(@spectator_double_name : String, @null = false)
|
||||
end
|
||||
include Stubbable
|
||||
extend StubbedType
|
||||
|
||||
private macro stub(definition, *types, return_type = :undefined, &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
|
||||
Log = Spectator::Log.for(self)
|
||||
|
||||
# Possibly a weird compiler bug, but syntax like this:
|
||||
# stub instance.==(other) { true }
|
||||
# Results in `other` being the call `other { true }`.
|
||||
# This works around the issue by pulling out the block
|
||||
# and setting the parameter to just the name.
|
||||
if params.last.is_a?(Call)
|
||||
body = params.last.block
|
||||
params[-1] = params.last.name
|
||||
# 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
|
||||
#
|
||||
# # Default implementation with a dynamic value.
|
||||
# stub def meth4
|
||||
# Time.utc
|
||||
# end
|
||||
# end
|
||||
# ```
|
||||
macro define(type_name, name = nil, **value_methods, &block)
|
||||
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
|
||||
class {{type_name.id}} < {{@type.name}}
|
||||
{% for key, value in value_methods %}
|
||||
default_stub def {{key.id}}(*%args, **%kwargs)
|
||||
{{value}}
|
||||
end
|
||||
|
||||
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
|
||||
default_stub def {{key.id}}(*%args, **%kwargs, &)
|
||||
{{key.id}}
|
||||
end
|
||||
|
||||
# The unless is here because `||=` can't be used in macros @_@
|
||||
unless body
|
||||
body = definition.block.is_a?(Nop) ? block : definition.block
|
||||
end
|
||||
elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol
|
||||
name = definition.var
|
||||
params = [] of MacroId
|
||||
args = [] of MacroId
|
||||
body = block
|
||||
elsif definition.is_a?(SymbolLiteral) # stub :foo, arg : Int32
|
||||
name = definition.id
|
||||
named = false
|
||||
params = types
|
||||
if params.last.is_a?(Call)
|
||||
body = params.last.block
|
||||
params[-1] = params.last.name
|
||||
end
|
||||
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 = block unless body
|
||||
else
|
||||
raise "Unrecognized stub format"
|
||||
end
|
||||
%}
|
||||
|
||||
def {{name}}({{params.splat}}){% if return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
%args = ::Spectator::Mocks::GenericArguments.create({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.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 return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
%args = ::Spectator::Mocks::GenericArguments.create({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.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 return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
{% if body && !body.is_a?(Nop) %}
|
||||
{{body.body}}
|
||||
{% elsif return_type.is_a?(ArrayLiteral) %}
|
||||
{{return_type.splat}}
|
||||
{% else %}
|
||||
%args = ::Spectator::Mocks::GenericArguments.create({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.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 return_type != :undefined %}
|
||||
%x = uninitialized {{return_type}}
|
||||
{% elsif definition.is_a?(TypeDeclaration) %}
|
||||
%x = uninitialized {{definition.type}}
|
||||
{% else %}
|
||||
nil
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
{% if block %}{{block.body}}{% end %}
|
||||
end
|
||||
end
|
||||
|
||||
macro method_missing(call)
|
||||
args = ::Spectator::Mocks::GenericArguments.create({{call.args.splat}})
|
||||
call = ::Spectator::Mocks::MethodCall.new({{call.name.symbolize}}, args)
|
||||
::Spectator::Harness.current.mocks.record_call(self, call)
|
||||
@calls = [] of MethodCall
|
||||
|
||||
return self if @null
|
||||
return self if ::Spectator::Harness.current.mocks.expected?(self, call)
|
||||
private class_getter _spectator_stubs : Array(Stub) = [] of Stub
|
||||
|
||||
raise ::Spectator::Mocks::UnexpectedMessageError.new("#{self} received unexpected message {{call.name}}")
|
||||
class_getter _spectator_calls : Array(MethodCall) = [] of MethodCall
|
||||
|
||||
# Creates the double.
|
||||
#
|
||||
# An initial set of *stubs* can be provided.
|
||||
def initialize(@stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub)
|
||||
end
|
||||
|
||||
def to_s(io)
|
||||
io << "Double(" << @spectator_double_name << ')'
|
||||
# Creates the double.
|
||||
#
|
||||
# An initial set of stubs can be provided with *value_methods*.
|
||||
def initialize(**value_methods)
|
||||
@stubs = value_methods.map do |key, value|
|
||||
ValueStub.new(key, value).as(Stub)
|
||||
end
|
||||
end
|
||||
|
||||
# Compares against another object.
|
||||
#
|
||||
# Always returns false.
|
||||
# This method exists as a workaround to provide an alternative to `Object#same?`,
|
||||
# which only accepts a `Reference` or `Nil`.
|
||||
def same?(other) : Bool
|
||||
false
|
||||
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
|
||||
Log.debug { "Defined stub for #{_spectator_stubbed_name} #{stub}" }
|
||||
@stubs.unshift(stub)
|
||||
end
|
||||
|
||||
protected def _spectator_clear_stubs : Nil
|
||||
Log.debug { "Clearing stubs for #{_spectator_stubbed_name}" }
|
||||
@stubs.clear
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def _spectator_stub_for_method?(method : Symbol) : Bool
|
||||
@stubs.any? { |stub| stub.method == method }
|
||||
end
|
||||
|
||||
def _spectator_record_call(call : MethodCall) : Nil
|
||||
@calls << call
|
||||
end
|
||||
|
||||
def _spectator_calls
|
||||
@calls
|
||||
end
|
||||
|
||||
def _spectator_clear_calls : Nil
|
||||
@calls.clear
|
||||
end
|
||||
|
||||
# Returns the double's name formatted for user output.
|
||||
private def _spectator_stubbed_name : String
|
||||
{% if anno = @type.annotation(StubbedName) %}
|
||||
"#<Double " + {{(anno[0] || :Anonymous.id).stringify}} + ">"
|
||||
{% else %}
|
||||
"#<Double Anonymous>"
|
||||
{% end %}
|
||||
end
|
||||
|
||||
private def self._spectator_stubbed_name : String
|
||||
{% if anno = @type.annotation(StubbedName) %}
|
||||
"#<Class Double " + {{(anno[0] || :Anonymous.id).stringify}} + ">"
|
||||
{% else %}
|
||||
"#<Class Double Anonymous>"
|
||||
{% end %}
|
||||
end
|
||||
|
||||
private def _spectator_stub_fallback(call : MethodCall, &)
|
||||
Log.trace { "Fallback for #{call} - call original" }
|
||||
yield
|
||||
end
|
||||
|
||||
private def _spectator_stub_fallback(call : MethodCall, type, &)
|
||||
_spectator_stub_fallback(call) { yield }
|
||||
end
|
||||
|
||||
private def _spectator_abstract_stub_fallback(call : MethodCall)
|
||||
Log.info do
|
||||
break unless _spectator_stub_for_method?(call.method)
|
||||
|
||||
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
|
||||
end
|
||||
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
end
|
||||
|
||||
private def _spectator_abstract_stub_fallback(call : MethodCall, type)
|
||||
_spectator_abstract_stub_fallback(call)
|
||||
end
|
||||
|
||||
# "Hide" existing methods and methods from ancestors by overriding them.
|
||||
macro finished
|
||||
stub_type {{@type.name(generic_args: false)}}
|
||||
end
|
||||
|
||||
# Handle all methods but only respond to configured messages.
|
||||
# Raises an `UnexpectedMessage` error for non-configures messages.
|
||||
macro method_missing(call)
|
||||
Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" }
|
||||
args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %})
|
||||
call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args)
|
||||
_spectator_record_call(call)
|
||||
|
||||
raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors".
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
require "./generic_arguments"
|
||||
require "./generic_method_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
class ExceptionMethodStub(ExceptionType) < GenericMethodStub(Nil)
|
||||
def initialize(name, location, @exception : ExceptionType, args = nil)
|
||||
super(name, location, args)
|
||||
end
|
||||
|
||||
def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
raise @exception
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,49 @@
|
|||
require "../location"
|
||||
require "./arguments"
|
||||
require "./stub"
|
||||
require "./stub_modifiers"
|
||||
|
||||
module Spectator
|
||||
# Stub that raises an exception.
|
||||
class ExceptionStub < Stub
|
||||
# Invokes the stubbed implementation.
|
||||
def call(call : MethodCall) : Nil
|
||||
raise @exception
|
||||
end
|
||||
|
||||
# Returns a new stub with constrained arguments.
|
||||
def with_constraint(constraint : AbstractArguments?)
|
||||
self.class.new(method, @exception, constraint, location)
|
||||
end
|
||||
|
||||
# Creates the stub.
|
||||
def initialize(method : Symbol, @exception : Exception, constraint : AbstractArguments? = nil, location : Location? = nil)
|
||||
super(method, constraint, location)
|
||||
end
|
||||
end
|
||||
|
||||
module StubModifiers
|
||||
# Returns a new stub that raises an exception.
|
||||
def and_raise(exception : Exception)
|
||||
ExceptionStub.new(method, exception, constraint, location)
|
||||
end
|
||||
|
||||
# :ditto:
|
||||
def and_raise(exception_class : Exception.class, message)
|
||||
exception = exception_class.new(message)
|
||||
and_raise(exception)
|
||||
end
|
||||
|
||||
# :ditto:
|
||||
def and_raise(message : String? = nil)
|
||||
exception = Exception.new(message)
|
||||
and_raise(exception)
|
||||
end
|
||||
|
||||
# :ditto:
|
||||
def and_raise(exception_class : Exception.class)
|
||||
exception = exception_class.new
|
||||
and_raise(exception)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,23 +0,0 @@
|
|||
require "./registry"
|
||||
|
||||
module Spectator::Mocks
|
||||
struct ExpectAnyInstance(T)
|
||||
def initialize(@location : Location)
|
||||
end
|
||||
|
||||
def to(stub : MethodStub) : Nil
|
||||
actual = Value.new(T)
|
||||
Harness.current.mocks.expect(T, stub)
|
||||
value = Value.new(stub.name, stub.to_s)
|
||||
matcher = Matchers::ReceiveTypeMatcher.new(value, stub.arguments?)
|
||||
target = Expectation::Target.new(actual, @location)
|
||||
target.to_eventually(matcher)
|
||||
end
|
||||
|
||||
def to(stubs : Enumerable(MethodStub)) : Nil
|
||||
stubs.each do |stub|
|
||||
to(stub)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,41 +0,0 @@
|
|||
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 << ": "
|
||||
value.inspect(io)
|
||||
io << ", " if i < @opts.size - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,26 +0,0 @@
|
|||
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, location, @args : Arguments? = nil)
|
||||
super(name, location)
|
||||
end
|
||||
|
||||
def callable?(call : MethodCall) : Bool
|
||||
super && (!@args || @args === call.args)
|
||||
end
|
||||
|
||||
def to_s(io)
|
||||
super(io)
|
||||
if @args
|
||||
io << '(' << @args << ')'
|
||||
end
|
||||
io << " : " << ReturnType << " at " << @location
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,82 @@
|
|||
require "../label"
|
||||
require "./arguments"
|
||||
require "./double"
|
||||
require "./method_call"
|
||||
require "./stub"
|
||||
require "./value_stub"
|
||||
|
||||
module Spectator
|
||||
# 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`.
|
||||
#
|
||||
# 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.
|
||||
class LazyDouble(Messages) < Double
|
||||
@name : String?
|
||||
|
||||
def initialize(_spectator_double_name = nil, _spectator_double_stubs = [] of Stub, **@messages : **Messages)
|
||||
@name = _spectator_double_name.try &.inspect
|
||||
message_stubs = messages.map do |method, value|
|
||||
ValueStub.new(method, value)
|
||||
end
|
||||
|
||||
super(_spectator_double_stubs + message_stubs)
|
||||
end
|
||||
|
||||
# Returns the double's name formatted for user output.
|
||||
private def _spectator_stubbed_name : String
|
||||
"#<LazyDouble #{@name || "Anonymous"}>"
|
||||
end
|
||||
|
||||
private def _spectator_stub_fallback(call : MethodCall, &)
|
||||
if _spectator_stub_for_method?(call.method)
|
||||
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
else
|
||||
Log.trace { "Fallback for #{call} - call original" }
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
# Handles all messages.
|
||||
macro method_missing(call)
|
||||
Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" }
|
||||
|
||||
# Capture information about the call.
|
||||
%args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %})
|
||||
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
|
||||
_spectator_record_call(%call)
|
||||
|
||||
# Attempt to find a stub that satisfies the method call and arguments.
|
||||
if %stub = _spectator_find_stub(%call)
|
||||
# Cast the stub or return value to the expected type.
|
||||
# This is necessary to match the expected return type of the original message.
|
||||
\{% if Messages.keys.includes?({{call.name.symbolize}}) %}
|
||||
_spectator_cast_stub_value(%stub, %call, \{{Messages[{{call.name.symbolize}}.id]}})
|
||||
\{% else %}
|
||||
# A method that was not defined during initialization was stubbed.
|
||||
# Even though all stubs will have a #call method, the compiler doesn't seem to agree.
|
||||
# Assert that it will (this should never fail).
|
||||
raise TypeCastError.new("Stub has no value") unless %stub.responds_to?(:call)
|
||||
|
||||
# Return the value of the stub as-is.
|
||||
# Might want to give a warning here, as this may produce a "bloated" union of all known stub types.
|
||||
%stub.call(%call)
|
||||
\{% end %}
|
||||
else
|
||||
# A stub wasn't found, invoke the fallback logic.
|
||||
\{% if Messages.keys.includes?({{call.name.symbolize}}.id) %}
|
||||
# Pass along the message type and a block to invoke it.
|
||||
_spectator_stub_fallback(%call, \{{Messages[{{call.name.symbolize}}.id]}}) { @messages[{{call.name.symbolize}}] }
|
||||
\{% else %}
|
||||
# Message received for a methods that isn't stubbed nor defined when initialized.
|
||||
_spectator_abstract_stub_fallback(%call)
|
||||
nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors".
|
||||
\{% end %}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,13 +1,28 @@
|
|||
module Spectator::Mocks
|
||||
class MethodCall
|
||||
getter name : Symbol
|
||||
getter args : Arguments
|
||||
require "./abstract_arguments"
|
||||
require "./arguments"
|
||||
|
||||
def initialize(@name : Symbol, @args : Arguments)
|
||||
module Spectator
|
||||
# Stores information about a call to a method.
|
||||
class MethodCall
|
||||
# Name of the method.
|
||||
getter method : Symbol
|
||||
|
||||
# Arguments passed to the method.
|
||||
getter arguments : AbstractArguments
|
||||
|
||||
# Creates a method call.
|
||||
def initialize(@method : Symbol, @arguments : AbstractArguments = Arguments.none)
|
||||
end
|
||||
|
||||
def to_s(io)
|
||||
io << '#' << @name
|
||||
# Creates a method call by splatting its arguments.
|
||||
def self.capture(method : Symbol, *args, **kwargs)
|
||||
arguments = Arguments.new(args, kwargs).as(AbstractArguments)
|
||||
new(method, arguments)
|
||||
end
|
||||
|
||||
# Constructs a string containing the method name and arguments.
|
||||
def to_s(io : IO) : Nil
|
||||
io << '#' << method << arguments
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
require "../location"
|
||||
require "./method_call"
|
||||
|
||||
module Spectator::Mocks
|
||||
abstract class MethodStub
|
||||
getter name : Symbol
|
||||
|
||||
getter location : Location
|
||||
|
||||
def initialize(@name, @location)
|
||||
end
|
||||
|
||||
def callable?(call : MethodCall) : Bool
|
||||
@name == call.name
|
||||
end
|
||||
|
||||
abstract def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
|
||||
def call!(args : GenericArguments(T, NT), & : -> RT) : RT forall T, NT, RT
|
||||
value = call(args) { |*ya| yield *ya }
|
||||
if value.is_a?(RT)
|
||||
value.as(RT)
|
||||
elsif value.nil? && RT == NoReturn
|
||||
raise SystemExit.new
|
||||
else
|
||||
raise TypeCastError.new("The return type of stub #{self} doesn't match the expected type #{RT}")
|
||||
end
|
||||
end
|
||||
|
||||
def to_s(io)
|
||||
io << '#' << @name
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,189 @@
|
|||
require "./method_call"
|
||||
require "./mocked"
|
||||
require "./reference_mock_registry"
|
||||
require "./stub"
|
||||
require "./stubbed_name"
|
||||
require "./stubbed_type"
|
||||
require "./value_mock_registry"
|
||||
require "./value_stub"
|
||||
|
||||
module Spectator
|
||||
# Module providing macros for defining new mocks from existing types and injecting mock features into concrete types.
|
||||
module Mock
|
||||
# Defines a type that inherits from another, existing type.
|
||||
# The newly defined subtype will have mocking functionality.
|
||||
#
|
||||
# Methods from the inherited type will be overridden to support stubs.
|
||||
# *base* is the keyword for the type being defined - class or struct.
|
||||
# *mocked_type* is the original type to inherit from.
|
||||
# *type_name* is the name of the new type to define.
|
||||
# An optional *name* of the mock can be provided.
|
||||
# Any key-value pairs provided with *value_methods* are used as initial stubs for the mocked type.
|
||||
#
|
||||
# A block can be provided to define additional methods and stubs.
|
||||
# The block is evaluated in the context of the derived type.
|
||||
#
|
||||
# ```
|
||||
# Mock.define_subtype(:class, SomeType, meth1: 42, meth2: "foobar") do
|
||||
# stub abstract def meth3 : Symbol
|
||||
#
|
||||
# # Default implementation with a dynamic value.
|
||||
# stub def meth4
|
||||
# Time.utc
|
||||
# end
|
||||
# end
|
||||
# ```
|
||||
macro define_subtype(base, mocked_type, type_name, name = nil, **value_methods, &block)
|
||||
{% begin %}
|
||||
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
|
||||
{{base.id}} {{type_name.id}} < {{mocked_type.id}}
|
||||
include ::Spectator::Mocked
|
||||
extend ::Spectator::StubbedType
|
||||
|
||||
{% begin %}
|
||||
private getter(_spectator_stubs) do
|
||||
[
|
||||
{% for key, value in value_methods %}
|
||||
::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}}),
|
||||
{% end %}
|
||||
] of ::Spectator::Stub
|
||||
end
|
||||
{% end %}
|
||||
|
||||
def _spectator_clear_stubs : Nil
|
||||
@_spectator_stubs = nil
|
||||
end
|
||||
|
||||
private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub
|
||||
|
||||
class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
|
||||
|
||||
getter _spectator_calls = [] of ::Spectator::MethodCall
|
||||
|
||||
# Returns the mock's name formatted for user output.
|
||||
private def _spectator_stubbed_name : String
|
||||
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
|
||||
"#<Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
|
||||
\{% else %}
|
||||
"#<Mock {{mocked_type.id}}>"
|
||||
\{% end %}
|
||||
end
|
||||
|
||||
private def self._spectator_stubbed_name : String
|
||||
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
|
||||
"#<Class Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
|
||||
\{% else %}
|
||||
"#<Class Mock {{mocked_type.id}}>"
|
||||
\{% end %}
|
||||
end
|
||||
|
||||
macro finished
|
||||
stub_type {{mocked_type.id}}
|
||||
|
||||
{% if block %}{{block.body}}{% end %}
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
# Injects mock functionality into an existing type.
|
||||
#
|
||||
# Generally this method of mocking should be avoiding.
|
||||
# It modifies types being tested, the mock functionality won't exist outside of tests.
|
||||
# This option should only be used when sub-types are not possible (e.g. concrete struct).
|
||||
#
|
||||
# Methods in the type will be overridden to support stubs.
|
||||
# The original method functionality will still be accessible, but pass through mock code first.
|
||||
# *base* is the keyword for the type being defined - class or struct.
|
||||
# *type_name* is the name of the type to inject mock functionality into.
|
||||
# An optional *name* of the mock can be provided.
|
||||
# Any key-value pairs provided with *value_methods* are used as initial stubs for the mocked type.
|
||||
#
|
||||
# A block can be provided to define additional methods and stubs.
|
||||
# The block is evaluated in the context of the derived type.
|
||||
#
|
||||
# ```
|
||||
# Mock.inject(:struct, SomeType, meth1: 42, meth2: "foobar") do
|
||||
# stub abstract def meth3 : Symbol
|
||||
#
|
||||
# # Default implementation with a dynamic value.
|
||||
# stub def meth4
|
||||
# Time.utc
|
||||
# end
|
||||
# end
|
||||
# ```
|
||||
macro inject(base, type_name, name = nil, **value_methods, &block)
|
||||
{% begin %}
|
||||
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
|
||||
{{base.id}} {{type_name.id}}
|
||||
include ::Spectator::Mocked
|
||||
extend ::Spectator::StubbedType
|
||||
|
||||
{% if base == :class %}
|
||||
@@_spectator_mock_registry = ::Spectator::ReferenceMockRegistry.new
|
||||
{% elsif base == :struct %}
|
||||
@@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new
|
||||
{% else %}
|
||||
{% raise "Unsupported base type #{base} for injecting mock" %}
|
||||
{% end %}
|
||||
|
||||
private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub
|
||||
|
||||
class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
|
||||
|
||||
private def _spectator_stubs
|
||||
entry = @@_spectator_mock_registry.fetch(self) do
|
||||
_spectator_default_stubs
|
||||
end
|
||||
entry.stubs
|
||||
end
|
||||
|
||||
def _spectator_clear_stubs : Nil
|
||||
@@_spectator_mock_registry.delete(self)
|
||||
end
|
||||
|
||||
def _spectator_calls
|
||||
entry = @@_spectator_mock_registry.fetch(self) do
|
||||
_spectator_default_stubs
|
||||
end
|
||||
entry.calls
|
||||
end
|
||||
|
||||
private def _spectator_default_stubs
|
||||
{% begin %}
|
||||
[
|
||||
{% for key, value in value_methods %}
|
||||
::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}}),
|
||||
{% end %}
|
||||
] of ::Spectator::Stub
|
||||
{% end %}
|
||||
end
|
||||
|
||||
# Returns the mock's name formatted for user output.
|
||||
private def _spectator_stubbed_name : String
|
||||
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
|
||||
"#<Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
|
||||
\{% else %}
|
||||
"#<Mock {{type_name.id}}>"
|
||||
\{% end %}
|
||||
end
|
||||
|
||||
# Returns the mock's name formatted for user output.
|
||||
private def self._spectator_stubbed_name : String
|
||||
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
|
||||
"#<Class Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
|
||||
\{% else %}
|
||||
"#<Class Mock {{type_name.id}}>"
|
||||
\{% end %}
|
||||
end
|
||||
|
||||
macro finished
|
||||
stub_type {{type_name.id}}
|
||||
|
||||
{% if block %}{{block.body}}{% end %}
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
require "./method_call"
|
||||
require "./stub"
|
||||
|
||||
module Spectator
|
||||
# Stubs and calls for a mock.
|
||||
private struct MockRegistryEntry
|
||||
# Retrieves all stubs defined for a mock.
|
||||
property stubs = [] of Stub
|
||||
|
||||
# Retrieves all calls to stubbed methods.
|
||||
getter calls = [] of MethodCall
|
||||
end
|
||||
end
|
|
@ -0,0 +1,123 @@
|
|||
require "./method_call"
|
||||
require "./stub"
|
||||
require "./stubbable"
|
||||
require "./unexpected_message"
|
||||
|
||||
module Spectator
|
||||
# Mix-in used for mocked types.
|
||||
#
|
||||
# Bridges functionality between mocks and stubs
|
||||
# Implements the abstracts methods from `Stubbable`.
|
||||
#
|
||||
# Types including this module will need to implement `#_spectator_stubs`.
|
||||
# It should return a mutable list of stubs.
|
||||
# This is used to store the stubs for the mocked type.
|
||||
#
|
||||
# Additionally, the `#_spectator_calls` (getter with no arguments) must be implemented.
|
||||
# It should return a mutable list of method calls.
|
||||
# This is used to store the calls to stubs for the mocked type.
|
||||
module Mocked
|
||||
include Stubbable
|
||||
|
||||
# Retrieves an mutable collection of stubs.
|
||||
abstract def _spectator_stubs
|
||||
|
||||
def _spectator_define_stub(stub : ::Spectator::Stub) : Nil
|
||||
_spectator_stubs.unshift(stub)
|
||||
end
|
||||
|
||||
def _spectator_clear_stubs : Nil
|
||||
_spectator_stubs.clear
|
||||
end
|
||||
|
||||
private def _spectator_find_stub(call : ::Spectator::MethodCall) : ::Spectator::Stub?
|
||||
_spectator_stubs.find &.===(call)
|
||||
end
|
||||
|
||||
def _spectator_stub_for_method?(method : Symbol) : Bool
|
||||
_spectator_stubs.any? { |stub| stub.method == method }
|
||||
end
|
||||
|
||||
def _spectator_record_call(call : MethodCall) : Nil
|
||||
_spectator_calls << call
|
||||
end
|
||||
|
||||
def _spectator_calls(method : Symbol) : Enumerable(MethodCall)
|
||||
_spectator_calls.select { |call| call.method == method }
|
||||
end
|
||||
|
||||
def _spectator_clear_calls : Nil
|
||||
_spectator_calls.clear
|
||||
end
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# The received message is captured in *call*.
|
||||
# Yield to call the original method's implementation.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
def _spectator_stub_fallback(call : MethodCall, &)
|
||||
if _spectator_stub_for_method?(call.method)
|
||||
Spectator::Log.info do # FIXME: Don't log to top-level Spectator logger (use mock or double logger).
|
||||
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
|
||||
end
|
||||
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
end
|
||||
|
||||
yield # Uninteresting message, allow through.
|
||||
end
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# The received message is captured in *call*.
|
||||
# The expected return type is provided by *type*.
|
||||
# Yield to call the original method's implementation.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
def _spectator_stub_fallback(call : MethodCall, type, &)
|
||||
value = _spectator_stub_fallback(call) { yield }
|
||||
|
||||
begin
|
||||
type.cast(value)
|
||||
rescue TypeCastError
|
||||
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `#{value.inspect}`, but returned type must be `#{type}`.")
|
||||
end
|
||||
end
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# This is similar to `#_spectator_stub_fallback`,
|
||||
# but called when the original (un-stubbed) method isn't available.
|
||||
# The received message is captured in *call*.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
def _spectator_abstract_stub_fallback(call : MethodCall)
|
||||
Spectator::Log.info do # FIXME: Don't log to top-level Spectator logger (use mock or double logger).
|
||||
break unless _spectator_stub_for_method?(call.method)
|
||||
|
||||
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
|
||||
end
|
||||
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
end
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# This is similar to `#_spectator_stub_fallback`,
|
||||
# but called when the original (un-stubbed) method isn't available.
|
||||
# The received message is captured in *call*.
|
||||
# The expected return type is provided by *type*.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
def _spectator_abstract_stub_fallback(call : MethodCall, type)
|
||||
value = _spectator_abstract_stub_fallback(call)
|
||||
|
||||
begin
|
||||
type.cast(value)
|
||||
rescue TypeCastError
|
||||
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `#{value.inspect}`, but returned type must be `#{type}`.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,19 +0,0 @@
|
|||
require "./generic_arguments"
|
||||
require "./generic_method_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
class MultiValueMethodStub(ReturnType) < GenericMethodStub(ReturnType)
|
||||
@index = 0
|
||||
|
||||
def initialize(name, location, @values : ReturnType, args = nil)
|
||||
super(name, location, args)
|
||||
raise ArgumentError.new("Values must have at least one item") if @values.size < 1
|
||||
end
|
||||
|
||||
def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
value = @values[@index]
|
||||
@index += 1 if @index < @values.size - 1
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,35 @@
|
|||
require "../location"
|
||||
require "./arguments"
|
||||
require "./stub_modifiers"
|
||||
require "./typed_stub"
|
||||
|
||||
module Spectator
|
||||
# Stub that responds with a multiple values in succession.
|
||||
class MultiValueStub(T) < TypedStub(T)
|
||||
# Invokes the stubbed implementation.
|
||||
def call(call : MethodCall) : T
|
||||
if @values.size == 1
|
||||
@values.first
|
||||
else
|
||||
@values.shift
|
||||
end
|
||||
end
|
||||
|
||||
# Returns a new stub with constrained arguments.
|
||||
def with_constraint(constraint : AbstractArguments?)
|
||||
self.class.new(method, @values, constraint, location)
|
||||
end
|
||||
|
||||
# Creates the stub.
|
||||
def initialize(method : Symbol, @values : Array(T), constraint : AbstractArguments? = nil, location : Location? = nil)
|
||||
super(method, constraint, location)
|
||||
end
|
||||
end
|
||||
|
||||
module StubModifiers
|
||||
# Returns a new stub that returns multiple values in succession.
|
||||
def and_return(value, *values)
|
||||
MultiValueStub.new(method, [value, *values], constraint, location)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,52 +0,0 @@
|
|||
require "./generic_arguments"
|
||||
require "./generic_method_stub"
|
||||
require "./value_method_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
class NilMethodStub < GenericMethodStub(Nil)
|
||||
def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
nil
|
||||
end
|
||||
|
||||
def and_return
|
||||
self
|
||||
end
|
||||
|
||||
def and_return(value)
|
||||
ValueMethodStub.new(@name, @location, value, @args)
|
||||
end
|
||||
|
||||
def and_return(*values)
|
||||
MultiValueMethodStub.new(@name, @location, values.to_a, @args)
|
||||
end
|
||||
|
||||
def and_raise(exception_type : Exception.class)
|
||||
ExceptionMethodStub.new(@name, @location, exception_type.new, @args)
|
||||
end
|
||||
|
||||
def and_raise(exception : Exception)
|
||||
ExceptionMethodStub.new(@name, @location, exception, @args)
|
||||
end
|
||||
|
||||
def and_raise(message : String)
|
||||
ExceptionMethodStub.new(@name, @location, Exception.new(message), @args)
|
||||
end
|
||||
|
||||
def and_raise(exception_type : Exception.class, *args) forall T
|
||||
ExceptionMethodStub.new(@name, @location, exception_type.new(*args), @args)
|
||||
end
|
||||
|
||||
def with(*args : *T, **opts : **NT) forall T, NT
|
||||
args = GenericArguments.new(args, opts)
|
||||
NilMethodStub.new(@name, @location, args)
|
||||
end
|
||||
|
||||
def with(args : Arguments)
|
||||
NilMethodStub.new(@name, @location, @args)
|
||||
end
|
||||
|
||||
def and_call_original
|
||||
OriginalMethodStub.new(@name, @location, @args)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,21 +0,0 @@
|
|||
require "./arguments"
|
||||
|
||||
module Spectator::Mocks
|
||||
class NoArguments < Arguments
|
||||
def args
|
||||
Tuple.new
|
||||
end
|
||||
|
||||
def opts
|
||||
NamedTuple.new
|
||||
end
|
||||
|
||||
def ===(other : Arguments) : Bool
|
||||
other.args.empty? && other.opts.empty?
|
||||
end
|
||||
|
||||
def ===(other) : Bool
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,78 @@
|
|||
require "./double"
|
||||
require "./method_call"
|
||||
require "./stubbed_name"
|
||||
require "./unexpected_message"
|
||||
|
||||
module Spectator
|
||||
# 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 return self.
|
||||
# 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 NullDouble < Double
|
||||
# Returns the double's name formatted for user output.
|
||||
private def _spectator_stubbed_name : String
|
||||
{% if anno = @type.annotation(StubbedName) %}
|
||||
"#<NullDouble " + {{(anno[0] || :Anonymous.id).stringify}} + ">"
|
||||
{% else %}
|
||||
"#<NullDouble Anonymous>"
|
||||
{% end %}
|
||||
end
|
||||
|
||||
private def _spectator_abstract_stub_fallback(call : MethodCall)
|
||||
if _spectator_stub_for_method?(call.method)
|
||||
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
else
|
||||
Log.trace { "Fallback for #{call} - return self" }
|
||||
self
|
||||
end
|
||||
end
|
||||
|
||||
# Specialization that matches when the return type matches self.
|
||||
private def _spectator_abstract_stub_fallback(call : MethodCall, _type : self)
|
||||
_spectator_abstract_stub_fallback(call)
|
||||
end
|
||||
|
||||
# Default implementation that raises a `TypeCastError` since the return type isn't self.
|
||||
private def _spectator_abstract_stub_fallback(call : MethodCall, type)
|
||||
if _spectator_stub_for_method?(call.method)
|
||||
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
else
|
||||
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.")
|
||||
end
|
||||
end
|
||||
|
||||
# Handles all undefined messages.
|
||||
# Returns stubbed values if available, otherwise delegates to `#_spectator_abstract_stub_fallback`.
|
||||
macro method_missing(call)
|
||||
Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" }
|
||||
|
||||
# Capture information about the call.
|
||||
%args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %})
|
||||
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
|
||||
_spectator_record_call(%call)
|
||||
|
||||
# Attempt to find a stub that satisfies the method call and arguments.
|
||||
if %stub = _spectator_find_stub(%call)
|
||||
# A method that was not defined during initialization was stubbed.
|
||||
# Even though all stubs will have a #call method, the compiler doesn't seem to agree.
|
||||
# Assert that it will (this should never fail).
|
||||
raise TypeCastError.new("Stub has no value") unless %stub.responds_to?(:call)
|
||||
|
||||
# Return the value of the stub as-is.
|
||||
# Might want to give a warning here, as this may produce a "bloated" union of all known stub types.
|
||||
%stub.call(%call)
|
||||
else
|
||||
# A stub wasn't found, invoke the fallback logic.
|
||||
# Message received for a methods that isn't stubbed nor defined when initialized.
|
||||
_spectator_abstract_stub_fallback(%call)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
require "./typed_stub"
|
||||
require "./value_stub"
|
||||
|
||||
module Spectator
|
||||
# Stub that does nothing and returns nil.
|
||||
class NullStub < TypedStub(Nil)
|
||||
# Invokes the stubbed implementation.
|
||||
def call(call : MethodCall) : Nil
|
||||
end
|
||||
|
||||
# Returns a new stub with constrained arguments.
|
||||
def with_constraint(constraint : AbstractArguments?)
|
||||
self.class.new(method, constraint, location)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,10 +0,0 @@
|
|||
require "./generic_arguments"
|
||||
require "./generic_method_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
class OriginalMethodStub < GenericMethodStub(Nil)
|
||||
def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,18 +0,0 @@
|
|||
require "./arguments"
|
||||
require "./generic_method_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
class ProcMethodStub(ReturnType) < GenericMethodStub(ReturnType)
|
||||
def initialize(name, location, @proc : -> ReturnType, args = nil)
|
||||
super(name, location, args)
|
||||
end
|
||||
|
||||
def self.create(name, location, args = nil, &block : -> T) forall T
|
||||
ProcMethodStub.new(name, location, block, args)
|
||||
end
|
||||
|
||||
def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
@proc.call
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
require "../location"
|
||||
require "./arguments"
|
||||
require "./typed_stub"
|
||||
|
||||
module Spectator
|
||||
# Stub that responds with a value returned by calling a proc.
|
||||
class ProcStub(T) < TypedStub(T)
|
||||
# Invokes the stubbed implementation.
|
||||
def call(call : MethodCall) : T
|
||||
@proc.call(call.arguments)
|
||||
end
|
||||
|
||||
# Returns a new stub with constrained arguments.
|
||||
def with_constraint(constraint : AbstractArguments?)
|
||||
self.class.new(method, @proc, constraint, location)
|
||||
end
|
||||
|
||||
# Creates the stub.
|
||||
def initialize(method : Symbol, @proc : Proc(AbstractArguments, T), constraint : AbstractArguments? = nil, location : Location? = nil)
|
||||
super(method, constraint, location)
|
||||
end
|
||||
|
||||
# Creates the stub.
|
||||
def initialize(method : Symbol, constraint : AbstractArguments? = nil, location : Location? = nil, &block : Proc(AbstractArguments, T))
|
||||
initialize(method, block, constraint, location)
|
||||
end
|
||||
end
|
||||
|
||||
module StubModifiers
|
||||
# Returns a new stub with an argument constraint.
|
||||
def with(*args, **kwargs, &block : AbstractArguments -> T) forall T
|
||||
constraint = Arguments.new(args, kwargs).as(AbstractArguments)
|
||||
ProcStub(T).new(method, block, constraint, location)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
require "./mock_registry_entry"
|
||||
require "./stub"
|
||||
|
||||
module Spectator
|
||||
# Stores collections of stubs for mocked reference (class) types.
|
||||
#
|
||||
# This type is intended for all mocked reference types that have functionality "injected."
|
||||
# That is, the type itself has mock functionality bolted on.
|
||||
# Adding instance members should be avoided, for instance, it could mess up serialization.
|
||||
# This registry works around that by mapping mocks (via their memory address) to a collection of stubs.
|
||||
# Doing so prevents adding data to the mocked type.
|
||||
class ReferenceMockRegistry
|
||||
@entries : Hash(Void*, MockRegistryEntry)
|
||||
|
||||
# Creates an empty registry.
|
||||
def initialize
|
||||
@entries = Hash(Void*, MockRegistryEntry).new do |hash, key|
|
||||
hash[key] = MockRegistryEntry.new
|
||||
end
|
||||
end
|
||||
|
||||
# Retrieves all stubs defined for a mocked object.
|
||||
def [](object : Reference)
|
||||
key = Box.box(object)
|
||||
@entries[key]
|
||||
end
|
||||
|
||||
# Retrieves all stubs defined for a mocked object.
|
||||
#
|
||||
# Yields to the block on the first retrieval.
|
||||
# This allows a mock to populate the registry with initial stubs.
|
||||
def fetch(object : Reference, & : -> Array(Stub))
|
||||
key = Box.box(object)
|
||||
@entries.fetch(key) do
|
||||
entry = MockRegistryEntry.new
|
||||
entry.stubs = yield
|
||||
@entries[key] = entry
|
||||
end
|
||||
end
|
||||
|
||||
# Clears all stubs defined for a mocked object.
|
||||
def delete(object : Reference) : Nil
|
||||
key = Box.box(object)
|
||||
@entries.delete(key)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,26 +0,0 @@
|
|||
require "../anything"
|
||||
|
||||
module Spectator::Mocks
|
||||
module Reflection
|
||||
private macro _spectator_reflect
|
||||
{% for meth in @type.methods %}
|
||||
%location = ::Spectator::Location.new({{meth.filename}}, {{meth.line_number}})
|
||||
%args = ::Spectator::Mocks::GenericArguments.create(
|
||||
{% for arg, i in meth.args %}
|
||||
{% matcher = if arg.restriction
|
||||
if arg.restriction == :self.id
|
||||
@type.id
|
||||
else
|
||||
arg.restriction
|
||||
end
|
||||
else
|
||||
"::Spectator::Anything.new".id
|
||||
end %}
|
||||
{{matcher}}{% if i < meth.args.size %},{% end %}
|
||||
{% end %}
|
||||
)
|
||||
::Spectator::Mocks::TypeRegistry.add({{@type.id.stringify}}, {{meth.name.symbolize}}, %location, %args)
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,110 +0,0 @@
|
|||
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 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_stub(type : T.class, stub : MethodStub) : Nil forall T
|
||||
add_type_stub(type, 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 : MethodCall)
|
||||
fetch_instance(object).stubs.find(&.callable?(call)) ||
|
||||
fetch_type(object.class).stubs.find(&.callable?(call))
|
||||
end
|
||||
|
||||
def find_stub(type : T.class, call : MethodCall) forall T
|
||||
fetch_type(type).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 : MethodCall) : Bool
|
||||
fetch_instance(object).expected.any?(&.callable?(call)) ||
|
||||
fetch_type(object.class).expected.any?(&.callable?(call))
|
||||
end
|
||||
|
||||
def exit_handled? : Bool
|
||||
# Lazily check if an `exit` method was called and it was expected.
|
||||
# This is okay since an `expect().to receive(:exit)` should check the details of the call.
|
||||
(@entries.any? { |_key, entry| entry.expected.any? { |stub| stub.name == :exit } } ||
|
||||
@all_instances.any? { |_key, entry| entry.expected.any? { |stub| stub.name == :exit } }) &&
|
||||
@entries.any? { |_key, entry| entry.calls.any? { |call| call.name == :exit } }
|
||||
end
|
||||
|
||||
def expect(object, stub : MethodStub) : Nil
|
||||
entry = fetch_instance(object)
|
||||
entry.expected.add(stub)
|
||||
entry.stubs.unshift(stub)
|
||||
end
|
||||
|
||||
def expect(type : T.class, stub : MethodStub) : Nil forall T
|
||||
entry = fetch_type(type)
|
||||
entry.expected.add(stub)
|
||||
entry.stubs.unshift(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
|
|
@ -0,0 +1,38 @@
|
|||
require "./abstract_arguments"
|
||||
require "./arguments"
|
||||
require "./method_call"
|
||||
require "./stub_modifiers"
|
||||
|
||||
module Spectator
|
||||
# Untyped response to a method call (message).
|
||||
abstract class Stub
|
||||
include StubModifiers
|
||||
|
||||
# Name of the method this stub is for.
|
||||
getter method : Symbol
|
||||
|
||||
# Arguments the method must have been called with to provide this response.
|
||||
# Is nil when there's no constraint - only the method name must match.
|
||||
getter constraint : AbstractArguments?
|
||||
|
||||
# Location the stub was defined.
|
||||
getter location : Location?
|
||||
|
||||
# Creates the base of the stub.
|
||||
def initialize(@method : Symbol, @constraint : AbstractArguments? = nil, @location : Location? = nil)
|
||||
end
|
||||
|
||||
# Checks if a method call should receive the response from this stub.
|
||||
def ===(call : MethodCall)
|
||||
return false if method != call.method
|
||||
return true unless constraint = @constraint
|
||||
|
||||
constraint === call.arguments
|
||||
end
|
||||
|
||||
# String representation of the stub, formatted as a method call.
|
||||
def to_s(io : IO) : Nil
|
||||
io << "#" << method << (constraint || "(any args)")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,21 @@
|
|||
require "./arguments"
|
||||
|
||||
module Spectator
|
||||
# Mixin intended for `Stub` to return new, modified stubs.
|
||||
module StubModifiers
|
||||
# Returns a new stub of the same type with constrained arguments.
|
||||
abstract def with_constraint(constraint : AbstractArguments?)
|
||||
|
||||
# :ditto:
|
||||
@[AlwaysInline]
|
||||
def with(constraint : AbstractArguments?)
|
||||
with_constraint(constraint)
|
||||
end
|
||||
|
||||
# :ditto:
|
||||
def with(*args, **kwargs)
|
||||
constraint = Arguments.new(args, kwargs).as(AbstractArguments)
|
||||
self.with_constraint(constraint)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,419 @@
|
|||
require "../dsl/reserved"
|
||||
require "./arguments"
|
||||
require "./method_call"
|
||||
require "./stub"
|
||||
require "./typed_stub"
|
||||
|
||||
module Spectator
|
||||
# Mix-in for mocks and doubles providing method stubs.
|
||||
#
|
||||
# Macros in this module can override existing methods.
|
||||
# Stubbed methods will look for stubs to evaluate in place of their original functionality.
|
||||
# The primary macro of interest is `#stub`.
|
||||
# The macros are intended to be called from within the type being stubbed.
|
||||
#
|
||||
# Types including this module must define `#_spectator_find_stub` and `#_spectator_stubbed_name`.
|
||||
# These are internal, reserved method names by Spectator, hence the `_spectator` prefix.
|
||||
# These methods can't (and shouldn't) be stubbed.
|
||||
module Stubbable
|
||||
# Attempts to find a stub that satisfies a method call.
|
||||
#
|
||||
# Returns a stub that matches the method *call*
|
||||
# or nil if no stubs satisfy it.
|
||||
abstract def _spectator_find_stub(call : MethodCall) : Stub?
|
||||
|
||||
# Utility method that looks for stubs for methods with the name specified.
|
||||
abstract def _spectator_stub_for_method?(method : Symbol) : Bool
|
||||
|
||||
# Defines a stub to change the behavior of a method.
|
||||
abstract def _spectator_define_stub(stub : Stub) : Nil
|
||||
|
||||
# Clears all previously defined stubs.
|
||||
abstract def _spectator_clear_stubs : Nil
|
||||
|
||||
# Saves a call that was made to a stubbed method.
|
||||
abstract def _spectator_record_call(call : MethodCall) : Nil
|
||||
|
||||
# Retrieves all previously saved calls.
|
||||
abstract def _spectator_calls
|
||||
|
||||
# Clears all previously saved calls.
|
||||
abstract def _spectator_clear_calls : Nil
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# The received message is captured in *call*.
|
||||
# Yield to call the original method's implementation.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
abstract def _spectator_stub_fallback(call : MethodCall, &)
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# The received message is captured in *call*.
|
||||
# The expected return type is provided by *type*.
|
||||
# Yield to call the original method's implementation.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
abstract def _spectator_stub_fallback(call : MethodCall, type, &)
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# This is similar to `#_spectator_stub_fallback`,
|
||||
# but called when the original (un-stubbed) method isn't available.
|
||||
# The received message is captured in *call*.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
abstract def _spectator_abstract_stub_fallback(call : MethodCall)
|
||||
|
||||
# Method called when a stub isn't found.
|
||||
#
|
||||
# This is similar to `#_spectator_stub_fallback`,
|
||||
# but called when the original (un-stubbed) method isn't available.
|
||||
# The received message is captured in *call*.
|
||||
# The expected return type is provided by *type*.
|
||||
# The stubbed method returns the value returned by this method.
|
||||
# This method can also raise an error if it's impossible to return something.
|
||||
abstract def _spectator_abstract_stub_fallback(call : MethodCall, type)
|
||||
|
||||
# Utility method returning the stubbed type's name formatted for user output.
|
||||
abstract def _spectator_stubbed_name : String
|
||||
|
||||
# Clears all previously defined calls and stubs.
|
||||
def _spectator_reset : Nil
|
||||
_spectator_clear_calls
|
||||
_spectator_clear_stubs
|
||||
end
|
||||
|
||||
# Redefines a method to accept stubs and provides a default response.
|
||||
#
|
||||
# The *method* must be a `Def`.
|
||||
# That is, a normal looking method definition should follow the `default_stub` keyword.
|
||||
#
|
||||
# ```
|
||||
# default_stub def stubbed_method
|
||||
# "foobar"
|
||||
# end
|
||||
# ```
|
||||
#
|
||||
# The method cannot be abstract, as this method requires a default (fallback) response if a stub isn't provided.
|
||||
#
|
||||
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
|
||||
# If no stub is found, then `#_spectator_stub_fallback` is called.
|
||||
# The block provided to `#_spectator_stub_fallback` will invoke the default response.
|
||||
# In other words, `#_spectator_stub_fallback` should yield if it's appropriate to return the default response.
|
||||
private macro default_stub(method)
|
||||
{% if method.is_a?(Def)
|
||||
visibility = method.visibility
|
||||
elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def)
|
||||
visibility = method.visibility
|
||||
method = method.exp
|
||||
else
|
||||
raise "`default_stub` requires a method definition"
|
||||
end %}
|
||||
{% raise "Cannot define a stub inside a method" if @def %}
|
||||
{% raise "Default stub cannot be an abstract method" if method.abstract? %}
|
||||
{% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %}
|
||||
|
||||
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
{{method.body}}
|
||||
end
|
||||
|
||||
{% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %}
|
||||
|
||||
{% # Reconstruct the method signature.
|
||||
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
|
||||
# This chunk of code must reconstruct the method signature exactly as it was originally.
|
||||
# If it doesn't match, it doesn't override the method and the stubbing won't work.
|
||||
%}
|
||||
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
|
||||
# Capture information about the call.
|
||||
%args = ::Spectator::Arguments.capture(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}{% end %}
|
||||
)
|
||||
%call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args)
|
||||
_spectator_record_call(%call)
|
||||
|
||||
# Attempt to find a stub that satisfies the method call and arguments.
|
||||
# Finding a suitable stub is delegated to the type including the `Stubbable` module.
|
||||
if %stub = _spectator_find_stub(%call)
|
||||
# Cast the stub or return value to the expected type.
|
||||
# This is necessary to match the expected return type of the original method.
|
||||
_spectator_cast_stub_value(%stub, %call, typeof({{original}}), {{method.return_type && method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)}})
|
||||
else
|
||||
# Delegate missing stub behavior to concrete type.
|
||||
_spectator_stub_fallback(%call, typeof({{original}})) do
|
||||
# Use the default response for the method.
|
||||
{{original}}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Redefines a method to require stubs.
|
||||
#
|
||||
# This macro is similar to `#default_stub` but requires that a stub is defined for the method if it's called.
|
||||
#
|
||||
# The *method* should be a `Def`.
|
||||
# That is, a normal looking method definition should follow the `stub` keyword.
|
||||
#
|
||||
# ```
|
||||
# abstract_stub def stubbed_method
|
||||
# "foobar"
|
||||
# end
|
||||
# ```
|
||||
#
|
||||
# The method being stubbed doesn't need to exist yet.
|
||||
# Its body of the method passed to this macro is ignored.
|
||||
# The method can be abstract.
|
||||
# It should have a return type annotation, otherwise the compiled return type will probably end up as a giant union.
|
||||
#
|
||||
# ```
|
||||
# abstract_stub abstract def stubbed_method : String
|
||||
# ```
|
||||
#
|
||||
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
|
||||
# If no stub is found, then `#_spectator_stub_fallback` or `#_spectator_abstract_stub_fallback` is called.
|
||||
private macro abstract_stub(method)
|
||||
{% if method.is_a?(Def)
|
||||
visibility = method.visibility
|
||||
elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def)
|
||||
visibility = method.visibility
|
||||
method = method.exp
|
||||
else
|
||||
raise "`abstract_stub` requires a method definition"
|
||||
end %}
|
||||
{% raise "Cannot define a stub inside a method" if @def %}
|
||||
{% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %}
|
||||
|
||||
{% # The logic in this macro follows mostly the same logic from `#default_stub`.
|
||||
# The main difference is that this macro cannot access the original method being stubbed.
|
||||
# It might exist or it might not.
|
||||
# The method could also be abstract.
|
||||
# For all intents and purposes, this macro defines logic that doesn't depend on an existing method.
|
||||
%}
|
||||
|
||||
{% unless method.abstract? %}
|
||||
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
{{method.body}}
|
||||
end
|
||||
|
||||
{% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %}
|
||||
{% end %}
|
||||
|
||||
{% # Reconstruct the method signature.
|
||||
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
|
||||
# This chunk of code must reconstruct the method signature exactly as it was originally.
|
||||
# If it doesn't match, it doesn't override the method and the stubbing won't work.
|
||||
%}
|
||||
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
|
||||
# Capture information about the call.
|
||||
%args = ::Spectator::Arguments.capture(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}{% end %}
|
||||
)
|
||||
%call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args)
|
||||
_spectator_record_call(%call)
|
||||
|
||||
# Attempt to find a stub that satisfies the method call and arguments.
|
||||
# Finding a suitable stub is delegated to the type including the `Stubbable` module.
|
||||
if %stub = _spectator_find_stub(%call)
|
||||
# Cast the stub or return value to the expected type.
|
||||
# This is necessary to match the expected return type of the original method.
|
||||
{% if method.return_type %}
|
||||
# Return type restriction takes priority since it can be a superset of the original implementation.
|
||||
_spectator_cast_stub_value(%stub, %call, {{method.return_type}}, {{method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)}})
|
||||
{% elsif !method.abstract? %}
|
||||
# The method isn't abstract, infer the type it returns without calling it.
|
||||
_spectator_cast_stub_value(%stub, %call, typeof({{original}}))
|
||||
{% else %}
|
||||
# Stubbed method is abstract and there's no return type annotation.
|
||||
# The value of the stub could be returned as-is.
|
||||
# This may produce a "bloated" union of all known stub types,
|
||||
# and generally causes more annoying problems.
|
||||
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{%call} but cannot resolve the return type. Please add a return type restriction.")
|
||||
{% end %}
|
||||
else
|
||||
# A stub wasn't found, invoke the type-specific fallback logic.
|
||||
{% if method.return_type %}
|
||||
# Pass along just the return type annotation.
|
||||
_spectator_abstract_stub_fallback(%call, {{method.return_type}})
|
||||
{% elsif !method.abstract? %}
|
||||
_spectator_abstract_stub_fallback(%call, typeof({{original}}))
|
||||
{% else %}
|
||||
# Stubbed method is abstract and there's no type annotation.
|
||||
_spectator_abstract_stub_fallback(%call)
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Redefines a method to require stubs.
|
||||
#
|
||||
# The *method* can be a `Def`.
|
||||
# That is, a normal looking method definition should follow the `stub` keyword.
|
||||
#
|
||||
# ```
|
||||
# stub def stubbed_method
|
||||
# "foobar"
|
||||
# end
|
||||
# ```
|
||||
#
|
||||
# If the *method* is abstract, then a stub must be provided otherwise attempts to call the method will raise `UnexpectedMessage`.
|
||||
#
|
||||
# ```
|
||||
# stub abstract def stubbed_method
|
||||
# ```
|
||||
#
|
||||
# A `Call` can also be specified.
|
||||
# In this case all methods in the stubbed type and its ancestors that match the call's signature are stubbed.
|
||||
#
|
||||
# ```
|
||||
# stub stubbed_method(arg)
|
||||
# ```
|
||||
#
|
||||
# The method being stubbed doesn't need to exist yet.
|
||||
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
|
||||
# If no stub is found, then `#_spectator_stub_fallback` or `#_spectator_abstract_stub_fallback` is called.
|
||||
macro stub(method)
|
||||
{% raise "Cannot define a stub inside a method" if @def %}
|
||||
|
||||
{% if method.is_a?(Def) %}
|
||||
{% if method.abstract? %}abstract_stub{% else %}default_stub{% end %} {{method}}
|
||||
{% elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def) %}
|
||||
{% if method.exp.abstract? %}abstract_stub{% else %}default_stub{% end %} {{method}}
|
||||
{% elsif method.is_a?(Call) %}
|
||||
{% raise "Stub on `Call` unsupported." %}
|
||||
{% else %}
|
||||
{% raise "Unrecognized syntax for `stub` - #{method}" %}
|
||||
{% end %}
|
||||
end
|
||||
|
||||
# Redefines all methods and ones inherited from its parents and mixins to support stubs.
|
||||
private macro stub_type(type_name = @type)
|
||||
{% type = type_name.resolve
|
||||
# Reverse order of ancestors (there's currently no reverse method for ArrayLiteral).
|
||||
count = type.ancestors.size
|
||||
ancestors = type.ancestors.map_with_index { |_, i| type.ancestors[count - i - 1] } %}
|
||||
{% for ancestor in ancestors %}
|
||||
{% for method in ancestor.methods.reject do |meth|
|
||||
meth.name.starts_with?("_spectator") ||
|
||||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
|
||||
end %}
|
||||
{{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
|
||||
end
|
||||
{% end %}
|
||||
|
||||
{% for method in ancestor.class.methods.reject do |meth|
|
||||
meth.name.starts_with?("_spectator") ||
|
||||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
|
||||
end %}
|
||||
default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
|
||||
end
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
{% for method in type.methods.reject do |meth|
|
||||
meth.name.starts_with?("_spectator") ||
|
||||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
|
||||
end %}
|
||||
{{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
{% unless method.abstract? %}
|
||||
{% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
|
||||
end
|
||||
{% end %}
|
||||
{% end %}
|
||||
|
||||
{% for method in type.class.methods.reject do |meth|
|
||||
meth.name.starts_with?("_spectator") ||
|
||||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
|
||||
end %}
|
||||
default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}(
|
||||
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
|
||||
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
|
||||
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
|
||||
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
|
||||
{% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
# Utility macro for casting a stub (and it's return value) to the correct type.
|
||||
#
|
||||
# *stub* is the variable holding the stub.
|
||||
# *call* is the variable holding the captured method call.
|
||||
# *type* is the expected type to cast the value to.
|
||||
# *nullable* indicates whether *type* can be nil or not.
|
||||
private macro _spectator_cast_stub_value(stub, call, type, nullable = true)
|
||||
# Attempt to cast the stub to the method's return type.
|
||||
# If successful, return the value of the stub.
|
||||
# This is a common usage where the return type is simple and matches the stub type exactly.
|
||||
if %typed = {{stub}}.as?(::Spectator::TypedStub({{type}}))
|
||||
%typed.call({{call}})
|
||||
else
|
||||
# The stub couldn't be easily cast to match the return type.
|
||||
|
||||
# Even though all stubs will have a `#call` method, the compiler doesn't seem to agree.
|
||||
# Assert that it will (this should never fail).
|
||||
raise TypeCastError.new("Stub has no value") unless {{stub}}.responds_to?(:call)
|
||||
|
||||
# Get the value as-is from the stub.
|
||||
# This will be compiled as a union of all known stubbed value types.
|
||||
%value = {{stub}}.call({{call}})
|
||||
|
||||
# Attempt to cast the value to the method's return type.
|
||||
# If successful, which it will be in most cases, return it.
|
||||
# The caller will receive a properly typed value without unions or other side-effects.
|
||||
if %cast = %value.as?({{type}})
|
||||
%cast
|
||||
else
|
||||
{% if nullable %}
|
||||
nil
|
||||
{% else %}
|
||||
# The stubbed value was something else entirely and cannot be cast to the return type.
|
||||
# There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail.
|
||||
%type = begin
|
||||
%value.class.to_s
|
||||
rescue
|
||||
"<Unknown>"
|
||||
end
|
||||
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.")
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,64 @@
|
|||
require "./method_call"
|
||||
require "./stub"
|
||||
|
||||
module Spectator
|
||||
# Defines stubbing functionality at the type level (classes and structs).
|
||||
#
|
||||
# This module is intended to be extended when a type includes `Stubbable`.
|
||||
module StubbedType
|
||||
private abstract def _spectator_stubs : Array(Stub)
|
||||
|
||||
def _spectator_find_stub(call : MethodCall) : Stub?
|
||||
_spectator_stubs.find &.===(call)
|
||||
end
|
||||
|
||||
def _spectator_stub_for_method?(method : Symbol) : Bool
|
||||
_spectator_stubs.any? { |stub| stub.method == method }
|
||||
end
|
||||
|
||||
def _spectator_define_stub(stub : Stub) : Nil
|
||||
_spectator_stubs.unshift(stub)
|
||||
end
|
||||
|
||||
def _spectator_clear_stubs : Nil
|
||||
_spectator_stubs.clear
|
||||
end
|
||||
|
||||
def _spectator_record_call(call : MethodCall) : Nil
|
||||
_spectator_calls << call
|
||||
end
|
||||
|
||||
def _spectator_clear_calls : Nil
|
||||
_spectator_calls.clear
|
||||
end
|
||||
|
||||
# Clears all previously defined calls and stubs.
|
||||
def _spectator_reset : Nil
|
||||
_spectator_clear_calls
|
||||
_spectator_clear_stubs
|
||||
end
|
||||
|
||||
def _spectator_stub_fallback(call : MethodCall, &)
|
||||
Log.trace { "Fallback for #{call} - call original" }
|
||||
yield
|
||||
end
|
||||
|
||||
def _spectator_stub_fallback(call : MethodCall, type, &)
|
||||
_spectator_stub_fallback(call) { yield }
|
||||
end
|
||||
|
||||
def _spectator_abstract_stub_fallback(call : MethodCall)
|
||||
Log.info do
|
||||
break unless _spectator_stub_for_method?(call.method)
|
||||
|
||||
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
|
||||
end
|
||||
|
||||
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
|
||||
end
|
||||
|
||||
def _spectator_abstract_stub_fallback(call : MethodCall, type)
|
||||
_spectator_abstract_stub_fallback(call)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,155 +0,0 @@
|
|||
module Spectator::Mocks
|
||||
module Stubs
|
||||
private macro stub(definition, *types, _file = __FILE__, _line = __LINE__, return_type = :undefined, &block)
|
||||
{%
|
||||
receiver = nil
|
||||
name = nil
|
||||
params = nil
|
||||
args = nil
|
||||
body = nil
|
||||
block_arg = nil
|
||||
if definition.is_a?(Call) # stub foo { :bar }
|
||||
receiver = definition.receiver.id
|
||||
named = false
|
||||
name = definition.name.id
|
||||
params = definition.args
|
||||
block_arg = definition.block_arg
|
||||
if params.last.is_a?(Call)
|
||||
body = params.last.block
|
||||
params[-1] = params.last.name
|
||||
end
|
||||
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
|
||||
unless body
|
||||
body = definition.block.is_a?(Nop) ? block : definition.block
|
||||
end
|
||||
elsif definition.is_a?(TypeDeclaration) # stub foo : Symbol
|
||||
name = definition.var
|
||||
params = [] of MacroId
|
||||
args = [] of MacroId
|
||||
body = block
|
||||
elsif definition.is_a?(SymbolLiteral) # stub :foo, arg : Int32
|
||||
name = definition.id
|
||||
named = false
|
||||
params = types
|
||||
if params.last.is_a?(Call)
|
||||
body = params.last.block
|
||||
params[-1] = params.last.name
|
||||
end
|
||||
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 = block unless body
|
||||
else
|
||||
raise "Unrecognized stub format"
|
||||
end
|
||||
|
||||
t = @type
|
||||
receiver = if receiver == :self.id
|
||||
t = t.class
|
||||
"self."
|
||||
else
|
||||
""
|
||||
end.id
|
||||
original = if (name == :new.id && receiver == "self.".id) ||
|
||||
(t.superclass && t.superclass.has_method?(name) && !t.overrides?(t.superclass, name))
|
||||
:super
|
||||
elsif t.has_method?(name)
|
||||
:previous_def
|
||||
else
|
||||
name
|
||||
end.id
|
||||
fallback = if original == :super.id || original == :previous_def.id
|
||||
original
|
||||
else
|
||||
"::#{original}(#{args.splat})".id
|
||||
end
|
||||
%}
|
||||
|
||||
{% if body && !body.is_a?(Nop) %}
|
||||
def {{receiver}}%method({{params.splat}}){% if return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
{{body.body}}
|
||||
end
|
||||
{% elsif return_type.is_a?(ArrayLiteral) %}
|
||||
def {{receiver}}%method({{params.splat}}) : {{return_type.type}}
|
||||
{{return_type.splat}}
|
||||
end
|
||||
{% end %}
|
||||
|
||||
def {{receiver}}{{name}}({{params.splat}}){% if return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
if (%harness = ::Spectator::Harness.current?)
|
||||
%args = ::Spectator::Mocks::GenericArguments.create({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.new({{name.symbolize}}, %args)
|
||||
%harness.mocks.record_call(self, %call)
|
||||
if (%stub = %harness.mocks.find_stub(self, %call))
|
||||
return %stub.call!(%args) { {{fallback}} }
|
||||
end
|
||||
|
||||
{% if body && !body.is_a?(Nop) || return_type.is_a?(ArrayLiteral) %}
|
||||
%method({{args.splat}})
|
||||
{% else %}
|
||||
{{fallback}}
|
||||
{% end %}
|
||||
else
|
||||
{{fallback}}
|
||||
end
|
||||
end
|
||||
|
||||
{% if block_arg.is_a?(Call) %}
|
||||
def {{receiver}}{{name}}({{params.splat}}){% if return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
if (%harness = ::Spectator::Harness.current?)
|
||||
%args = ::Spectator::Mocks::GenericArguments.create({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.new({{name.symbolize}}, %args)
|
||||
%harness.mocks.record_call(self, %call)
|
||||
if (%stub = %harness.mocks.find_stub(self, %call))
|
||||
return %stub.call!(%args) { {{fallback}} { yield } }
|
||||
end
|
||||
|
||||
{% if body && !body.is_a?(Nop) || return_type.is_a?(ArrayLiteral) %}
|
||||
%method({{args.splat}}) { yield }
|
||||
{% else %}
|
||||
{{fallback}} do
|
||||
yield
|
||||
end
|
||||
{% end %}
|
||||
else
|
||||
{{fallback}} do
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{% else %}
|
||||
def {{receiver}}{{name}}({{params.splat}}){% if return_type.is_a?(ArrayLiteral) %} : {{return_type.type}}{% elsif return_type != :undefined %} : {{return_type.id}}{% elsif definition.is_a?(TypeDeclaration) %} : {{definition.type}}{% end %}
|
||||
if (%harness = ::Spectator::Harness.current?)
|
||||
%args = ::Spectator::Mocks::GenericArguments.create({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.new({{name.symbolize}}, %args)
|
||||
%harness.mocks.record_call(self, %call)
|
||||
if (%stub = %harness.mocks.find_stub(self, %call))
|
||||
return %stub.call!(%args) { {{fallback}} { |*%ya| yield *%ya } }
|
||||
end
|
||||
|
||||
{% if body && !body.is_a?(Nop) || return_type.is_a?(ArrayLiteral) %}
|
||||
%method({{args.splat}}) { |*%ya| yield *%ya }
|
||||
{% else %}
|
||||
{{fallback}} do |*%yield_args|
|
||||
yield *%yield_args
|
||||
end
|
||||
{% end %}
|
||||
else
|
||||
{{fallback}} do |*%yield_args|
|
||||
yield *%yield_args
|
||||
end
|
||||
end
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,25 +0,0 @@
|
|||
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, location : Location, 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, location, args)
|
||||
end
|
||||
|
||||
def exists?(type_name : String, call : MethodCall) : Bool
|
||||
key = {type_name, call.name}
|
||||
list = @@entries.fetch(key) { return false }
|
||||
list.any?(&.callable?(call))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
require "./method_call"
|
||||
require "./stub"
|
||||
|
||||
module Spectator
|
||||
# Abstract type of stub that identifies the type of value produced by a stub.
|
||||
#
|
||||
# *T* is the type produced by the stub.
|
||||
# How the stub produces this value is up to subclasses.
|
||||
abstract class TypedStub(T) < Stub
|
||||
# Invokes the stubbed implementation.
|
||||
abstract def call(call : MethodCall) : T
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
module Spectator
|
||||
# Exception raised by a mock or double when a message is received that have been.
|
||||
class UnexpectedMessage < Exception
|
||||
end
|
||||
end
|
|
@ -1,4 +0,0 @@
|
|||
module Spectator::Mocks
|
||||
class UnexpectedMessageError < Exception
|
||||
end
|
||||
end
|
|
@ -1,14 +0,0 @@
|
|||
require "./generic_arguments"
|
||||
require "./generic_method_stub"
|
||||
|
||||
module Spectator::Mocks
|
||||
class ValueMethodStub(ReturnType) < GenericMethodStub(ReturnType)
|
||||
def initialize(name, location, @value : ReturnType, args = nil)
|
||||
super(name, location, args)
|
||||
end
|
||||
|
||||
def call(args : GenericArguments(T, NT), & : -> RT) forall T, NT, RT
|
||||
@value
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,64 @@
|
|||
require "string_pool"
|
||||
require "./mock_registry_entry"
|
||||
require "./stub"
|
||||
|
||||
module Spectator
|
||||
# Stores collections of stubs for mocked value (struct) types.
|
||||
#
|
||||
# *T* is the type of value to track.
|
||||
#
|
||||
# This type is intended for all mocked struct types that have functionality "injected."
|
||||
# That is, the type itself has mock functionality bolted on.
|
||||
# Adding instance members should be avoided, for instance, it could mess up serialization.
|
||||
# This registry works around that by mapping mocks (via their raw memory content) to a collection of stubs.
|
||||
# Doing so prevents adding data to the mocked type.
|
||||
class ValueMockRegistry(T)
|
||||
@pool = StringPool.new # Used to de-dup values.
|
||||
@entries : Hash(String, MockRegistryEntry)
|
||||
|
||||
# Creates an empty registry.
|
||||
def initialize
|
||||
@entries = Hash(String, MockRegistryEntry).new do |hash, key|
|
||||
hash[key] = MockRegistryEntry.new
|
||||
end
|
||||
end
|
||||
|
||||
# Retrieves all stubs defined for a mocked object.
|
||||
def [](object : T)
|
||||
key = value_bytes(object)
|
||||
@entries[key]
|
||||
end
|
||||
|
||||
# Retrieves all stubs defined for a mocked object.
|
||||
#
|
||||
# Yields to the block on the first retrieval.
|
||||
# This allows a mock to populate the registry with initial stubs.
|
||||
def fetch(object : T, & : -> Array(Stub))
|
||||
key = value_bytes(object)
|
||||
@entries.fetch(key) do
|
||||
entry = MockRegistryEntry.new
|
||||
entry.stubs = yield
|
||||
@entries[key] = entry
|
||||
end
|
||||
end
|
||||
|
||||
# Clears all stubs defined for a mocked object.
|
||||
def delete(object : T) : Nil
|
||||
key = value_bytes(object)
|
||||
@entries.delete(key)
|
||||
end
|
||||
|
||||
# Extracts heap-managed bytes for a value.
|
||||
#
|
||||
# Strings are used because a string pool is used.
|
||||
# However, the strings are treated as an array of bytes.
|
||||
@[AlwaysInline]
|
||||
private def value_bytes(value : T) : String
|
||||
# Get slice pointing to the memory used by the value (does not allocate).
|
||||
bytes = Bytes.new(pointerof(value).as(UInt8*), sizeof(T), read_only: true)
|
||||
|
||||
# De-dup the value (may allocate).
|
||||
@pool.get(bytes)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,31 @@
|
|||
require "../location"
|
||||
require "./arguments"
|
||||
require "./stub_modifiers"
|
||||
require "./typed_stub"
|
||||
|
||||
module Spectator
|
||||
# Stub that responds with a static value.
|
||||
class ValueStub(T) < TypedStub(T)
|
||||
# Invokes the stubbed implementation.
|
||||
def call(call : MethodCall) : T
|
||||
@value
|
||||
end
|
||||
|
||||
# Returns a new stub with constrained arguments.
|
||||
def with_constraint(constraint : AbstractArguments?)
|
||||
self.class.new(method, @value, constraint, location)
|
||||
end
|
||||
|
||||
# Creates the stub.
|
||||
def initialize(method : Symbol, @value : T, constraint : AbstractArguments? = nil, location : Location? = nil)
|
||||
super(method, constraint, location)
|
||||
end
|
||||
end
|
||||
|
||||
module StubModifiers
|
||||
# Returns a new stub that returns a static value.
|
||||
def and_return(value)
|
||||
ValueStub.new(method, value, constraint, location)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,106 +0,0 @@
|
|||
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::MethodCall.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::MethodCall.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({{args.splat}})
|
||||
%call = ::Spectator::Mocks::MethodCall.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::MethodCall.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(" << T << ')'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,5 +1,25 @@
|
|||
module Spectator
|
||||
# Exception raised when `exit` is called and intercepted from a stub.
|
||||
# Indicates a call to exit the application was performed.
|
||||
class SystemExit < Exception
|
||||
# Status code passed to the exit call.
|
||||
getter status : Int32
|
||||
|
||||
# Creates the exception.
|
||||
def initialize(message : String? = nil, cause : Exception? = nil, @status : Int32 = 0)
|
||||
super(message, cause)
|
||||
end
|
||||
end
|
||||
|
||||
# Allow Spectator to exit normally when needed.
|
||||
private def self.exit(status = 0) : NoReturn
|
||||
::Crystal::System::Process.exit(status)
|
||||
end
|
||||
end
|
||||
|
||||
class Process
|
||||
# Replace the typically used exit method with a method that raises.
|
||||
# This allows tests to catch attempts to exit the application.
|
||||
def self.exit(status = 0) : NoReturn
|
||||
raise ::Spectator::SystemExit.new(status: status)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue