Compare commits

...

70 commits

Author SHA1 Message Date
Michael Miller
dcaa05531a
Release v0.12.1 2024-08-13 19:00:30 -06:00
Michael Miller
a93908d507
Fix usage of deprecated double splat syntax in macros 2024-08-13 18:51:00 -06:00
Michael Miller
a634046a86
Conditionally insert top-level namespace (double colon) 2024-08-13 18:42:22 -06:00
Michael Miller
12f3f35958
Merge remote-tracking branch 'GrantBirki/master'
PR: https://github.com/icy-arctic-fox/spectator/pull/57
2024-08-13 18:18:06 -06:00
Grant Birkinbine
73e1686219
fix: Error: expecting token 'CONST', not '::' 2024-08-11 15:28:19 -07:00
Michael Miller
287758e6af
Update README to point at v0.12.0 2024-02-03 08:05:02 -07:00
Michael Miller
f39ceb8eba
Release v0.12.0 2024-02-03 07:56:57 -07:00
Michael Miller
9b1d400ee1
Update CHANGELOG 2024-01-27 11:29:11 -07:00
Michael Miller
edb20e5b2f
Additional handling when comparing ranges against unexpected types 2024-01-27 11:25:59 -07:00
Michael Miller
526a998e41
Shorten compare_values case statements 2024-01-27 11:25:25 -07:00
Michael Miller
556d4783bf
Support case equality of tuples, arrays, named tuples, and hashes in stub argument matching 2024-01-27 11:18:10 -07:00
Michael Miller
b5fbc96195
Allow matchers to be used in case equality 2024-01-27 11:17:19 -07:00
Michael Miller
5520999b6d
Add spec for GitHub issue 55
https://github.com/icy-arctic-fox/spectator/issues/55
2024-01-27 11:16:57 -07:00
Michael Miller
4a630b1ebf
Bump version to v0.11.7 2023-10-16 17:34:49 -06:00
Michael Miller
d72895fe10
Merge branch 'stufro-fix-readme-mocking-example' 2023-05-21 09:58:19 -06:00
Stuart Frost
04f151fddf Fix mocking example in README.md 2023-05-19 19:39:22 +01:00
Michael Miller
9cbb5d2cf7
Workaround issue using Box with union
Addresses issue found relating to https://gitlab.com/arctic-fox/spectator/-/issues/81
See https://github.com/crystal-lang/crystal/issues/11839
2023-03-27 18:37:50 -06:00
Mike Miller
3852606b28 Merge branch 'gh-49' into 'master'
Fix splat argument expansion in method redefinition

See merge request arctic-fox/spectator!36
2023-01-27 00:28:42 +00:00
Michael Miller
726a2e1515
Add non-captured block argument
Preparing for Crystal 1.8.0
https://github.com/crystal-lang/crystal/issues/8764
2023-01-26 17:19:31 -07:00
Michael Miller
5c08427ca0
Add utility script to run nightly spec 2023-01-26 16:43:19 -07:00
Michael Miller
735122a94b
Bump v0.11.6 2023-01-26 16:21:33 -07:00
Michael Miller
9ea5c261b1
Add entry for GitHub issue 49
https://github.com/icy-arctic-fox/spectator/issues/49
2023-01-26 16:19:55 -07:00
Michael Miller
24a860ea11
Add reference to new issue
https://github.com/icy-arctic-fox/spectator/issues/51
2023-01-26 16:18:26 -07:00
Michael Miller
528ad7257d
Disable GitHub issue 49 spec for now 2023-01-26 16:17:29 -07:00
Michael Miller
7149ef7df5
Revert "Compiler bug when using unsafe_as"
This reverts commit cb89589155.
2023-01-26 16:12:54 -07:00
Michael Miller
cb89589155
Compiler bug when using unsafe_as 2023-01-25 16:09:16 -07:00
Michael Miller
a5e8f11e11
Store type to reduce a bit of bloat 2023-01-23 16:02:30 -07:00
Michael Miller
abbd6ffd71
Fix splat argument expansion in method redefinition
The constructed previous_def call was malformed for stub methods.
Resolves the original issue in
https://github.com/icy-arctic-fox/spectator/issues/49
2023-01-23 11:55:52 -07:00
Michael Miller
fd372226ab
Revert "Use separate context for example name interpolation"
This reverts commit d46698d81a.
2022-12-21 18:51:09 -07:00
Michael Miller
6a5e5b8f7a
Catch errors while evaluating node labels 2022-12-20 21:40:47 -07:00
Michael Miller
4a0bfc1cb2
Add smoke tag 2022-12-20 20:52:01 -07:00
Michael Miller
d46698d81a
Use separate context for example name interpolation
This simplifies some code.
2022-12-20 20:43:47 -07:00
Michael Miller
8c3900adcb
Add support for interpolation in context names 2022-12-20 20:32:40 -07:00
Michael Miller
30602663fe
Add tests for interpolated labels
The context label test intentionally fails.
This functionality still needs to be implemented.
2022-12-20 20:12:58 -07:00
Michael Miller
b8901f522a
Remove unnecessary cast 2022-12-20 20:11:09 -07:00
Michael Miller
c4bcf54b98
Support casting types with should statements 2022-12-19 22:40:55 -07:00
Michael Miller
acf810553a
Use location of the 'should' keyword for their expectation 2022-12-19 22:27:58 -07:00
Michael Miller
faff2933e6
Only capture splat if it has a name 2022-12-19 22:15:53 -07:00
Michael Miller
0f8c46d6ef
Support casting types with expect statements 2022-12-19 21:29:21 -07:00
Michael Miller
7620f58fb8
Test file, please ignore 2022-12-19 02:31:12 -07:00
Michael Miller
feaf1c6015
Bump version to 0.11.5 2022-12-18 19:15:25 -07:00
Michael Miller
8f80b10fc1
Support injecting mock functionality into modules
Add mock registry for a single module.
2022-12-18 19:04:50 -07:00
Michael Miller
a3c55dfa47
Add tests for module mocks docs 2022-12-18 18:52:08 -07:00
Michael Miller
fa99987780
Support creating instances of mocked modules via class
This is a bit of a hack.
The `.new` method is added to the module, which creates an instance that includes the mocked module.
No changes to the def_mock and new_mock methods are necessary.

