Merge branch 'mock-redesign' into 'master'

Overhaul mock system

See merge request arctic-fox/spectator!35
This commit is contained in:
Mike Miller 2022-07-13 05:43:53 +00:00
commit b65f53f105
98 changed files with 8113 additions and 1664 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

34
spec/docs/readme_spec.cr Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/**"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
module Spectator
# Untyped arguments to a method call (message).
abstract class AbstractArguments
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

189
src/spectator/mocks/mock.cr Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +0,0 @@
module Spectator::Mocks
class UnexpectedMessageError < Exception
end
end

View File

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

View File

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

View File

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

View File

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

View File

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