diff --git a/.gitignore b/.gitignore index c4166ba..f76b510 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ # Ignore JUnit output output.xml + +/test.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 88b37de..f2d8d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,54 @@ All notable changes to this project will be documented in this file. 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). -## [0.11.4] +## [0.12.1] - 2024-08-13 +### Fixed +- Fixed some global namespace issues with Crystal 1.13. [#57](https://github.com/icy-arctic-fox/spectator/pull/57) Thanks @GrantBirki ! +- Remove usage of deprecated double splat in macros. + +## [0.12.0] - 2024-02-03 +### Added +- Added ability to use matchers for case equality. [#55](https://github.com/icy-arctic-fox/spectator/issues/55) +- Added support for nested case equality when checking arguments with Array, Tuple, Hash, and NamedTuple. + +### Fixed +- Fixed some issues with the `be_within` matcher when used with expected and union types. + +## [0.11.7] - 2023-10-16 +### Fixed +- Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81) + +## [0.11.6] - 2023-01-26 +### Added +- Added ability to cast types using the return value from expect/should statements with a type matcher. +- Added support for string interpolation in context names/labels. + +### Fixed +- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. [#51](https://github.com/icy-arctic-fox/spectator/issues/51) +- Fix malformed method signature when using named splat with keyword arguments in mocked type. [#49](https://github.com/icy-arctic-fox/spectator/issues/49) + +### Changed +- Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start. +- Add non-captured block argument in preparation for Crystal 1.8.0. + +## [0.11.5] - 2022-12-18 +### Added +- Added support for mock modules and types that include mocked modules. + +### Fixed +- Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) +- Fix method stubs used on methods that capture blocks. +- Fix type name resolution for when using custom types in a mocked typed. +- Prevent comparing range arguments with non-compatible types in stubs. [#48](https://github.com/icy-arctic-fox/spectator/issues/48) + +### Changed +- Simplify string representation of mock-related types. +- Remove unnecessary redefinitions of methods when adding stub functionality to a type. +- Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags. +- Use normal equality (==) instead of case-equality (===) with proc arguments in stubs. +- Change stub value cast logic to avoid compiler bug. [#80](https://gitlab.com/arctic-fox/spectator/-/issues/80) + +## [0.11.4] - 2022-11-27 ### Added - Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47) - Add `before`, `after`, and `around` as aliases for `before_each`, `after_each`, and `around_each` respectively. @@ -416,8 +463,13 @@ 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.11.4...master -[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 +[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.1...master +[0.12.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.0...v0.12.1 +[0.12.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...v0.12.0 +[0.11.7]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...v0.11.7 +[0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6 +[0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5 +[0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 [0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3 [0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2 [0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1 diff --git a/README.md b/README.md index 973b9a9..00b5eb9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Add this to your application's `shard.yml`: development_dependencies: spectator: gitlab: arctic-fox/spectator - version: ~> 0.11.0 + version: ~> 0.12.0 ``` Usage @@ -287,7 +287,7 @@ Spectator.describe Driver do # Call the mock method. subject.do_something(interface, dbl) # Verify everything went okay. - expect(interface).to have_received(:invoke).with(thing) + expect(interface).to have_received(:invoke).with(dbl) end end ``` diff --git a/shard.yml b/shard.yml index 15c9a65..ac8b54f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: spectator -version: 0.11.4 +version: 0.12.1 description: | Feature-rich testing framework for Crystal inspired by RSpec. diff --git a/spec/docs/mocks_spec.cr b/spec/docs/mocks_spec.cr index 5908787..58c16f8 100644 --- a/spec/docs/mocks_spec.cr +++ b/spec/docs/mocks_spec.cr @@ -123,6 +123,109 @@ Spectator.describe "Mocks Docs" do end end + context "Mock Modules" do + module MyModule + def something + # ... + end + end + + describe "#something" do + # Define a mock for MyModule. + mock MyClass + + it "does something" do + # Use mock here. + end + end + + module MyFileUtils + def self.rm_rf(path) + # ... + end + end + + mock MyFileUtils + + it "deletes all of my files" do + utils = class_mock(MyFileUtils) + allow(utils).to receive(:rm_rf) + utils.rm_rf("/") + expect(utils).to have_received(:rm_rf).with("/") + end + + module MyFileUtils2 + extend self + + def rm_rf(path) + # ... + end + end + + mock(MyFileUtils2) do + # Define a default stub for the method. + stub def self.rm_rf(path) + # ... + end + end + + it "deletes all of my files part 2" do + utils = class_mock(MyFileUtils2) + allow(utils).to receive(:rm_rf) + utils.rm_rf("/") + expect(utils).to have_received(:rm_rf).with("/") + end + + module Runnable + def run + # ... + end + end + + mock Runnable + + specify do + runnable = mock(Runnable) # or new_mock(Runnable) + runnable.run + end + + module Runnable2 + abstract def command : String + + def run_one + "Running #{command}" + end + end + + mock Runnable2, command: "ls -l" + + specify do + runnable = mock(Runnable2) + expect(runnable.run_one).to eq("Running ls -l") + runnable = mock(Runnable2, command: "echo foo") + expect(runnable.run_one).to eq("Running echo foo") + end + + context "Injecting Mocks" do + module MyFileUtils + def self.rm_rf(path) + true + end + end + + inject_mock MyFileUtils do + stub def self.rm_rf(path) + "Simulating deletion of #{path}" + false + end + end + + specify do + expect(MyFileUtils.rm_rf("/")).to be_false + end + end + end + context "Injecting Mocks" do struct MyStruct def something diff --git a/spec/docs/readme_spec.cr b/spec/docs/readme_spec.cr index 6024906..2ed7fd5 100644 --- a/spec/docs/readme_spec.cr +++ b/spec/docs/readme_spec.cr @@ -1,26 +1,28 @@ require "../spec_helper" -private abstract class Interface - abstract def invoke(thing) : String -end +module Readme + abstract class Interface + abstract def invoke(thing) : String + end -# Type being tested. -private class Driver - def do_something(interface : Interface, thing) - interface.invoke(thing) + # Type being tested. + class Driver + def do_something(interface : Interface, thing) + interface.invoke(thing) + end end end -Spectator.describe Driver do +Spectator.describe Readme::Driver do # Define a mock for Interface. - mock Interface + mock Readme::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) + interface = mock(Readme::Interface) # Indicate that `#invoke` should return "test" when called. allow(interface).to receive(:invoke).and_return("test") diff --git a/spec/features/expect_type_spec.cr b/spec/features/expect_type_spec.cr new file mode 100644 index 0000000..6ed9949 --- /dev/null +++ b/spec/features/expect_type_spec.cr @@ -0,0 +1,70 @@ +require "../spec_helper" + +Spectator.describe "Expect Type", :smoke do + context "with expect syntax" do + it "ensures a type is cast" do + value = 42.as(String | Int32) + expect(value).to be_a(String | Int32) + expect(value).to compile_as(String | Int32) + value = expect(value).to be_a(Int32) + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect(value).to_not respond_to(:downcase) + end + + it "ensures a type is not nil" do + value = 42.as(Int32?) + expect(value).to be_a(Int32?) + expect(value).to compile_as(Int32?) + value = expect(value).to_not be_nil + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect { value.not_nil! }.to_not raise_error(NilAssertionError) + end + + it "removes types from a union" do + value = 42.as(String | Int32) + expect(value).to be_a(String | Int32) + expect(value).to compile_as(String | Int32) + value = expect(value).to_not be_a(String) + expect(value).to eq(42) + expect(value).to be_a(Int32) + expect(value).to compile_as(Int32) + expect(value).to_not respond_to(:downcase) + end + end + + context "with should syntax" do + it "ensures a type is cast" do + value = 42.as(String | Int32) + value.should be_a(String | Int32) + value = value.should be_a(Int32) + value.should eq(42) + value.should be_a(Int32) + value.should compile_as(Int32) + value.should_not respond_to(:downcase) + end + + it "ensures a type is not nil" do + value = 42.as(Int32?) + value.should be_a(Int32?) + value = value.should_not be_nil + value.should eq(42) + value.should be_a(Int32) + value.should compile_as(Int32) + expect { value.not_nil! }.to_not raise_error(NilAssertionError) + end + + it "removes types from a union" do + value = 42.as(String | Int32) + value.should be_a(String | Int32) + value = value.should_not be_a(String) + value.should eq(42) + value.should be_a(Int32) + value.should compile_as(Int32) + value.should_not respond_to(:downcase) + end + end +end diff --git a/spec/features/interpolated_label_spec.cr b/spec/features/interpolated_label_spec.cr new file mode 100644 index 0000000..add8e93 --- /dev/null +++ b/spec/features/interpolated_label_spec.cr @@ -0,0 +1,22 @@ +require "../spec_helper" + +Spectator.describe "Interpolated Label", :smoke do + let(foo) { "example" } + let(bar) { "context" } + + it "interpolates #{foo} labels" do |example| + expect(example.name).to eq("interpolates example labels") + end + + context "within a #{bar}" do + let(foo) { "multiple" } + + it "interpolates context labels" do |example| + expect(example.group.name).to eq("within a context") + end + + it "interpolates #{foo} levels" do |example| + expect(example.name).to eq("interpolates multiple levels") + end + end +end diff --git a/spec/issues/github_issue_44_spec.cr b/spec/issues/github_issue_44_spec.cr index d1b9716..da6cbf3 100644 --- a/spec/issues/github_issue_44_spec.cr +++ b/spec/issues/github_issue_44_spec.cr @@ -26,12 +26,15 @@ Spectator.describe "GitHub Issue #44" do # Original issue uses keyword arguments in place of positional arguments. context "keyword arguments in place of positional arguments" do before_each do - expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception) + pipe = Process::Redirect::Pipe + expect(Process).to receive(:run).with(command, shell: true, output: pipe).and_raise(exception) end - it "must stub Process.run", skip: "Keyword arguments in place of positional arguments not supported with expect-receive" do - Process.run(command, shell: true, output: :pipe) do |_process| - end + it "must stub Process.run" do + expect do + Process.run(command, shell: true, output: :pipe) do |_process| + end + end.to raise_error(File::NotFoundError, "File not found") end end end diff --git a/spec/issues/github_issue_48_spec.cr b/spec/issues/github_issue_48_spec.cr new file mode 100644 index 0000000..b958c1b --- /dev/null +++ b/spec/issues/github_issue_48_spec.cr @@ -0,0 +1,135 @@ +require "../spec_helper" + +Spectator.describe "GitHub Issue #48" do + class Test + def return_this(thing : T) : T forall T + thing + end + + def map(thing : T, & : T -> U) : U forall T, U + yield thing + end + + def make_nilable(thing : T) : T? forall T + thing.as(T?) + end + + def itself : self + self + end + + def itself? : self? + self.as(self?) + end + + def generic(thing : T) : Array(T) forall T + Array.new(100) { thing } + end + + def union : Int32 | String + 42.as(Int32 | String) + end + + def capture(&block : -> T) forall T + block + end + + def capture(thing : T, &block : T -> T) forall T + block.call(thing) + block + end + + def range(r : Range) + r + end + end + + mock Test, make_nilable: nil + + let(fake) { mock(Test) } + + it "handles free variables" do + allow(fake).to receive(:return_this).and_return("different") + expect(fake.return_this("test")).to eq("different") + end + + it "raises on type cast error with free variables" do + allow(fake).to receive(:return_this).and_return(42) + expect { fake.return_this("test") }.to raise_error(TypeCastError, /String/) + end + + it "handles free variables with a block" do + allow(fake).to receive(:map).and_return("stub") + expect(fake.map(:mapped, &.to_s)).to eq("stub") + end + + it "raises on type cast error with a block and free variables" do + allow(fake).to receive(:map).and_return(42) + expect { fake.map(:mapped, &.to_s) }.to raise_error(TypeCastError, /String/) + end + + it "handles nilable free variables" do + expect(fake.make_nilable("foo")).to be_nil + end + + it "handles 'self' return type" do + not_self = mock(Test) + allow(fake).to receive(:itself).and_return(not_self) + expect(fake.itself).to be(not_self) + end + + it "raises on type cast error with 'self' return type" do + allow(fake).to receive(:itself).and_return(42) + expect { fake.itself }.to raise_error(TypeCastError, /#{class_mock(Test)}/) + end + + it "handles nilable 'self' return type" do + not_self = mock(Test) + allow(fake).to receive(:itself?).and_return(not_self) + expect(fake.itself?).to be(not_self) + end + + it "handles generic return type" do + allow(fake).to receive(:generic).and_return([42]) + expect(fake.generic(42)).to eq([42]) + end + + it "raises on type cast error with generic return type" do + allow(fake).to receive(:generic).and_return("test") + expect { fake.generic(42) }.to raise_error(TypeCastError, /Array\(Int32\)/) + end + + it "handles union return types" do + allow(fake).to receive(:union).and_return("test") + expect(fake.union).to eq("test") + end + + it "raises on type cast error with union return type" do + allow(fake).to receive(:union).and_return(:test) + expect { fake.union }.to raise_error(TypeCastError, /Symbol/) + end + + it "handles captured blocks" do + proc = ->{} + allow(fake).to receive(:capture).and_return(proc) + expect(fake.capture { nil }).to be(proc) + end + + it "raises on type cast error with captured blocks" do + proc = ->{ 42 } + allow(fake).to receive(:capture).and_return(proc) + expect { fake.capture { "other" } }.to raise_error(TypeCastError, /Proc\(String\)/) + end + + it "handles captured blocks with arguments" do + proc = ->(x : Int32) { x * 2 } + allow(fake).to receive(:capture).and_return(proc) + expect(fake.capture(5) { 5 }).to be(proc) + end + + it "handles range comparisons against non-comparable types" do + range = 1..10 + allow(fake).to receive(:range).and_return(range) + expect(fake.range(1..3)).to eq(range) + end +end diff --git a/spec/issues/github_issue_49_spec.cr b/spec/issues/github_issue_49_spec.cr new file mode 100644 index 0000000..6161a57 --- /dev/null +++ b/spec/issues/github_issue_49_spec.cr @@ -0,0 +1,6 @@ +require "../spec_helper" + +# https://github.com/icy-arctic-fox/spectator/issues/49 +Spectator.describe "GitHub Issue #49" do + # mock File +end diff --git a/spec/issues/github_issue_55_spec.cr b/spec/issues/github_issue_55_spec.cr new file mode 100644 index 0000000..92c2b42 --- /dev/null +++ b/spec/issues/github_issue_55_spec.cr @@ -0,0 +1,48 @@ +require "../spec_helper" + +Spectator.describe "GitHub Issue #55" do + GROUP_NAME = "CallCenter" + + let(name) { "TimeTravel" } + let(source) { "my.time.travel.experiment" } + + class Analytics(T) + property start_time = Time.local + property end_time = Time.local + + def initialize(@brain_talker : T) + end + + def instrument(*, name, source, &) + @brain_talker.send(payload: { + :group => GROUP_NAME, + :name => name, + :source => source, + :start => start_time, + :end => end_time, + }, action: "analytics") + end + end + + double(:brain_talker, send: nil) + + let(brain_talker) { double(:brain_talker) } + let(analytics) { Analytics.new(brain_talker) } + + it "tracks the time it takes to run the block" do + analytics.start_time = expected_start_time = Time.local + expected_end_time = expected_start_time + 10.seconds + analytics.end_time = expected_end_time + 0.5.seconds # Offset to ensure non-exact match. + + analytics.instrument(name: name, source: source) do + end + + expect(brain_talker).to have_received(:send).with(payload: { + :group => GROUP_NAME, + :name => name, + :source => source, + :start => expected_start_time, + :end => be_within(1.second).of(expected_end_time), + }, action: "analytics") + end +end diff --git a/spec/issues/gitlab_issue_51_spec.cr b/spec/issues/gitlab_issue_51_spec.cr index c809607..996af80 100644 --- a/spec/issues/gitlab_issue_51_spec.cr +++ b/spec/issues/gitlab_issue_51_spec.cr @@ -1,31 +1,33 @@ require "../spec_helper" -private class Foo - def call(str : String) : String? - "" +module GitLabIssue51 + class Foo + def call(str : String) : String? + "" + end + + def alt1_call(str : String) : String? + nil + end + + def alt2_call(str : String) : String? + [str, nil].sample + end end - def alt1_call(str : String) : String? - nil - end - - def alt2_call(str : String) : String? - [str, nil].sample + class Bar + def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call. + a_foo.call("") + a_foo.alt1_call("") + a_foo.alt2_call("") + end end end -private class Bar - def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call. - a_foo.call("") - a_foo.alt1_call("") - a_foo.alt2_call("") - end -end +Spectator.describe GitLabIssue51::Bar do + mock GitLabIssue51::Foo, call: "", alt1_call: "", alt2_call: "" -Spectator.describe Bar do - mock Foo, call: "", alt1_call: "", alt2_call: "" - - let(:foo) { mock(Foo) } + let(:foo) { mock(GitLabIssue51::Foo) } subject(:call) { described_class.new.call(foo) } describe "#call" do diff --git a/spec/issues/gitlab_issue_80_spec.cr b/spec/issues/gitlab_issue_80_spec.cr new file mode 100644 index 0000000..9090130 --- /dev/null +++ b/spec/issues/gitlab_issue_80_spec.cr @@ -0,0 +1,30 @@ +require "../spec_helper" + +# https://gitlab.com/arctic-fox/spectator/-/issues/80 + +class Item +end + +class ItemUser + @item = Item.new + + def item + @item + end +end + +Spectator.describe "test1" do + it "without mock" do + item_user = ItemUser.new + item = item_user.item + item == item + end +end + +Spectator.describe "test2" do + mock Item do + end + + it "without mock" do + end +end diff --git a/spec/spectator/dsl/mocks/double_spec.cr b/spec/spectator/dsl/mocks/double_spec.cr index 89f652c..5547ae0 100644 --- a/spec/spectator/dsl/mocks/double_spec.cr +++ b/spec/spectator/dsl/mocks/double_spec.cr @@ -168,7 +168,7 @@ Spectator.describe "Double DSL", :smoke do context "methods accepting blocks" do double(:test7) do - stub def foo + stub def foo(&) yield end diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index db63dd9..cd57cdc 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -40,17 +40,17 @@ Spectator.describe "Mock DSL", :smoke do arg end - def method4 : Symbol + def method4(&) : Symbol @_spectator_invocations << :method4 yield end - def method5 + def method5(&) @_spectator_invocations << :method5 yield.to_i end - def method6 + def method6(&) @_spectator_invocations << :method6 yield end @@ -60,7 +60,7 @@ Spectator.describe "Mock DSL", :smoke do {arg, args, kwarg, kwargs} end - def method8(arg, *args, kwarg, **kwargs) + def method8(arg, *args, kwarg, **kwargs, &) @_spectator_invocations << :method8 yield {arg, args, kwarg, kwargs} @@ -80,7 +80,7 @@ Spectator.describe "Mock DSL", :smoke do "stubbed" end - stub def method4 : Symbol + stub def method4(&) : Symbol yield :block end @@ -258,12 +258,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end @@ -381,12 +381,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end end @@ -454,12 +454,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end @@ -577,12 +577,12 @@ Spectator.describe "Mock DSL", :smoke do # NOTE: Abstract methods that yield must have yield functionality defined in the method. # This requires that yielding methods have a default implementation. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition. - stub def method5 + stub def method5(&) yield end # NOTE: Another quirk where a default implementation must be provided because `&` is dropped. - stub def method6 : Symbol + stub def method6(&) : Symbol yield end end @@ -620,11 +620,11 @@ Spectator.describe "Mock DSL", :smoke do :original end - def method3 + def method3(&) yield end - def method4 : Int32 + def method4(&) : Int32 yield.to_i end @@ -749,11 +749,11 @@ Spectator.describe "Mock DSL", :smoke do :original end - def method3 + def method3(&) yield end - def method4 : Int32 + def method4(&) : Int32 yield.to_i end @@ -1027,4 +1027,262 @@ Spectator.describe "Mock DSL", :smoke do expect(fake.reference).to eq("reference") end end + + describe "mock module" do + module Dummy + # `extend self` cannot be used. + # The Crystal compiler doesn't report the methods as class methods when doing so. + + def self.abstract_method + :not_really_abstract + end + + def self.default_method + :original + end + + def self.args(arg) + arg + end + + def self.method1 + :original + end + + def self.reference + method1.to_s + end + end + + mock(Dummy) do + abstract_stub def self.abstract_method + :abstract + end + + stub def self.default_method + :default + end + end + + let(fake) { class_mock(Dummy) } + + it "raises on abstract stubs" do + expect { fake.abstract_method }.to raise_error(Spectator::UnexpectedMessage, /abstract_method/) + end + + it "can define default stubs" do + expect(fake.default_method).to eq(:default) + end + + it "can define new stubs" do + expect { allow(fake).to receive(:args).and_return(42) }.to change { fake.args(5) }.from(5).to(42) + end + + it "can override class method stubs" do + allow(fake).to receive(:method1).and_return(:override) + expect(fake.method1).to eq(:override) + end + + xit "can reference stubs", pending: "Default stub of module class methods always refer to original" do + allow(fake).to receive(:method1).and_return(:reference) + expect(fake.reference).to eq("reference") + end + end + + context "with a class including a mocked module" do + module Dummy + getter _spectator_invocations = [] of Symbol + + def method1 + @_spectator_invocations << :method1 + "original" + end + + def method2 : Symbol + @_spectator_invocations << :method2 + :original + end + + def method3(arg) + @_spectator_invocations << :method3 + arg + end + + def method4(&) : Symbol + @_spectator_invocations << :method4 + yield + end + + def method5(&) + @_spectator_invocations << :method5 + yield.to_i + end + + def method6(&) + @_spectator_invocations << :method6 + yield + end + + def method7(arg, *args, kwarg, **kwargs) + @_spectator_invocations << :method7 + {arg, args, kwarg, kwargs} + end + + def method8(arg, *args, kwarg, **kwargs, &) + @_spectator_invocations << :method8 + yield + {arg, args, kwarg, kwargs} + end + end + + # method1 stubbed via mock block + # method2 stubbed via keyword args + # method3 not stubbed (calls original) + # method4 stubbed via mock block (yields) + # method5 stubbed via keyword args (yields) + # method6 not stubbed (calls original and yields) + # method7 not stubbed (calls original) testing args + # method8 not stubbed (calls original and yields) testing args + mock(Dummy, method2: :stubbed, method5: 42) do + stub def method1 + "stubbed" + end + + stub def method4(&) : Symbol + yield + :block + end + end + + subject(fake) { mock(Dummy) } + + it "defines a subclass" do + expect(fake).to be_a(Dummy) + end + + it "defines stubs in the block" do + expect(fake.method1).to eq("stubbed") + end + + it "can stub methods defined in the block" do + stub = Spectator::ValueStub.new(:method1, "override") + expect { fake._spectator_define_stub(stub) }.to change { fake.method1 }.from("stubbed").to("override") + end + + it "defines stubs from keyword arguments" do + expect(fake.method2).to eq(:stubbed) + end + + it "can stub methods from keyword arguments" do + stub = Spectator::ValueStub.new(:method2, :override) + expect { fake._spectator_define_stub(stub) }.to change { fake.method2 }.from(:stubbed).to(:override) + end + + it "calls the original implementation for methods not provided a stub" do + expect(fake.method3(:xyz)).to eq(:xyz) + end + + it "can stub methods after declaration" do + stub = Spectator::ValueStub.new(:method3, :abc) + expect { fake._spectator_define_stub(stub) }.to change { fake.method3(:xyz) }.from(:xyz).to(:abc) + end + + it "defines stubs with yield in the block" do + expect(fake.method4 { :wrong }).to eq(:block) + end + + it "can stub methods with yield in the block" do + stub = Spectator::ValueStub.new(:method4, :override) + expect { fake._spectator_define_stub(stub) }.to change { fake.method4 { :wrong } }.from(:block).to(:override) + end + + it "defines stubs with yield from keyword arguments" do + expect(fake.method5 { :wrong }).to eq(42) + end + + it "can stub methods with yield from keyword arguments" do + stub = Spectator::ValueStub.new(:method5, 123) + expect { fake._spectator_define_stub(stub) }.to change { fake.method5 { "0" } }.from(42).to(123) + end + + it "can stub yielding methods after declaration" do + stub = Spectator::ValueStub.new(:method6, :abc) + expect { fake._spectator_define_stub(stub) }.to change { fake.method6 { :xyz } }.from(:xyz).to(:abc) + end + + it "handles arguments correctly" do + args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + aggregate_failures do + expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + end + end + + it "handles arguments correctly with stubs" do + stub1 = Spectator::ProcStub.new(:method7, args_proc) + stub2 = Spectator::ProcStub.new(:method8, args_proc) + fake._spectator_define_stub(stub1) + fake._spectator_define_stub(stub2) + args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + aggregate_failures do + expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}}) + end + end + + it "compiles types without unions" do + aggregate_failures do + expect(fake.method1).to compile_as(String) + expect(fake.method2).to compile_as(Symbol) + expect(fake.method3(42)).to compile_as(Int32) + expect(fake.method4 { :foo }).to compile_as(Symbol) + expect(fake.method5 { "123" }).to compile_as(Int32) + expect(fake.method6 { "123" }).to compile_as(String) + end + end + + def restricted(thing : Dummy) + thing.method1 + end + + it "can be used in type restricted methods" do + expect(restricted(fake)).to eq("stubbed") + end + + it "does not call the original method when stubbed" do + fake.method1 + fake.method2 + fake.method3("foo") + fake.method4 { :foo } + fake.method5 { "42" } + fake.method6 { 42 } + fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) + fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block } + expect(fake._spectator_invocations).to contain_exactly(:method3, :method6, :method7, :method8) + end + + # Cannot test unexpected messages - will not compile due to missing methods. + + describe "deferred default stubs" do + mock(Dummy) + + let(fake2) do + mock(Dummy, + method1: "stubbed", + method3: 123, + method4: :xyz) + end + + it "uses the keyword arguments as stubs" do + aggregate_failures do + expect(fake2.method1).to eq("stubbed") + expect(fake2.method2).to eq(:original) + expect(fake2.method3(42)).to eq(123) + expect(fake2.method4 { :foo }).to eq(:xyz) + end + end + end + end end diff --git a/spec/spectator/dsl/mocks/null_double_spec.cr b/spec/spectator/dsl/mocks/null_double_spec.cr index 1219f50..06d35ee 100644 --- a/spec/spectator/dsl/mocks/null_double_spec.cr +++ b/spec/spectator/dsl/mocks/null_double_spec.cr @@ -156,7 +156,7 @@ Spectator.describe "Null double DSL" do context "methods accepting blocks" do double(:test7) do - stub def foo + stub def foo(&) yield end diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index 1b1abc9..e55c549 100644 --- a/spec/spectator/mocks/double_spec.cr +++ b/spec/spectator/mocks/double_spec.cr @@ -212,14 +212,10 @@ Spectator.describe Spectator::Double do 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 @@ -301,7 +297,7 @@ Spectator.describe Spectator::Double do arg end - stub def self.baz(arg) + stub def self.baz(arg, &) yield end end @@ -469,7 +465,7 @@ Spectator.describe Spectator::Double do 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]) + expect(called_method_names(dbl)).to contain(:baz) end it "stores arguments for a call" do @@ -479,4 +475,68 @@ Spectator.describe Spectator::Double do expect(call.arguments).to eq(args) end end + + describe "#to_s" do + subject(string) { dbl.to_s } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + end + end + + describe "#inspect" do + subject(string) { dbl.inspect } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "indicates it's a double" do + expect(string).to contain("Double") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + end end diff --git a/spec/spectator/mocks/lazy_double_spec.cr b/spec/spectator/mocks/lazy_double_spec.cr index c3402b5..8ea5a5d 100644 --- a/spec/spectator/mocks/lazy_double_spec.cr +++ b/spec/spectator/mocks/lazy_double_spec.cr @@ -275,7 +275,7 @@ Spectator.describe Spectator::LazyDouble do 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]) + expect(called_method_names(dbl)).to contain(:baz) end it "stores arguments for a call" do @@ -285,4 +285,68 @@ Spectator.describe Spectator::LazyDouble do expect(call.arguments).to eq(args) end end + + describe "#to_s" do + subject(string) { dbl.to_s } + + context "with a name" do + let(dbl) { Spectator::LazyDouble.new("dbl-name") } + + it "indicates it's a double" do + expect(string).to contain("LazyDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + end + + context "without a name" do + let(dbl) { Spectator::LazyDouble.new } + + it "contains the double type" do + expect(string).to contain("LazyDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + end + end + + describe "#inspect" do + subject(string) { dbl.inspect } + + context "with a name" do + let(dbl) { Spectator::LazyDouble.new("dbl-name") } + + it "contains the double type" do + expect(string).to contain("LazyDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + + context "without a name" do + let(dbl) { Spectator::LazyDouble.new } + + it "contains the double type" do + expect(string).to contain("LazyDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + end end diff --git a/spec/spectator/mocks/mock_spec.cr b/spec/spectator/mocks/mock_spec.cr index 40b4bf0..3ddd0fe 100644 --- a/spec/spectator/mocks/mock_spec.cr +++ b/spec/spectator/mocks/mock_spec.cr @@ -29,8 +29,18 @@ Spectator.describe Spectator::Mock do @_spectator_invocations << :method3 "original" end + + def method4 : Thing + self + end + + def method5 : OtherThing + OtherThing.new + end end + class OtherThing; end + Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method1: 123) do stub def method2 :stubbed @@ -104,6 +114,20 @@ Spectator.describe Spectator::Mock do mock.method3 expect(mock._spectator_invocations).to contain_exactly(:method3) end + + it "can reference its own type" do + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:method4, new_mock) + mock._spectator_define_stub(stub) + expect(mock.method4).to be(new_mock) + end + + it "can reference other types in the original namespace" do + other = OtherThing.new + stub = Spectator::ValueStub.new(:method5, other) + mock._spectator_define_stub(stub) + expect(mock.method5).to be(other) + end end context "with an abstract class" do @@ -120,8 +144,14 @@ Spectator.describe Spectator::Mock do end abstract def method4 + + abstract def method4 : Thing + + abstract def method5 : OtherThing end + class OtherThing; 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 @@ -199,6 +229,20 @@ Spectator.describe Spectator::Mock do mock.method3 expect(mock._spectator_invocations).to contain_exactly(:method3) end + + it "can reference its own type" do + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:method4, new_mock) + mock._spectator_define_stub(stub) + expect(mock.method4).to be(new_mock) + end + + it "can reference other types in the original namespace" do + other = OtherThing.new + stub = Spectator::ValueStub.new(:method5, other) + mock._spectator_define_stub(stub) + expect(mock.method5).to be(other) + end end context "with an abstract struct" do @@ -215,8 +259,14 @@ Spectator.describe Spectator::Mock do end abstract def method4 + + abstract def method4 : Thing + + abstract def method5 : OtherThing end + class OtherThing; 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 @@ -286,6 +336,22 @@ Spectator.describe Spectator::Mock do mock.method3 expect(mock._spectator_invocations).to contain_exactly(:method3) end + + it "can reference its own type" do + mock = self.mock # FIXME: Workaround for passing by value messing with stubs. + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:method4, new_mock) + mock._spectator_define_stub(stub) + expect(mock.method4).to be_a(Thing) + end + + it "can reference other types in the original namespace" do + mock = self.mock # FIXME: Workaround for passing by value messing with stubs. + other = OtherThing.new + stub = Spectator::ValueStub.new(:method5, other) + mock._spectator_define_stub(stub) + expect(mock.method5).to be(other) + end end context "class method stubs" do @@ -298,11 +364,21 @@ Spectator.describe Spectator::Mock do arg end - def self.baz(arg) + def self.baz(arg, &) yield end + + def self.thing : Thing + new + end + + def self.other : OtherThing + OtherThing.new + end end + class OtherThing; end + Spectator::Mock.define_subtype(:class, Thing, MockThing) do stub def self.foo :stub @@ -367,6 +443,20 @@ Spectator.describe Spectator::Mock do expect(restricted(mock)).to eq(:stub) end + it "can reference its own type" do + new_mock = MockThing.new + stub = Spectator::ValueStub.new(:thing, new_mock) + mock._spectator_define_stub(stub) + expect(mock.thing).to be(new_mock) + end + + it "can reference other types in the original namespace" do + other = OtherThing.new + stub = Spectator::ValueStub.new(:other, other) + mock._spectator_define_stub(stub) + expect(mock.other).to be(other) + end + describe "._spectator_clear_stubs" do before { mock._spectator_define_stub(foo_stub) } @@ -401,6 +491,203 @@ Spectator.describe Spectator::Mock do end end + context "with a module" do + module Thing + # `extend self` cannot be used. + # The Crystal compiler doesn't report the methods as class methods when doing so. + + def self.original_method + :original + end + + def self.default_method + :original + end + + def self.stubbed_method(_value = 42) + :original + end + end + + Spectator::Mock.define_subtype(:module, Thing, MockThing) do + stub def self.stubbed_method(_value = 42) + :stubbed + end + end + + let(mock) { MockThing } + + after { mock._spectator_clear_stubs } + + it "overrides an existing method" do + stub = Spectator::ValueStub.new(:original_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) + end + + it "doesn't affect other methods" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } + end + + it "replaces an existing default stub" do + stub = Spectator::ValueStub.new(:default_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) + end + + it "replaces an existing stubbed method" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) + end + + def restricted(thing : Thing.class) + thing.stubbed_method + end + + it "can be used in type restricted methods" do + expect(restricted(mock)).to eq(:stubbed) + end + + describe "._spectator_clear_stubs" do + before do + stub = Spectator::ValueStub.new(:original_method, :override) + mock._spectator_define_stub(stub) + end + + it "removes previously defined stubs" do + expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) + end + end + + describe "._spectator_calls" do + before { 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 original methods" do + expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) + end + + it "stores calls to default methods" do + expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) + end + + it "stores calls to stubbed methods" do + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) + end + + it "stores multiple calls to the same stub" do + mock.stubbed_method + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) + end + + it "stores arguments for a call" do + mock.stubbed_method(5) + args = Spectator::Arguments.capture(5) + call = mock._spectator_calls.first + expect(call.arguments).to eq(args) + end + end + end + + context "with a mocked module included in a class" do + module Thing + def original_method + :original + end + + def default_method + :original + end + + def stubbed_method(_value = 42) + :original + end + end + + Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do + stub def stubbed_method(_value = 42) + :stubbed + end + end + + class IncludedMock + include MockThing + end + + let(mock) { IncludedMock.new } + + it "overrides an existing method" do + stub = Spectator::ValueStub.new(:original_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override) + end + + it "doesn't affect other methods" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method } + end + + it "replaces an existing default stub" do + stub = Spectator::ValueStub.new(:default_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override) + end + + it "replaces an existing stubbed method" do + stub = Spectator::ValueStub.new(:stubbed_method, :override) + expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override) + end + + def restricted(thing : Thing.class) + thing.default_method + end + + describe "#_spectator_clear_stubs" do + before do + stub = Spectator::ValueStub.new(:original_method, :override) + mock._spectator_define_stub(stub) + end + + it "removes previously defined stubs" do + expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original) + end + end + + describe "#_spectator_calls" do + before { 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 original methods" do + expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method]) + end + + it "stores calls to default methods" do + expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method]) + end + + it "stores calls to stubbed methods" do + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method]) + end + + it "stores multiple calls to the same stub" do + mock.stubbed_method + expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method]) + end + + it "stores arguments for a call" do + mock.stubbed_method(5) + args = Spectator::Arguments.capture(5) + call = mock._spectator_calls.first + expect(call.arguments).to eq(args) + end + end + end + context "with a method that uses NoReturn" do abstract class Thing abstract def oops : NoReturn @@ -642,7 +929,7 @@ Spectator.describe Spectator::Mock do arg end - def self.baz(arg) + def self.baz(arg, &) yield end end diff --git a/spec/spectator/mocks/null_double_spec.cr b/spec/spectator/mocks/null_double_spec.cr index 4a8ef3a..a6fc7d2 100644 --- a/spec/spectator/mocks/null_double_spec.cr +++ b/spec/spectator/mocks/null_double_spec.cr @@ -186,12 +186,9 @@ Spectator.describe Spectator::NullDouble do 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 @@ -262,7 +259,7 @@ Spectator.describe Spectator::NullDouble do arg end - stub def self.baz(arg) + stub def self.baz(arg, &) yield end end @@ -439,4 +436,68 @@ Spectator.describe Spectator::NullDouble do expect(call.arguments).to eq(args) end end + + describe "#to_s" do + subject(string) { dbl.to_s } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "indicates it's a double" do + expect(string).to contain("NullDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "contains the double type" do + expect(string).to contain("NullDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + end + end + + describe "#inspect" do + subject(string) { dbl.inspect } + + context "with a name" do + let(dbl) { FooBarDouble.new } + + it "contains the double type" do + expect(string).to contain("NullDouble") + end + + it "contains the double name" do + expect(string).to contain("dbl-name") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + + context "without a name" do + let(dbl) { EmptyDouble.new } + + it "contains the double type" do + expect(string).to contain("NullDouble") + end + + it "contains \"Anonymous\"" do + expect(string).to contain("Anonymous") + end + + it "contains the object ID" do + expect(string).to contain(dbl.object_id.to_s(16)) + end + end + end end diff --git a/src/spectator/context.cr b/src/spectator/context.cr index 7fc126b..15b9335 100644 --- a/src/spectator/context.cr +++ b/src/spectator/context.cr @@ -4,6 +4,11 @@ # This type is intentionally outside the `Spectator` module. # The reason for this is to prevent name collision when using the DSL to define a spec. abstract class SpectatorContext + # Evaluates the contents of a block within the scope of the context. + def eval(&) + with self yield + end + # Produces a dummy string to represent the context as a string. # This prevents the default behavior, which normally stringifies instance variables. # Due to the sheer amount of types Spectator can create diff --git a/src/spectator/dsl/expectations.cr b/src/spectator/dsl/expectations.cr index a35a15c..dba2e9b 100644 --- a/src/spectator/dsl/expectations.cr +++ b/src/spectator/dsl/expectations.cr @@ -182,7 +182,7 @@ module Spectator::DSL # expect(false).to be_true # end # ``` - def aggregate_failures(label = nil) + def aggregate_failures(label = nil, &) ::Spectator::Harness.current.aggregate_failures(label) do yield end diff --git a/src/spectator/dsl/groups.cr b/src/spectator/dsl/groups.cr index 0e7b47b..da06906 100644 --- a/src/spectator/dsl/groups.cr +++ b/src/spectator/dsl/groups.cr @@ -137,7 +137,11 @@ module Spectator::DSL what.is_a?(NilLiteral) %} {{what}} {% elsif what.is_a?(StringInterpolation) %} - {% raise "String interpolation isn't supported for example group names" %} + {{@type.name}}.new.eval do + {{what}} + rescue e + "" + end {% else %} {{what.stringify}} {% end %} diff --git a/src/spectator/dsl/metadata.cr b/src/spectator/dsl/metadata.cr index 308bcbd..04092b9 100644 --- a/src/spectator/dsl/metadata.cr +++ b/src/spectator/dsl/metadata.cr @@ -6,6 +6,9 @@ module Spectator::DSL private macro _spectator_metadata(name, source, *tags, **metadata) private def self.{{name.id}} %metadata = {{source.id}}.dup + {% unless tags.empty? && metadata.empty? %} + %metadata ||= ::Spectator::Metadata.new + {% end %} {% for k in tags %} %metadata[{{k.id.symbolize}}] = nil {% end %} diff --git a/src/spectator/dsl/mocks.cr b/src/spectator/dsl/mocks.cr index 94a41f9..5eff1a9 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -31,7 +31,7 @@ module Spectator::DSL ::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 + ::Spectator::Double.define({{double_type_name}}, {{name}}, {{value_methods.double_splat}}) do # Returns a new double that responds to undefined methods with itself. # See: `NullDouble` def as_null_object @@ -43,7 +43,7 @@ module Spectator::DSL {% begin %} # Define a matching null double type. - ::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{**value_methods}}) {{block}} + ::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{value_methods.double_splat}}) {{block}} {% end %} end @@ -94,9 +94,9 @@ module Spectator::DSL begin %double = {% if found_tuple %} - {{found_tuple[2].id}}.new({{**value_methods}}) + {{found_tuple[2].id}}.new({{value_methods.double_splat}}) {% else %} - ::Spectator::LazyDouble.new({{name}}, {{**value_methods}}) + ::Spectator::LazyDouble.new({{name}}, {{value_methods.double_splat}}) {% end %} ::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset }) %double @@ -176,7 +176,7 @@ module Spectator::DSL # See `#def_double`. macro double(name, **value_methods, &block) {% begin %} - {% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{**value_methods}}) {{block}} + {% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{value_methods.double_splat}}) {{block}} {% end %} end @@ -189,7 +189,7 @@ module Spectator::DSL # expect(dbl.foo).to eq(42) # ``` macro double(**value_methods) - ::Spectator::LazyDouble.new({{**value_methods}}) + ::Spectator::LazyDouble.new({{value_methods.double_splat}}) end # Defines a new mock type. @@ -218,24 +218,29 @@ module Spectator::DSL # 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 + {% resolved = type.resolve + # Construct a unique type name for the mock by using the number of defined types. + index = ::Spectator::DSL::Mocks::TYPES.size + # The type is nested under the original so that any type names from the original can be resolved. + 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} + # 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, "#{"::".id unless resolved.name.starts_with?("::")}#{resolved.name}::#{mock_type_name}".id.symbolize} - resolved = type.resolve - base = if resolved.class? - :class - elsif resolved.struct? - :struct - else - :module - end %} + 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}} + {% begin %} + {{base.id}} {{"::".id unless resolved.name.starts_with?("::")}}{{resolved.name}} + ::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{value_methods.double_splat}}) {{block}} + end + {% end %} end # Instantiates a mock. @@ -316,7 +321,7 @@ module Spectator::DSL 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 @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{**value_methods}}) {{block}} + {% if @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{value_methods.double_splat}}) {{block}} {% end %} end @@ -426,7 +431,7 @@ module Spectator::DSL # 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}}, ::{{resolved.name}}, {{**value_methods}}) {{block}} + ::Spectator::Mock.inject({{base}}, {{resolved.name}}, {{value_methods.double_splat}}) {{block}} end # Targets a stubbable object (such as a mock or double) for operations. diff --git a/src/spectator/error_result.cr b/src/spectator/error_result.cr index a4531fb..f58da20 100644 --- a/src/spectator/error_result.cr +++ b/src/spectator/error_result.cr @@ -11,7 +11,7 @@ module Spectator end # Calls the `error` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.error(yield self) end diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 3625ded..e18676c 100644 --- a/src/spectator/example.cr +++ b/src/spectator/example.cr @@ -40,7 +40,7 @@ module Spectator # Note: The metadata will not be merged with the parent metadata. def initialize(@context : Context, @entrypoint : self ->, name : String? = nil, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = Metadata.new) + @group : ExampleGroup? = nil, metadata = nil) super(name, location, metadata) # Ensure group is linked. @@ -58,7 +58,7 @@ module Spectator # Note: The metadata will not be merged with the parent metadata. def initialize(@context : Context, @entrypoint : self ->, @name_proc : Example -> String, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = Metadata.new) + @group : ExampleGroup? = nil, metadata = nil) super(nil, location, metadata) # Ensure group is linked. @@ -75,7 +75,7 @@ module Spectator # A set of *metadata* can be used for filtering and modifying example behavior. # Note: The metadata will not be merged with the parent metadata. def initialize(name : String? = nil, location : Location? = nil, - @group : ExampleGroup? = nil, metadata = Metadata.new, &block : self ->) + @group : ExampleGroup? = nil, metadata = nil, &block : self ->) super(name, location, metadata) @context = NullContext.new @@ -93,9 +93,10 @@ module Spectator # A set of *metadata* can be used for filtering and modifying example behavior. # Note: The metadata will not be merged with the parent metadata. def self.pending(name : String? = nil, location : Location? = nil, - group : ExampleGroup? = nil, metadata = Metadata.new, reason = nil) + group : ExampleGroup? = nil, metadata = nil, reason = nil) # Add pending tag and reason if they don't exist. - metadata = metadata.merge({:pending => nil, :reason => reason}) { |_, v, _| v } + tags = {:pending => nil, :reason => reason} + metadata = metadata ? metadata.merge(tags) { |_, v, _| v } : tags new(name, location, group, metadata) { nil } end @@ -117,7 +118,7 @@ module Spectator begin @result = Harness.run do - if proc = @name_proc.as?(Proc(Example, String)) + if proc = @name_proc self.name = proc.call(self) end @@ -163,7 +164,7 @@ module Spectator # The context casted to an instance of *klass* is provided as a block argument. # # TODO: Benchmark compiler performance using this method versus client-side casting in a proc. - protected def with_context(klass) + protected def with_context(klass, &) context = klass.cast(@context) with context yield end @@ -183,7 +184,7 @@ module Spectator end # Yields this example and all parent groups. - def ascend + def ascend(&) node = self while node yield node @@ -278,7 +279,7 @@ module Spectator # The block given to this method will be executed within the test context. # # TODO: Benchmark compiler performance using this method versus client-side casting in a proc. - protected def with_context(klass) + protected def with_context(klass, &) context = @example.cast_context(klass) with context yield end diff --git a/src/spectator/example_builder.cr b/src/spectator/example_builder.cr index bb640df..23398d2 100644 --- a/src/spectator/example_builder.cr +++ b/src/spectator/example_builder.cr @@ -15,7 +15,7 @@ module Spectator # The *entrypoint* indicates the proc used to invoke the test code in the example. # The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`. def initialize(@context_builder : -> Context, @entrypoint : Example ->, - @name : String? = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) + @name : String? = nil, @location : Location? = nil, @metadata : Metadata? = nil) end # Creates the builder. @@ -24,7 +24,7 @@ module Spectator # The *name* is an interpolated string that runs in the context of the example. # *location*, and *metadata* will be applied to the `Example` produced by `#build`. def initialize(@context_builder : -> Context, @entrypoint : Example ->, - @name : Example -> String, @location : Location? = nil, @metadata : Metadata = Metadata.new) + @name : Example -> String, @location : Location? = nil, @metadata : Metadata? = nil) end # Constructs an example with previously defined attributes and context. diff --git a/src/spectator/example_group.cr b/src/spectator/example_group.cr index dc1fa57..55a3233 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -79,7 +79,7 @@ module Spectator # This group will be assigned to the parent *group* if it is provided. # A set of *metadata* can be used for filtering and modifying example behavior. def initialize(@name : Label = nil, @location : Location? = nil, - @group : ExampleGroup? = nil, @metadata : Metadata = Metadata.new) + @group : ExampleGroup? = nil, @metadata : Metadata? = nil) # Ensure group is linked. group << self if group end @@ -87,7 +87,7 @@ module Spectator delegate size, unsafe_fetch, to: @nodes # Yields this group and all parent groups. - def ascend + def ascend(&) group = self while group yield group diff --git a/src/spectator/example_group_builder.cr b/src/spectator/example_group_builder.cr index 05c740f..207cb6e 100644 --- a/src/spectator/example_group_builder.cr +++ b/src/spectator/example_group_builder.cr @@ -28,7 +28,7 @@ module Spectator # Creates the builder. # Initially, the builder will have no children and no hooks. # The *name*, *location*, and *metadata* will be applied to the `ExampleGroup` produced by `#build`. - def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) + def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata? = nil) end # Constructs an example group with previously defined attributes, children, and hooks. diff --git a/src/spectator/example_group_iteration.cr b/src/spectator/example_group_iteration.cr index d6576d2..0d20a29 100644 --- a/src/spectator/example_group_iteration.cr +++ b/src/spectator/example_group_iteration.cr @@ -18,7 +18,7 @@ module Spectator # This group will be assigned to the parent *group* if it is provided. # A set of *metadata* can be used for filtering and modifying example behavior. def initialize(@item : T, name : Label = nil, location : Location? = nil, - group : ExampleGroup? = nil, metadata : Metadata = Metadata.new) + group : ExampleGroup? = nil, metadata : Metadata? = nil) super(name, location, group, metadata) end end diff --git a/src/spectator/expectation.cr b/src/spectator/expectation.cr index 780e299..79d8473 100644 --- a/src/spectator/expectation.cr +++ b/src/spectator/expectation.cr @@ -114,6 +114,21 @@ module Spectator report(match_data, message) end + # Asserts that some criteria defined by the matcher is satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as the expected type, if the matcher is satisfied. + def to(matcher : Matchers::TypeMatcher(U), message = nil) forall U + match_data = matcher.match(@expression) + value = @expression.value + if report(match_data, message) + return value if value.is_a?(U) + + raise "Spectator bug: expected value should have cast to #{U}" + else + raise TypeCastError.new("#{@expression.label} is expected to be a #{U}, but was actually #{value.class}") + end + end + # Asserts that a method is not called before the example completes. @[AlwaysInline] def to_not(stub : Stub, message = nil) : Nil @@ -136,6 +151,36 @@ module Spectator report(match_data, message) end + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast without the unexpected type, if the matcher is satisfied. + def to_not(matcher : Matchers::TypeMatcher(U), message = nil) forall U + match_data = matcher.negated_match(@expression) + value = @expression.value + if report(match_data, message) + return value unless value.is_a?(U) + + raise "Spectator bug: expected value should not be #{U}" + else + raise TypeCastError.new("#{@expression.label} is not expected to be a #{U}, but was actually #{value.class}") + end + end + + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as a non-nillable type, if the matcher is satisfied. + def to_not(matcher : Matchers::NilMatcher, message = nil) + match_data = matcher.negated_match(@expression) + if report(match_data, message) + value = @expression.value + return value unless value.nil? + + raise "Spectator bug: expected value should not be nil" + else + raise NilAssertionError.new("#{@expression.label} is not expected to be nil.") + end + end + # :ditto: @[AlwaysInline] def not_to(matcher, message = nil) : Nil diff --git a/src/spectator/fail_result.cr b/src/spectator/fail_result.cr index c283a80..082a0d2 100644 --- a/src/spectator/fail_result.cr +++ b/src/spectator/fail_result.cr @@ -24,7 +24,7 @@ module Spectator end # Calls the `failure` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.fail(yield self) end diff --git a/src/spectator/formatting/components/block.cr b/src/spectator/formatting/components/block.cr index 40cd5a8..22411a7 100644 --- a/src/spectator/formatting/components/block.cr +++ b/src/spectator/formatting/components/block.cr @@ -13,7 +13,7 @@ module Spectator::Formatting::Components end # Increases the indent by the a specific *amount* for the duration of the block. - private def indent(amount = INDENT) + private def indent(amount = INDENT, &) @indent += amount yield @indent -= amount @@ -23,7 +23,7 @@ module Spectator::Formatting::Components # The contents of the line should be generated by a block provided to this method. # Ensure that _only_ one line is produced by the block, # otherwise the indent will be lost. - private def line(io) + private def line(io, &) @indent.times { io << ' ' } yield io.puts diff --git a/src/spectator/harness.cr b/src/spectator/harness.cr index 6be48b9..1f9fa09 100644 --- a/src/spectator/harness.cr +++ b/src/spectator/harness.cr @@ -43,7 +43,7 @@ module Spectator # The value of `.current` is set to the harness for the duration of the test. # It will be reset after the test regardless of the outcome. # The result of running the test code will be returned. - def self.run : Result + def self.run(&) : Result with_harness do |harness| harness.run { yield } end @@ -53,7 +53,7 @@ module Spectator # The `.current` harness is set to the new harness for the duration of the block. # `.current` is reset to the previous value (probably nil) afterwards, even if the block raises. # The result of the block is returned. - private def self.with_harness + private def self.with_harness(&) previous = @@current begin @@current = harness = new @@ -70,7 +70,7 @@ module Spectator # Runs test code and produces a result based on the outcome. # The test code should be called from within the block given to this method. - def run : Result + def run(&) : Result elapsed, error = capture { yield } elapsed2, error2 = capture { run_deferred } run_cleanup @@ -106,7 +106,7 @@ module Spectator @cleanup << block end - def aggregate_failures(label = nil) + def aggregate_failures(label = nil, &) previous = @aggregate @aggregate = aggregate = [] of Expectation begin @@ -135,7 +135,7 @@ module Spectator # Yields to run the test code and returns information about the outcome. # Returns a tuple with the elapsed time and an error if one occurred (otherwise nil). - private def capture : Tuple(Time::Span, Exception?) + private def capture(&) : Tuple(Time::Span, Exception?) error = nil elapsed = Time.measure do error = catch { yield } @@ -146,7 +146,7 @@ module Spectator # Yields to run a block of code and captures exceptions. # If the block of code raises an error, the error is caught and returned. # If the block doesn't raise an error, then nil is returned. - private def catch : Exception? + private def catch(&) : Exception? yield rescue e e diff --git a/src/spectator/iterative_example_group_builder.cr b/src/spectator/iterative_example_group_builder.cr index 39ad549..fe67c7a 100644 --- a/src/spectator/iterative_example_group_builder.cr +++ b/src/spectator/iterative_example_group_builder.cr @@ -15,7 +15,7 @@ module Spectator # The *collection* is the set of items to create sub-nodes for. # The *iterators* is a list of optional names given to items in the collection. def initialize(@collection : Enumerable(T), name : String? = nil, @iterators : Array(String) = [] of String, - location : Location? = nil, metadata : Metadata = Metadata.new) + location : Location? = nil, metadata : Metadata? = nil) super(name, location, metadata) end diff --git a/src/spectator/matchers/exception_matcher.cr b/src/spectator/matchers/exception_matcher.cr index adec663..b26d390 100644 --- a/src/spectator/matchers/exception_matcher.cr +++ b/src/spectator/matchers/exception_matcher.cr @@ -97,7 +97,7 @@ module Spectator::Matchers # Runs a block of code and returns the exception it threw. # If no exception was thrown, *nil* is returned. - private def capture_exception + private def capture_exception(&) exception = nil begin yield diff --git a/src/spectator/matchers/matcher.cr b/src/spectator/matchers/matcher.cr index e54e55e..05adb81 100644 --- a/src/spectator/matchers/matcher.cr +++ b/src/spectator/matchers/matcher.cr @@ -1,3 +1,4 @@ +require "../value" require "./match_data" module Spectator::Matchers @@ -22,6 +23,19 @@ module Spectator::Matchers # A successful match with `#match` should normally fail for this method, and vice-versa. abstract def negated_match(actual : Expression(T)) : MatchData forall T + # Compares a matcher against a value. + # Enables composable matchers. + def ===(actual : Expression(T)) : Bool + match(actual).matched? + end + + # Compares a matcher against a value. + # Enables composable matchers. + def ===(other) : Bool + expression = Value.new(other) + match(expression).matched? + end + private def match_data_description(actual : Expression(T)) : String forall T match_data_description(actual.label) end diff --git a/src/spectator/matchers/range_matcher.cr b/src/spectator/matchers/range_matcher.cr index 8c31810..8a9a307 100644 --- a/src/spectator/matchers/range_matcher.cr +++ b/src/spectator/matchers/range_matcher.cr @@ -29,7 +29,26 @@ module Spectator::Matchers # Checks whether the matcher is satisfied with the expression given to it. private def match?(actual : Expression(T)) : Bool forall T - expected.value.includes?(actual.value) + actual_value = actual.value + expected_value = expected.value + if expected_value.is_a?(Range) && actual_value.is_a?(Comparable) + return match_impl?(expected_value, actual_value) + end + return false unless actual_value.is_a?(Comparable(typeof(expected_value.begin))) + expected_value.includes?(actual_value) + end + + private def match_impl?(expected_value : Range(B, E), actual_value : Comparable(B)) : Bool forall B, E + expected_value.includes?(actual_value) + end + + private def match_impl?(expected_value : Range(B, E), actual_value : T) : Bool forall B, E, T + return false unless actual_value.is_a?(B) || actual_value.is_a?(Comparable(B)) + expected_value.includes?(actual_value) + end + + private def match_impl?(expected_value : Range(Number, Number), actual_value : Number) : Bool + expected_value.includes?(actual_value) end # Message displayed when the matcher isn't satisfied. diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index 810d2fe..4a6f75f 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -1,13 +1,61 @@ module Spectator # Untyped arguments to a method call (message). abstract class AbstractArguments - # Utility method for comparing two named tuples ignoring order. - private def compare_named_tuples(a : NamedTuple, b : NamedTuple) - a.each do |k, v1| - v2 = b.fetch(k) { return false } - return false unless v1 === v2 + # Use the string representation to avoid over complicating debug output. + def inspect(io : IO) : Nil + to_s(io) + end + + # Utility method for comparing two tuples considering special types. + private def compare_tuples(a : Tuple | Array, b : Tuple | Array) + return false if a.size != b.size + + a.zip(b) do |a_value, b_value| + return false unless compare_values(a_value, b_value) end true end + + # Utility method for comparing two tuples considering special types. + # Supports nilable tuples (ideal for splats). + private def compare_tuples(a : Tuple? | Array?, b : Tuple? | Array?) + return false if a.nil? ^ b.nil? + + compare_tuples(a.not_nil!, b.not_nil!) + end + + # Utility method for comparing two named tuples ignoring order. + private def compare_named_tuples(a : NamedTuple | Hash, b : NamedTuple | Hash) + a.each do |k, v1| + v2 = b.fetch(k) { return false } + return false unless compare_values(v1, v2) + end + true + end + + # Utility method for comparing two arguments considering special types. + # Some types used for case-equality don't work well with unexpected right-hand types. + # This can happen when the right side is a massive union of types. + private def compare_values(a, b) + case a + when Proc + # Using procs as argument matchers isn't supported currently. + # Compare directly instead. + a == b + when Range + # Ranges can only be matched against if their right side is comparable. + # Ensure the right side is comparable, otherwise compare directly. + return a === b if b.is_a?(Comparable(typeof(b))) + a == b + when Tuple, Array + return compare_tuples(a, b) if b.is_a?(Tuple) || b.is_a?(Array) + a === b + when NamedTuple, Hash + return compare_named_tuples(a, b) if b.is_a?(NamedTuple) || b.is_a?(Hash) + a === b + else + a === b + end + end end end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index f9c6e19..f15a0ba 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -81,7 +81,7 @@ module Spectator # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - positional === other.positional && compare_named_tuples(kwargs, other.kwargs) + compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs) end # :ditto: @@ -90,17 +90,18 @@ module Spectator i = 0 other.args.each do |k, v2| + break if i >= positional.size next if kwargs.has_key?(k) # Covered by named arguments. - v1 = positional.fetch(i) { return false } + v1 = positional[i] i += 1 - return false unless v1 === v2 + return false unless compare_values(v1, v2) end other.splat.try &.each do |v2| v1 = positional.fetch(i) { return false } i += 1 - return false unless v1 === v2 + return false unless compare_values(v1, v2) end i == positional.size diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index 8143ba0..e5e43f4 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -98,24 +98,35 @@ module Spectator # Simplified string representation of a double. # Avoids displaying nested content and bloating method instantiation. def to_s(io : IO) : Nil + io << "#<" + {{@type.name(generic_args: false).stringify}} + " " + io << _spectator_stubbed_name << '>' + end + + # :ditto: + def inspect(io : IO) : Nil + io << "#<" + {{@type.name(generic_args: false).stringify}} + " " io << _spectator_stubbed_name + + io << ":0x" + object_id.to_s(io, 16) + io << '>' 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}" } + Log.debug { "Defined stub for #{inspect} #{stub}" } @stubs.unshift(stub) end protected def _spectator_remove_stub(stub : Stub) : Nil - Log.debug { "Removing stub #{stub} from #{_spectator_stubbed_name}" } + Log.debug { "Removing stub #{stub} from #{inspect}" } @stubs.delete(stub) end protected def _spectator_clear_stubs : Nil - Log.debug { "Clearing stubs for #{_spectator_stubbed_name}" } + Log.debug { "Clearing stubs for #{inspect}" } @stubs.clear end @@ -145,17 +156,17 @@ module Spectator # Returns the double's name formatted for user output. private def _spectator_stubbed_name : String {% if anno = @type.annotation(StubbedName) %} - "#" + {{(anno[0] || :Anonymous.id).stringify}} {% else %} - "#" + "Anonymous" {% end %} end private def self._spectator_stubbed_name : String {% if anno = @type.annotation(StubbedName) %} - "#" + {{(anno[0] || :Anonymous.id).stringify}} {% else %} - "#" + "Anonymous" {% end %} end @@ -175,7 +186,7 @@ module Spectator "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}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") end private def _spectator_abstract_stub_fallback(call : MethodCall, type) @@ -194,9 +205,9 @@ module Spectator call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args) _spectator_record_call(call) - Log.trace { "#{_spectator_stubbed_name} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } + Log.trace { "#{inspect} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } - raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + raise ::Spectator::UnexpectedMessage.new("#{inspect} 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 diff --git a/src/spectator/mocks/formal_arguments.cr b/src/spectator/mocks/formal_arguments.cr index e440214..1c0ca69 100644 --- a/src/spectator/mocks/formal_arguments.cr +++ b/src/spectator/mocks/formal_arguments.cr @@ -122,12 +122,12 @@ module Spectator # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - positional === other.positional && compare_named_tuples(kwargs, other.kwargs) + compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs) end # :ditto: def ===(other : FormalArguments) - compare_named_tuples(args, other.args) && splat === other.splat && compare_named_tuples(kwargs, other.kwargs) + compare_named_tuples(args, other.args) && compare_tuples(splat, other.splat) && compare_named_tuples(kwargs, other.kwargs) end end end diff --git a/src/spectator/mocks/lazy_double.cr b/src/spectator/mocks/lazy_double.cr index 9a9257d..75fe30c 100644 --- a/src/spectator/mocks/lazy_double.cr +++ b/src/spectator/mocks/lazy_double.cr @@ -37,13 +37,13 @@ module Spectator # Returns the double's name formatted for user output. private def _spectator_stubbed_name : String - "#" + @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}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") else Log.trace { "Fallback for #{call} - call original" } yield @@ -57,7 +57,7 @@ module Spectator %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) - Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } # Attempt to find a stub that satisfies the method call and arguments. if %stub = _spectator_find_stub(%call) diff --git a/src/spectator/mocks/method_call.cr b/src/spectator/mocks/method_call.cr index b8e973e..9c5fd01 100644 --- a/src/spectator/mocks/method_call.cr +++ b/src/spectator/mocks/method_call.cr @@ -30,7 +30,13 @@ module Spectator # Constructs a string containing the method name and arguments. def to_s(io : IO) : Nil - io << '#' << method << arguments + io << '#' << method + arguments.inspect(io) + end + + # :ditto: + def inspect(io : IO) : Nil + to_s(io) end end end diff --git a/src/spectator/mocks/mock.cr b/src/spectator/mocks/mock.cr index ba72b37..d2a1fde 100644 --- a/src/spectator/mocks/mock.cr +++ b/src/spectator/mocks/mock.cr @@ -1,5 +1,6 @@ require "./method_call" require "./mocked" +require "./mock_registry" require "./reference_mock_registry" require "./stub" require "./stubbed_name" @@ -36,7 +37,35 @@ module Spectator 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}} + {% if base.id == :module.id %} + {{base.id}} {{type_name.id}} + include {{mocked_type.id}} + + # Mock class that includes the mocked module {{mocked_type.id}} + {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} + private class ClassIncludingMock{{type_name.id}} + include {{type_name.id}} + end + + # Returns a mock class that includes the mocked module {{mocked_type.id}}. + def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}} + # FIXME: Creating the instance normally with `.new` causing infinite recursion. + inst = ClassIncludingMock{{type_name.id}}.allocate + inst.initialize(*args, **kwargs) + inst + end + + # Returns a mock class that includes the mocked module {{mocked_type.id}}. + def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}} + # FIXME: Creating the instance normally with `.new` causing infinite recursion. + inst = ClassIncludingMock{{type_name.id}}.allocate + inst.initialize(*args, **kwargs) { |*yargs| yield *yargs } + inst + end + + {% else %} + {{base.id}} {{type_name.id}} < {{mocked_type.id}} + {% end %} include ::Spectator::Mocked extend ::Spectator::StubbedType @@ -50,22 +79,22 @@ module Spectator end {% end %} - def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil + def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil @_spectator_stubs.try &.delete(stub) end - def _spectator_clear_stubs : Nil + def _spectator_clear_stubs : ::Nil @_spectator_stubs = nil end - private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub + private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub - class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall + 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 + private def _spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} @@ -73,7 +102,7 @@ module Spectator \{% end %} end - private def self._spectator_stubbed_name : String + private def self._spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} @@ -120,7 +149,7 @@ module Spectator macro inject(base, type_name, name = nil, **value_methods, &block) {% begin %} {% if name %}@[::Spectator::StubbedName({{name}})]{% end %} - {{base.id}} ::{{type_name.id}} + {{base.id}} {{"::".id unless type_name.id.starts_with?("::")}}{{type_name.id}} include ::Spectator::Mocked extend ::Spectator::StubbedType @@ -129,12 +158,12 @@ module Spectator {% elsif base == :struct %} @@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new {% else %} - {% raise "Unsupported base type #{base} for injecting mock" %} + @@_spectator_mock_registry = ::Spectator::MockRegistry.new {% end %} - private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub + private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub - class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall + class_getter _spectator_calls : ::Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall private def _spectator_stubs entry = @@_spectator_mock_registry.fetch(self) do @@ -143,11 +172,11 @@ module Spectator entry.stubs end - def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil + def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil @@_spectator_mock_registry[self]?.try &.stubs.delete(stub) end - def _spectator_clear_stubs : Nil + def _spectator_clear_stubs : ::Nil @@_spectator_mock_registry.delete(self) end @@ -169,7 +198,7 @@ module Spectator end # Returns the mock's name formatted for user output. - private def _spectator_stubbed_name : String + private def _spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} @@ -178,7 +207,7 @@ module Spectator end # Returns the mock's name formatted for user output. - private def self._spectator_stubbed_name : String + private def self._spectator_stubbed_name : ::String \{% if anno = @type.annotation(::Spectator::StubbedName) %} "#" \{% else %} diff --git a/src/spectator/mocks/mock_registry.cr b/src/spectator/mocks/mock_registry.cr new file mode 100644 index 0000000..29390c6 --- /dev/null +++ b/src/spectator/mocks/mock_registry.cr @@ -0,0 +1,43 @@ +require "./mock_registry_entry" +require "./stub" + +module Spectator + # Stores collections of stubs for mocked types. + # + # This type is intended for all mocked modules 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. + class MockRegistry + @entry : MockRegistryEntry? + + # Retrieves all stubs. + def [](_object = nil) + @entry.not_nil! + end + + # Retrieves all stubs. + def []?(_object = nil) + @entry + end + + # Retrieves all stubs. + # + # 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)) + entry = @entry + if entry.nil? + entry = MockRegistryEntry.new + entry.stubs = yield + @entry = entry + else + entry + end + end + + # Clears all stubs defined for a mocked object. + def delete(object : Reference) : Nil + @entry = nil + end + end +end diff --git a/src/spectator/mocks/null_double.cr b/src/spectator/mocks/null_double.cr index 0ddd03d..587f4ab 100644 --- a/src/spectator/mocks/null_double.cr +++ b/src/spectator/mocks/null_double.cr @@ -26,7 +26,7 @@ module Spectator 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}") + raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}") else Log.trace { "Fallback for #{call} - return self" } self @@ -42,9 +42,9 @@ module Spectator 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}") + raise UnexpectedMessage.new("#{inspect} 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}`.") + raise TypeCastError.new("#{inspect} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.") end end @@ -56,7 +56,7 @@ module Spectator %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) - Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } self end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index cd7c407..3385ad4 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -126,7 +126,41 @@ module Spectator {{method.body}} end - {% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %} + {% original = "previous_def" + # Workaround for Crystal not propagating block with previous_def/super. + if method.accepts_block? + original += "(" + if method.splat_index + method.args.each_with_index do |arg, i| + if i == method.splat_index + if arg.internal_name && arg.internal_name.size > 0 + original += "*#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat + elsif i > method.splat_index + original += "#{arg.name}: #{arg.internal_name}, " + else + original += "#{arg.internal_name}, " + end + end + else + method.args.each do |arg| + original += "#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat + end + # If the block is captured (i.e. `&block` syntax), it must be passed along as an argument. + # Otherwise, use `yield` to forward the block. + captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 + method.block_arg.name + else + nil + end + original += "&#{captured_block}" if captured_block + original += ")" + original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block + end + original = original.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). @@ -145,7 +179,7 @@ module Spectator ::NamedTuple.new( {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), - {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} + {% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %} ::NamedTuple.new( {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ).merge({{method.double_splat}}) @@ -158,10 +192,24 @@ module Spectator # 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}}), - {{ if method.return_type && method.return_type.resolve == NoReturn - :no_return - elsif method.return_type && method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil) - :nil + {{ if rt = method.return_type + if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn + :no_return + else + # Process as an enumerable type to reduce code repetition. + rt = rt.is_a?(Union) ? rt.types : [rt] + # Check if any types are nilable. + nilable = rt.any? do |t| + # These are all macro types that have the `resolve?` method. + (t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) && + (resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil + end + if nilable + :nil + else + :raise + end + end else :raise end }}) @@ -227,7 +275,42 @@ module Spectator {{method.body}} end - {% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %} + {% original = "previous_def" + # Workaround for Crystal not propagating block with previous_def/super. + if method.accepts_block? + original += "(" + if method.splat_index + method.args.each_with_index do |arg, i| + if i == method.splat_index + if arg.internal_name && arg.internal_name.size > 0 + original += "*#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat + elsif i > method.splat_index + original += "#{arg.name}: #{arg.internal_name}" + else + original += "#{arg.internal_name}, " + end + end + else + method.args.each do |arg| + original += "#{arg.internal_name}, " + end + original += "**#{method.double_splat}, " if method.double_splat + end + # If the block is captured (i.e. `&block` syntax), it must be passed along as an argument. + # Otherwise, use `yield` to forward the block. + captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 + method.block_arg.name + else + nil + end + original += "&#{captured_block}" if captured_block + original += ")" + original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block + end + original = original.id %} + {% end %} {% # Reconstruct the method signature. @@ -247,7 +330,7 @@ module Spectator ::NamedTuple.new( {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ), - {% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} + {% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %} ::NamedTuple.new( {% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %} ).merge({{method.double_splat}}) @@ -259,15 +342,25 @@ module Spectator 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 %} + {% if rt = 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}}, - {{ if method.return_type.resolve == NoReturn + {{ if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn :no_return - elsif method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil) - :nil else - :raise + # Process as an enumerable type to reduce code repetition. + rt = rt.is_a?(Union) ? rt.types : [rt] + # Check if any types are nilable. + nilable = rt.any? do |t| + # These are all macro types that have the `resolve?` method. + (t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) && + (resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil + end + if nilable + :nil + else + :raise + end end }}) {% elsif !method.abstract? %} # The method isn't abstract, infer the type it returns without calling it. @@ -338,64 +431,93 @@ module Spectator # 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}.".id if method.receiver}}{{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 %} + definitions = [] of Nil + scope = if type == @type + :previous_def + elsif type.module? + type.name + else + :super + end.id - {% 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 %} + # Add entries for methods in the target type and its class type. + [[:self.id, type.class], [nil, type]].each do |(receiver, t)| + t.methods.each do |method| + definitions << { + type: t, + method: method, + scope: scope, + receiver: receiver, + } + 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}.".id if method.receiver}}{{method.name}}( + # Iterate through all ancestors and add their methods. + type.ancestors.each do |ancestor| + [[:self.id, ancestor.class], [nil, ancestor]].each do |(receiver, t)| + t.methods.each do |method| + # Skip methods already found to prevent redefining them multiple times. + unless definitions.any? do |d| + m = d[:method] + m.name == method.name && + m.args == method.args && + m.splat_index == method.splat_index && + m.double_splat == method.double_splat && + m.block_arg == method.block_arg + end + definitions << { + type: t, + method: method, + scope: :super.id, + receiver: receiver, + } + end + end + end + end + + definitions = definitions.reject do |definition| + name = definition[:method].name + name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.symbolize) + end %} + + {% for definition in definitions %} + {% original_type = definition[:type] + method = definition[:method] + scope = definition[:scope] + receiver = definition[:receiver] + rewrite_args = method.accepts_block? + # Handle calling methods on other objects (primarily for mock modules). + if scope != :super.id && scope != :previous_def.id + if receiver == :self.id + scope = "#{scope}.#{method.name}".id + rewrite_args = true + else + scope = :super.id + end + end %} + # Redefinition of {{original_type}}{{"#".id}}{{method.name}} + {{(method.abstract? ? "abstract_stub abstract" : "default_stub").id}} {{method.visibility.id if method.visibility != :public}} def {{"#{receiver}.".id if receiver}}{{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 %} + {% unless method.abstract? %} + {{scope}}{% if rewrite_args %}({% for arg, i in method.args %} + {% if i == method.splat_index && arg.internal_name && arg.internal_name.size > 0 %}*{{arg.internal_name}}, {% if method.double_splat %}**{{method.double_splat}}, {% end %}{% end %} + {% if method.splat_index && i > method.splat_index %}{{arg.name}}: {{arg.internal_name}}, {% end %} + {% if !method.splat_index || i < method.splat_index %}{{arg.internal_name}}, {% end %}{% end %} + {% if !method.splat_index && method.double_splat %}**{{method.double_splat}}, {% end %} + {% captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0 + method.block_arg.name + else + nil + end %} + {% if captured_block %}&{{captured_block}}{% end %} + ){% if !captured_block && method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}{% 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 its return value) to the correct type. @@ -415,29 +537,30 @@ module Spectator # 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}}) + %type = {{type}} # 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. %cast = %value.as?({{type}}) - if %cast.is_a?({{type}}) + + {% if fail_cast == :nil %} %cast - else - {% if fail_cast == :nil %} - nil - {% elsif fail_cast == :raise %} + {% elsif fail_cast == :raise %} + # Check if nil was returned by the stub and if its okay to return it. + if %value.nil? && %type.nilable? + # Value was nil and nil is allowed to be returned. + %type.cast(%cast) + elsif %cast.nil? # 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 - "" - end - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.") - {% else %} - {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %} - {% end %} - end + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.") + else + # Types match and value can be returned as cast type. + %cast + end + {% else %} + {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %} + {% end %} {% end %} end end diff --git a/src/spectator/node.cr b/src/spectator/node.cr index 807c8df..6a5d068 100644 --- a/src/spectator/node.cr +++ b/src/spectator/node.cr @@ -30,14 +30,16 @@ module Spectator end # User-defined tags and values used for filtering and behavior modification. - getter metadata : Metadata + def metadata : Metadata + @metadata ||= Metadata.new + end # Creates the node. # The *name* describes the purpose of the node. # It can be a `Symbol` to describe a type. # The *location* tracks where the node exists in source code. # A set of *metadata* can be used for filtering and modifying example behavior. - def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new) + def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata? = nil) end # Indicates whether the node has completed. @@ -46,17 +48,25 @@ module Spectator # Checks if the node has been marked as pending. # Pending items should be skipped during execution. def pending? - metadata.has_key?(:pending) || metadata.has_key?(:skip) + return false unless md = @metadata + + md.has_key?(:pending) || md.has_key?(:skip) end # Gets the reason the node has been marked as pending. def pending_reason - metadata[:pending]? || metadata[:skip]? || metadata[:reason]? || DEFAULT_PENDING_REASON + return DEFAULT_PENDING_REASON unless md = @metadata + + md[:pending]? || md[:skip]? || md[:reason]? || DEFAULT_PENDING_REASON end # Retrieves just the tag names applied to the node. def tags - Tags.new(metadata.keys) + if md = @metadata + Tags.new(md.keys) + else + Tags.new + end end # Non-nil name used to show the node name. diff --git a/src/spectator/pass_result.cr b/src/spectator/pass_result.cr index 20e3b04..21ed6c5 100644 --- a/src/spectator/pass_result.cr +++ b/src/spectator/pass_result.cr @@ -9,7 +9,7 @@ module Spectator end # Calls the `pass` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.pass(yield self) end diff --git a/src/spectator/pending_example_builder.cr b/src/spectator/pending_example_builder.cr index a1f0292..434efe5 100644 --- a/src/spectator/pending_example_builder.cr +++ b/src/spectator/pending_example_builder.cr @@ -11,7 +11,7 @@ module Spectator # The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`. # A default *reason* can be given in case the user didn't provide one. def initialize(@name : String? = nil, @location : Location? = nil, - @metadata : Metadata = Metadata.new, @reason : String? = nil) + @metadata : Metadata? = nil, @reason : String? = nil) end # Constructs an example with previously defined attributes. diff --git a/src/spectator/pending_result.cr b/src/spectator/pending_result.cr index cff38c5..57f7fd7 100644 --- a/src/spectator/pending_result.cr +++ b/src/spectator/pending_result.cr @@ -28,7 +28,7 @@ module Spectator end # Calls the `pending` method on the *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.pending(yield self) end diff --git a/src/spectator/should.cr b/src/spectator/should.cr index eb0733f..f0fe075 100644 --- a/src/spectator/should.cr +++ b/src/spectator/should.cr @@ -22,51 +22,106 @@ class Object # ``` # require "spectator/should" # ``` - def should(matcher, message = nil) + def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end + # Asserts that some criteria defined by the matcher is satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as the expected type, if the matcher is satisfied. + def should(matcher : ::Spectator::Matchers::TypeMatcher(U), message = nil, *, _file = __FILE__, _line = __LINE__) forall U + actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) + match_data = matcher.match(actual) + expectation = ::Spectator::Expectation.new(match_data, location, message) + if ::Spectator::Harness.current.report(expectation) + return self if self.is_a?(U) + + raise "Spectator bug: expected value should have cast to #{U}" + else + raise TypeCastError.new("Expected value should be a #{U}, but was actually #{self.class}") + end + end + # Works the same as `#should` except the condition is inverted. # When `#should` succeeds, this method will fail, and vice-versa. - def should_not(matcher, message = nil) + def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.negated_match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast without the unexpected type, if the matcher is satisfied. + def should_not(matcher : ::Spectator::Matchers::TypeMatcher(U), message = nil, *, _file = __FILE__, _line = __LINE__) forall U + actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) + match_data = matcher.negated_match(actual) + expectation = ::Spectator::Expectation.new(match_data, location, message) + if ::Spectator::Harness.current.report(expectation) + return self unless self.is_a?(U) + + raise "Spectator bug: expected value should not be #{U}" + else + raise TypeCastError.new("Expected value is not expected to be a #{U}, but was actually #{self.class}") + end + end + + # Asserts that some criteria defined by the matcher is not satisfied. + # Allows a custom message to be used. + # Returns the expected value cast as a non-nillable type, if the matcher is satisfied. + def should_not(matcher : ::Spectator::Matchers::NilMatcher, message = nil, *, _file = __FILE__, _line = __LINE__) + actual = ::Spectator::Value.new(self) + location = ::Spectator::Location.new(_file, _line) + match_data = matcher.negated_match(actual) + expectation = ::Spectator::Expectation.new(match_data, location, message) + if ::Spectator::Harness.current.report(expectation) + return self unless self.nil? + + raise "Spectator bug: expected value should not be nil" + else + raise NilAssertionError.new("Expected value should not be nil.") + end + end + # Works the same as `#should` except that the condition check is postponed. # The expectation is checked after the example finishes and all hooks have run. - def should_eventually(matcher, message = nil) - ::Spectator::Harness.current.defer { should(matcher, message) } + def should_eventually(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) + ::Spectator::Harness.current.defer { should(matcher, message, _file: _file, _line: _line) } end # Works the same as `#should_not` except that the condition check is postponed. # The expectation is checked after the example finishes and all hooks have run. - def should_never(matcher, message = nil) - ::Spectator::Harness.current.defer { should_not(matcher, message) } + def should_never(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) + ::Spectator::Harness.current.defer { should_not(matcher, message, _file: _file, _line: _line) } end end struct Proc(*T, R) # Extension method to create an expectation for a block of code (proc). # Depending on the matcher, the proc may be executed multiple times. - def should(matcher, message = nil) + def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Block.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end # Works the same as `#should` except the condition is inverted. # When `#should` succeeds, this method will fail, and vice-versa. - def should_not(matcher, message = nil) + def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__) actual = ::Spectator::Block.new(self) + location = ::Spectator::Location.new(_file, _line) match_data = matcher.negated_match(actual) - expectation = ::Spectator::Expectation.new(match_data, message: message) + expectation = ::Spectator::Expectation.new(match_data, location, message) ::Spectator::Harness.current.report(expectation) end end diff --git a/src/spectator/spec_builder.cr b/src/spectator/spec_builder.cr index 265b41d..17b0284 100644 --- a/src/spectator/spec_builder.cr +++ b/src/spectator/spec_builder.cr @@ -60,7 +60,7 @@ module Spectator # # A set of *metadata* can be used for filtering and modifying example behavior. # For instance, adding a "pending" tag will mark tests as pending and skip execution. - def start_group(name, location = nil, metadata = Metadata.new) : Nil + def start_group(name, location = nil, metadata = nil) : Nil Log.trace { "Start group: #{name.inspect} @ #{location}; metadata: #{metadata}" } builder = ExampleGroupBuilder.new(name, location, metadata) @@ -86,7 +86,7 @@ module Spectator # # A set of *metadata* can be used for filtering and modifying example behavior. # For instance, adding a "pending" tag will mark tests as pending and skip execution. - def start_iterative_group(collection, name, iterator = nil, location = nil, metadata = Metadata.new) : Nil + def start_iterative_group(collection, name, iterator = nil, location = nil, metadata = nil) : Nil Log.trace { "Start iterative group: #{name} (#{typeof(collection)}) @ #{location}; metadata: #{metadata}" } builder = IterativeExampleGroupBuilder.new(collection, name, iterator, location, metadata) @@ -127,7 +127,7 @@ module Spectator # It will be yielded two arguments - the example created by this method, and the *context* argument. # The return value of the block is ignored. # It is expected that the test code runs when the block is called. - def add_example(name, location, context_builder, metadata = Metadata.new, &block : Example -> _) : Nil + def add_example(name, location, context_builder, metadata = nil, &block : Example -> _) : Nil Log.trace { "Add example: #{name} @ #{location}; metadata: #{metadata}" } current << ExampleBuilder.new(context_builder, block, name, location, metadata) end @@ -144,7 +144,7 @@ module Spectator # A set of *metadata* can be used for filtering and modifying example behavior. # For instance, adding a "pending" tag will mark the test as pending and skip execution. # A default *reason* can be given in case the user didn't provide one. - def add_pending_example(name, location, metadata = Metadata.new, reason = nil) : Nil + def add_pending_example(name, location, metadata = nil, reason = nil) : Nil Log.trace { "Add pending example: #{name} @ #{location}; metadata: #{metadata}" } current << PendingExampleBuilder.new(name, location, metadata, reason) end diff --git a/src/spectator/tag_node_filter.cr b/src/spectator/tag_node_filter.cr index d360712..0dedd59 100644 --- a/src/spectator/tag_node_filter.cr +++ b/src/spectator/tag_node_filter.cr @@ -10,7 +10,9 @@ module Spectator # Checks whether the node satisfies the filter. def includes?(node) : Bool - node.metadata.any? { |key, value| key.to_s == @tag && (!@value || value == @value) } + return false unless metadata = node.metadata + + metadata.any? { |key, value| key.to_s == @tag && (!@value || value == @value) } end end end diff --git a/src/spectator/test_context.cr b/src/spectator/test_context.cr index a68c5b9..e04fe56 100644 --- a/src/spectator/test_context.cr +++ b/src/spectator/test_context.cr @@ -34,7 +34,7 @@ class SpectatorTestContext < SpectatorContext # Initial metadata for tests. # This method should be overridden by example groups and examples. - private def self.metadata - ::Spectator::Metadata.new + private def self.metadata : ::Spectator::Metadata? + nil end end diff --git a/src/spectator/wrapper.cr b/src/spectator/wrapper.cr index 9874dec..76f6f44 100644 --- a/src/spectator/wrapper.cr +++ b/src/spectator/wrapper.cr @@ -13,18 +13,13 @@ module Spectator # Creates a wrapper for the specified value. def initialize(value) - @pointer = Box.box(value) + @pointer = Value.new(value).as(Void*) end # Retrieves the previously wrapped value. # The *type* of the wrapped value must match otherwise an error will be raised. def get(type : T.class) : T forall T - {% begin %} - {% if T.nilable? %} - @pointer.null? ? nil : - {% end %} - Box(T).unbox(@pointer) - {% end %} + @pointer.unsafe_as(Value(T)).get end # Retrieves the previously wrapped value. @@ -39,12 +34,20 @@ module Spectator # type = wrapper.get { Int32 } # Returns Int32 # ``` def get(& : -> T) : T forall T - {% begin %} - {% if T.nilable? %} - @pointer.null? ? nil : - {% end %} - Box(T).unbox(@pointer) - {% end %} + @pointer.unsafe_as(Value(T)).get + end + + # Wrapper for a value. + # Similar to `Box`, but doesn't segfault on nil and unions. + private class Value(T) + # Creates the wrapper. + def initialize(@value : T) + end + + # Retrieves the value. + def get : T + @value + end end end end diff --git a/util/nightly.sh b/util/nightly.sh new file mode 100755 index 0000000..460a839 --- /dev/null +++ b/util/nightly.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +set -e + +readonly image=crystallang/crystal:nightly +readonly code=/project + +docker run -it -v "$PWD:${code}" -w "${code}" "${image}" crystal spec "$@" diff --git a/util/test-all-individually.sh b/util/test-all-individually.sh new file mode 100755 index 0000000..97bdd36 --- /dev/null +++ b/util/test-all-individually.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +find spec/ -type f -name \*_spec.cr -print0 | \ + xargs -0 -n1 crystal spec --error-on-warnings -v