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/.gitlab-ci.yml b/.gitlab-ci.yml index b3adb42..d627d27 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,7 +13,7 @@ before_script: spec: script: - - crystal spec --error-on-warnings --junit_output=. spec/runtime_example_spec.cr spec/matchers/ spec/spectator/*.cr + - crystal spec --error-on-warnings --junit_output=. spec/matchers/ spec/spectator/*.cr artifacts: when: always paths: diff --git a/CHANGELOG.md b/CHANGELOG.md index cc33e24..f2d8d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,82 @@ 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.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. + +### Fixed +- Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests. +- Ensure stubs defined with `allow().to receive()` syntax are cleared after test finishes when used inside a test (another leakage). +- Fix crash caused when logging is enabled after running an example that attempts to exit. + +### Removed +- Removed support for stubbing undefined (untyped) methods in lazy doubles. Avoids possible segfault. + +## [0.11.3] - 2022-09-03 +### Fixed +- Display error block (failure message and stack trace) when using `fail`. [#78](https://gitlab.com/arctic-fox/spectator/-/issues/78) +- Defining a custom matcher outside of the `Spectator` namespace no longer produces a compilation error. [#46](https://github.com/icy-arctic-fox/spectator/issues/46) + +## [0.11.2] - 2022-08-07 +### Fixed +- `expect_raises` with block and no arguments produces compilation error. [#77](https://gitlab.com/arctic-fox/spectator/-/issues/77) + +### Changed +- `-e` (`--example`) CLI option performs a partial match instead of exact match. [#71](https://gitlab.com/arctic-fox/spectator/-/issues/71) [#45](https://github.com/icy-arctic-fox/spectator/issues/45) + +## [0.11.1] - 2022-07-18 +### Fixed +- Workaround nilable type issue with memoized value. [#76](https://gitlab.com/arctic-fox/spectator/-/issues/76) + ## [0.11.0] - 2022-07-14 ### Changed - Overhauled mock and double system. [#63](https://gitlab.com/arctic-fox/spectator/-/issues/63) @@ -387,7 +463,16 @@ 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.0...master +[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 [0.11.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...v0.11.0 [0.10.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...v0.10.6 [0.10.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.4...v0.10.5 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 dc55c9b..ac8b54f 100644 --- a/shard.yml +++ b/shard.yml @@ -1,16 +1,16 @@ name: spectator -version: 0.11.0 +version: 0.12.1 description: | - A feature-rich spec testing framework for Crystal with similarities to RSpec. + Feature-rich testing framework for Crystal inspired by RSpec. authors: - Michael Miller -crystal: 1.5.0 +crystal: 1.6.0 license: MIT development_dependencies: ameba: github: crystal-ameba/ameba - version: ~> 1.0.0 + version: ~> 1.2.0 diff --git a/spec/docs/custom_matchers_spec.cr b/spec/docs/custom_matchers_spec.cr new file mode 100644 index 0000000..d3ee565 --- /dev/null +++ b/spec/docs/custom_matchers_spec.cr @@ -0,0 +1,91 @@ +require "../spec_helper" + +# https://gitlab.com/arctic-fox/spectator/-/wikis/Custom-Matchers +Spectator.describe "Custom Matchers Docs" do + context "value matcher" do + # Sub-type of Matcher to suit our needs. + # Notice this is a struct. + struct MultipleOfMatcher(ExpectedType) < Spectator::Matchers::ValueMatcher(ExpectedType) + # Short text about the matcher's purpose. + # This explains what condition satisfies the matcher. + # The description is used when the one-liner syntax is used. + def description : String + "is a multiple of #{expected.label}" + end + + # Checks whether the matcher is satisfied with the expression given to it. + private def match?(actual : Spectator::Expression(T)) : Bool forall T + actual.value % expected.value == 0 + end + + # Message displayed when the matcher isn't satisfied. + # The message should typically only contain the test expression labels. + private def failure_message(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is not a multiple of #{expected.label}" + end + + # Message displayed when the matcher isn't satisfied and is negated. + # This is essentially what would satisfy the matcher if it wasn't negated. + # The message should typically only contain the test expression labels. + private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is a multiple of #{expected.label}" + end + end + + # The DSL portion of the matcher. + # This captures the test expression and creates an instance of the matcher. + macro be_a_multiple_of(expected) + %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}}) + MultipleOfMatcher.new(%value) + end + + specify do + expect(9).to be_a_multiple_of(3) + # or negated: + expect(5).to_not be_a_multiple_of(2) + end + + specify "failure messages" do + expect { expect(9).to be_a_multiple_of(5) }.to raise_error(Spectator::ExpectationFailed, "9 is not a multiple of 5") + expect { expect(6).to_not be_a_multiple_of(3) }.to raise_error(Spectator::ExpectationFailed, "6 is a multiple of 3") + end + end + + context "standard matcher" do + struct OddMatcher < Spectator::Matchers::StandardMatcher + def description : String + "is odd" + end + + private def match?(actual : Spectator::Expression(T)) : Bool forall T + actual.value % 2 == 1 + end + + private def failure_message(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is not odd" + end + + private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T + "#{actual.label} is odd" + end + + private def does_not_match?(actual : Spectator::Expression(T)) : Bool forall T + actual.value % 2 == 0 + end + end + + macro be_odd + OddMatcher.new + end + + specify do + expect(9).to be_odd + expect(2).to_not be_odd + end + + specify "failure messages" do + expect { expect(2).to be_odd }.to raise_error(Spectator::ExpectationFailed, "2 is not odd") + expect { expect(3).to_not be_odd }.to raise_error(Spectator::ExpectationFailed, "3 is odd") + end + end +end diff --git a/spec/docs/mocks_spec.cr b/spec/docs/mocks_spec.cr index 6e164ae..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 @@ -146,9 +249,9 @@ Spectator.describe "Mocks Docs" do inst.something end - it "leaks stubs to other examples" do + it "reverts to default stub for other examples" do inst = mock(MyStruct) - expect(inst.something).to eq(7) # Previous stub was leaked. + expect(inst.something).to eq(5) # Default stub used instead of original behavior. end end end 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/helpers/.gitkeep b/spec/helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/helpers/example.cr b/spec/helpers/example.cr deleted file mode 100644 index 34034ec..0000000 --- a/spec/helpers/example.cr +++ /dev/null @@ -1,71 +0,0 @@ -require "ecr" -require "json" -require "./result" - -module Spectator::SpecHelpers - # Wrapper for compiling and running an example at runtime and getting a result. - class Example - # Creates the example. - # The *spec_helper_path* is the path to spec_helper.cr file. - # The name or ID of the example is given by *example_id*. - # Lastly, the source code for the example is given by *example_code*. - def initialize(@spec_helper_path : String, @example_id : String, @example_code : String) - end - - # Instructs the Crystal compiler to compile the test. - # Returns an instance of `JSON::Any`. - # This will be the outcome and information about the test. - # Output will be suppressed for the test. - # If an error occurs while attempting to compile and run the test, an error will be raised. - def compile - # Create a temporary file containing the test. - with_tempfile do |source_file| - args = ["run", "--no-color", source_file, "--", "--json"] - Process.run(crystal_executable, args) do |process| - JSON.parse(process.output) - rescue JSON::ParseException - raise "Compilation of example #{@example_id} failed\n\n#{process.error.gets_to_end}" - end - end - end - - # Same as `#compile`, but returns the result of the first example in the test. - # Returns a `SpectatorHelpers::Result` instance. - def result - output = compile - example = output["examples"][0] - Result.from_json_any(example) - end - - # Constructs the string representation of the example. - # This produces the Crystal source code. - # *io* is the file handle to write to. - # The *dir* is the directory of the file being written to. - # This is needed to resolve the relative path to the spec_helper.cr file. - private def write(io, dir) - spec_helper_path = Path[@spec_helper_path].relative_to(dir) # ameba:disable Lint/UselessAssign - ECR.embed(__DIR__ + "/example.ecr", io) - end - - # Creates a temporary file containing the compilable example code. - # Yields the path of the temporary file. - # Ensures the file is deleted after it is done being used. - private def with_tempfile - tempfile = File.tempfile("_#{@example_id}_spec.cr") do |file| - dir = File.dirname(file.path) - write(file, dir) - end - - begin - yield tempfile.path - ensure - tempfile.delete - end - end - - # Attempts to find the Crystal compiler on the system or raises an error. - private def crystal_executable - Process.find_executable("crystal") || raise("Could not find Crystal compiler") - end - end -end diff --git a/spec/helpers/example.ecr b/spec/helpers/example.ecr deleted file mode 100644 index 53355bf..0000000 --- a/spec/helpers/example.ecr +++ /dev/null @@ -1,5 +0,0 @@ -require "<%= spec_helper_path %>" - -Spectator.describe "<%= @example_id %>" do - <%= @example_code %> -end diff --git a/spec/helpers/expectation.cr b/spec/helpers/expectation.cr deleted file mode 100644 index fd4d84d..0000000 --- a/spec/helpers/expectation.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Spectator::SpecHelpers - # Information about an `expect` call in an example. - struct Expectation - # Indicates whether the expectation passed or failed. - getter? satisfied : Bool - - # Message when the expectation failed. - # Only available when `#satisfied?` is false. - getter! message : String - - # Additional information about the expectation. - # Only available when `#satisfied?` is false. - getter! values : Hash(String, String) - - # Creates the expectation outcome. - def initialize(@satisfied, @message, @values) - end - - # Extracts the expectation information from a `JSON::Any` object. - def self.from_json_any(object : JSON::Any) - satisfied = object["satisfied"].as_bool - message = object["failure"]?.try(&.as_s?) - values = object["values"]?.try(&.as_h?) - values = values.transform_values(&.as_s) if values - new(satisfied, message, values) - end - end -end diff --git a/spec/helpers/result.cr b/spec/helpers/result.cr deleted file mode 100644 index edafd75..0000000 --- a/spec/helpers/result.cr +++ /dev/null @@ -1,67 +0,0 @@ -module Spectator::SpecHelpers - # Information about an example compiled and run at runtime. - struct Result - # Status of the example after running. - enum Outcome - Success - Failure - Error - Unknown - end - - # Full name and description of the example. - getter name : String - - # Status of the example after running. - getter outcome : Outcome - - # List of expectations ran in the example. - getter expectations : Array(Expectation) - - # Creates the result. - def initialize(@name, @outcome, @expectations) - end - - # Checks if the example was successful. - def success? - outcome.success? - end - - # :ditto: - def successful? - outcome.success? - end - - # Checks if the example failed, but did not error. - def failure? - outcome.failure? - end - - # Checks if the example encountered an error. - def error? - outcome.error? - end - - # Extracts the result information from a `JSON::Any` object. - def self.from_json_any(object : JSON::Any) - name = object["description"].as_s - outcome = parse_outcome_string(object["status"].as_s) - expectations = if (list = object["expectations"].as_a?) - list.map { |e| Expectation.from_json_any(e) } - else - [] of Expectation - end - new(name, outcome, expectations) - end - - # Converts a result string, such as "fail" to an enum value. - private def self.parse_outcome_string(string) - case string - when /pass/i then Outcome::Success - when /fail/i then Outcome::Failure - when /error/i then Outcome::Error - else Outcome::Unknown - end - end - end -end diff --git a/spec/issues/github_issue_44_spec.cr b/spec/issues/github_issue_44_spec.cr index 0ab646d..da6cbf3 100644 --- a/spec/issues/github_issue_44_spec.cr +++ b/spec/issues/github_issue_44_spec.cr @@ -9,12 +9,32 @@ Spectator.describe "GitHub Issue #44" do let(command) { "ls -l" } let(exception) { File::NotFoundError.new("File not found", file: "test.file") } - before_each do - expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception) + context "with positional arguments" do + before_each do + pipe = Process::Redirect::Pipe + expect(Process).to receive(:run).with(command, nil, nil, false, true, pipe, pipe, pipe, nil).and_raise(exception) + 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 - skip "must stub Process.run", skip: "Method mock not applied" do - Process.run(command, shell: true, output: :pipe) do |_process| + # Original issue uses keyword arguments in place of positional arguments. + context "keyword arguments in place of positional arguments" do + before_each do + pipe = Process::Redirect::Pipe + expect(Process).to receive(:run).with(command, shell: true, output: pipe).and_raise(exception) + 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_47_spec.cr b/spec/issues/github_issue_47_spec.cr new file mode 100644 index 0000000..3576a2d --- /dev/null +++ b/spec/issues/github_issue_47_spec.cr @@ -0,0 +1,18 @@ +require "../spec_helper" + +Spectator.describe "GitHub Issue #47" do + class Original + def foo(arg1, arg2) + # ... + end + end + + mock Original + + let(fake) { mock(Original) } + + specify do + expect(fake).to receive(:foo).with("arg1", arg2: "arg2") + fake.foo("arg1", "arg2") + 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 new file mode 100644 index 0000000..996af80 --- /dev/null +++ b/spec/issues/gitlab_issue_51_spec.cr @@ -0,0 +1,109 @@ +require "../spec_helper" + +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 + + 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 + +Spectator.describe GitLabIssue51::Bar do + mock GitLabIssue51::Foo, call: "", alt1_call: "", alt2_call: "" + + let(:foo) { mock(GitLabIssue51::Foo) } + subject(:call) { described_class.new.call(foo) } + + describe "#call" do + it "invokes Foo#call" do + call + expect(foo).to have_received(:call) + end + + it "invokes Foo#alt1_call" do + call + expect(foo).to have_received(:alt1_call) + end + + it "invokes Foo#alt2_call" do + call + expect(foo).to have_received(:alt2_call) + end + + describe "with an explicit return of nil" do + it "should invoke Foo#call?" do + allow(foo).to receive(:call).and_return(nil) + call + expect(foo).to have_received(:call) + end + + it "invokes Foo#alt1_call" do + allow(foo).to receive(:alt1_call).and_return(nil) + call + expect(foo).to have_received(:alt1_call) + end + + it "invokes Foo#alt2_call" do + allow(foo).to receive(:alt2_call).and_return(nil) + call + expect(foo).to have_received(:alt2_call) + end + end + + describe "with returns set in before_each for all calls" do + before_each do + allow(foo).to receive(:call).and_return(nil) + allow(foo).to receive(:alt1_call).and_return(nil) + allow(foo).to receive(:alt2_call).and_return(nil) + end + + it "should invoke Foo#call?" do + call + expect(foo).to have_received(:call) + end + + it "should invoke Foo#alt1_call?" do + call + expect(foo).to have_received(:alt1_call) + end + + it "should invoke Foo#alt2_call?" do + call + expect(foo).to have_received(:alt2_call) + end + end + + describe "with returns set in before_each for alt calls only" do + before_each do + allow(foo).to receive(:alt1_call).and_return(nil) + allow(foo).to receive(:alt2_call).and_return(nil) + end + + it "invokes Foo#alt1_call" do + call + expect(foo).to have_received(:alt1_call) + end + + it "invokes Foo#alt2_call" do + call + expect(foo).to have_received(:alt2_call) + end + end + end +end diff --git a/spec/issues/gitlab_issue_76_spec.cr b/spec/issues/gitlab_issue_76_spec.cr new file mode 100644 index 0000000..3427af8 --- /dev/null +++ b/spec/issues/gitlab_issue_76_spec.cr @@ -0,0 +1,6 @@ +require "../spec_helper" + +Spectator.describe "GitLab Issue #76" do + let(:value) { nil.as(Int32?) } + specify { expect(value).to be_nil } +end diff --git a/spec/issues/gitlab_issue_77_spec.cr b/spec/issues/gitlab_issue_77_spec.cr new file mode 100644 index 0000000..f13c1b7 --- /dev/null +++ b/spec/issues/gitlab_issue_77_spec.cr @@ -0,0 +1,10 @@ +require "../spec_helper" + +# https://gitlab.com/arctic-fox/spectator/-/issues/77 +Spectator.describe "GitLab Issue #77" do + it "fails" do + expect_raises do + raise "Error!" + end + end +end 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/matchers/receive_matcher_spec.cr b/spec/matchers/receive_matcher_spec.cr index 6dfdd28..b5addc5 100644 --- a/spec/matchers/receive_matcher_spec.cr +++ b/spec/matchers/receive_matcher_spec.cr @@ -169,7 +169,7 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do end context "with method calls" do - before_each do + before do dbl.test_method dbl.test_method(1, "wrong", :xyz, foo: "foobarbaz") dbl.irrelevant("foo") @@ -289,14 +289,14 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do pre_condition { expect(match_data).to be_a(failed_match) } - before_each do + before do dbl.test_method dbl.test_method(1, "test", :xyz, foo: "foobarbaz") dbl.irrelevant("foo") end it "has the expected call listed" do - is_expected.to contain({:expected, "Not #{stub}"}) + is_expected.to contain({:expected, "Not #{stub.message}"}) end it "has the list of called methods" do diff --git a/spec/rspec/core/explicit_subject_spec.cr b/spec/rspec/core/explicit_subject_spec.cr index 3775b8a..075bad7 100644 --- a/spec/rspec/core/explicit_subject_spec.cr +++ b/spec/rspec/core/explicit_subject_spec.cr @@ -52,7 +52,7 @@ Spectator.describe "Explicit Subject" do describe Array(Int32) do # TODO: Multiple arguments to describe/context. subject { [] of Int32 } - before_each { subject.push(1, 2, 3) } + before { subject.push(1, 2, 3) } it "has the prescribed elements" do expect(subject).to eq([1, 2, 3]) diff --git a/spec/runtime_example_spec.cr b/spec/runtime_example_spec.cr deleted file mode 100644 index 01ae9a3..0000000 --- a/spec/runtime_example_spec.cr +++ /dev/null @@ -1,58 +0,0 @@ -require "./spec_helper" - -# This is a meta test that ensures specs can be compiled and run at runtime. -# The purpose of this is to report an error if this process fails. -# Other tests will fail, but display a different name/description of the test. -# This clearly indicates that runtime testing failed. -# -# Runtime compilation is used to get output of tests as well as check syntax. -# Some specs are too complex to be ran normally. -# Additionally, this allows examples to easily check specific failure cases. -# Plus, it makes testing user-reported issues easy. -Spectator.describe "Runtime compilation", :slow, :compile do - given_example passing_example do - it "does something" do - expect(true).to be_true - end - end - - it "can compile and retrieve the result of an example" do - expect(passing_example).to be_successful - end - - it "can retrieve expectations" do - expect(passing_example.expectations).to_not be_empty - end - - given_example failing_example do - it "does something" do - expect(true).to be_false - end - - it "doesn't run" do - expect(true).to be_false - end - end - - it "detects failed examples" do - expect(failing_example).to be_failure - end - - given_example malformed_example do - it "does something" do - asdf - end - end - - it "raises on compilation errors" do - expect { malformed_example }.to raise_error(/compilation/i) - end - - given_expectation satisfied_expectation do - expect(true).to be_true - end - - it "can compile and retrieve expectations" do - expect(satisfied_expectation).to be_satisfied - end -end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 4c16fd3..e2f9578 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -15,35 +15,3 @@ end macro specify_fails(description = nil, &block) it_fails {{description}} {{block}} end - -# Defines an example ("it" block) that is lazily compiled. -# When the example is referenced with *id*, it will be compiled and the results retrieved. -# The value returned by *id* will be a `Spectator::SpecHelpers::Result`. -# This allows the test result to be inspected. -macro given_example(id, &block) - let({{id}}) do - ::Spectator::SpecHelpers::Example.new( - {{__FILE__}}, - {{id.id.stringify}}, - {{block.body.stringify}} - ).result - end -end - -# Defines an example ("it" block) that is lazily compiled. -# The "it" block must be omitted, as the block provided to this macro will be wrapped in one. -# When the expectation is referenced with *id*, it will be compiled and the result retrieved. -# The value returned by *id* will be a `Spectator::SpecHelpers::Expectation`. -# This allows an expectation to be inspected. -# Only the last expectation performed will be returned. -# An error is raised if no expectations ran. -macro given_expectation(id, &block) - let({{id}}) do - result = ::Spectator::SpecHelpers::Example.new( - {{__FILE__}}, - {{id.id.stringify}}, - {{"it do\n" + block.body.stringify + "\nend"}} - ).result - result.expectations.last || raise("No expectations found from {{id.id}}") - end -end diff --git a/spec/spectator/dsl/mocks/allow_receive_spec.cr b/spec/spectator/dsl/mocks/allow_receive_spec.cr new file mode 100644 index 0000000..473c74b --- /dev/null +++ b/spec/spectator/dsl/mocks/allow_receive_spec.cr @@ -0,0 +1,188 @@ +require "../../../spec_helper" + +Spectator.describe "Allow stub DSL" do + context "with a double" do + double(:dbl) do + # Ensure the original is never called. + stub abstract def foo : Nil + stub abstract def foo(arg) : Nil + stub abstract def value : Int32 + end + + let(dbl) { double(:dbl) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + end + + it "matches when a message is received" do + allow(dbl).to receive(:foo) + expect { dbl.foo }.to_not raise_error + end + + it "returns the correct value" do + allow(dbl).to receive(:value).and_return(42) + expect(dbl.value).to eq(42) + end + + it "matches when a message is received with matching arguments" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo(:bar) }.to_not raise_error + end + + it "raises when a message without arguments is received" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + + it "raises when a message with different arguments is received" do + allow(dbl).to receive(:foo).with(:baz) + expect { dbl.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + end + + context "with a class double" do + double(:dbl) do + # Ensure the original is never called. + abstract_stub def self.foo : Nil + end + + abstract_stub def self.foo(arg) : Nil + end + + abstract_stub def self.value : Int32 + 42 + end + end + + let(dbl) { class_double(:dbl) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + end + + it "matches when a message is received" do + allow(dbl).to receive(:foo) + expect { dbl.foo }.to_not raise_error + end + + it "returns the correct value" do + allow(dbl).to receive(:value).and_return(42) + expect(dbl.value).to eq(42) + end + + it "matches when a message is received with matching arguments" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo(:bar) }.to_not raise_error + end + + it "raises when a message without arguments is received" do + allow(dbl).to receive(:foo).with(:bar) + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + + it "raises when a message with different arguments is received" do + allow(dbl).to receive(:foo).with(:baz) + expect { dbl.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + end + + context "with a mock" do + abstract class MyClass + abstract def foo : Int32 + abstract def foo(arg) : Int32 + end + + mock(MyClass) + + let(fake) { mock(MyClass) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition do + expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage) + end + + it "matches when a message is received" do + allow(fake).to receive(:foo).and_return(42) + expect(fake.foo).to eq(42) + end + + it "returns the correct value" do + allow(fake).to receive(:foo).and_return(42) + expect(fake.foo).to eq(42) + end + + it "matches when a message is received with matching arguments" do + allow(fake).to receive(:foo).with(:bar).and_return(42) + expect(fake.foo(:bar)).to eq(42) + end + + it "raises when a message without arguments is received" do + allow(fake).to receive(:foo).with(:bar) + expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + + it "raises when a message with different arguments is received" do + allow(fake).to receive(:foo).with(:baz) + expect { fake.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/) + end + end + + context "with a class mock" do + class MyClass + def self.foo : Int32 + 42 + end + + def self.foo(arg) : Int32 + 42 + end + end + + mock(MyClass) + + let(fake) { class_mock(MyClass) } + + # Ensure invocations don't leak between examples. + pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + + # Ensure stubs don't leak between examples. + pre_condition { expect(fake.foo).to eq(42) } + + it "matches when a message is received" do + allow(fake).to receive(:foo).and_return(0) + expect(fake.foo).to eq(0) + end + + it "returns the correct value" do + allow(fake).to receive(:foo).and_return(0) + expect(fake.foo).to eq(0) + end + + it "matches when a message is received with matching arguments" do + allow(fake).to receive(:foo).with(:bar).and_return(0) + expect(fake.foo(:bar)).to eq(0) + end + + it "calls the original when a message without arguments is received" do + allow(fake).to receive(:foo).with(:bar) + expect(fake.foo).to eq(42) + end + + it "calls the original when a message with different arguments is received" do + allow(fake).to receive(:foo).with(:baz) + expect(fake.foo(:bar)).to eq(42) + end + end +end diff --git a/spec/spectator/dsl/mocks/double_spec.cr b/spec/spectator/dsl/mocks/double_spec.cr index d3eda6d..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 @@ -312,7 +312,7 @@ Spectator.describe "Double DSL", :smoke do let(override) { :override } let(dbl) { double(:context_double, override: override) } - before_each { allow(dbl).to receive(:memoize).and_return(memoize) } + before { allow(dbl).to receive(:memoize).and_return(memoize) } it "doesn't change predefined values" do expect(dbl.predefined).to eq(:predefined) diff --git a/spec/spectator/dsl/mocks/expect_receive_spec.cr b/spec/spectator/dsl/mocks/expect_receive_spec.cr index 0a0242b..a249ad3 100644 --- a/spec/spectator/dsl/mocks/expect_receive_spec.cr +++ b/spec/spectator/dsl/mocks/expect_receive_spec.cr @@ -14,6 +14,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + dbl._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(dbl).to receive(:foo) dbl.foo @@ -67,6 +73,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage) + dbl._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(dbl).to receive(:foo) dbl.foo @@ -114,6 +126,12 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage) + fake._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do expect(fake).to receive(:foo).and_return(42) fake.foo(:bar) @@ -166,14 +184,20 @@ Spectator.describe "Deferred stub expectation DSL" do # Ensure invocations don't leak between examples. pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" } + # Ensure stubs don't leak between examples. + pre_condition do + expect(fake.foo).to eq(42) + fake._spectator_clear_calls # Don't include previous call in results. + end + it "matches when a message is received" do - expect(fake).to receive(:foo).and_return(42) + expect(fake).to receive(:foo).and_return(0) fake.foo(:bar) end it "returns the correct value" do - expect(fake).to receive(:foo).and_return(42) - expect(fake.foo).to eq(42) + expect(fake).to receive(:foo).and_return(0) + expect(fake.foo).to eq(0) end it "matches when a message isn't received" do @@ -181,12 +205,12 @@ Spectator.describe "Deferred stub expectation DSL" do end it "matches when a message is received with matching arguments" do - expect(fake).to receive(:foo).with(:bar).and_return(42) + expect(fake).to receive(:foo).with(:bar).and_return(0) fake.foo(:bar) end it "matches when a message without arguments is received" do - expect(fake).to_not receive(:foo).with(:bar).and_return(42) + expect(fake).to_not receive(:foo).with(:bar).and_return(0) fake.foo end @@ -195,7 +219,7 @@ Spectator.describe "Deferred stub expectation DSL" do end it "matches when a message with arguments isn't received" do - expect(fake).to_not receive(:foo).with(:baz).and_return(42) + expect(fake).to_not receive(:foo).with(:baz).and_return(0) fake.foo(:bar) end end diff --git a/spec/spectator/dsl/mocks/mock_spec.cr b/spec/spectator/dsl/mocks/mock_spec.cr index d636826..cd57cdc 100644 --- a/spec/spectator/dsl/mocks/mock_spec.cr +++ b/spec/spectator/dsl/mocks/mock_spec.cr @@ -11,7 +11,7 @@ Spectator.describe "Mock DSL", :smoke do args[1].as(Int32), args[2].as(Int32), }, - args[3].as(Int32), + args[:kwarg].as(Int32), { x: args[:x].as(Int32), y: args[:y].as(Int32), @@ -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 @@ -947,7 +947,7 @@ Spectator.describe "Mock DSL", :smoke do let(override) { :override } let(fake) { mock(Dummy, override: override) } - before_each { allow(fake).to receive(:memoize).and_return(memoize) } + before { allow(fake).to receive(:memoize).and_return(memoize) } it "doesn't change predefined values" do expect(fake.predefined).to eq(:predefined) @@ -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/allow_spec.cr b/spec/spectator/mocks/allow_spec.cr index 5bc16a8..090014e 100644 --- a/spec/spectator/mocks/allow_spec.cr +++ b/spec/spectator/mocks/allow_spec.cr @@ -9,5 +9,31 @@ Spectator.describe Spectator::Allow do it "applies a stub" do expect { alw.to(stub) }.to change { dbl.foo }.from(42).to(123) end + + context "leak" do + class Thing + def foo + 42 + end + end + + mock Thing + + getter(thing : Thing) { mock(Thing) } + + # Workaround type restrictions requiring a constant. + def fake + class_mock(Thing).cast(thing) + end + + specify do + expect { allow(fake).to(stub) }.to change { fake.foo }.from(42).to(123) + end + + # This example must be run after the previous (random order may break this). + it "clears the stub after the example completes" do + expect { fake.foo }.to eq(42) + end + end end end diff --git a/spec/spectator/mocks/arguments_spec.cr b/spec/spectator/mocks/arguments_spec.cr index 422b6c6..f6a09b7 100644 --- a/spec/spectator/mocks/arguments_spec.cr +++ b/spec/spectator/mocks/arguments_spec.cr @@ -1,21 +1,15 @@ require "../../spec_helper" Spectator.describe Spectator::Arguments do - subject(arguments) do - Spectator::Arguments.new( + subject(arguments) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) } + + it "stores the arguments" do + expect(arguments).to have_attributes( args: {42, "foo"}, kwargs: {bar: "baz", qux: 123} ) end - it "stores the arguments" do - expect(arguments.args).to eq({42, "foo"}) - end - - it "stores the keyword arguments" do - expect(arguments.kwargs).to eq({bar: "baz", qux: 123}) - end - describe ".capture" do subject { Spectator::Arguments.capture(42, "foo", bar: "baz", qux: 123) } @@ -24,22 +18,20 @@ Spectator.describe Spectator::Arguments do end end - describe "#[]" do - context "with an index" do - it "returns a positional argument" do - aggregate_failures do - expect(arguments[0]).to eq(42) - expect(arguments[1]).to eq("foo") - end + describe "#[](index)" do + it "returns a positional argument" do + aggregate_failures do + expect(arguments[0]).to eq(42) + expect(arguments[1]).to eq("foo") end end + end - context "with a symbol" do - it "returns a named argument" do - aggregate_failures do - expect(arguments[:bar]).to eq("baz") - expect(arguments[:qux]).to eq(123) - end + describe "#[](symbol)" do + it "returns a keyword argument" do + aggregate_failures do + expect(arguments[:bar]).to eq("baz") + expect(arguments[:qux]).to eq(123) end end end @@ -63,50 +55,79 @@ Spectator.describe Spectator::Arguments do describe "#==" do subject { arguments == other } - context "with equal arguments" do - let(other) { arguments } + context "with Arguments" do + context "with equal arguments" do + let(other) { arguments } - it "returns true" do - is_expected.to be_true + it { is_expected.to be_true } + end + + context "with different arguments" do + let(other) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(other) { Spectator::Arguments.new(arguments.args, {qux: 123, bar: "baz"}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(other) { Spectator::Arguments.new(arguments.args, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::Arguments.new(arguments.args, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } end end - context "with different arguments" do - let(other) do - Spectator::Arguments.new( - args: {123, :foo, "bar"}, - kwargs: {opt: "foobar"} - ) + context "with FormalArguments" do + context "with equal arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } end - it "returns false" do - is_expected.to be_false - end - end + context "with different arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, {opt: "foobar"}) } - context "with the same kwargs in a different order" do - let(other) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {qux: 123, bar: "baz"} - ) + it { is_expected.to be_false } end - it "returns true" do - is_expected.to be_true - end - end + context "with the same kwargs in a different order" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {qux: 123, bar: "baz"}) } - context "with a missing kwarg" do - let(other) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {bar: "baz"} - ) + it { is_expected.to be_true } end - it "returns false" do - is_expected.to be_false + context "with a missing kwarg" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_false } + end + + context "with mixed positional tuple types" do + let(other) { Spectator::FormalArguments.new({arg1: 42}, :splat, {"foo"}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } end end end @@ -114,76 +135,149 @@ Spectator.describe Spectator::Arguments do describe "#===" do subject { pattern === arguments } - context "with equal arguments" do - let(pattern) { arguments } + context "with Arguments" do + context "with equal arguments" do + let(pattern) { arguments } - it "returns true" do - is_expected.to be_true + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::Arguments.new({Int32, /foo/}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::Arguments.new({Float64, /bar/}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } + end + + context "with different arguments" do + let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(pattern) { Spectator::Arguments.new(arguments.args, {qux: Int32, bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.args, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.args, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } end end - context "with different arguments" do - let(pattern) do - Spectator::Arguments.new( - args: {123, :foo, "bar"}, - kwargs: {opt: "foobar"} - ) + context "with FormalArguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } + + context "with equal arguments" do + let(pattern) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } end - it "returns false" do - is_expected.to be_false - end - end + context "with matching arguments" do + let(pattern) { Spectator::Arguments.new({Int32, /foo/}, {bar: /baz/, qux: Int32}) } - context "with the same kwargs in a different order" do - let(pattern) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {qux: 123, bar: "baz"} - ) + it { is_expected.to be_true } end - it "returns true" do - is_expected.to be_true - end - end + context "with non-matching arguments" do + let(pattern) { Spectator::Arguments.new({Float64, /bar/}, {bar: /foo/, qux: "123"}) } - context "with a missing kwarg" do - let(pattern) do - Spectator::Arguments.new( - args: arguments.args, - kwargs: {bar: "baz"} - ) + it { is_expected.to be_false } end - it "returns false" do - is_expected.to be_false - end - end + context "with different arguments" do + let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } - context "with matching types and regex" do - let(pattern) do - Spectator::Arguments.new( - args: {Int32, /foo/}, - kwargs: {bar: String, qux: 123} - ) + it { is_expected.to be_false } end - it "returns true" do - is_expected.to be_true - end - end + context "with the same kwargs in a different order" do + let(pattern) { Spectator::Arguments.new(arguments.positional, {qux: Int32, bar: /baz/}) } - context "with different types and regex" do - let(pattern) do - Spectator::Arguments.new( - args: {Symbol, /bar/}, - kwargs: {bar: String, qux: 42} - ) + it { is_expected.to be_true } end - it "returns false" do - is_expected.to be_false + context "with an additional kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.positional, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::Arguments.new(arguments.positional, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) } + let(pattern) { Spectator::Arguments.new({Int32, /foo/, 5}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with matching mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) } + let(pattern) { Spectator::Arguments.new({Int32, /foo/, 1, 2, 3}, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with non-matching mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) } + let(pattern) { Spectator::Arguments.new({Float64, /bar/, 3, 2, Symbol}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with matching args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(Int32, /foo/, Symbol, Symbol, :z, bar: /baz/, qux: Int32) } + + it { is_expected.to be_true } + end + + context "with non-matching args spilling over into splat and mixed positional tuple types" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(Float64, /bar/, Symbol, String, :z, bar: /foo/, qux: Int32) } + + it { is_expected.to be_false } + end + + context "with matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(/foo/, Symbol, :y, Symbol, arg1: Int32, bar: /baz/, qux: 123) } + + it { is_expected.to be_true } + end + + context "with non-matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(5, Symbol, :z, Symbol, arg2: /foo/, bar: /baz/, qux: Int32) } + + it { is_expected.to be_false } + end + + context "with non-matching mixed named positional and keyword arguments" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(pattern) { Spectator::Arguments.capture(/bar/, String, :y, Symbol, arg1: 0, bar: /foo/, qux: Float64) } + + it { is_expected.to be_false } end end end diff --git a/spec/spectator/mocks/double_spec.cr b/spec/spectator/mocks/double_spec.cr index c93a723..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 @@ -309,7 +305,7 @@ Spectator.describe Spectator::Double do subject(dbl) { ClassDouble } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { dbl._spectator_clear_stubs } + after { dbl._spectator_clear_stubs } it "overrides an existing method" do expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override) @@ -357,7 +353,7 @@ Spectator.describe Spectator::Double do end describe "._spectator_clear_stubs" do - before_each { dbl._spectator_define_stub(foo_stub) } + before { dbl._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub) @@ -365,7 +361,7 @@ Spectator.describe Spectator::Double do end describe "._spectator_calls" do - before_each { dbl._spectator_clear_calls } + before { dbl._spectator_clear_calls } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -440,7 +436,7 @@ Spectator.describe Spectator::Double do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) @@ -451,7 +447,7 @@ Spectator.describe Spectator::Double do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -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/formal_arguments_spec.cr b/spec/spectator/mocks/formal_arguments_spec.cr new file mode 100644 index 0000000..963b6eb --- /dev/null +++ b/spec/spectator/mocks/formal_arguments_spec.cr @@ -0,0 +1,325 @@ +require "../../spec_helper" + +Spectator.describe Spectator::FormalArguments do + subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "stores the arguments" do + expect(arguments).to have_attributes( + args: {arg1: 42, arg2: "foo"}, + splat_name: :splat, + splat: {:x, :y, :z}, + kwargs: {bar: "baz", qux: 123} + ) + end + + describe ".build" do + subject { Spectator::FormalArguments.build({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, {bar: "baz", qux: 123}) } + + it "stores the arguments and keyword arguments" do + is_expected.to have_attributes( + args: {arg1: 42, arg2: "foo"}, + splat_name: :splat, + splat: {1, 2, 3}, + kwargs: {bar: "baz", qux: 123} + ) + end + + context "without a splat" do + subject { Spectator::FormalArguments.build({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } + + it "stores the arguments and keyword arguments" do + is_expected.to have_attributes( + args: {arg1: 42, arg2: "foo"}, + splat: nil, + kwargs: {bar: "baz", qux: 123} + ) + end + end + end + + describe "#[](index)" do + it "returns a positional argument" do + aggregate_failures do + expect(arguments[0]).to eq(42) + expect(arguments[1]).to eq("foo") + end + end + + it "returns splat arguments" do + aggregate_failures do + expect(arguments[2]).to eq(:x) + expect(arguments[3]).to eq(:y) + expect(arguments[4]).to eq(:z) + end + end + + context "with named positional arguments" do + subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "returns a positional argument" do + aggregate_failures do + expect(arguments[0]).to eq(42) + expect(arguments[1]).to eq("foo") + end + end + + it "returns splat arguments" do + aggregate_failures do + expect(arguments[2]).to eq(:x) + expect(arguments[3]).to eq(:y) + expect(arguments[4]).to eq(:z) + end + end + end + end + + describe "#[](symbol)" do + it "returns a keyword argument" do + aggregate_failures do + expect(arguments[:bar]).to eq("baz") + expect(arguments[:qux]).to eq(123) + end + end + + context "with named positional arguments" do + subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "returns a positional argument" do + aggregate_failures do + expect(arguments[:arg1]).to eq(42) + expect(arguments[:arg2]).to eq("foo") + end + end + + it "returns a keyword argument" do + aggregate_failures do + expect(arguments[:bar]).to eq("baz") + expect(arguments[:qux]).to eq(123) + end + end + end + end + + describe "#to_s" do + subject { arguments.to_s } + + it "formats the arguments" do + is_expected.to eq("(arg1: 42, arg2: \"foo\", *splat: {:x, :y, :z}, bar: \"baz\", qux: 123)") + end + + context "when empty" do + let(arguments) { Spectator::FormalArguments.none } + + it "returns (no args)" do + is_expected.to eq("(no args)") + end + end + + context "with a splat and no arguments" do + let(arguments) { Spectator::FormalArguments.build(NamedTuple.new, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it "omits the splat name" do + is_expected.to eq("(:x, :y, :z, bar: \"baz\", qux: 123)") + end + end + end + + describe "#==" do + subject { arguments == other } + + context "with Arguments" do + context "with equal arguments" do + let(other) { Spectator::Arguments.new(arguments.positional, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with different arguments" do + let(other) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(other) { Spectator::Arguments.new(arguments.positional, {qux: 123, bar: "baz"}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(other) { Spectator::Arguments.new(arguments.positional, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::Arguments.new(arguments.positional, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end + end + + context "with FormalArguments" do + context "with equal arguments" do + let(other) { arguments } + + it { is_expected.to be_true } + end + + context "with different arguments" do + let(other) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: 123, bar: "baz"}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz"}) } + + it { is_expected.to be_false } + end + + context "with an extra kwarg" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz", qux: 123, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with mixed positional tuple types" do + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with mixed positional tuple types (flipped)" do + let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } + end + end + end + + describe "#===" do + subject { pattern === arguments } + + context "with Arguments" do + let(arguments) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) } + + context "with equal arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) } + + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } + end + + context "with different arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {qux: Int32, bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } + end + end + + context "with FormalArguments" do + context "with equal arguments" do + let(pattern) { arguments } + + it { is_expected.to be_true } + end + + context "with matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, :splat, {Symbol, Symbol, :z}, {bar: /baz/, qux: Int32}) } + + it { is_expected.to be_true } + end + + context "with non-matching arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, :splat, {String, Int32, :x}, {bar: /foo/, qux: "123"}) } + + it { is_expected.to be_false } + end + + context "with different arguments" do + let(pattern) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) } + + it { is_expected.to be_false } + end + + context "with the same kwargs in a different order" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: Int32, bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with an additional kwarg" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/}) } + + it { is_expected.to be_true } + end + + context "with a missing kwarg" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/, qux: Int32, extra: 0}) } + + it { is_expected.to be_false } + end + + context "with different splat arguments" do + let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) } + + it { is_expected.to be_false } + end + + context "with matching mixed positional tuple types" do + let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_true } + end + + context "with non-matching mixed positional tuple types" do + let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, arguments.splat_name, arguments.splat, arguments.kwargs) } + + it { is_expected.to be_false } + end + end + end +end diff --git a/spec/spectator/mocks/lazy_double_spec.cr b/spec/spectator/mocks/lazy_double_spec.cr index 34883dc..8ea5a5d 100644 --- a/spec/spectator/mocks/lazy_double_spec.cr +++ b/spec/spectator/mocks/lazy_double_spec.cr @@ -235,16 +235,9 @@ Spectator.describe Spectator::LazyDouble do end context "with previously undefined methods" do - it "can stub methods" do + it "raises an error" do stub = Spectator::ValueStub.new(:baz, :xyz) - dbl._spectator_define_stub(stub) - expect(dbl.baz).to eq(:xyz) - end - - it "uses a stub only if an argument constraint is met" do - stub = Spectator::ValueStub.new(:baz, :xyz, Spectator::Arguments.capture(:right)) - dbl._spectator_define_stub(stub) - expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) + expect { dbl._spectator_define_stub(stub) }.to raise_error(/stub/) end end end @@ -253,27 +246,18 @@ Spectator.describe Spectator::LazyDouble do subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) end - - it "raises on methods without an implementation" do - stub = Spectator::ValueStub.new(:baz, :xyz) - dbl._spectator_define_stub(stub) - expect(dbl.baz).to eq(:xyz) - - dbl._spectator_clear_stubs - expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) - end end describe "#_spectator_calls" do subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -291,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 @@ -301,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 0c19759..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 @@ -312,7 +388,7 @@ Spectator.describe Spectator::Mock do let(mock) { MockThing } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "overrides an existing method" do expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override) @@ -367,8 +443,22 @@ 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_each { mock._spectator_define_stub(foo_stub) } + before { mock._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub) @@ -376,7 +466,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_calls" do - before_each { mock._spectator_clear_calls } + before { mock._spectator_clear_calls } # Retrieves symbolic names of methods called on a mock. def called_method_names(mock) @@ -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 @@ -410,7 +697,7 @@ Spectator.describe Spectator::Mock do let(mock) { MockThing.new } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "raises a TypeCastError when using a value-based stub" do stub = Spectator::ValueStub.new(:oops, nil).as(Spectator::Stub) @@ -461,7 +748,7 @@ Spectator.describe Spectator::Mock do let(mock) { MockedClass.new } # Necessary to clear stubs to prevent leakages between tests. - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "overrides responses from methods with keyword arguments" do expect(mock.method1).to eq(123) @@ -571,8 +858,8 @@ Spectator.describe Spectator::Mock do let(mock) { MockedStruct.new } # Necessary to clear stubs to prevent leakages between tests. - after_each { mock._spectator_clear_stubs } - after_each { MockedStruct._spectator_invocations.clear } + after { mock._spectator_clear_stubs } + after { MockedStruct._spectator_invocations.clear } it "overrides responses from methods with keyword arguments" do expect(mock.method1).to eq(123) @@ -642,7 +929,7 @@ Spectator.describe Spectator::Mock do arg end - def self.baz(arg) + def self.baz(arg, &) yield end end @@ -656,7 +943,7 @@ Spectator.describe Spectator::Mock do let(mock) { Thing } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "overrides an existing method" do expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override) @@ -712,7 +999,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_clear_stubs" do - before_each { mock._spectator_define_stub(foo_stub) } + before { mock._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub) @@ -720,7 +1007,7 @@ Spectator.describe Spectator::Mock do end describe "._spectator_calls" do - before_each { mock._spectator_clear_calls } + before { mock._spectator_clear_calls } # Retrieves symbolic names of methods called on a mock. def called_method_names(mock) @@ -756,7 +1043,7 @@ Spectator.describe Spectator::Mock do let(mock) { NoReturnThing.new } - after_each { mock._spectator_clear_stubs } + after { mock._spectator_clear_stubs } it "raises a TypeCastError when using a value-based stub" do stub = Spectator::ValueStub.new(:oops, nil).as(Spectator::Stub) diff --git a/spec/spectator/mocks/null_double_spec.cr b/spec/spectator/mocks/null_double_spec.cr index 1aa86ca..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 @@ -270,7 +267,7 @@ Spectator.describe Spectator::NullDouble do subject(dbl) { ClassDouble } let(foo_stub) { Spectator::ValueStub.new(:foo, :override) } - after_each { dbl._spectator_clear_stubs } + after { dbl._spectator_clear_stubs } it "overrides an existing method" do expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override) @@ -318,7 +315,7 @@ Spectator.describe Spectator::NullDouble do end describe "._spectator_clear_stubs" do - before_each { dbl._spectator_define_stub(foo_stub) } + before { dbl._spectator_define_stub(foo_stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub) @@ -326,7 +323,7 @@ Spectator.describe Spectator::NullDouble do end describe "._spectator_calls" do - before_each { dbl._spectator_clear_calls } + before { dbl._spectator_clear_calls } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -401,7 +398,7 @@ Spectator.describe Spectator::NullDouble do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } it "removes previously defined stubs" do expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42) @@ -412,7 +409,7 @@ Spectator.describe Spectator::NullDouble do subject(dbl) { FooBarDouble.new } let(stub) { Spectator::ValueStub.new(:foo, 5) } - before_each { dbl._spectator_define_stub(stub) } + before { dbl._spectator_define_stub(stub) } # Retrieves symbolic names of methods called on a double. def called_method_names(dbl) @@ -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/abstract_expression.cr b/src/spectator/abstract_expression.cr index b58c1cc..6ab4cbf 100644 --- a/src/spectator/abstract_expression.cr +++ b/src/spectator/abstract_expression.cr @@ -34,7 +34,7 @@ module Spectator # Produces a string representation of the expression. # This consists of the label (if one is available) and the value. - def to_s(io) + def to_s(io : IO) : Nil if (label = @label) io << label << ": " end @@ -43,7 +43,7 @@ module Spectator # Produces a detailed string representation of the expression. # This consists of the label (if one is available) and the value. - def inspect(io) + def inspect(io : IO) : Nil if (label = @label) io << label << ": " end diff --git a/src/spectator/anything.cr b/src/spectator/anything.cr index e4d7b34..aa25e3c 100644 --- a/src/spectator/anything.cr +++ b/src/spectator/anything.cr @@ -13,12 +13,12 @@ module Spectator end # Displays "anything". - def to_s(io) + def to_s(io : IO) : Nil io << "anything" end # Displays "". - def inspect(io) + def inspect(io : IO) : Nil io << "" end end diff --git a/src/spectator/config/cli_arguments_applicator.cr b/src/spectator/config/cli_arguments_applicator.cr index 496fc1a..15c9f94 100644 --- a/src/spectator/config/cli_arguments_applicator.cr +++ b/src/spectator/config/cli_arguments_applicator.cr @@ -112,7 +112,7 @@ module Spectator # Adds the example filter option to the parser. private def example_option(parser, builder) parser.on("-e", "--example STRING", "Run examples whose full nested names include STRING") do |pattern| - Log.debug { "Filtering for examples named '#{pattern}' (-e '#{pattern}')" } + Log.debug { "Filtering for examples containing '#{pattern}' (-e '#{pattern}')" } filter = NameNodeFilter.new(pattern) builder.add_node_filter(filter) end diff --git a/src/spectator/context.cr b/src/spectator/context.cr index c3213e3..15b9335 100644 --- a/src/spectator/context.cr +++ b/src/spectator/context.cr @@ -4,18 +4,23 @@ # 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 # and that the Crystal compiler instantiates a `#to_s` and/or `#inspect` for each of those types, # an explosion in method instances can be created. # The compile time is drastically reduced by using a dummy string instead. - def to_s(io) + def to_s(io : IO) : Nil io << "Context" end # :ditto: - def inspect(io) + def inspect(io : IO) : Nil io << "Context<" << self.class << '>' end end 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/hooks.cr b/src/spectator/dsl/hooks.cr index 3cd4d87..6672e59 100644 --- a/src/spectator/dsl/hooks.cr +++ b/src/spectator/dsl/hooks.cr @@ -124,11 +124,21 @@ module Spectator::DSL # This means that values defined by `let` and `subject` are available. define_example_hook :before_each + # :ditto: + macro before(&block) + before_each {{block}} + end + # Defines a block of code that will be invoked after every example in the group. # The block will be run in the context of the current running example. # This means that values defined by `let` and `subject` are available. define_example_hook :after_each + # :ditto: + macro after(&block) + after_each {{block}} + end + # Defines a block of code that will be invoked around every example in the group. # The block will be run in the context of the current running example. # This means that values defined by `let` and `subject` are available. @@ -139,6 +149,11 @@ module Spectator::DSL # More code can run afterwards (in the block). define_example_hook :around_each + # :ditto: + macro around(&block) + around_each {{block}} + end + # Defines a block of code that will be invoked before every example in the group. # The block will be run in the context of the current running example. # This means that values defined by `let` and `subject` are available. diff --git a/src/spectator/dsl/matchers.cr b/src/spectator/dsl/matchers.cr index b42bc88..95e6c5d 100644 --- a/src/spectator/dsl/matchers.cr +++ b/src/spectator/dsl/matchers.cr @@ -790,7 +790,7 @@ module Spectator::DSL # ``` # expect_raises { raise "foobar" } # ``` - macro expect_raises + macro expect_raises(&block) expect {{block}}.to raise_error 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 628fe6b..5eff1a9 100644 --- a/src/spectator/dsl/mocks.cr +++ b/src/spectator/dsl/mocks.cr @@ -31,19 +31,19 @@ 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 {{null_double_type_name}}.new(@stubs) end - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end {% 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 4babc2a..f58da20 100644 --- a/src/spectator/error_result.cr +++ b/src/spectator/error_result.cr @@ -11,12 +11,12 @@ module Spectator end # Calls the `error` method on *visitor*. - def accept(visitor) + def accept(visitor, &) visitor.error(yield self) end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "error" end diff --git a/src/spectator/example.cr b/src/spectator/example.cr index 8f99e93..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 @@ -103,8 +104,8 @@ module Spectator # Returns the result of the execution. # The result will also be stored in `#result`. def run : Result - Log.debug { "Running example #{self}" } - Log.warn { "Example #{self} already ran" } if @finished + Log.debug { "Running example: #{self}" } + Log.warn { "Example already ran: #{self}" } if @finished if pending? Log.debug { "Skipping example #{self} - marked pending" } @@ -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 @@ -142,8 +143,10 @@ module Spectator group.call_before_each(self) group.call_pre_condition(self) end + Log.trace { "Running example code for: #{self}" } @entrypoint.call(self) @finished = true + Log.trace { "Finished running example code for: #{self}" } if group = @group group.call_post_condition(self) group.call_after_each(self) @@ -161,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 @@ -181,7 +184,7 @@ module Spectator end # Yields this example and all parent groups. - def ascend + def ascend(&) node = self while node yield node @@ -191,7 +194,7 @@ module Spectator # Constructs the full name or description of the example. # This prepends names of groups this example is part of. - def to_s(io) + def to_s(io : IO) : Nil name = @name # Prefix with group's full name if the node belongs to a group. @@ -210,9 +213,9 @@ module Spectator end # Exposes information about the example useful for debugging. - def inspect(io) + def inspect(io : IO) : Nil super - io << ' ' << result + io << " - " << result end # Creates the JSON representation of the example, @@ -276,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 @@ -286,7 +289,7 @@ module Spectator # Constructs the full name or description of the example. # This prepends names of groups this example is part of. - def to_s(io) : Nil + def to_s(io : IO) : Nil @example.to_s(io) end 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 0481fc4..55a3233 100644 --- a/src/spectator/example_group.cr +++ b/src/spectator/example_group.cr @@ -19,14 +19,14 @@ module Spectator protected setter group : ExampleGroup? define_hook before_all : ExampleGroupHook do - Log.trace { "Processing before_all hooks for #{self}" } + Log.trace { "Processing before_all hooks for: #{self}" } @group.try &.call_before_all before_all_hooks.each &.call_once end define_hook after_all : ExampleGroupHook, :prepend do - Log.trace { "Processing after_all hooks for #{self}" } + Log.trace { "Processing after_all hooks for: #{self}" } after_all_hooks.each &.call_once if finished? if group = @group @@ -35,21 +35,21 @@ module Spectator end define_hook before_each : ExampleHook do |example| - Log.trace { "Processing before_each hooks for #{self}" } + Log.trace { "Processing before_each hooks for: #{self}" } @group.try &.call_before_each(example) before_each_hooks.each &.call(example) end define_hook after_each : ExampleHook, :prepend do |example| - Log.trace { "Processing after_each hooks for #{self}" } + Log.trace { "Processing after_each hooks for: #{self}" } after_each_hooks.each &.call(example) @group.try &.call_after_each(example) end define_hook around_each : ExampleProcsyHook do |procsy| - Log.trace { "Processing around_each hooks for #{self}" } + Log.trace { "Processing around_each hooks for: #{self}" } around_each_hooks.reverse_each { |hook| procsy = hook.wrap(procsy) } if group = @group @@ -59,14 +59,14 @@ module Spectator end define_hook pre_condition : ExampleHook do |example| - Log.trace { "Processing pre_condition hooks for #{self}" } + Log.trace { "Processing pre_condition hooks for: #{self}" } @group.try &.call_pre_condition(example) pre_condition_hooks.each &.call(example) end define_hook post_condition : ExampleHook, :prepend do |example| - Log.trace { "Processing post_condition hooks for #{self}" } + Log.trace { "Processing post_condition hooks for: #{self}" } post_condition_hooks.each &.call(example) @group.try &.call_post_condition(example) @@ -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 @@ -112,11 +112,15 @@ module Spectator # Constructs the full name or description of the example group. # This prepends names of groups this group is part of. - def to_s(io) - # Prefix with group's full name if the node belongs to a group. - return unless parent = @group + def to_s(io : IO, *, nested = false) : Nil + unless parent = @group + # Display special string when called directly. + io << "" unless nested + return + end - parent.to_s(io) + # Prefix with group's full name if the node belongs to a group. + parent.to_s(io, nested: true) name = @name # Add padding between the node names @@ -126,7 +130,7 @@ module Spectator (parent.name?.is_a?(Symbol) && name.is_a?(String) && (name.starts_with?('#') || name.starts_with?('.'))) - super + super(io) end # Adds the specified *node* to the 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_hook.cr b/src/spectator/example_group_hook.cr index bd6bac8..aee357f 100644 --- a/src/spectator/example_group_hook.cr +++ b/src/spectator/example_group_hook.cr @@ -42,7 +42,7 @@ module Spectator # Produces the string representation of the hook. # Includes the location and label if they're not nil. - def to_s(io) + def to_s(io : IO) : Nil io << "example group hook" if (label = @label) 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/example_hook.cr b/src/spectator/example_hook.cr index edebf26..6bc77a0 100644 --- a/src/spectator/example_hook.cr +++ b/src/spectator/example_hook.cr @@ -37,7 +37,7 @@ module Spectator # Produces the string representation of the hook. # Includes the location and label if they're not nil. - def to_s(io) + def to_s(io : IO) : Nil io << "example hook" if (label = @label) diff --git a/src/spectator/example_procsy_hook.cr b/src/spectator/example_procsy_hook.cr index 8a64f17..16bc970 100644 --- a/src/spectator/example_procsy_hook.cr +++ b/src/spectator/example_procsy_hook.cr @@ -39,7 +39,7 @@ module Spectator # Produces the string representation of the hook. # Includes the location and label if they're not nil. - def to_s(io) + def to_s(io : IO) : Nil io << "example hook" if (label = @label) diff --git a/src/spectator/expectation.cr b/src/spectator/expectation.cr index bfc0248..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 @@ -160,9 +205,15 @@ module Spectator stubbable._spectator_define_stub(unconstrained_stub) end + # Apply the stub that is expected to be called. stubbable._spectator_define_stub(stub) + + # Check if the stub was invoked after the test completes. matcher = Matchers::ReceiveMatcher.new(stub) - to_eventually(matcher, message) + Harness.current.defer { to(matcher, message) } + + # Prevent leaking stubs between tests. + Harness.current.cleanup { stubbable._spectator_remove_stub(stub) } end # Asserts that some criteria defined by the matcher is eventually satisfied. @@ -190,9 +241,15 @@ module Spectator stubbable._spectator_define_stub(unconstrained_stub) end + # Apply the stub that could be called in case it is. stubbable._spectator_define_stub(stub) + + # Check if the stub was invoked after the test completes. matcher = Matchers::ReceiveMatcher.new(stub) - to_never(matcher, message) + Harness.current.defer { to_not(matcher, message) } + + # Prevent leaking stubs between tests. + Harness.current.cleanup { stubbable._spectator_remove_stub(stub) } end # :ditto: diff --git a/src/spectator/fail_result.cr b/src/spectator/fail_result.cr index 36ea8fb..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 @@ -55,7 +55,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "fail" 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/formatting/components/comment.cr b/src/spectator/formatting/components/comment.cr index b398840..30f4293 100644 --- a/src/spectator/formatting/components/comment.cr +++ b/src/spectator/formatting/components/comment.cr @@ -16,7 +16,7 @@ module Spectator::Formatting::Components end # Writes the comment to the output. - def to_s(io) + def to_s(io : IO) : Nil io << "# " << @content end end diff --git a/src/spectator/formatting/components/error_result_block.cr b/src/spectator/formatting/components/error_result_block.cr index 353c096..a24784a 100644 --- a/src/spectator/formatting/components/error_result_block.cr +++ b/src/spectator/formatting/components/error_result_block.cr @@ -7,36 +7,38 @@ module Spectator::Formatting::Components # Displays information about an error result. struct ErrorResultBlock < ResultBlock # Creates the component. - def initialize(example : Example, index : Int32, @result : ErrorResult, subindex = 0) + def initialize(example : Example, index : Int32, @error : Exception, subindex = 0) super(example, index, subindex) end # Content displayed on the second line of the block after the label. private def subtitle - @result.error.message.try(&.each_line.first) + @error.message.try(&.each_line.first) end # Prefix for the second line of the block. private def subtitle_label - "Error: ".colorize(:red) + case @error + when ExampleFailed then "Failure: " + else "Error: " + end.colorize(:red) end # Display error information. private def content(io) # Fetch the error and message. - error = @result.error - lines = error.message.try(&.lines) + lines = @error.message.try(&.lines) # Write the error and message if available. case - when lines.nil? then write_error_class(io, error) - when lines.size == 1 then write_error_message(io, error, lines.first) - when lines.size > 1 then write_multiline_error_message(io, error, lines) - else write_error_class(io, error) + when lines.nil? then write_error_class(io) + when lines.size == 1 then write_error_message(io, lines.first) + when lines.size > 1 then write_multiline_error_message(io, lines) + else write_error_class(io) end # Display the backtrace if it's available. - if backtrace = error.backtrace? + if backtrace = @error.backtrace? indent { write_backtrace(io, backtrace) } end @@ -44,24 +46,24 @@ module Spectator::Formatting::Components end # Display just the error type. - private def write_error_class(io, error) + private def write_error_class(io) line(io) do - io << error.class.colorize(:red) + io << @error.class.colorize(:red) end end # Display the error type and first line of the message. - private def write_error_message(io, error, message) + private def write_error_message(io, message) line(io) do - io << "#{error.class}: ".colorize(:red) + io << "#{@error.class}: ".colorize(:red) io << message end end # Display the error type and its multi-line message. - private def write_multiline_error_message(io, error, lines) + private def write_multiline_error_message(io, lines) # Use the normal formatting for the first line. - write_error_message(io, error, lines.first) + write_error_message(io, lines.first) # Display additional lines after the first. lines.skip(1).each do |entry| diff --git a/src/spectator/formatting/components/example_command.cr b/src/spectator/formatting/components/example_command.cr index 8b3c0b9..b1246db 100644 --- a/src/spectator/formatting/components/example_command.cr +++ b/src/spectator/formatting/components/example_command.cr @@ -9,7 +9,7 @@ module Spectator::Formatting::Components end # Produces output for running the previously specified example. - def to_s(io) + def to_s(io : IO) : Nil io << "crystal spec " # Use location for argument if it's available, since it's simpler. diff --git a/src/spectator/formatting/components/failure_command_list.cr b/src/spectator/formatting/components/failure_command_list.cr index 7da5ed2..7eab4f5 100644 --- a/src/spectator/formatting/components/failure_command_list.cr +++ b/src/spectator/formatting/components/failure_command_list.cr @@ -10,7 +10,7 @@ module Spectator::Formatting::Components end # Produces the list of commands to run failed examples. - def to_s(io) + def to_s(io : IO) : Nil io.puts "Failed examples:" io.puts @failures.each do |failure| diff --git a/src/spectator/formatting/components/profile.cr b/src/spectator/formatting/components/profile.cr index 2a56e0d..b98d8f5 100644 --- a/src/spectator/formatting/components/profile.cr +++ b/src/spectator/formatting/components/profile.cr @@ -9,7 +9,7 @@ module Spectator::Formatting::Components end # Produces the output containing the profiling information. - def to_s(io) + def to_s(io : IO) : Nil io << "Top " io << @profile.size io << " slowest examples (" diff --git a/src/spectator/formatting/components/result_block.cr b/src/spectator/formatting/components/result_block.cr index bf4b3d2..ddd9c47 100644 --- a/src/spectator/formatting/components/result_block.cr +++ b/src/spectator/formatting/components/result_block.cr @@ -41,7 +41,7 @@ module Spectator::Formatting::Components private abstract def content(io) # Writes the component's output to the specified stream. - def to_s(io) + def to_s(io : IO) : Nil title_line(io) # Ident over to align with the spacing used by the index. indent(index_digit_count + 2) do diff --git a/src/spectator/formatting/components/runtime.cr b/src/spectator/formatting/components/runtime.cr index 9f28813..9638d93 100644 --- a/src/spectator/formatting/components/runtime.cr +++ b/src/spectator/formatting/components/runtime.cr @@ -15,7 +15,7 @@ module Spectator::Formatting::Components # #:##:## # # days #:##:## # ``` - def to_s(io) + def to_s(io : IO) : Nil millis = @span.total_milliseconds return format_micro(io, millis * 1000) if millis < 1 diff --git a/src/spectator/formatting/components/stats.cr b/src/spectator/formatting/components/stats.cr index ab76c7c..47e1063 100644 --- a/src/spectator/formatting/components/stats.cr +++ b/src/spectator/formatting/components/stats.cr @@ -11,7 +11,7 @@ module Spectator::Formatting::Components end # Displays the stats. - def to_s(io) + def to_s(io : IO) : Nil runtime(io) totals(io) if seed = @report.random_seed? diff --git a/src/spectator/formatting/components/tap_profile.cr b/src/spectator/formatting/components/tap_profile.cr index 64c36b0..4154e07 100644 --- a/src/spectator/formatting/components/tap_profile.cr +++ b/src/spectator/formatting/components/tap_profile.cr @@ -10,7 +10,7 @@ module Spectator::Formatting::Components end # Produces the output containing the profiling information. - def to_s(io) + def to_s(io : IO) : Nil io << "# Top " io << @profile.size io << " slowest examples (" diff --git a/src/spectator/formatting/components/totals.cr b/src/spectator/formatting/components/totals.cr index 941b6ee..4063cae 100644 --- a/src/spectator/formatting/components/totals.cr +++ b/src/spectator/formatting/components/totals.cr @@ -31,7 +31,7 @@ module Spectator::Formatting::Components end # Writes the counts to the output. - def to_s(io) + def to_s(io : IO) : Nil io << @examples << " examples, " << @failures << " failures" if @errors > 0 diff --git a/src/spectator/formatting/summary.cr b/src/spectator/formatting/summary.cr index 50a2b1b..b866307 100644 --- a/src/spectator/formatting/summary.cr +++ b/src/spectator/formatting/summary.cr @@ -63,15 +63,22 @@ module Spectator::Formatting # Displays one or more blocks for a failed example. # Each block is a failed expectation or error raised in the example. private def dump_failed_example(example, index) - result = example.result.as?(ErrorResult) + # Retrieve the ultimate reason for failing. + error = example.result.as?(FailResult).try(&.error) + + # Prevent displaying duplicated output from expectation. + # Display `ExampleFailed` but not `ExpectationFailed`. + error = nil if error.responds_to?(:expectation) + + # Gather all failed expectations. failed_expectations = example.result.expectations.select(&.failed?) block_count = failed_expectations.size - block_count += 1 if result + block_count += 1 if error # Add an extra block for final error if it's significant. # Don't use sub-index if there was only one problem. if block_count == 1 - if result - io.puts Components::ErrorResultBlock.new(example, index, result) + if error + io.puts Components::ErrorResultBlock.new(example, index, error) else io.puts Components::FailResultBlock.new(example, index, failed_expectations.first) end @@ -79,7 +86,7 @@ module Spectator::Formatting failed_expectations.each_with_index(1) do |expectation, subindex| io.puts Components::FailResultBlock.new(example, index, expectation, subindex) end - io.puts Components::ErrorResultBlock.new(example, index, result, block_count) if result + io.puts Components::ErrorResultBlock.new(example, index, error, block_count) if error end end end 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/location.cr b/src/spectator/location.cr index 688fdb6..7b06eb9 100644 --- a/src/spectator/location.cr +++ b/src/spectator/location.cr @@ -59,7 +59,7 @@ module Spectator # ```text # FILE:LINE # ``` - def to_s(io) + def to_s(io : IO) : Nil io << path << ':' << line end end diff --git a/src/spectator/matchers/attributes_matcher.cr b/src/spectator/matchers/attributes_matcher.cr index 8ecd714..c66ecc4 100644 --- a/src/spectator/matchers/attributes_matcher.cr +++ b/src/spectator/matchers/attributes_matcher.cr @@ -15,7 +15,7 @@ module Spectator::Matchers extend self # Text displayed when a method is undefined. - def inspect(io) + def inspect(io : IO) : Nil io << "" end 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/matchers/receive_matcher.cr b/src/spectator/matchers/receive_matcher.cr index d261db1..560cabd 100644 --- a/src/spectator/matchers/receive_matcher.cr +++ b/src/spectator/matchers/receive_matcher.cr @@ -81,7 +81,7 @@ module Spectator::Matchers # Short text about the matcher's purpose. def description : String - "received #{@stub} #{humanize_count}" + "received #{@stub.message} #{humanize_count}" end # Actually performs the test against the expression (value or block). @@ -89,10 +89,10 @@ module Spectator::Matchers stubbed = actual.value calls = relevant_calls(stubbed) if @count.includes?(calls.size) - SuccessfulMatchData.new("#{actual.label} received #{@stub} #{humanize_count}") + SuccessfulMatchData.new("#{actual.label} received #{@stub.message} #{humanize_count}") else - FailedMatchData.new("#{actual.label} received #{@stub} #{humanize_count}", - "#{actual.label} did not receive #{@stub}", values(actual).to_a) + FailedMatchData.new("#{actual.label} received #{@stub.message} #{humanize_count}", + "#{actual.label} did not receive #{@stub.message}", values(actual).to_a) end end @@ -106,9 +106,9 @@ module Spectator::Matchers stubbed = actual.value calls = relevant_calls(stubbed) if @count.includes?(calls.size) - FailedMatchData.new("#{actual.label} did not receive #{@stub}", "#{actual.label} received #{@stub}", negated_values(actual).to_a) + FailedMatchData.new("#{actual.label} did not receive #{@stub.message}", "#{actual.label} received #{@stub.message}", negated_values(actual).to_a) else - SuccessfulMatchData.new("#{actual.label} did not receive #{@stub} #{humanize_count}") + SuccessfulMatchData.new("#{actual.label} did not receive #{@stub.message} #{humanize_count}") end end @@ -120,7 +120,7 @@ module Spectator::Matchers # Additional information about the match failure. private def values(actual : Expression(T)) forall T { - expected: @stub.to_s, + expected: @stub.message, actual: method_call_list(actual.value), } end @@ -128,7 +128,7 @@ module Spectator::Matchers # Additional information about the match failure when negated. private def negated_values(actual : Expression(T)) forall T { - expected: "Not #{@stub}", + expected: "Not #{@stub.message}", actual: method_call_list(actual.value), } end diff --git a/src/spectator/matchers/value_matcher.cr b/src/spectator/matchers/value_matcher.cr index 760578b..a88f457 100644 --- a/src/spectator/matchers/value_matcher.cr +++ b/src/spectator/matchers/value_matcher.cr @@ -1,3 +1,5 @@ +require "../expression" +require "../value" require "./standard_matcher" module Spectator::Matchers @@ -22,7 +24,7 @@ module Spectator::Matchers # Creates the value matcher. # The expected value is stored for later use. - def initialize(@expected : Value(ExpectedType)) + def initialize(@expected : ::Spectator::Value(ExpectedType)) end # Additional information about the match failure. diff --git a/src/spectator/mocks/abstract_arguments.cr b/src/spectator/mocks/abstract_arguments.cr index dacc43f..4a6f75f 100644 --- a/src/spectator/mocks/abstract_arguments.cr +++ b/src/spectator/mocks/abstract_arguments.cr @@ -1,5 +1,61 @@ module Spectator # Untyped arguments to a method call (message). abstract class AbstractArguments + # 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/allow.cr b/src/spectator/mocks/allow.cr index dd163b2..4754690 100644 --- a/src/spectator/mocks/allow.cr +++ b/src/spectator/mocks/allow.cr @@ -1,3 +1,4 @@ +require "../harness" require "./stub" require "./stubbable" require "./stubbed_type" @@ -21,6 +22,7 @@ module Spectator # Applies a stub to the targeted stubbable object. def to(stub : Stub) : Nil @target._spectator_define_stub(stub) + Harness.current?.try &.cleanup { @target._spectator_remove_stub(stub) } end end end diff --git a/src/spectator/mocks/arguments.cr b/src/spectator/mocks/arguments.cr index 264f0fc..f15a0ba 100644 --- a/src/spectator/mocks/arguments.cr +++ b/src/spectator/mocks/arguments.cr @@ -4,22 +4,19 @@ module Spectator # Arguments used in a method call. # # Can also be used to match arguments. - # *T* must be a `Tuple` type representing the positional arguments. - # *NT* must be a `NamedTuple` type representing the keyword arguments. - class Arguments(T, NT) < AbstractArguments + # *Args* must be a `Tuple` representing the standard arguments. + # *KWArgs* must be a `NamedTuple` type representing extra keyword arguments. + class Arguments(Args, KWArgs) < AbstractArguments # Positional arguments. - getter args : T + getter args : Args # Keyword arguments. - getter kwargs : NT + getter kwargs : KWArgs # Creates arguments used in a method call. - def initialize(@args : T, @kwargs : NT) - end - - # Constructs an instance from literal arguments. - def self.capture(*args, **kwargs) : AbstractArguments - new(args, kwargs).as(AbstractArguments) + def initialize(@args : Args, @kwargs : KWArgs) + {% raise "Positional arguments (generic type Args) must be a Tuple" unless Args <= Tuple %} + {% raise "Keyword arguments (generic type KWArgs) must be a NamedTuple" unless KWArgs <= NamedTuple %} end # Instance of empty arguments. @@ -30,9 +27,14 @@ module Spectator nil.as(AbstractArguments?) end + # Friendlier constructor for capturing arguments. + def self.capture(*args, **kwargs) + new(args, kwargs) + end + # Returns the positional argument at the specified index. def [](index : Int) - @args[index] + args[index] end # Returns the specified named argument. @@ -40,6 +42,16 @@ module Spectator @kwargs[arg] end + # Returns all arguments and splatted arguments as a tuple. + def positional : Tuple + args + end + + # Returns all named positional and keyword arguments as a named tuple. + def named : NamedTuple + kwargs + end + # Constructs a string representation of the arguments. def to_s(io : IO) : Nil return io << "(no args)" if args.empty? && kwargs.empty? @@ -53,39 +65,46 @@ module Spectator end # Add the keyword arguments. - size = args.size + kwargs.size - kwargs.each_with_index(args.size) do |k, v, i| - io << ", " if 0 < i < size - io << k << ": " - v.inspect(io) + kwargs.each_with_index(args.size) do |key, value, i| + io << ", " if i > 0 + io << key << ": " + value.inspect(io) end io << ')' end # Checks if this set of arguments and another are equal. - def ==(other : Arguments) - args == other.args && kwargs == other.kwargs + def ==(other : AbstractArguments) + positional == other.positional && kwargs == other.kwargs end # Checks if another set of arguments matches this set of arguments. def ===(other : Arguments) - args === other.args && named_tuples_match?(kwargs, other.kwargs) + compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs) end - # Checks if two named tuples match. - # - # Uses case equality (`===`) on every key-value pair. - # NamedTuple doesn't have a `===` operator, even though Tuple does. - private def named_tuples_match?(a : NamedTuple, b : NamedTuple) - return false if a.size != b.size + # :ditto: + def ===(other : FormalArguments) + return false unless compare_named_tuples(kwargs, other.named) - a.each do |k, v| - return false unless b.has_key?(k) - return false unless v === b[k] + 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[i] + i += 1 + return false unless compare_values(v1, v2) end - true + other.splat.try &.each do |v2| + v1 = positional.fetch(i) { return false } + i += 1 + return false unless compare_values(v1, v2) + end + + i == positional.size end end end diff --git a/src/spectator/mocks/double.cr b/src/spectator/mocks/double.cr index 8562ca6..e5e43f4 100644 --- a/src/spectator/mocks/double.cr +++ b/src/spectator/mocks/double.cr @@ -61,7 +61,7 @@ module Spectator end {% end %} - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end end @@ -95,16 +95,38 @@ module Spectator false end + # 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 #{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 @@ -134,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 @@ -164,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) @@ -179,12 +201,13 @@ module Spectator # Handle all methods but only respond to configured messages. # Raises an `UnexpectedMessage` error for non-configures messages. macro method_missing(call) - Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" } - args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %}) + args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args) _spectator_record_call(call) - raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") + Log.trace { "#{inspect} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } + + 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/exception_stub.cr b/src/spectator/mocks/exception_stub.cr index 9aee69c..e7b6cb9 100644 --- a/src/spectator/mocks/exception_stub.cr +++ b/src/spectator/mocks/exception_stub.cr @@ -20,6 +20,12 @@ module Spectator def initialize(method : Symbol, @exception : Exception, constraint : AbstractArguments? = nil, location : Location? = nil) super(method, constraint, location) end + + # String representation of the stub, formatted as a method call. + def to_s(io : IO) : Nil + super + io << " # raises " << @exception + end end module StubModifiers diff --git a/src/spectator/mocks/formal_arguments.cr b/src/spectator/mocks/formal_arguments.cr new file mode 100644 index 0000000..1c0ca69 --- /dev/null +++ b/src/spectator/mocks/formal_arguments.cr @@ -0,0 +1,133 @@ +require "./abstract_arguments" + +module Spectator + # Arguments passed into a method. + # + # *Args* must be a `NamedTuple` type representing the standard arguments. + # *Splat* must be a `Tuple` type representing the extra positional arguments. + # *DoubleSplat* must be a `NamedTuple` type representing extra keyword arguments. + class FormalArguments(Args, Splat, DoubleSplat) < AbstractArguments + # Positional arguments. + getter args : Args + + # Additional positional arguments. + getter splat : Splat + + # Keyword arguments. + getter kwargs : DoubleSplat + + # Name of the splat argument, if used. + getter splat_name : Symbol? + + # Creates arguments used in a method call. + def initialize(@args : Args, @splat_name : Symbol?, @splat : Splat, @kwargs : DoubleSplat) + {% raise "Positional arguments (generic type Args) must be a NamedTuple" unless Args <= NamedTuple %} + {% raise "Splat arguments (generic type Splat) must be a Tuple" unless Splat <= Tuple || Splat <= Nil %} + {% raise "Keyword arguments (generic type DoubleSplat) must be a NamedTuple" unless DoubleSplat <= NamedTuple %} + end + + # Creates arguments used in a method call. + def self.new(args : Args, kwargs : DoubleSplat) + new(args, nil, nil, kwargs) + end + + # Captures arguments passed to a call. + def self.build(args = NamedTuple.new, kwargs = NamedTuple.new) + new(args, nil, nil, kwargs) + end + + # :ditto: + def self.build(args : NamedTuple, splat_name : Symbol, splat : Tuple, kwargs = NamedTuple.new) + new(args, splat_name, splat, kwargs) + end + + # Instance of empty arguments. + class_getter none : AbstractArguments = build + + # Returns the positional argument at the specified index. + def [](index : Int) + positional[index] + end + + # Returns the specified named argument. + def [](arg : Symbol) + return @args[arg] if @args.has_key?(arg) + @kwargs[arg] + end + + # Returns all arguments and splatted arguments as a tuple. + def positional : Tuple + if (splat = @splat) + args.values + splat + else + args.values + end + end + + # Returns all named positional and keyword arguments as a named tuple. + def named : NamedTuple + args.merge(kwargs) + end + + # Constructs a string representation of the arguments. + def to_s(io : IO) : Nil + return io << "(no args)" if args.empty? && ((splat = @splat).nil? || splat.empty?) && kwargs.empty? + + io << '(' + + # Add the positional arguments. + {% if Args < NamedTuple %} + # Include argument names. + args.each_with_index do |name, value, i| + io << ", " if i > 0 + io << name << ": " + value.inspect(io) + end + {% else %} + args.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) + end + {% end %} + + # Add the splat arguments. + if (splat = @splat) && !splat.empty? + io << ", " unless args.empty? + if splat_name = !args.empty? && @splat_name + io << '*' << splat_name << ": {" + end + splat.each_with_index do |arg, i| + io << ", " if i > 0 + arg.inspect(io) + end + io << '}' if splat_name + end + + # Add the keyword arguments. + offset = args.size + offset += splat.size if (splat = @splat) + kwargs.each_with_index(offset) do |key, value, i| + io << ", " if i > 0 + io << key << ": " + value.inspect(io) + end + + io << ')' + end + + # Checks if this set of arguments and another are equal. + def ==(other : AbstractArguments) + positional == other.positional && kwargs == other.kwargs + end + + # Checks if another set of arguments matches this set of arguments. + def ===(other : Arguments) + compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs) + end + + # :ditto: + def ===(other : FormalArguments) + 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 af3925d..75fe30c 100644 --- a/src/spectator/mocks/lazy_double.cr +++ b/src/spectator/mocks/lazy_double.cr @@ -26,15 +26,24 @@ module Spectator super(_spectator_double_stubs + message_stubs) 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 raises an error. + protected def _spectator_define_stub(stub : Stub) : Nil + return super if Messages.types.has_key?(stub.method) + + raise "Can't define stub #{stub} on lazy double because it wasn't initially defined." + end + # 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 @@ -43,13 +52,13 @@ module Spectator # Handles all messages. macro method_missing(call) - Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" } - # Capture information about the call. - %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %}) + %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) + 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) # Cast the stub or return value to the expected type. diff --git a/src/spectator/mocks/method_call.cr b/src/spectator/mocks/method_call.cr index 7094f4a..9c5fd01 100644 --- a/src/spectator/mocks/method_call.cr +++ b/src/spectator/mocks/method_call.cr @@ -1,5 +1,6 @@ require "./abstract_arguments" require "./arguments" +require "./formal_arguments" module Spectator # Stores information about a call to a method. @@ -16,13 +17,26 @@ module Spectator # Creates a method call by splatting its arguments. def self.capture(method : Symbol, *args, **kwargs) - arguments = Arguments.new(args, kwargs).as(AbstractArguments) + arguments = Arguments.capture(*args, **kwargs).as(AbstractArguments) + new(method, arguments) + end + + # Creates a method call from within a method. + # Takes the same arguments as `FormalArguments.build` but with the method name first. + def self.build(method : Symbol, *args, **kwargs) + arguments = FormalArguments.build(*args, **kwargs).as(AbstractArguments) new(method, arguments) end # Constructs a string containing the method name and arguments. def to_s(io : IO) : Nil - io << '#' << method << arguments + 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 174d183..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,18 +79,22 @@ module Spectator end {% end %} - def _spectator_clear_stubs : Nil + def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil + @_spectator_stubs.try &.delete(stub) + end + + 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 %} @@ -69,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 %} @@ -80,7 +113,7 @@ module Spectator macro finished stub_type {{mocked_type.id}} - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end end {% end %} @@ -116,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 @@ -125,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 @@ -139,7 +172,11 @@ module Spectator entry.stubs end - def _spectator_clear_stubs : Nil + def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil + @@_spectator_mock_registry[self]?.try &.stubs.delete(stub) + end + + def _spectator_clear_stubs : ::Nil @@_spectator_mock_registry.delete(self) end @@ -161,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 %} @@ -170,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 %} @@ -181,7 +218,7 @@ module Spectator macro finished stub_type {{type_name.id}} - {% if block %}{{block.body}}{% end %} + {{block.body if block}} end end {% end %} 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/mocked.cr b/src/spectator/mocks/mocked.cr index be25ef0..280eef8 100644 --- a/src/spectator/mocks/mocked.cr +++ b/src/spectator/mocks/mocked.cr @@ -26,6 +26,10 @@ module Spectator _spectator_stubs.unshift(stub) end + def _spectator_remove_stub(stub : Stub) : Nil + _spectator_stubs.delete(stub) + end + def _spectator_clear_stubs : Nil _spectator_stubs.clear end diff --git a/src/spectator/mocks/null_double.cr b/src/spectator/mocks/null_double.cr index 978418c..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,22 +42,22 @@ 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 # Handles all undefined messages. # Returns stubbed values if available, otherwise delegates to `#_spectator_abstract_stub_fallback`. macro method_missing(call) - Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" } - # Capture information about the call. - %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %}) + %args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{{call.named_args.splat if call.named_args}}) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) _spectator_record_call(%call) + Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } + self end end diff --git a/src/spectator/mocks/reference_mock_registry.cr b/src/spectator/mocks/reference_mock_registry.cr index 20481f0..84227d1 100644 --- a/src/spectator/mocks/reference_mock_registry.cr +++ b/src/spectator/mocks/reference_mock_registry.cr @@ -25,6 +25,12 @@ module Spectator @entries[key] end + # Retrieves all stubs defined for a mocked object or nil if the object isn't mocked yet. + def []?(object : Reference) + key = Box.box(object) + @entries[key]? + end + # Retrieves all stubs defined for a mocked object. # # Yields to the block on the first retrieval. diff --git a/src/spectator/mocks/stub.cr b/src/spectator/mocks/stub.cr index e28c431..606894d 100644 --- a/src/spectator/mocks/stub.cr +++ b/src/spectator/mocks/stub.cr @@ -22,6 +22,23 @@ module Spectator def initialize(@method : Symbol, @constraint : AbstractArguments? = nil, @location : Location? = nil) end + # String representation of the stub, formatted as a method call. + def message(io : IO) : Nil + io << "#" << method << (constraint || "(any args)") + end + + # String representation of the stub, formatted as a method call. + def message + String.build do |str| + message(str) + end + end + + # String representation of the stub, formatted as a method definition. + def to_s(io : IO) : Nil + message(io) + end + # Checks if a method call should receive the response from this stub. def ===(call : MethodCall) return false if method != call.method @@ -29,10 +46,5 @@ module Spectator constraint === call.arguments end - - # String representation of the stub, formatted as a method call. - def to_s(io : IO) : Nil - io << "#" << method << (constraint || "(any args)") - end end end diff --git a/src/spectator/mocks/stubbable.cr b/src/spectator/mocks/stubbable.cr index aa02976..3385ad4 100644 --- a/src/spectator/mocks/stubbable.cr +++ b/src/spectator/mocks/stubbable.cr @@ -1,5 +1,5 @@ require "../dsl/reserved" -require "./arguments" +require "./formal_arguments" require "./method_call" require "./stub" require "./typed_stub" @@ -28,6 +28,9 @@ module Spectator # Defines a stub to change the behavior of a method. abstract def _spectator_define_stub(stub : Stub) : Nil + # Removes a specific, previously defined stub. + abstract def _spectator_remove_stub(stub : Stub) : Nil + # Clears all previously defined stubs. abstract def _spectator_clear_stubs : Nil @@ -115,7 +118,7 @@ module Spectator {% raise "Default stub cannot be an abstract method" if method.abstract? %} {% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if 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 %} @@ -123,25 +126,64 @@ 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). # This chunk of code must reconstruct the method signature exactly as it was originally. # If it doesn't match, it doesn't override the method and the stubbing won't work. %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if 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 %} # Capture information about the call. - %args = ::Spectator::Arguments.capture( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}{% end %} + %call = ::Spectator::MethodCall.build( + {{method.name.symbolize}}, + ::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).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}}) ) - %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) # Attempt to find a stub that satisfies the method call and arguments. @@ -150,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 }}) @@ -211,7 +267,7 @@ module Spectator %} {% unless method.abstract? %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if 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 %} @@ -219,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. @@ -227,18 +318,23 @@ module Spectator # This chunk of code must reconstruct the method signature exactly as it was originally. # If it doesn't match, it doesn't override the method and the stubbing won't work. %} - {{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}( + {{visibility.id if 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 %} # Capture information about the call. - %args = ::Spectator::Arguments.capture( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}{% end %} + %call = ::Spectator::MethodCall.build( + {{method.name.symbolize}}, + ::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).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}}) ) - %call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args) _spectator_record_call(%call) # Attempt to find a stub that satisfies the method call and arguments. @@ -246,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. @@ -325,67 +431,96 @@ 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 && "#{method.receiver}.".id}}{{method.name}}( - {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} - {% if method.double_splat %}**{{method.double_splat}}, {% end %} - {% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} - ){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %} - super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} - end - {% end %} + 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 && "#{method.receiver}.".id}}{{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 it's return value) to the correct type. + # Utility macro for casting a stub (and its return value) to the correct type. # # *stub* is the variable holding the stub. # *call* is the variable holding the captured method call. @@ -395,49 +530,38 @@ module Spectator # - `:raise` - raise a `TypeCastError`. # - `:no_return` - raise as no value should be returned. private macro _spectator_cast_stub_value(stub, call, type, fail_cast = :nil) - # Attempt to cast the stub to the method's return type. - # If successful, return the value of the stub. - # This is a common usage where the return type is simple and matches the stub type exactly. - if %typed = {{stub}}.as?(::Spectator::TypedStub({{type}})) - %typed.call({{call}}) - else - # The stub couldn't be easily cast to match the return type. + {% if fail_cast == :no_return %} + {{stub}}.call({{call}}) + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a value, but it shouldn't have returned (`NoReturn`).") + {% else %} + # 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}} - # Even though all stubs will have a `#call` method, the compiler doesn't seem to agree. - # Assert that it will (this should never fail). - raise TypeCastError.new("Stub has no value") unless {{stub}}.responds_to?(:call) + # 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 fail_cast == :no_return %} - {{stub}}.call({{call}}) - raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a value, but it shouldn't have returned (`NoReturn`).") - {% else %} - # Get the value as-is from the stub. - # This will be compiled as a union of all known stubbed value types. - %value = {{stub}}.call({{call}}) - - # Attempt to cast the value to the method's return type. - # If successful, which it will be in most cases, return it. - # The caller will receive a properly typed value without unions or other side-effects. - if %cast = %value.as?({{type}}) - %cast + {% if fail_cast == :nil %} + %cast + {% 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. + raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.") else - {% if fail_cast == :nil %} - nil - {% elsif fail_cast == :raise %} - # 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 %} + # 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 end end diff --git a/src/spectator/mocks/stubbed_type.cr b/src/spectator/mocks/stubbed_type.cr index a5588ee..5362b84 100644 --- a/src/spectator/mocks/stubbed_type.cr +++ b/src/spectator/mocks/stubbed_type.cr @@ -20,6 +20,10 @@ module Spectator _spectator_stubs.unshift(stub) end + def _spectator_remove_stub(stub : Stub) : Nil + _spectator_stubs.delete(stub) + end + def _spectator_clear_stubs : Nil _spectator_stubs.clear end diff --git a/src/spectator/mocks/typed_stub.cr b/src/spectator/mocks/typed_stub.cr index 5067215..eabbcb9 100644 --- a/src/spectator/mocks/typed_stub.cr +++ b/src/spectator/mocks/typed_stub.cr @@ -9,5 +9,11 @@ module Spectator abstract class TypedStub(T) < Stub # Invokes the stubbed implementation. abstract def call(call : MethodCall) : T + + # String representation of the stub, formatted as a method call. + def to_s(io : IO) : Nil + super + io << " : " << T + end end end diff --git a/src/spectator/mocks/value_mock_registry.cr b/src/spectator/mocks/value_mock_registry.cr index 5763509..1efd0b0 100644 --- a/src/spectator/mocks/value_mock_registry.cr +++ b/src/spectator/mocks/value_mock_registry.cr @@ -29,6 +29,12 @@ module Spectator @entries[key] end + # Retrieves all stubs defined for a mocked object or nil if the object isn't mocked yet. + def []?(object : T) + key = value_bytes(object) + @entries[key]? + end + # Retrieves all stubs defined for a mocked object. # # Yields to the block on the first retrieval. diff --git a/src/spectator/mocks/value_stub.cr b/src/spectator/mocks/value_stub.cr index 464c38b..7a84d19 100644 --- a/src/spectator/mocks/value_stub.cr +++ b/src/spectator/mocks/value_stub.cr @@ -20,6 +20,13 @@ module Spectator def initialize(method : Symbol, @value : T, constraint : AbstractArguments? = nil, location : Location? = nil) super(method, constraint, location) end + + # String representation of the stub, formatted as a method call and return value. + def to_s(io : IO) : Nil + super + io << " # => " + @value.inspect(io) + end end module StubModifiers diff --git a/src/spectator/name_node_filter.cr b/src/spectator/name_node_filter.cr index 6d4e64a..c404246 100644 --- a/src/spectator/name_node_filter.cr +++ b/src/spectator/name_node_filter.cr @@ -9,7 +9,7 @@ module Spectator # Checks whether the node satisfies the filter. def includes?(node) : Bool - @name == node.to_s + node.to_s.includes?(@name) end end end diff --git a/src/spectator/node.cr b/src/spectator/node.cr index c5a64b6..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. @@ -66,12 +76,12 @@ module Spectator # Constructs the full name or description of the node. # This prepends names of groups this node is part of. - def to_s(io) + def to_s(io : IO) : Nil display_name.to_s(io) end # Exposes information about the node useful for debugging. - def inspect(io) + def inspect(io : IO) : Nil # Full node name. io << '"' << self << '"' diff --git a/src/spectator/pass_result.cr b/src/spectator/pass_result.cr index 2b62383..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 @@ -24,7 +24,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "pass" 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 03700d9..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 @@ -43,7 +43,7 @@ module Spectator end # One-word description of the result. - def to_s(io) + def to_s(io : IO) : Nil io << "pending" 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/system_exit.cr b/src/spectator/system_exit.cr index 95a2b22..d94711e 100644 --- a/src/spectator/system_exit.cr +++ b/src/spectator/system_exit.cr @@ -20,6 +20,9 @@ class Process # Replace the typically used exit method with a method that raises. # This allows tests to catch attempts to exit the application. def self.exit(status = 0) : NoReturn + # Re-enable log that is disabled from at-exit handlers. + ::Log.setup_from_env(default_level: :none) + raise ::Spectator::SystemExit.new(status: status) end 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 5c79910..76f6f44 100644 --- a/src/spectator/wrapper.cr +++ b/src/spectator/wrapper.cr @@ -13,13 +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 - Box(T).unbox(@pointer) + @pointer.unsafe_as(Value(T)).get end # Retrieves the previously wrapped value. @@ -34,7 +34,20 @@ module Spectator # type = wrapper.get { Int32 } # Returns Int32 # ``` def get(& : -> T) : T forall T - Box(T).unbox(@pointer) + @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