For some reason, infinite recursion occurs when calling `.new` on the class.
To get around the issue for now, the internal method of allocation is used.
That is, allocate + initialize.
2022-12-18 16:04:49 -07:00
Michael Miller
d378583054
Support mocking modules 2022-12-18 15:18:20 -07:00
Michael Miller
6255cc85c4
Handle original call reaching to another type
Primary use case for this is mock modules.
Allows default stubs to access more than previous_def and super.
2022-12-18 15:17:48 -07:00
Michael Miller
e6584c9f04
Prevent comparing range arguments with non-compatible types in stubs
Addresses https://github.com/icy-arctic-fox/spectator/issues/48
2022-12-18 11:35:43 -07:00
Michael Miller
f55c60e01f
Fix README spec
Mocked types cannot be private.
Moved to a module to prevent polluting the global namespace.
2022-12-17 21:01:22 -07:00
Michael Miller
4b68b8e3de
Fix resolution issue when mocked types use custom types
GitLab issue 51 is affected.
https://gitlab.com/arctic-fox/spectator/-/issues/51
Private types cannot be referenced with mocks.
2022-12-17 20:56:16 -07:00
Michael Miller
c3e7edc700
Use absolute names of types in mocked type methods
Prevent possibly type name collisions.
This could happen if, for instance, Array or String was redefined in the scope of the mocked type.
2022-12-17 20:37:27 -07:00
Michael Miller
149c0e6e4b
Don't use case-matching for proc arguments
A proc on the left side of === calls itself passing in the right side.
This causes typing issues and is easier to avoid for now.
Procs arguments are compared with standard equality (==) instead of case-equality (===).
2022-12-17 19:19:33 -07:00
Michael Miller
9f54a9e542
Additional handling for passing blocks 2022-12-17 19:16:38 -07:00
Michael Miller
65a4b8e756
Populate previous_def/super with captured block args
The previous_def and super keywords do not propagate blocks.
See: https://github.com/crystal-lang/crystal/issues/10399
This works around the issue by populating arguments if the method uses a block.
2022-12-17 16:41:22 -07:00
Michael Miller
b52593dbde
Cleanup 2022-12-17 16:39:47 -07:00
Michael Miller
7e2ec4ee37
Fix 0.11.4 in changelog 2022-12-13 22:59:42 -07:00
Michael Miller
952e949307
Handle 'self' and some other variants in method return types 2022-12-13 22:48:21 -07:00
Michael Miller
293faccd5c
Support free variables in mocked types 2022-12-13 18:22:22 -07:00
Michael Miller
2985ef5919
Remove error handling around type resolution failure
This might not be necessary anymore.
2022-12-09 02:22:21 -07:00
Michael Miller
bd44b5562e
Possible fix for GitLab issue 80
Remove `is_a?` check on line 425.
Replace with alternate logic that achieves the same thing.
The `{{type}}` in `is_a?` was causing a compiler bug.
I'm unsure of the root cause, but this works around it.
2022-12-09 02:16:16 -07:00
Michael Miller
47a62ece78
Add reduced test code for GitLab issue 80
https://gitlab.com/arctic-fox/spectator/-/issues/80
Note: This test only triggers a compiler bug when the file is compiled by itself.
Compiling/running the entire spec suite *does not* cause the bug.
2022-12-08 17:14:09 -07:00
Michael Miller
7ffa63718b
Use original type in redefinition comment 2022-12-08 16:55:27 -07:00
Michael Miller
275b217c6c
Allow metadata to be stored as nil 2022-11-29 23:22:42 -07:00
Michael Miller
fbe877690d
Adjust call argument matching
Reenable test for https://github.com/icy-arctic-fox/spectator/issues/44 and https://github.com/icy-arctic-fox/spectator/issues/47
2022-11-29 22:31:22 -07:00
Michael Miller
a967dce241
Adjust double string representation
to_s and inspect (with variants) are no longer "original implementation."
2022-11-29 21:24:31 -07:00
Michael Miller
1f98bf9ff1
Update CHANGELOG 2022-11-29 20:32:45 -07:00
Michael Miller
5f499336ac
Remove individual spec runs from CI 2022-11-29 20:30:42 -07:00
Michael Miller
df10c8e75b
Prevent multiple redefinitions of the same method 2022-11-29 20:29:36 -07:00
Michael Miller
a585ef0996
Simplify string (inspect) representation
These types make heavy use of generics and combined types.
Instantiating string representation methods for all possibilities is unecesssary and slows down compilation.
2022-11-29 20:28:15 -07:00
Michael Miller
2d6c8844d4
Remove time 2022-11-29 03:34:26 -07:00
Michael Miller
321c15407d
Add utility to test specs individually 2022-11-29 03:14:24 -07:00
61 changed files with 1931 additions and 287 deletions

2
.gitignore vendored
View file

@ -10,3 +10,5 @@
# Ignore JUnit output # Ignore JUnit output
output.xml output.xml
/test.cr

View file

@ -4,7 +4,54 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.11.4] ## [0.12.1] - 2024-08-13
### Fixed
- Fixed some global namespace issues with Crystal 1.13. [#57](https://github.com/icy-arctic-fox/spectator/pull/57) Thanks @GrantBirki !
- Remove usage of deprecated double splat in macros.
## [0.12.0] - 2024-02-03
### Added
- Added ability to use matchers for case equality. [#55](https://github.com/icy-arctic-fox/spectator/issues/55)
- Added support for nested case equality when checking arguments with Array, Tuple, Hash, and NamedTuple.
### Fixed
- Fixed some issues with the `be_within` matcher when used with expected and union types.
## [0.11.7] - 2023-10-16
### Fixed
- Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81)
## [0.11.6] - 2023-01-26
### Added
- Added ability to cast types using the return value from expect/should statements with a type matcher.
- Added support for string interpolation in context names/labels.
### Fixed
- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. [#51](https://github.com/icy-arctic-fox/spectator/issues/51)
- Fix malformed method signature when using named splat with keyword arguments in mocked type. [#49](https://github.com/icy-arctic-fox/spectator/issues/49)
### Changed
- Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start.
- Add non-captured block argument in preparation for Crystal 1.8.0.
## [0.11.5] - 2022-12-18
### Added
- Added support for mock modules and types that include mocked modules.
### Fixed
- Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48)
- Fix method stubs used on methods that capture blocks.
- Fix type name resolution for when using custom types in a mocked typed.
- Prevent comparing range arguments with non-compatible types in stubs. [#48](https://github.com/icy-arctic-fox/spectator/issues/48)
### Changed
- Simplify string representation of mock-related types.
- Remove unnecessary redefinitions of methods when adding stub functionality to a type.
- Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags.
- Use normal equality (==) instead of case-equality (===) with proc arguments in stubs.
- Change stub value cast logic to avoid compiler bug. [#80](https://gitlab.com/arctic-fox/spectator/-/issues/80)
## [0.11.4] - 2022-11-27
### Added ### 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 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. - Add `before`, `after`, and `around` as aliases for `before_each`, `after_each`, and `around_each` respectively.
@ -416,8 +463,13 @@ This has been changed so that it compiles and raises an error at runtime with a
First version ready for public use. First version ready for public use.
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...master [Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.1...master
[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4 [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.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.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.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1

View file

@ -25,7 +25,7 @@ Add this to your application's `shard.yml`:
development_dependencies: development_dependencies:
spectator: spectator:
gitlab: arctic-fox/spectator gitlab: arctic-fox/spectator
version: ~> 0.11.0 version: ~> 0.12.0
``` ```
Usage Usage
@ -287,7 +287,7 @@ Spectator.describe Driver do
# Call the mock method. # Call the mock method.
subject.do_something(interface, dbl) subject.do_something(interface, dbl)
# Verify everything went okay. # Verify everything went okay.
expect(interface).to have_received(:invoke).with(thing) expect(interface).to have_received(:invoke).with(dbl)
end end
end end
``` ```

View file

@ -1,5 +1,5 @@
name: spectator name: spectator
version: 0.11.4 version: 0.12.1
description: | description: |
Feature-rich testing framework for Crystal inspired by RSpec. Feature-rich testing framework for Crystal inspired by RSpec.

View file

@ -123,6 +123,109 @@ Spectator.describe "Mocks Docs" do
end end
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 context "Injecting Mocks" do
struct MyStruct struct MyStruct
def something def something

View file

@ -1,26 +1,28 @@
require "../spec_helper" require "../spec_helper"
private abstract class Interface module Readme
abstract def invoke(thing) : String abstract class Interface
end abstract def invoke(thing) : String
end
# Type being tested. # Type being tested.
private class Driver class Driver
def do_something(interface : Interface, thing) def do_something(interface : Interface, thing)
interface.invoke(thing) interface.invoke(thing)
end
end end
end end
Spectator.describe Driver do Spectator.describe Readme::Driver do
# Define a mock for Interface. # Define a mock for Interface.
mock Interface mock Readme::Interface
# Define a double that the interface will use. # Define a double that the interface will use.
double(:my_double, foo: 42) double(:my_double, foo: 42)
it "does a thing" do it "does a thing" do
# Create an instance of the mock interface. # Create an instance of the mock interface.
interface = mock(Interface) interface = mock(Readme::Interface)
# Indicate that `#invoke` should return "test" when called. # Indicate that `#invoke` should return "test" when called.
allow(interface).to receive(:invoke).and_return("test") allow(interface).to receive(:invoke).and_return("test")

View file

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

View file

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

View file

@ -26,12 +26,15 @@ Spectator.describe "GitHub Issue #44" do
# Original issue uses keyword arguments in place of positional arguments. # Original issue uses keyword arguments in place of positional arguments.
context "keyword arguments in place of positional arguments" do context "keyword arguments in place of positional arguments" do
before_each do before_each do
expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception) pipe = Process::Redirect::Pipe
expect(Process).to receive(:run).with(command, shell: true, output: pipe).and_raise(exception)
end end
it "must stub Process.run", skip: "Keyword arguments in place of positional arguments not supported with expect-receive" do it "must stub Process.run" do
Process.run(command, shell: true, output: :pipe) do |_process| expect do
end Process.run(command, shell: true, output: :pipe) do |_process|
end
end.to raise_error(File::NotFoundError, "File not found")
end end
end end
end end

View file

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

View file

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

View file

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

View file

@ -1,31 +1,33 @@
require "../spec_helper" require "../spec_helper"
private class Foo module GitLabIssue51
def call(str : String) : String? 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 end
def alt1_call(str : String) : String? class Bar
nil def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call.
end a_foo.call("")
a_foo.alt1_call("")
def alt2_call(str : String) : String? a_foo.alt2_call("")
[str, nil].sample end
end end
end end
private class Bar Spectator.describe GitLabIssue51::Bar do
def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call. mock GitLabIssue51::Foo, call: "", alt1_call: "", alt2_call: ""
a_foo.call("")
a_foo.alt1_call("")
a_foo.alt2_call("")
end
end
Spectator.describe Bar do let(:foo) { mock(GitLabIssue51::Foo) }
mock Foo, call: "", alt1_call: "", alt2_call: ""
let(:foo) { mock(Foo) }
subject(:call) { described_class.new.call(foo) } subject(:call) { described_class.new.call(foo) }
describe "#call" do describe "#call" do

View file

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

View file

@ -168,7 +168,7 @@ Spectator.describe "Double DSL", :smoke do
context "methods accepting blocks" do context "methods accepting blocks" do
double(:test7) do double(:test7) do
stub def foo stub def foo(&)
yield yield
end end

View file

@ -40,17 +40,17 @@ Spectator.describe "Mock DSL", :smoke do
arg arg
end end
def method4 : Symbol def method4(&) : Symbol
@_spectator_invocations << :method4 @_spectator_invocations << :method4
yield yield
end end
def method5 def method5(&)
@_spectator_invocations << :method5 @_spectator_invocations << :method5
yield.to_i yield.to_i
end end
def method6 def method6(&)
@_spectator_invocations << :method6 @_spectator_invocations << :method6
yield yield
end end
@ -60,7 +60,7 @@ Spectator.describe "Mock DSL", :smoke do
{arg, args, kwarg, kwargs} {arg, args, kwarg, kwargs}
end end
def method8(arg, *args, kwarg, **kwargs) def method8(arg, *args, kwarg, **kwargs, &)
@_spectator_invocations << :method8 @_spectator_invocations << :method8
yield yield
{arg, args, kwarg, kwargs} {arg, args, kwarg, kwargs}
@ -80,7 +80,7 @@ Spectator.describe "Mock DSL", :smoke do
"stubbed" "stubbed"
end end
stub def method4 : Symbol stub def method4(&) : Symbol
yield yield
:block :block
end end
@ -258,12 +258,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method. # NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation. # 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. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5 stub def method5(&)
yield yield
end end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped. # NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol stub def method6(&) : Symbol
yield yield
end end
@ -381,12 +381,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method. # NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation. # 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. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5 stub def method5(&)
yield yield
end end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped. # NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol stub def method6(&) : Symbol
yield yield
end end
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. # NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation. # 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. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5 stub def method5(&)
yield yield
end end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped. # NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol stub def method6(&) : Symbol
yield yield
end end
@ -577,12 +577,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method. # NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation. # 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. # Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5 stub def method5(&)
yield yield
end end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped. # NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol stub def method6(&) : Symbol
yield yield
end end
end end
@ -620,11 +620,11 @@ Spectator.describe "Mock DSL", :smoke do
:original :original
end end
def method3 def method3(&)
yield yield
end end
def method4 : Int32 def method4(&) : Int32
yield.to_i yield.to_i
end end
@ -749,11 +749,11 @@ Spectator.describe "Mock DSL", :smoke do
:original :original
end end
def method3 def method3(&)
yield yield
end end
def method4 : Int32 def method4(&) : Int32
yield.to_i yield.to_i
end end
@ -1027,4 +1027,262 @@ Spectator.describe "Mock DSL", :smoke do
expect(fake.reference).to eq("reference") expect(fake.reference).to eq("reference")
end end
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 end

View file

@ -156,7 +156,7 @@ Spectator.describe "Null double DSL" do
context "methods accepting blocks" do context "methods accepting blocks" do
double(:test7) do double(:test7) do
stub def foo stub def foo(&)
yield yield
end end

View file

@ -212,14 +212,10 @@ Spectator.describe Spectator::Double do
expect(dbl.hash).to be_a(UInt64) expect(dbl.hash).to be_a(UInt64)
expect(dbl.in?([42])).to be_false expect(dbl.in?([42])).to be_false
expect(dbl.in?(1, 2, 3)).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.itself).to be(dbl)
expect(dbl.not_nil!).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.pretty_print(pp)).to be_nil
expect(dbl.tap { nil }).to be(dbl) 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.try { nil }).to be_nil
expect(dbl.object_id).to be_a(UInt64) expect(dbl.object_id).to be_a(UInt64)
expect(dbl.same?(dbl)).to be_true expect(dbl.same?(dbl)).to be_true
@ -301,7 +297,7 @@ Spectator.describe Spectator::Double do
arg arg
end end
stub def self.baz(arg) stub def self.baz(arg, &)
yield yield
end end
end end
@ -469,7 +465,7 @@ Spectator.describe Spectator::Double do
it "stores calls to non-stubbed methods" do it "stores calls to non-stubbed methods" do
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) 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 end
it "stores arguments for a call" do it "stores arguments for a call" do
@ -479,4 +475,68 @@ Spectator.describe Spectator::Double do
expect(call.arguments).to eq(args) expect(call.arguments).to eq(args)
end end
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 end

View file

@ -275,7 +275,7 @@ Spectator.describe Spectator::LazyDouble do
it "stores calls to non-stubbed methods" do it "stores calls to non-stubbed methods" do
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/) 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 end
it "stores arguments for a call" do it "stores arguments for a call" do
@ -285,4 +285,68 @@ Spectator.describe Spectator::LazyDouble do
expect(call.arguments).to eq(args) expect(call.arguments).to eq(args)
end end
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 end

View file

@ -29,8 +29,18 @@ Spectator.describe Spectator::Mock do
@_spectator_invocations << :method3 @_spectator_invocations << :method3
"original" "original"
end end
def method4 : Thing
self
end
def method5 : OtherThing
OtherThing.new
end
end end
class OtherThing; end
Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method1: 123) do Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method1: 123) do
stub def method2 stub def method2
:stubbed :stubbed
@ -104,6 +114,20 @@ Spectator.describe Spectator::Mock do
mock.method3 mock.method3
expect(mock._spectator_invocations).to contain_exactly(:method3) expect(mock._spectator_invocations).to contain_exactly(:method3)
end 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 end
context "with an abstract class" do context "with an abstract class" do
@ -120,8 +144,14 @@ Spectator.describe Spectator::Mock do
end end
abstract def method4 abstract def method4
abstract def method4 : Thing
abstract def method5 : OtherThing
end end
class OtherThing; end
Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method2: :stubbed) do 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. stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent.
123 123
@ -199,6 +229,20 @@ Spectator.describe Spectator::Mock do
mock.method3 mock.method3
expect(mock._spectator_invocations).to contain_exactly(:method3) expect(mock._spectator_invocations).to contain_exactly(:method3)
end 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 end
context "with an abstract struct" do context "with an abstract struct" do
@ -215,8 +259,14 @@ Spectator.describe Spectator::Mock do
end end
abstract def method4 abstract def method4
abstract def method4 : Thing
abstract def method5 : OtherThing
end end
class OtherThing; end
Spectator::Mock.define_subtype(:struct, Thing, MockThing, :mock_name, method2: :stubbed) do 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. stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent.
123 123
@ -286,6 +336,22 @@ Spectator.describe Spectator::Mock do
mock.method3 mock.method3
expect(mock._spectator_invocations).to contain_exactly(:method3) expect(mock._spectator_invocations).to contain_exactly(:method3)
end 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 end
context "class method stubs" do context "class method stubs" do
@ -298,11 +364,21 @@ Spectator.describe Spectator::Mock do
arg arg
end end
def self.baz(arg) def self.baz(arg, &)
yield yield
end end
def self.thing : Thing
new
end
def self.other : OtherThing
OtherThing.new
end
end end
class OtherThing; end
Spectator::Mock.define_subtype(:class, Thing, MockThing) do Spectator::Mock.define_subtype(:class, Thing, MockThing) do
stub def self.foo stub def self.foo
:stub :stub
@ -367,6 +443,20 @@ Spectator.describe Spectator::Mock do
expect(restricted(mock)).to eq(:stub) expect(restricted(mock)).to eq(:stub)
end 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 describe "._spectator_clear_stubs" do
before { mock._spectator_define_stub(foo_stub) } before { mock._spectator_define_stub(foo_stub) }
@ -401,6 +491,203 @@ Spectator.describe Spectator::Mock do
end end
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 context "with a method that uses NoReturn" do
abstract class Thing abstract class Thing
abstract def oops : NoReturn abstract def oops : NoReturn
@ -642,7 +929,7 @@ Spectator.describe Spectator::Mock do
arg arg
end end
def self.baz(arg) def self.baz(arg, &)
yield yield
end end
end end

View file

@ -186,12 +186,9 @@ Spectator.describe Spectator::NullDouble do
expect(dbl.hash).to be_a(UInt64) expect(dbl.hash).to be_a(UInt64)
expect(dbl.in?([42])).to be_false expect(dbl.in?([42])).to be_false
expect(dbl.in?(1, 2, 3)).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.itself).to be(dbl)
expect(dbl.not_nil!).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.tap { nil }).to be(dbl)
expect(dbl.to_s).to contain("EmptyDouble")
expect(dbl.try { nil }).to be_nil expect(dbl.try { nil }).to be_nil
expect(dbl.object_id).to be_a(UInt64) expect(dbl.object_id).to be_a(UInt64)
expect(dbl.same?(dbl)).to be_true expect(dbl.same?(dbl)).to be_true
@ -262,7 +259,7 @@ Spectator.describe Spectator::NullDouble do
arg arg
end end
stub def self.baz(arg) stub def self.baz(arg, &)
yield yield
end end
end end
@ -439,4 +436,68 @@ Spectator.describe Spectator::NullDouble do
expect(call.arguments).to eq(args) expect(call.arguments).to eq(args)
end end
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 end

View file

@ -4,6 +4,11 @@
# This type is intentionally outside the `Spectator` module. # 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. # The reason for this is to prevent name collision when using the DSL to define a spec.
abstract class SpectatorContext 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. # Produces a dummy string to represent the context as a string.
# This prevents the default behavior, which normally stringifies instance variables. # This prevents the default behavior, which normally stringifies instance variables.
# Due to the sheer amount of types Spectator can create # Due to the sheer amount of types Spectator can create

View file

@ -182,7 +182,7 @@ module Spectator::DSL
# expect(false).to be_true # expect(false).to be_true
# end # end
# ``` # ```
def aggregate_failures(label = nil) def aggregate_failures(label = nil, &)
::Spectator::Harness.current.aggregate_failures(label) do ::Spectator::Harness.current.aggregate_failures(label) do
yield yield
end end

View file

@ -137,7 +137,11 @@ module Spectator::DSL
what.is_a?(NilLiteral) %} what.is_a?(NilLiteral) %}
{{what}} {{what}}
{% elsif what.is_a?(StringInterpolation) %} {% elsif what.is_a?(StringInterpolation) %}
{% raise "String interpolation isn't supported for example group names" %} {{@type.name}}.new.eval do
{{what}}
rescue e
"<Failed to evaluate context label - #{e.class}: #{e}>"
end
{% else %} {% else %}
{{what.stringify}} {{what.stringify}}
{% end %} {% end %}

View file

@ -6,6 +6,9 @@ module Spectator::DSL
private macro _spectator_metadata(name, source, *tags, **metadata) private macro _spectator_metadata(name, source, *tags, **metadata)
private def self.{{name.id}} private def self.{{name.id}}
%metadata = {{source.id}}.dup %metadata = {{source.id}}.dup
{% unless tags.empty? && metadata.empty? %}
%metadata ||= ::Spectator::Metadata.new
{% end %}
{% for k in tags %} {% for k in tags %}
%metadata[{{k.id.symbolize}}] = nil %metadata[{{k.id.symbolize}}] = nil
{% end %} {% end %}

View file

@ -31,7 +31,7 @@ module Spectator::DSL
::Spectator::DSL::Mocks::TYPES << {name.id.symbolize, @type.name(generic_args: false).symbolize, double_type_name.symbolize} %} ::Spectator::DSL::Mocks::TYPES << {name.id.symbolize, @type.name(generic_args: false).symbolize, double_type_name.symbolize} %}
# Define the plain double type. # 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. # Returns a new double that responds to undefined methods with itself.
# See: `NullDouble` # See: `NullDouble`
def as_null_object def as_null_object
@ -43,7 +43,7 @@ module Spectator::DSL
{% begin %} {% begin %}
# Define a matching null double type. # 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 %}
end end
@ -94,9 +94,9 @@ module Spectator::DSL
begin begin
%double = {% if found_tuple %} %double = {% if found_tuple %}
{{found_tuple[2].id}}.new({{**value_methods}}) {{found_tuple[2].id}}.new({{value_methods.double_splat}})
{% else %} {% else %}
::Spectator::LazyDouble.new({{name}}, {{**value_methods}}) ::Spectator::LazyDouble.new({{name}}, {{value_methods.double_splat}})
{% end %} {% end %}
::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset }) ::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset })
%double %double
@ -176,7 +176,7 @@ module Spectator::DSL
# See `#def_double`. # See `#def_double`.
macro double(name, **value_methods, &block) macro double(name, **value_methods, &block)
{% begin %} {% 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 %}
end end
@ -189,7 +189,7 @@ module Spectator::DSL
# expect(dbl.foo).to eq(42) # expect(dbl.foo).to eq(42)
# ``` # ```
macro double(**value_methods) macro double(**value_methods)
::Spectator::LazyDouble.new({{**value_methods}}) ::Spectator::LazyDouble.new({{value_methods.double_splat}})
end end
# Defines a new mock type. # Defines a new mock type.
@ -218,24 +218,29 @@ module Spectator::DSL
# end # end
# ``` # ```
private macro def_mock(type, name = nil, **value_methods, &block) 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. {% resolved = type.resolve
index = ::Spectator::DSL::Mocks::TYPES.size # Construct a unique type name for the mock by using the number of defined types.
mock_type_name = "Mock#{index}".id 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. # Store information about how the mock is defined and its context.
# This is important for constructing an instance of the mock later. # 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} ::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?
base = if resolved.class? :class
:class elsif resolved.struct?
elsif resolved.struct? :struct
:struct else
else :module
:module end %}
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 end
# Instantiates a mock. # Instantiates a mock.
@ -316,7 +321,7 @@ module Spectator::DSL
macro mock(type, **value_methods, &block) 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) %} {% 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 %} {% 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 %}
end end
@ -426,7 +431,7 @@ module Spectator::DSL
# This isn't required, but new_mock() should still find this type. # 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::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 end
# Targets a stubbable object (such as a mock or double) for operations. # Targets a stubbable object (such as a mock or double) for operations.

View file

@ -11,7 +11,7 @@ module Spectator
end end
# Calls the `error` method on *visitor*. # Calls the `error` method on *visitor*.
def accept(visitor) def accept(visitor, &)
visitor.error(yield self) visitor.error(yield self)
end end

View file

@ -40,7 +40,7 @@ module Spectator
# Note: The metadata will not be merged with the parent metadata. # Note: The metadata will not be merged with the parent metadata.
def initialize(@context : Context, @entrypoint : self ->, def initialize(@context : Context, @entrypoint : self ->,
name : String? = nil, location : Location? = nil, name : String? = nil, location : Location? = nil,
@group : ExampleGroup? = nil, metadata = Metadata.new) @group : ExampleGroup? = nil, metadata = nil)
super(name, location, metadata) super(name, location, metadata)
# Ensure group is linked. # Ensure group is linked.
@ -58,7 +58,7 @@ module Spectator
# Note: The metadata will not be merged with the parent metadata. # Note: The metadata will not be merged with the parent metadata.
def initialize(@context : Context, @entrypoint : self ->, def initialize(@context : Context, @entrypoint : self ->,
@name_proc : Example -> String, location : Location? = nil, @name_proc : Example -> String, location : Location? = nil,
@group : ExampleGroup? = nil, metadata = Metadata.new) @group : ExampleGroup? = nil, metadata = nil)
super(nil, location, metadata) super(nil, location, metadata)
# Ensure group is linked. # Ensure group is linked.
@ -75,7 +75,7 @@ module Spectator
# A set of *metadata* can be used for filtering and modifying example behavior. # A set of *metadata* can be used for filtering and modifying example behavior.
# Note: The metadata will not be merged with the parent metadata. # Note: The metadata will not be merged with the parent metadata.
def initialize(name : String? = nil, location : Location? = nil, 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) super(name, location, metadata)
@context = NullContext.new @context = NullContext.new
@ -93,9 +93,10 @@ module Spectator
# A set of *metadata* can be used for filtering and modifying example behavior. # A set of *metadata* can be used for filtering and modifying example behavior.
# Note: The metadata will not be merged with the parent metadata. # Note: The metadata will not be merged with the parent metadata.
def self.pending(name : String? = nil, location : Location? = nil, 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. # 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 } new(name, location, group, metadata) { nil }
end end
@ -117,7 +118,7 @@ module Spectator
begin begin
@result = Harness.run do @result = Harness.run do
if proc = @name_proc.as?(Proc(Example, String)) if proc = @name_proc
self.name = proc.call(self) self.name = proc.call(self)
end end
@ -163,7 +164,7 @@ module Spectator
# The context casted to an instance of *klass* is provided as a block argument. # 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. # 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) context = klass.cast(@context)
with context yield with context yield
end end
@ -183,7 +184,7 @@ module Spectator
end end
# Yields this example and all parent groups. # Yields this example and all parent groups.
def ascend def ascend(&)
node = self node = self
while node while node
yield node yield node
@ -278,7 +279,7 @@ module Spectator
# The block given to this method will be executed within the test context. # 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. # 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) context = @example.cast_context(klass)
with context yield with context yield
end end

View file

@ -15,7 +15,7 @@ module Spectator
# The *entrypoint* indicates the proc used to invoke the test code in the example. # 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`. # The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`.
def initialize(@context_builder : -> Context, @entrypoint : Example ->, 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 end
# Creates the builder. # Creates the builder.
@ -24,7 +24,7 @@ module Spectator
# The *name* is an interpolated string that runs in the context of the example. # 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`. # *location*, and *metadata* will be applied to the `Example` produced by `#build`.
def initialize(@context_builder : -> Context, @entrypoint : Example ->, 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 end
# Constructs an example with previously defined attributes and context. # Constructs an example with previously defined attributes and context.

View file

@ -79,7 +79,7 @@ module Spectator
# This group will be assigned to the parent *group* if it is provided. # 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. # A set of *metadata* can be used for filtering and modifying example behavior.
def initialize(@name : Label = nil, @location : Location? = nil, 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. # Ensure group is linked.
group << self if group group << self if group
end end
@ -87,7 +87,7 @@ module Spectator
delegate size, unsafe_fetch, to: @nodes delegate size, unsafe_fetch, to: @nodes
# Yields this group and all parent groups. # Yields this group and all parent groups.
def ascend def ascend(&)
group = self group = self
while group while group
yield group yield group

View file

@ -28,7 +28,7 @@ module Spectator
# Creates the builder. # Creates the builder.
# Initially, the builder will have no children and no hooks. # Initially, the builder will have no children and no hooks.
# The *name*, *location*, and *metadata* will be applied to the `ExampleGroup` produced by `#build`. # 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 end
# Constructs an example group with previously defined attributes, children, and hooks. # Constructs an example group with previously defined attributes, children, and hooks.

View file

@ -18,7 +18,7 @@ module Spectator
# This group will be assigned to the parent *group* if it is provided. # 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. # A set of *metadata* can be used for filtering and modifying example behavior.
def initialize(@item : T, name : Label = nil, location : Location? = nil, 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) super(name, location, group, metadata)
end end
end end

View file

@ -114,6 +114,21 @@ module Spectator
report(match_data, message) report(match_data, message)
end 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. # Asserts that a method is not called before the example completes.
@[AlwaysInline] @[AlwaysInline]
def to_not(stub : Stub, message = nil) : Nil def to_not(stub : Stub, message = nil) : Nil
@ -136,6 +151,36 @@ module Spectator
report(match_data, message) report(match_data, message)
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 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: # :ditto:
@[AlwaysInline] @[AlwaysInline]
def not_to(matcher, message = nil) : Nil def not_to(matcher, message = nil) : Nil

View file

@ -24,7 +24,7 @@ module Spectator
end end
# Calls the `failure` method on *visitor*. # Calls the `failure` method on *visitor*.
def accept(visitor) def accept(visitor, &)
visitor.fail(yield self) visitor.fail(yield self)
end end

View file

@ -13,7 +13,7 @@ module Spectator::Formatting::Components
end end
# Increases the indent by the a specific *amount* for the duration of the block. # 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 @indent += amount
yield yield
@indent -= amount @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. # 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, # Ensure that _only_ one line is produced by the block,
# otherwise the indent will be lost. # otherwise the indent will be lost.
private def line(io) private def line(io, &)
@indent.times { io << ' ' } @indent.times { io << ' ' }
yield yield
io.puts io.puts

View file

@ -43,7 +43,7 @@ module Spectator
# The value of `.current` is set to the harness for the duration of the test. # 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. # It will be reset after the test regardless of the outcome.
# The result of running the test code will be returned. # The result of running the test code will be returned.
def self.run : Result def self.run(&) : Result
with_harness do |harness| with_harness do |harness|
harness.run { yield } harness.run { yield }
end end
@ -53,7 +53,7 @@ module Spectator
# The `.current` harness is set to the new harness for the duration of the block. # 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. # `.current` is reset to the previous value (probably nil) afterwards, even if the block raises.
# The result of the block is returned. # The result of the block is returned.
private def self.with_harness private def self.with_harness(&)
previous = @@current previous = @@current
begin begin
@@current = harness = new @@current = harness = new
@ -70,7 +70,7 @@ module Spectator
# Runs test code and produces a result based on the outcome. # 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. # The test code should be called from within the block given to this method.
def run : Result def run(&) : Result
elapsed, error = capture { yield } elapsed, error = capture { yield }
elapsed2, error2 = capture { run_deferred } elapsed2, error2 = capture { run_deferred }
run_cleanup run_cleanup
@ -106,7 +106,7 @@ module Spectator
@cleanup << block @cleanup << block
end end
def aggregate_failures(label = nil) def aggregate_failures(label = nil, &)
previous = @aggregate previous = @aggregate
@aggregate = aggregate = [] of Expectation @aggregate = aggregate = [] of Expectation
begin begin
@ -135,7 +135,7 @@ module Spectator
# Yields to run the test code and returns information about the outcome. # 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). # 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 error = nil
elapsed = Time.measure do elapsed = Time.measure do
error = catch { yield } error = catch { yield }
@ -146,7 +146,7 @@ module Spectator
# Yields to run a block of code and captures exceptions. # 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 of code raises an error, the error is caught and returned.
# If the block doesn't raise an error, then nil is returned. # If the block doesn't raise an error, then nil is returned.
private def catch : Exception? private def catch(&) : Exception?
yield yield
rescue e rescue e
e e

View file

@ -15,7 +15,7 @@ module Spectator
# The *collection* is the set of items to create sub-nodes for. # 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. # 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, 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) super(name, location, metadata)
end end

View file

@ -97,7 +97,7 @@ module Spectator::Matchers
# Runs a block of code and returns the exception it threw. # Runs a block of code and returns the exception it threw.
# If no exception was thrown, *nil* is returned. # If no exception was thrown, *nil* is returned.
private def capture_exception private def capture_exception(&)
exception = nil exception = nil
begin begin
yield yield

View file

@ -1,3 +1,4 @@
require "../value"
require "./match_data" require "./match_data"
module Spectator::Matchers module Spectator::Matchers
@ -22,6 +23,19 @@ module Spectator::Matchers
# A successful match with `#match` should normally fail for this method, and vice-versa. # A successful match with `#match` should normally fail for this method, and vice-versa.
abstract def negated_match(actual : Expression(T)) : MatchData forall T 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 private def match_data_description(actual : Expression(T)) : String forall T
match_data_description(actual.label) match_data_description(actual.label)
end end

View file

@ -29,7 +29,26 @@ module Spectator::Matchers
# Checks whether the matcher is satisfied with the expression given to it. # Checks whether the matcher is satisfied with the expression given to it.
private def match?(actual : Expression(T)) : Bool forall T 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 end
# Message displayed when the matcher isn't satisfied. # Message displayed when the matcher isn't satisfied.

View file

@ -1,13 +1,61 @@
module Spectator module Spectator
# Untyped arguments to a method call (message). # Untyped arguments to a method call (message).
abstract class AbstractArguments abstract class AbstractArguments
# Utility method for comparing two named tuples ignoring order. # Use the string representation to avoid over complicating debug output.
private def compare_named_tuples(a : NamedTuple, b : NamedTuple) def inspect(io : IO) : Nil
a.each do |k, v1| to_s(io)
v2 = b.fetch(k) { return false } end
return false unless v1 === v2
# 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 end
true true
end 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
end end

View file

@ -81,7 +81,7 @@ module Spectator
# Checks if another set of arguments matches this set of arguments. # Checks if another set of arguments matches this set of arguments.
def ===(other : Arguments) def ===(other : Arguments)
positional === other.positional && compare_named_tuples(kwargs, other.kwargs) compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs)
end end
# :ditto: # :ditto:
@ -90,17 +90,18 @@ module Spectator
i = 0 i = 0
other.args.each do |k, v2| other.args.each do |k, v2|
break if i >= positional.size
next if kwargs.has_key?(k) # Covered by named arguments. next if kwargs.has_key?(k) # Covered by named arguments.
v1 = positional.fetch(i) { return false } v1 = positional[i]
i += 1 i += 1
return false unless v1 === v2 return false unless compare_values(v1, v2)
end end
other.splat.try &.each do |v2| other.splat.try &.each do |v2|
v1 = positional.fetch(i) { return false } v1 = positional.fetch(i) { return false }
i += 1 i += 1
return false unless v1 === v2 return false unless compare_values(v1, v2)
end end
i == positional.size i == positional.size

View file

@ -98,24 +98,35 @@ module Spectator
# Simplified string representation of a double. # Simplified string representation of a double.
# Avoids displaying nested content and bloating method instantiation. # Avoids displaying nested content and bloating method instantiation.
def to_s(io : IO) : Nil 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 << _spectator_stubbed_name
io << ":0x"
object_id.to_s(io, 16)
io << '>'
end end
# Defines a stub to change the behavior of a method in this double. # 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. # 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 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) @stubs.unshift(stub)
end end
protected def _spectator_remove_stub(stub : Stub) : Nil protected def _spectator_remove_stub(stub : Stub) : Nil
Log.debug { "Removing stub #{stub} from #{_spectator_stubbed_name}" } Log.debug { "Removing stub #{stub} from #{inspect}" }
@stubs.delete(stub) @stubs.delete(stub)
end end
protected def _spectator_clear_stubs : Nil protected def _spectator_clear_stubs : Nil
Log.debug { "Clearing stubs for #{_spectator_stubbed_name}" } Log.debug { "Clearing stubs for #{inspect}" }
@stubs.clear @stubs.clear
end end
@ -145,17 +156,17 @@ module Spectator
# Returns the double's name formatted for user output. # Returns the double's name formatted for user output.
private def _spectator_stubbed_name : String private def _spectator_stubbed_name : String
{% if anno = @type.annotation(StubbedName) %} {% if anno = @type.annotation(StubbedName) %}
"#<Double " + {{(anno[0] || :Anonymous.id).stringify}} + ">" {{(anno[0] || :Anonymous.id).stringify}}
{% else %} {% else %}
"#<Double Anonymous>" "Anonymous"
{% end %} {% end %}
end end
private def self._spectator_stubbed_name : String private def self._spectator_stubbed_name : String
{% if anno = @type.annotation(StubbedName) %} {% if anno = @type.annotation(StubbedName) %}
"#<Class Double " + {{(anno[0] || :Anonymous.id).stringify}} + ">" {{(anno[0] || :Anonymous.id).stringify}}
{% else %} {% else %}
"#<Class Double Anonymous>" "Anonymous"
{% end %} {% end %}
end end
@ -175,7 +186,7 @@ module Spectator
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
end end
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}")
end end
private def _spectator_abstract_stub_fallback(call : MethodCall, type) private def _spectator_abstract_stub_fallback(call : MethodCall, type)
@ -194,9 +205,9 @@ module Spectator
call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args) call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args)
_spectator_record_call(call) _spectator_record_call(call)
Log.trace { "#{_spectator_stubbed_name} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" } Log.trace { "#{inspect} got undefined method `#{call}{% if call.block %} { ... }{% end %}`" }
raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}") raise ::Spectator::UnexpectedMessage.new("#{inspect} received unexpected message #{call}")
nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors". nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors".
end end
end end

View file

@ -122,12 +122,12 @@ module Spectator
# Checks if another set of arguments matches this set of arguments. # Checks if another set of arguments matches this set of arguments.
def ===(other : Arguments) def ===(other : Arguments)
positional === other.positional && compare_named_tuples(kwargs, other.kwargs) compare_tuples(positional, other.positional) && compare_named_tuples(kwargs, other.kwargs)
end end
# :ditto: # :ditto:
def ===(other : FormalArguments) def ===(other : FormalArguments)
compare_named_tuples(args, other.args) && splat === other.splat && compare_named_tuples(kwargs, other.kwargs) compare_named_tuples(args, other.args) && compare_tuples(splat, other.splat) && compare_named_tuples(kwargs, other.kwargs)
end end
end end
end end

View file

@ -37,13 +37,13 @@ module Spectator
# Returns the double's name formatted for user output. # Returns the double's name formatted for user output.
private def _spectator_stubbed_name : String private def _spectator_stubbed_name : String
"#<LazyDouble #{@name || "Anonymous"}>" @name || "Anonymous"
end end
private def _spectator_stub_fallback(call : MethodCall, &) private def _spectator_stub_fallback(call : MethodCall, &)
if _spectator_stub_for_method?(call.method) if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." } 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 else
Log.trace { "Fallback for #{call} - call original" } Log.trace { "Fallback for #{call} - call original" }
yield yield
@ -57,7 +57,7 @@ module Spectator
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
_spectator_record_call(%call) _spectator_record_call(%call)
Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" }
# Attempt to find a stub that satisfies the method call and arguments. # Attempt to find a stub that satisfies the method call and arguments.
if %stub = _spectator_find_stub(%call) if %stub = _spectator_find_stub(%call)

View file

@ -30,7 +30,13 @@ module Spectator
# Constructs a string containing the method name and arguments. # Constructs a string containing the method name and arguments.
def to_s(io : IO) : Nil 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 end
end end

View file

@ -1,5 +1,6 @@
require "./method_call" require "./method_call"
require "./mocked" require "./mocked"
require "./mock_registry"
require "./reference_mock_registry" require "./reference_mock_registry"
require "./stub" require "./stub"
require "./stubbed_name" require "./stubbed_name"
@ -36,7 +37,35 @@ module Spectator
macro define_subtype(base, mocked_type, type_name, name = nil, **value_methods, &block) macro define_subtype(base, mocked_type, type_name, name = nil, **value_methods, &block)
{% begin %} {% begin %}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %} {% 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 include ::Spectator::Mocked
extend ::Spectator::StubbedType extend ::Spectator::StubbedType
@ -50,22 +79,22 @@ module Spectator
end end
{% end %} {% end %}
def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil
@_spectator_stubs.try &.delete(stub) @_spectator_stubs.try &.delete(stub)
end end
def _spectator_clear_stubs : Nil def _spectator_clear_stubs : ::Nil
@_spectator_stubs = nil @_spectator_stubs = nil
end 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 getter _spectator_calls = [] of ::Spectator::MethodCall
# Returns the mock's name formatted for user output. # 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) %} \{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">" "#<Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %} \{% else %}
@ -73,7 +102,7 @@ module Spectator
\{% end %} \{% end %}
end end
private def self._spectator_stubbed_name : String private def self._spectator_stubbed_name : ::String
\{% if anno = @type.annotation(::Spectator::StubbedName) %} \{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Class Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">" "#<Class Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %} \{% else %}
@ -120,7 +149,7 @@ module Spectator
macro inject(base, type_name, name = nil, **value_methods, &block) macro inject(base, type_name, name = nil, **value_methods, &block)
{% begin %} {% begin %}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %} {% 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 include ::Spectator::Mocked
extend ::Spectator::StubbedType extend ::Spectator::StubbedType
@ -129,12 +158,12 @@ module Spectator
{% elsif base == :struct %} {% elsif base == :struct %}
@@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new @@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new
{% else %} {% else %}
{% raise "Unsupported base type #{base} for injecting mock" %} @@_spectator_mock_registry = ::Spectator::MockRegistry.new
{% end %} {% 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 private def _spectator_stubs
entry = @@_spectator_mock_registry.fetch(self) do entry = @@_spectator_mock_registry.fetch(self) do
@ -143,11 +172,11 @@ module Spectator
entry.stubs entry.stubs
end end
def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil
@@_spectator_mock_registry[self]?.try &.stubs.delete(stub) @@_spectator_mock_registry[self]?.try &.stubs.delete(stub)
end end
def _spectator_clear_stubs : Nil def _spectator_clear_stubs : ::Nil
@@_spectator_mock_registry.delete(self) @@_spectator_mock_registry.delete(self)
end end
@ -169,7 +198,7 @@ module Spectator
end end
# Returns the mock's name formatted for user output. # 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) %} \{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">" "#<Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %} \{% else %}
@ -178,7 +207,7 @@ module Spectator
end end
# Returns the mock's name formatted for user output. # 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) %} \{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Class Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">" "#<Class Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %} \{% else %}

View file

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

View file

@ -26,7 +26,7 @@ module Spectator
private def _spectator_abstract_stub_fallback(call : MethodCall) private def _spectator_abstract_stub_fallback(call : MethodCall)
if _spectator_stub_for_method?(call.method) if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." } 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 else
Log.trace { "Fallback for #{call} - return self" } Log.trace { "Fallback for #{call} - return self" }
self self
@ -42,9 +42,9 @@ module Spectator
private def _spectator_abstract_stub_fallback(call : MethodCall, type) private def _spectator_abstract_stub_fallback(call : MethodCall, type)
if _spectator_stub_for_method?(call.method) if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." } 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 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
end end
@ -56,7 +56,7 @@ module Spectator
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args) %call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
_spectator_record_call(%call) _spectator_record_call(%call)
Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" } Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" }
self self
end end

View file

@ -126,7 +126,41 @@ module Spectator
{{method.body}} {{method.body}}
end 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. {% # 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). # I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
@ -145,7 +179,7 @@ module Spectator
::NamedTuple.new( ::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 %} {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
), ),
{% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} {% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new( ::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 %} {% 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}}) ).merge({{method.double_splat}})
@ -158,10 +192,24 @@ module Spectator
# Cast the stub or return value to the expected type. # Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method. # This is necessary to match the expected return type of the original method.
_spectator_cast_stub_value(%stub, %call, typeof({{original}}), _spectator_cast_stub_value(%stub, %call, typeof({{original}}),
{{ if method.return_type && method.return_type.resolve == NoReturn {{ if rt = method.return_type
:no_return if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn
elsif method.return_type && method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil) :no_return
:nil 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 else
:raise :raise
end }}) end }})
@ -227,7 +275,42 @@ module Spectator
{{method.body}} {{method.body}}
end 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 %} {% end %}
{% # Reconstruct the method signature. {% # Reconstruct the method signature.
@ -247,7 +330,7 @@ module Spectator
::NamedTuple.new( ::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 %} {% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
), ),
{% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %} {% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new( ::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 %} {% 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}}) ).merge({{method.double_splat}})
@ -259,15 +342,25 @@ module Spectator
if %stub = _spectator_find_stub(%call) if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type. # Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method. # 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. # Return type restriction takes priority since it can be a superset of the original implementation.
_spectator_cast_stub_value(%stub, %call, {{method.return_type}}, _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 :no_return
elsif method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)
:nil
else 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 }}) end }})
{% elsif !method.abstract? %} {% elsif !method.abstract? %}
# The method isn't abstract, infer the type it returns without calling it. # The method isn't abstract, infer the type it returns without calling it.
@ -338,64 +431,93 @@ module Spectator
# Redefines all methods and ones inherited from its parents and mixins to support stubs. # Redefines all methods and ones inherited from its parents and mixins to support stubs.
private macro stub_type(type_name = @type) private macro stub_type(type_name = @type)
{% type = type_name.resolve {% type = type_name.resolve
# Reverse order of ancestors (there's currently no reverse method for ArrayLiteral). definitions = [] of Nil
count = type.ancestors.size scope = if type == @type
ancestors = type.ancestors.map_with_index { |_, i| type.ancestors[count - i - 1] } %} :previous_def
{% for ancestor in ancestors %} elsif type.module?
{% for method in ancestor.methods.reject do |meth| type.name
meth.name.starts_with?("_spectator") || else
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) :super
end %} end.id
{{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
end
{% end %}
{% for method in ancestor.class.methods.reject do |meth| # Add entries for methods in the target type and its class type.
meth.name.starts_with?("_spectator") || [[:self.id, type.class], [nil, type]].each do |(receiver, t)|
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) t.methods.each do |method|
end %} definitions << {
default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}( type: t,
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %} method: method,
{% if method.double_splat %}**{{method.double_splat}}, {% end %} scope: scope,
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %} receiver: receiver,
){% 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
{% end %}
{% end %}
{% for method in type.methods.reject do |meth| # Iterate through all ancestors and add their methods.
meth.name.starts_with?("_spectator") || type.ancestors.each do |ancestor|
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize) [[:self.id, ancestor.class], [nil, ancestor]].each do |(receiver, t)|
end %} t.methods.each do |method|
{{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}( # 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 %} {% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %} {% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% 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 method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
{% unless method.abstract? %} {% unless method.abstract? %}
{% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %} {{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 %} {% 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 end
# Utility macro for casting a stub (and its return value) to the correct type. # Utility macro for casting a stub (and its return value) to the correct type.
@ -415,29 +537,30 @@ module Spectator
# Get the value as-is from the stub. # Get the value as-is from the stub.
# This will be compiled as a union of all known stubbed value types. # This will be compiled as a union of all known stubbed value types.
%value = {{stub}}.call({{call}}) %value = {{stub}}.call({{call}})
%type = {{type}}
# Attempt to cast the value to the method's return type. # Attempt to cast the value to the method's return type.
# If successful, which it will be in most cases, return it. # 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. # The caller will receive a properly typed value without unions or other side-effects.
%cast = %value.as?({{type}}) %cast = %value.as?({{type}})
if %cast.is_a?({{type}})
{% if fail_cast == :nil %}
%cast %cast
else {% elsif fail_cast == :raise %}
{% if fail_cast == :nil %} # Check if nil was returned by the stub and if its okay to return it.
nil if %value.nil? && %type.nilable?
{% elsif fail_cast == :raise %} # 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. # 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. raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.")
%type = begin else
%value.class.to_s # Types match and value can be returned as cast type.
rescue %cast
"<Unknown>" end
end {% else %}
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.") {% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %}
{% else %} {% end %}
{% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %}
{% end %}
end
{% end %} {% end %}
end end
end end

View file

@ -30,14 +30,16 @@ module Spectator
end end
# User-defined tags and values used for filtering and behavior modification. # User-defined tags and values used for filtering and behavior modification.
getter metadata : Metadata def metadata : Metadata
@metadata ||= Metadata.new
end
# Creates the node. # Creates the node.
# The *name* describes the purpose of the node. # The *name* describes the purpose of the node.
# It can be a `Symbol` to describe a type. # It can be a `Symbol` to describe a type.
# The *location* tracks where the node exists in source code. # The *location* tracks where the node exists in source code.
# A set of *metadata* can be used for filtering and modifying example behavior. # 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 end
# Indicates whether the node has completed. # Indicates whether the node has completed.
@ -46,17 +48,25 @@ module Spectator
# Checks if the node has been marked as pending. # Checks if the node has been marked as pending.
# Pending items should be skipped during execution. # Pending items should be skipped during execution.
def pending? def pending?
metadata.has_key?(:pending) || metadata.has_key?(:skip) return false unless md = @metadata
md.has_key?(:pending) || md.has_key?(:skip)
end end
# Gets the reason the node has been marked as pending. # Gets the reason the node has been marked as pending.
def pending_reason 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 end
# Retrieves just the tag names applied to the node. # Retrieves just the tag names applied to the node.
def tags def tags
Tags.new(metadata.keys) if md = @metadata
Tags.new(md.keys)
else
Tags.new
end
end end
# Non-nil name used to show the node name. # Non-nil name used to show the node name.

View file

@ -9,7 +9,7 @@ module Spectator
end end
# Calls the `pass` method on *visitor*. # Calls the `pass` method on *visitor*.
def accept(visitor) def accept(visitor, &)
visitor.pass(yield self) visitor.pass(yield self)
end end

View file

@ -11,7 +11,7 @@ module Spectator
# The *name*, *location*, and *metadata* will be applied to the `Example` produced by `#build`. # 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. # A default *reason* can be given in case the user didn't provide one.
def initialize(@name : String? = nil, @location : Location? = nil, def initialize(@name : String? = nil, @location : Location? = nil,
@metadata : Metadata = Metadata.new, @reason : String? = nil) @metadata : Metadata? = nil, @reason : String? = nil)
end end
# Constructs an example with previously defined attributes. # Constructs an example with previously defined attributes.

View file

@ -28,7 +28,7 @@ module Spectator
end end
# Calls the `pending` method on the *visitor*. # Calls the `pending` method on the *visitor*.
def accept(visitor) def accept(visitor, &)
visitor.pending(yield self) visitor.pending(yield self)
end end

View file

@ -22,51 +22,106 @@ class Object
# ``` # ```
# require "spectator/should" # require "spectator/should"
# ``` # ```
def should(matcher, message = nil) def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
actual = ::Spectator::Value.new(self) actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.match(actual) 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) ::Spectator::Harness.current.report(expectation)
end 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. # Works the same as `#should` except the condition is inverted.
# When `#should` succeeds, this method will fail, and vice-versa. # 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) actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.negated_match(actual) 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) ::Spectator::Harness.current.report(expectation)
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 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. # 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. # The expectation is checked after the example finishes and all hooks have run.
def should_eventually(matcher, message = nil) def should_eventually(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
::Spectator::Harness.current.defer { should(matcher, message) } ::Spectator::Harness.current.defer { should(matcher, message, _file: _file, _line: _line) }
end end
# Works the same as `#should_not` except that the condition check is postponed. # 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. # The expectation is checked after the example finishes and all hooks have run.
def should_never(matcher, message = nil) def should_never(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
::Spectator::Harness.current.defer { should_not(matcher, message) } ::Spectator::Harness.current.defer { should_not(matcher, message, _file: _file, _line: _line) }
end end
end end
struct Proc(*T, R) struct Proc(*T, R)
# Extension method to create an expectation for a block of code (proc). # Extension method to create an expectation for a block of code (proc).
# Depending on the matcher, the proc may be executed multiple times. # 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) actual = ::Spectator::Block.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.match(actual) 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) ::Spectator::Harness.current.report(expectation)
end end
# Works the same as `#should` except the condition is inverted. # Works the same as `#should` except the condition is inverted.
# When `#should` succeeds, this method will fail, and vice-versa. # 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) actual = ::Spectator::Block.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.negated_match(actual) 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) ::Spectator::Harness.current.report(expectation)
end end
end end

View file

@ -60,7 +60,7 @@ module Spectator
# #
# A set of *metadata* can be used for filtering and modifying example behavior. # 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. # 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}" } Log.trace { "Start group: #{name.inspect} @ #{location}; metadata: #{metadata}" }
builder = ExampleGroupBuilder.new(name, location, 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. # 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. # 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}" } Log.trace { "Start iterative group: #{name} (#{typeof(collection)}) @ #{location}; metadata: #{metadata}" }
builder = IterativeExampleGroupBuilder.new(collection, name, iterator, location, 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. # It will be yielded two arguments - the example created by this method, and the *context* argument.
# The return value of the block is ignored. # The return value of the block is ignored.
# It is expected that the test code runs when the block is called. # 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}" } Log.trace { "Add example: #{name} @ #{location}; metadata: #{metadata}" }
current << ExampleBuilder.new(context_builder, block, name, location, metadata) current << ExampleBuilder.new(context_builder, block, name, location, metadata)
end end
@ -144,7 +144,7 @@ module Spectator
# A set of *metadata* can be used for filtering and modifying example behavior. # 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. # 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. # 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}" } Log.trace { "Add pending example: #{name} @ #{location}; metadata: #{metadata}" }
current << PendingExampleBuilder.new(name, location, metadata, reason) current << PendingExampleBuilder.new(name, location, metadata, reason)
end end

View file

@ -10,7 +10,9 @@ module Spectator
# Checks whether the node satisfies the filter. # Checks whether the node satisfies the filter.
def includes?(node) : Bool 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 end
end end

View file

@ -34,7 +34,7 @@ class SpectatorTestContext < SpectatorContext
# Initial metadata for tests. # Initial metadata for tests.
# This method should be overridden by example groups and examples. # This method should be overridden by example groups and examples.
private def self.metadata private def self.metadata : ::Spectator::Metadata?
::Spectator::Metadata.new nil
end end
end end

View file

@ -13,18 +13,13 @@ module Spectator
# Creates a wrapper for the specified value. # Creates a wrapper for the specified value.
def initialize(value) def initialize(value)
@pointer = Box.box(value) @pointer = Value.new(value).as(Void*)
end end
# Retrieves the previously wrapped value. # Retrieves the previously wrapped value.
# The *type* of the wrapped value must match otherwise an error will be raised. # The *type* of the wrapped value must match otherwise an error will be raised.
def get(type : T.class) : T forall T def get(type : T.class) : T forall T
{% begin %} @pointer.unsafe_as(Value(T)).get
{% if T.nilable? %}
@pointer.null? ? nil :
{% end %}
Box(T).unbox(@pointer)
{% end %}
end end
# Retrieves the previously wrapped value. # Retrieves the previously wrapped value.
@ -39,12 +34,20 @@ module Spectator
# type = wrapper.get { Int32 } # Returns Int32 # type = wrapper.get { Int32 } # Returns Int32
# ``` # ```
def get(& : -> T) : T forall T def get(& : -> T) : T forall T
{% begin %} @pointer.unsafe_as(Value(T)).get
{% if T.nilable? %} end
@pointer.null? ? nil :
{% end %} # Wrapper for a value.
Box(T).unbox(@pointer) # Similar to `Box`, but doesn't segfault on nil and unions.
{% end %} private class Value(T)
# Creates the wrapper.
def initialize(@value : T)
end
# Retrieves the value.
def get : T
@value
end
end end
end end
end end

7
util/nightly.sh Executable file
View file

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

5
util/test-all-individually.sh Executable file
View file

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