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

View file

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

View file

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

View file

@ -123,6 +123,109 @@ Spectator.describe "Mocks Docs" do
end
end
context "Mock Modules" do
module MyModule
def something
# ...
end
end
describe "#something" do
# Define a mock for MyModule.
mock MyClass
it "does something" do
# Use mock here.
end
end
module MyFileUtils
def self.rm_rf(path)
# ...
end
end
mock MyFileUtils
it "deletes all of my files" do
utils = class_mock(MyFileUtils)
allow(utils).to receive(:rm_rf)
utils.rm_rf("/")
expect(utils).to have_received(:rm_rf).with("/")
end
module MyFileUtils2
extend self
def rm_rf(path)
# ...
end
end
mock(MyFileUtils2) do
# Define a default stub for the method.
stub def self.rm_rf(path)
# ...
end
end
it "deletes all of my files part 2" do
utils = class_mock(MyFileUtils2)
allow(utils).to receive(:rm_rf)
utils.rm_rf("/")
expect(utils).to have_received(:rm_rf).with("/")
end
module Runnable
def run
# ...
end
end
mock Runnable
specify do
runnable = mock(Runnable) # or new_mock(Runnable)
runnable.run
end
module Runnable2
abstract def command : String
def run_one
"Running #{command}"
end
end
mock Runnable2, command: "ls -l"
specify do
runnable = mock(Runnable2)
expect(runnable.run_one).to eq("Running ls -l")
runnable = mock(Runnable2, command: "echo foo")
expect(runnable.run_one).to eq("Running echo foo")
end
context "Injecting Mocks" do
module MyFileUtils
def self.rm_rf(path)
true
end
end
inject_mock MyFileUtils do
stub def self.rm_rf(path)
"Simulating deletion of #{path}"
false
end
end
specify do
expect(MyFileUtils.rm_rf("/")).to be_false
end
end
end
context "Injecting Mocks" do
struct MyStruct
def something

View file

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

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.
context "keyword arguments in place of positional arguments" do
before_each do
expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception)
pipe = Process::Redirect::Pipe
expect(Process).to receive(:run).with(command, shell: true, output: pipe).and_raise(exception)
end
it "must stub Process.run", skip: "Keyword arguments in place of positional arguments not supported with expect-receive" do
it "must stub Process.run" do
expect do
Process.run(command, shell: true, output: :pipe) do |_process|
end
end.to raise_error(File::NotFoundError, "File not found")
end
end
end

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

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
double(:test7) do
stub def foo
stub def foo(&)
yield
end

View file

@ -40,17 +40,17 @@ Spectator.describe "Mock DSL", :smoke do
arg
end
def method4 : Symbol
def method4(&) : Symbol
@_spectator_invocations << :method4
yield
end
def method5
def method5(&)
@_spectator_invocations << :method5
yield.to_i
end
def method6
def method6(&)
@_spectator_invocations << :method6
yield
end
@ -60,7 +60,7 @@ Spectator.describe "Mock DSL", :smoke do
{arg, args, kwarg, kwargs}
end
def method8(arg, *args, kwarg, **kwargs)
def method8(arg, *args, kwarg, **kwargs, &)
@_spectator_invocations << :method8
yield
{arg, args, kwarg, kwargs}
@ -80,7 +80,7 @@ Spectator.describe "Mock DSL", :smoke do
"stubbed"
end
stub def method4 : Symbol
stub def method4(&) : Symbol
yield
:block
end
@ -258,12 +258,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation.
# Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5
stub def method5(&)
yield
end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol
stub def method6(&) : Symbol
yield
end
@ -381,12 +381,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation.
# Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5
stub def method5(&)
yield
end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol
stub def method6(&) : Symbol
yield
end
end
@ -454,12 +454,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation.
# Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5
stub def method5(&)
yield
end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol
stub def method6(&) : Symbol
yield
end
@ -577,12 +577,12 @@ Spectator.describe "Mock DSL", :smoke do
# NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation.
# Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5
stub def method5(&)
yield
end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol
stub def method6(&) : Symbol
yield
end
end
@ -620,11 +620,11 @@ Spectator.describe "Mock DSL", :smoke do
:original
end
def method3
def method3(&)
yield
end
def method4 : Int32
def method4(&) : Int32
yield.to_i
end
@ -749,11 +749,11 @@ Spectator.describe "Mock DSL", :smoke do
:original
end
def method3
def method3(&)
yield
end
def method4 : Int32
def method4(&) : Int32
yield.to_i
end
@ -1027,4 +1027,262 @@ Spectator.describe "Mock DSL", :smoke do
expect(fake.reference).to eq("reference")
end
end
describe "mock module" do
module Dummy
# `extend self` cannot be used.
# The Crystal compiler doesn't report the methods as class methods when doing so.
def self.abstract_method
:not_really_abstract
end
def self.default_method
:original
end
def self.args(arg)
arg
end
def self.method1
:original
end
def self.reference
method1.to_s
end
end
mock(Dummy) do
abstract_stub def self.abstract_method
:abstract
end
stub def self.default_method
:default
end
end
let(fake) { class_mock(Dummy) }
it "raises on abstract stubs" do
expect { fake.abstract_method }.to raise_error(Spectator::UnexpectedMessage, /abstract_method/)
end
it "can define default stubs" do
expect(fake.default_method).to eq(:default)
end
it "can define new stubs" do
expect { allow(fake).to receive(:args).and_return(42) }.to change { fake.args(5) }.from(5).to(42)
end
it "can override class method stubs" do
allow(fake).to receive(:method1).and_return(:override)
expect(fake.method1).to eq(:override)
end
xit "can reference stubs", pending: "Default stub of module class methods always refer to original" do
allow(fake).to receive(:method1).and_return(:reference)
expect(fake.reference).to eq("reference")
end
end
context "with a class including a mocked module" do
module Dummy
getter _spectator_invocations = [] of Symbol
def method1
@_spectator_invocations << :method1
"original"
end
def method2 : Symbol
@_spectator_invocations << :method2
:original
end
def method3(arg)
@_spectator_invocations << :method3
arg
end
def method4(&) : Symbol
@_spectator_invocations << :method4
yield
end
def method5(&)
@_spectator_invocations << :method5
yield.to_i
end
def method6(&)
@_spectator_invocations << :method6
yield
end
def method7(arg, *args, kwarg, **kwargs)
@_spectator_invocations << :method7
{arg, args, kwarg, kwargs}
end
def method8(arg, *args, kwarg, **kwargs, &)
@_spectator_invocations << :method8
yield
{arg, args, kwarg, kwargs}
end
end
# method1 stubbed via mock block
# method2 stubbed via keyword args
# method3 not stubbed (calls original)
# method4 stubbed via mock block (yields)
# method5 stubbed via keyword args (yields)
# method6 not stubbed (calls original and yields)
# method7 not stubbed (calls original) testing args
# method8 not stubbed (calls original and yields) testing args
mock(Dummy, method2: :stubbed, method5: 42) do
stub def method1
"stubbed"
end
stub def method4(&) : Symbol
yield
:block
end
end
subject(fake) { mock(Dummy) }
it "defines a subclass" do
expect(fake).to be_a(Dummy)
end
it "defines stubs in the block" do
expect(fake.method1).to eq("stubbed")
end
it "can stub methods defined in the block" do
stub = Spectator::ValueStub.new(:method1, "override")
expect { fake._spectator_define_stub(stub) }.to change { fake.method1 }.from("stubbed").to("override")
end
it "defines stubs from keyword arguments" do
expect(fake.method2).to eq(:stubbed)
end
it "can stub methods from keyword arguments" do
stub = Spectator::ValueStub.new(:method2, :override)
expect { fake._spectator_define_stub(stub) }.to change { fake.method2 }.from(:stubbed).to(:override)
end
it "calls the original implementation for methods not provided a stub" do
expect(fake.method3(:xyz)).to eq(:xyz)
end
it "can stub methods after declaration" do
stub = Spectator::ValueStub.new(:method3, :abc)
expect { fake._spectator_define_stub(stub) }.to change { fake.method3(:xyz) }.from(:xyz).to(:abc)
end
it "defines stubs with yield in the block" do
expect(fake.method4 { :wrong }).to eq(:block)
end
it "can stub methods with yield in the block" do
stub = Spectator::ValueStub.new(:method4, :override)
expect { fake._spectator_define_stub(stub) }.to change { fake.method4 { :wrong } }.from(:block).to(:override)
end
it "defines stubs with yield from keyword arguments" do
expect(fake.method5 { :wrong }).to eq(42)
end
it "can stub methods with yield from keyword arguments" do
stub = Spectator::ValueStub.new(:method5, 123)
expect { fake._spectator_define_stub(stub) }.to change { fake.method5 { "0" } }.from(42).to(123)
end
it "can stub yielding methods after declaration" do
stub = Spectator::ValueStub.new(:method6, :abc)
expect { fake._spectator_define_stub(stub) }.to change { fake.method6 { :xyz } }.from(:xyz).to(:abc)
end
it "handles arguments correctly" do
args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7)
args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block }
aggregate_failures do
expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}})
expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}})
end
end
it "handles arguments correctly with stubs" do
stub1 = Spectator::ProcStub.new(:method7, args_proc)
stub2 = Spectator::ProcStub.new(:method8, args_proc)
fake._spectator_define_stub(stub1)
fake._spectator_define_stub(stub2)
args1 = fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7)
args2 = fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block }
aggregate_failures do
expect(args1).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}})
expect(args2).to eq({1, {2, 3}, 4, {x: 5, y: 6, z: 7}})
end
end
it "compiles types without unions" do
aggregate_failures do
expect(fake.method1).to compile_as(String)
expect(fake.method2).to compile_as(Symbol)
expect(fake.method3(42)).to compile_as(Int32)
expect(fake.method4 { :foo }).to compile_as(Symbol)
expect(fake.method5 { "123" }).to compile_as(Int32)
expect(fake.method6 { "123" }).to compile_as(String)
end
end
def restricted(thing : Dummy)
thing.method1
end
it "can be used in type restricted methods" do
expect(restricted(fake)).to eq("stubbed")
end
it "does not call the original method when stubbed" do
fake.method1
fake.method2
fake.method3("foo")
fake.method4 { :foo }
fake.method5 { "42" }
fake.method6 { 42 }
fake.method7(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7)
fake.method8(1, 2, 3, kwarg: 4, x: 5, y: 6, z: 7) { :block }
expect(fake._spectator_invocations).to contain_exactly(:method3, :method6, :method7, :method8)
end
# Cannot test unexpected messages - will not compile due to missing methods.
describe "deferred default stubs" do
mock(Dummy)
let(fake2) do
mock(Dummy,
method1: "stubbed",
method3: 123,
method4: :xyz)
end
it "uses the keyword arguments as stubs" do
aggregate_failures do
expect(fake2.method1).to eq("stubbed")
expect(fake2.method2).to eq(:original)
expect(fake2.method3(42)).to eq(123)
expect(fake2.method4 { :foo }).to eq(:xyz)
end
end
end
end
end

View file

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

View file

@ -212,14 +212,10 @@ Spectator.describe Spectator::Double do
expect(dbl.hash).to be_a(UInt64)
expect(dbl.in?([42])).to be_false
expect(dbl.in?(1, 2, 3)).to be_false
expect(dbl.inspect).to contain("EmptyDouble")
expect(dbl.itself).to be(dbl)
expect(dbl.not_nil!).to be(dbl)
expect(dbl.pretty_inspect).to contain("EmptyDouble")
expect(dbl.pretty_print(pp)).to be_nil
expect(dbl.tap { nil }).to be(dbl)
expect(dbl.to_s).to contain("EmptyDouble")
expect(dbl.to_s(io)).to be_nil
expect(dbl.try { nil }).to be_nil
expect(dbl.object_id).to be_a(UInt64)
expect(dbl.same?(dbl)).to be_true
@ -301,7 +297,7 @@ Spectator.describe Spectator::Double do
arg
end
stub def self.baz(arg)
stub def self.baz(arg, &)
yield
end
end
@ -469,7 +465,7 @@ Spectator.describe Spectator::Double do
it "stores calls to non-stubbed methods" do
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
expect(called_method_names(dbl)).to eq(%i[baz])
expect(called_method_names(dbl)).to contain(:baz)
end
it "stores arguments for a call" do
@ -479,4 +475,68 @@ Spectator.describe Spectator::Double do
expect(call.arguments).to eq(args)
end
end
describe "#to_s" do
subject(string) { dbl.to_s }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
end
end
describe "#inspect" do
subject(string) { dbl.inspect }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
end
end

View file

@ -275,7 +275,7 @@ Spectator.describe Spectator::LazyDouble do
it "stores calls to non-stubbed methods" do
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
expect(called_method_names(dbl)).to eq(%i[baz])
expect(called_method_names(dbl)).to contain(:baz)
end
it "stores arguments for a call" do
@ -285,4 +285,68 @@ Spectator.describe Spectator::LazyDouble do
expect(call.arguments).to eq(args)
end
end
describe "#to_s" do
subject(string) { dbl.to_s }
context "with a name" do
let(dbl) { Spectator::LazyDouble.new("dbl-name") }
it "indicates it's a double" do
expect(string).to contain("LazyDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
end
context "without a name" do
let(dbl) { Spectator::LazyDouble.new }
it "contains the double type" do
expect(string).to contain("LazyDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
end
end
describe "#inspect" do
subject(string) { dbl.inspect }
context "with a name" do
let(dbl) { Spectator::LazyDouble.new("dbl-name") }
it "contains the double type" do
expect(string).to contain("LazyDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
context "without a name" do
let(dbl) { Spectator::LazyDouble.new }
it "contains the double type" do
expect(string).to contain("LazyDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
end
end

View file

@ -29,8 +29,18 @@ Spectator.describe Spectator::Mock do
@_spectator_invocations << :method3
"original"
end
def method4 : Thing
self
end
def method5 : OtherThing
OtherThing.new
end
end
class OtherThing; end
Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method1: 123) do
stub def method2
:stubbed
@ -104,6 +114,20 @@ Spectator.describe Spectator::Mock do
mock.method3
expect(mock._spectator_invocations).to contain_exactly(:method3)
end
it "can reference its own type" do
new_mock = MockThing.new
stub = Spectator::ValueStub.new(:method4, new_mock)
mock._spectator_define_stub(stub)
expect(mock.method4).to be(new_mock)
end
it "can reference other types in the original namespace" do
other = OtherThing.new
stub = Spectator::ValueStub.new(:method5, other)
mock._spectator_define_stub(stub)
expect(mock.method5).to be(other)
end
end
context "with an abstract class" do
@ -120,8 +144,14 @@ Spectator.describe Spectator::Mock do
end
abstract def method4
abstract def method4 : Thing
abstract def method5 : OtherThing
end
class OtherThing; end
Spectator::Mock.define_subtype(:class, Thing, MockThing, :mock_name, method2: :stubbed) do
stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent.
123
@ -199,6 +229,20 @@ Spectator.describe Spectator::Mock do
mock.method3
expect(mock._spectator_invocations).to contain_exactly(:method3)
end
it "can reference its own type" do
new_mock = MockThing.new
stub = Spectator::ValueStub.new(:method4, new_mock)
mock._spectator_define_stub(stub)
expect(mock.method4).to be(new_mock)
end
it "can reference other types in the original namespace" do
other = OtherThing.new
stub = Spectator::ValueStub.new(:method5, other)
mock._spectator_define_stub(stub)
expect(mock.method5).to be(other)
end
end
context "with an abstract struct" do
@ -215,8 +259,14 @@ Spectator.describe Spectator::Mock do
end
abstract def method4
abstract def method4 : Thing
abstract def method5 : OtherThing
end
class OtherThing; end
Spectator::Mock.define_subtype(:struct, Thing, MockThing, :mock_name, method2: :stubbed) do
stub def method1 : Int32 # NOTE: Return type is required since one wasn't provided in the parent.
123
@ -286,6 +336,22 @@ Spectator.describe Spectator::Mock do
mock.method3
expect(mock._spectator_invocations).to contain_exactly(:method3)
end
it "can reference its own type" do
mock = self.mock # FIXME: Workaround for passing by value messing with stubs.
new_mock = MockThing.new
stub = Spectator::ValueStub.new(:method4, new_mock)
mock._spectator_define_stub(stub)
expect(mock.method4).to be_a(Thing)
end
it "can reference other types in the original namespace" do
mock = self.mock # FIXME: Workaround for passing by value messing with stubs.
other = OtherThing.new
stub = Spectator::ValueStub.new(:method5, other)
mock._spectator_define_stub(stub)
expect(mock.method5).to be(other)
end
end
context "class method stubs" do
@ -298,11 +364,21 @@ Spectator.describe Spectator::Mock do
arg
end
def self.baz(arg)
def self.baz(arg, &)
yield
end
def self.thing : Thing
new
end
def self.other : OtherThing
OtherThing.new
end
end
class OtherThing; end
Spectator::Mock.define_subtype(:class, Thing, MockThing) do
stub def self.foo
:stub
@ -367,6 +443,20 @@ Spectator.describe Spectator::Mock do
expect(restricted(mock)).to eq(:stub)
end
it "can reference its own type" do
new_mock = MockThing.new
stub = Spectator::ValueStub.new(:thing, new_mock)
mock._spectator_define_stub(stub)
expect(mock.thing).to be(new_mock)
end
it "can reference other types in the original namespace" do
other = OtherThing.new
stub = Spectator::ValueStub.new(:other, other)
mock._spectator_define_stub(stub)
expect(mock.other).to be(other)
end
describe "._spectator_clear_stubs" do
before { mock._spectator_define_stub(foo_stub) }
@ -401,6 +491,203 @@ Spectator.describe Spectator::Mock do
end
end
context "with a module" do
module Thing
# `extend self` cannot be used.
# The Crystal compiler doesn't report the methods as class methods when doing so.
def self.original_method
:original
end
def self.default_method
:original
end
def self.stubbed_method(_value = 42)
:original
end
end
Spectator::Mock.define_subtype(:module, Thing, MockThing) do
stub def self.stubbed_method(_value = 42)
:stubbed
end
end
let(mock) { MockThing }
after { mock._spectator_clear_stubs }
it "overrides an existing method" do
stub = Spectator::ValueStub.new(:original_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override)
end
it "doesn't affect other methods" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method }
end
it "replaces an existing default stub" do
stub = Spectator::ValueStub.new(:default_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override)
end
it "replaces an existing stubbed method" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override)
end
def restricted(thing : Thing.class)
thing.stubbed_method
end
it "can be used in type restricted methods" do
expect(restricted(mock)).to eq(:stubbed)
end
describe "._spectator_clear_stubs" do
before do
stub = Spectator::ValueStub.new(:original_method, :override)
mock._spectator_define_stub(stub)
end
it "removes previously defined stubs" do
expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original)
end
end
describe "._spectator_calls" do
before { mock._spectator_clear_calls }
# Retrieves symbolic names of methods called on a mock.
def called_method_names(mock)
mock._spectator_calls.map(&.method)
end
it "stores calls to original methods" do
expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method])
end
it "stores calls to default methods" do
expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method])
end
it "stores calls to stubbed methods" do
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method])
end
it "stores multiple calls to the same stub" do
mock.stubbed_method
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method])
end
it "stores arguments for a call" do
mock.stubbed_method(5)
args = Spectator::Arguments.capture(5)
call = mock._spectator_calls.first
expect(call.arguments).to eq(args)
end
end
end
context "with a mocked module included in a class" do
module Thing
def original_method
:original
end
def default_method
:original
end
def stubbed_method(_value = 42)
:original
end
end
Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do
stub def stubbed_method(_value = 42)
:stubbed
end
end
class IncludedMock
include MockThing
end
let(mock) { IncludedMock.new }
it "overrides an existing method" do
stub = Spectator::ValueStub.new(:original_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override)
end
it "doesn't affect other methods" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method }
end
it "replaces an existing default stub" do
stub = Spectator::ValueStub.new(:default_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override)
end
it "replaces an existing stubbed method" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override)
end
def restricted(thing : Thing.class)
thing.default_method
end
describe "#_spectator_clear_stubs" do
before do
stub = Spectator::ValueStub.new(:original_method, :override)
mock._spectator_define_stub(stub)
end
it "removes previously defined stubs" do
expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original)
end
end
describe "#_spectator_calls" do
before { mock._spectator_clear_calls }
# Retrieves symbolic names of methods called on a mock.
def called_method_names(mock)
mock._spectator_calls.map(&.method)
end
it "stores calls to original methods" do
expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method])
end
it "stores calls to default methods" do
expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method])
end
it "stores calls to stubbed methods" do
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method])
end
it "stores multiple calls to the same stub" do
mock.stubbed_method
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method])
end
it "stores arguments for a call" do
mock.stubbed_method(5)
args = Spectator::Arguments.capture(5)
call = mock._spectator_calls.first
expect(call.arguments).to eq(args)
end
end
end
context "with a method that uses NoReturn" do
abstract class Thing
abstract def oops : NoReturn
@ -642,7 +929,7 @@ Spectator.describe Spectator::Mock do
arg
end
def self.baz(arg)
def self.baz(arg, &)
yield
end
end

View file

@ -186,12 +186,9 @@ Spectator.describe Spectator::NullDouble do
expect(dbl.hash).to be_a(UInt64)
expect(dbl.in?([42])).to be_false
expect(dbl.in?(1, 2, 3)).to be_false
expect(dbl.inspect).to contain("EmptyDouble")
expect(dbl.itself).to be(dbl)
expect(dbl.not_nil!).to be(dbl)
expect(dbl.pretty_inspect).to contain("EmptyDouble")
expect(dbl.tap { nil }).to be(dbl)
expect(dbl.to_s).to contain("EmptyDouble")
expect(dbl.try { nil }).to be_nil
expect(dbl.object_id).to be_a(UInt64)
expect(dbl.same?(dbl)).to be_true
@ -262,7 +259,7 @@ Spectator.describe Spectator::NullDouble do
arg
end
stub def self.baz(arg)
stub def self.baz(arg, &)
yield
end
end
@ -439,4 +436,68 @@ Spectator.describe Spectator::NullDouble do
expect(call.arguments).to eq(args)
end
end
describe "#to_s" do
subject(string) { dbl.to_s }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "indicates it's a double" do
expect(string).to contain("NullDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "contains the double type" do
expect(string).to contain("NullDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
end
end
describe "#inspect" do
subject(string) { dbl.inspect }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "contains the double type" do
expect(string).to contain("NullDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "contains the double type" do
expect(string).to contain("NullDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
end
end

View file

@ -4,6 +4,11 @@
# This type is intentionally outside the `Spectator` module.
# The reason for this is to prevent name collision when using the DSL to define a spec.
abstract class SpectatorContext
# Evaluates the contents of a block within the scope of the context.
def eval(&)
with self yield
end
# Produces a dummy string to represent the context as a string.
# This prevents the default behavior, which normally stringifies instance variables.
# Due to the sheer amount of types Spectator can create

View file

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

View file

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

View file

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

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} %}
# Define the plain double type.
::Spectator::Double.define({{double_type_name}}, {{name}}, {{**value_methods}}) do
::Spectator::Double.define({{double_type_name}}, {{name}}, {{value_methods.double_splat}}) do
# Returns a new double that responds to undefined methods with itself.
# See: `NullDouble`
def as_null_object
@ -43,7 +43,7 @@ module Spectator::DSL
{% begin %}
# Define a matching null double type.
::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{**value_methods}}) {{block}}
::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{value_methods.double_splat}}) {{block}}
{% end %}
end
@ -94,9 +94,9 @@ module Spectator::DSL
begin
%double = {% if found_tuple %}
{{found_tuple[2].id}}.new({{**value_methods}})
{{found_tuple[2].id}}.new({{value_methods.double_splat}})
{% else %}
::Spectator::LazyDouble.new({{name}}, {{**value_methods}})
::Spectator::LazyDouble.new({{name}}, {{value_methods.double_splat}})
{% end %}
::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset })
%double
@ -176,7 +176,7 @@ module Spectator::DSL
# See `#def_double`.
macro double(name, **value_methods, &block)
{% begin %}
{% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{**value_methods}}) {{block}}
{% if @def %}new_double{% else %}def_double{% end %}({{name}}, {{value_methods.double_splat}}) {{block}}
{% end %}
end
@ -189,7 +189,7 @@ module Spectator::DSL
# expect(dbl.foo).to eq(42)
# ```
macro double(**value_methods)
::Spectator::LazyDouble.new({{**value_methods}})
::Spectator::LazyDouble.new({{value_methods.double_splat}})
end
# Defines a new mock type.
@ -218,15 +218,16 @@ module Spectator::DSL
# end
# ```
private macro def_mock(type, name = nil, **value_methods, &block)
{% # Construct a unique type name for the mock by using the number of defined types.
{% resolved = type.resolve
# Construct a unique type name for the mock by using the number of defined types.
index = ::Spectator::DSL::Mocks::TYPES.size
# The type is nested under the original so that any type names from the original can be resolved.
mock_type_name = "Mock#{index}".id
# Store information about how the mock is defined and its context.
# This is important for constructing an instance of the mock later.
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, mock_type_name.symbolize}
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, "#{"::".id unless resolved.name.starts_with?("::")}#{resolved.name}::#{mock_type_name}".id.symbolize}
resolved = type.resolve
base = if resolved.class?
:class
elsif resolved.struct?
@ -235,7 +236,11 @@ module Spectator::DSL
:module
end %}
::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}}
{% begin %}
{{base.id}} {{"::".id unless resolved.name.starts_with?("::")}}{{resolved.name}}
::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{value_methods.double_splat}}) {{block}}
end
{% end %}
end
# Instantiates a mock.
@ -316,7 +321,7 @@ module Spectator::DSL
macro mock(type, **value_methods, &block)
{% raise "First argument of `mock` must be a type name, not #{type}" unless type.is_a?(Path) || type.is_a?(Generic) || type.is_a?(Union) || type.is_a?(Metaclass) || type.is_a?(TypeNode) %}
{% begin %}
{% if @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{**value_methods}}) {{block}}
{% if @def %}new_mock{% else %}def_mock{% end %}({{type}}, {{value_methods.double_splat}}) {{block}}
{% end %}
end
@ -426,7 +431,7 @@ module Spectator::DSL
# This isn't required, but new_mock() should still find this type.
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, resolved.name.symbolize} %}
::Spectator::Mock.inject({{base}}, ::{{resolved.name}}, {{**value_methods}}) {{block}}
::Spectator::Mock.inject({{base}}, {{resolved.name}}, {{value_methods.double_splat}}) {{block}}
end
# Targets a stubbable object (such as a mock or double) for operations.

View file

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

View file

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

View file

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

View file

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

View file

@ -28,7 +28,7 @@ module Spectator
# Creates the builder.
# Initially, the builder will have no children and no hooks.
# The *name*, *location*, and *metadata* will be applied to the `ExampleGroup` produced by `#build`.
def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata = Metadata.new)
def initialize(@name : Label = nil, @location : Location? = nil, @metadata : Metadata? = nil)
end
# Constructs an example group with previously defined attributes, children, and hooks.

View file

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

View file

@ -114,6 +114,21 @@ module Spectator
report(match_data, message)
end
# Asserts that some criteria defined by the matcher is satisfied.
# Allows a custom message to be used.
# Returns the expected value cast as the expected type, if the matcher is satisfied.
def to(matcher : Matchers::TypeMatcher(U), message = nil) forall U
match_data = matcher.match(@expression)
value = @expression.value
if report(match_data, message)
return value if value.is_a?(U)
raise "Spectator bug: expected value should have cast to #{U}"
else
raise TypeCastError.new("#{@expression.label} is expected to be a #{U}, but was actually #{value.class}")
end
end
# Asserts that a method is not called before the example completes.
@[AlwaysInline]
def to_not(stub : Stub, message = nil) : Nil
@ -136,6 +151,36 @@ module Spectator
report(match_data, message)
end
# Asserts that some criteria defined by the matcher is not satisfied.
# Allows a custom message to be used.
# Returns the expected value cast without the unexpected type, if the matcher is satisfied.
def to_not(matcher : Matchers::TypeMatcher(U), message = nil) forall U
match_data = matcher.negated_match(@expression)
value = @expression.value
if report(match_data, message)
return value unless value.is_a?(U)
raise "Spectator bug: expected value should not be #{U}"
else
raise TypeCastError.new("#{@expression.label} is not expected to be a #{U}, but was actually #{value.class}")
end
end
# Asserts that some criteria defined by the matcher is not satisfied.
# Allows a custom message to be used.
# Returns the expected value cast as a non-nillable type, if the matcher is satisfied.
def to_not(matcher : Matchers::NilMatcher, message = nil)
match_data = matcher.negated_match(@expression)
if report(match_data, message)
value = @expression.value
return value unless value.nil?
raise "Spectator bug: expected value should not be nil"
else
raise NilAssertionError.new("#{@expression.label} is not expected to be nil.")
end
end
# :ditto:
@[AlwaysInline]
def not_to(matcher, message = nil) : Nil

View file

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

View file

@ -13,7 +13,7 @@ module Spectator::Formatting::Components
end
# Increases the indent by the a specific *amount* for the duration of the block.
private def indent(amount = INDENT)
private def indent(amount = INDENT, &)
@indent += amount
yield
@indent -= amount
@ -23,7 +23,7 @@ module Spectator::Formatting::Components
# The contents of the line should be generated by a block provided to this method.
# Ensure that _only_ one line is produced by the block,
# otherwise the indent will be lost.
private def line(io)
private def line(io, &)
@indent.times { io << ' ' }
yield
io.puts

View file

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

View file

@ -15,7 +15,7 @@ module Spectator
# The *collection* is the set of items to create sub-nodes for.
# The *iterators* is a list of optional names given to items in the collection.
def initialize(@collection : Enumerable(T), name : String? = nil, @iterators : Array(String) = [] of String,
location : Location? = nil, metadata : Metadata = Metadata.new)
location : Location? = nil, metadata : Metadata? = nil)
super(name, location, metadata)
end

View file

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

View file

@ -1,3 +1,4 @@
require "../value"
require "./match_data"
module Spectator::Matchers
@ -22,6 +23,19 @@ module Spectator::Matchers
# A successful match with `#match` should normally fail for this method, and vice-versa.
abstract def negated_match(actual : Expression(T)) : MatchData forall T
# Compares a matcher against a value.
# Enables composable matchers.
def ===(actual : Expression(T)) : Bool
match(actual).matched?
end
# Compares a matcher against a value.
# Enables composable matchers.
def ===(other) : Bool
expression = Value.new(other)
match(expression).matched?
end
private def match_data_description(actual : Expression(T)) : String forall T
match_data_description(actual.label)
end

View file

@ -29,7 +29,26 @@ module Spectator::Matchers
# Checks whether the matcher is satisfied with the expression given to it.
private def match?(actual : Expression(T)) : Bool forall T
expected.value.includes?(actual.value)
actual_value = actual.value
expected_value = expected.value
if expected_value.is_a?(Range) && actual_value.is_a?(Comparable)
return match_impl?(expected_value, actual_value)
end
return false unless actual_value.is_a?(Comparable(typeof(expected_value.begin)))
expected_value.includes?(actual_value)
end
private def match_impl?(expected_value : Range(B, E), actual_value : Comparable(B)) : Bool forall B, E
expected_value.includes?(actual_value)
end
private def match_impl?(expected_value : Range(B, E), actual_value : T) : Bool forall B, E, T
return false unless actual_value.is_a?(B) || actual_value.is_a?(Comparable(B))
expected_value.includes?(actual_value)
end
private def match_impl?(expected_value : Range(Number, Number), actual_value : Number) : Bool
expected_value.includes?(actual_value)
end
# Message displayed when the matcher isn't satisfied.

View file

@ -1,13 +1,61 @@
module Spectator
# Untyped arguments to a method call (message).
abstract class AbstractArguments
# Utility method for comparing two named tuples ignoring order.
private def compare_named_tuples(a : NamedTuple, b : NamedTuple)
a.each do |k, v1|
v2 = b.fetch(k) { return false }
return false unless v1 === v2
# Use the string representation to avoid over complicating debug output.
def inspect(io : IO) : Nil
to_s(io)
end
# Utility method for comparing two tuples considering special types.
private def compare_tuples(a : Tuple | Array, b : Tuple | Array)
return false if a.size != b.size
a.zip(b) do |a_value, b_value|
return false unless compare_values(a_value, b_value)
end
true
end
# Utility method for comparing two tuples considering special types.
# Supports nilable tuples (ideal for splats).
private def compare_tuples(a : Tuple? | Array?, b : Tuple? | Array?)
return false if a.nil? ^ b.nil?
compare_tuples(a.not_nil!, b.not_nil!)
end
# Utility method for comparing two named tuples ignoring order.
private def compare_named_tuples(a : NamedTuple | Hash, b : NamedTuple | Hash)
a.each do |k, v1|
v2 = b.fetch(k) { return false }
return false unless compare_values(v1, v2)
end
true
end
# Utility method for comparing two arguments considering special types.
# Some types used for case-equality don't work well with unexpected right-hand types.
# This can happen when the right side is a massive union of types.
private def compare_values(a, b)
case a
when Proc
# Using procs as argument matchers isn't supported currently.
# Compare directly instead.
a == b
when Range
# Ranges can only be matched against if their right side is comparable.
# Ensure the right side is comparable, otherwise compare directly.
return a === b if b.is_a?(Comparable(typeof(b)))
a == b
when Tuple, Array
return compare_tuples(a, b) if b.is_a?(Tuple) || b.is_a?(Array)
a === b
when NamedTuple, Hash
return compare_named_tuples(a, b) if b.is_a?(NamedTuple) || b.is_a?(Hash)
a === b
else
a === b
end
end
end
end

View file

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

View file

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

View file

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

View file

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

View file

@ -30,7 +30,13 @@ module Spectator
# Constructs a string containing the method name and arguments.
def to_s(io : IO) : Nil
io << '#' << method << arguments
io << '#' << method
arguments.inspect(io)
end
# :ditto:
def inspect(io : IO) : Nil
to_s(io)
end
end
end

View file

@ -1,5 +1,6 @@
require "./method_call"
require "./mocked"
require "./mock_registry"
require "./reference_mock_registry"
require "./stub"
require "./stubbed_name"
@ -36,7 +37,35 @@ module Spectator
macro define_subtype(base, mocked_type, type_name, name = nil, **value_methods, &block)
{% begin %}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
{% if base.id == :module.id %}
{{base.id}} {{type_name.id}}
include {{mocked_type.id}}
# Mock class that includes the mocked module {{mocked_type.id}}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
private class ClassIncludingMock{{type_name.id}}
include {{type_name.id}}
end
# Returns a mock class that includes the mocked module {{mocked_type.id}}.
def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}}
# FIXME: Creating the instance normally with `.new` causing infinite recursion.
inst = ClassIncludingMock{{type_name.id}}.allocate
inst.initialize(*args, **kwargs)
inst
end
# Returns a mock class that includes the mocked module {{mocked_type.id}}.
def self.new(*args, **kwargs) : ClassIncludingMock{{type_name.id}}
# FIXME: Creating the instance normally with `.new` causing infinite recursion.
inst = ClassIncludingMock{{type_name.id}}.allocate
inst.initialize(*args, **kwargs) { |*yargs| yield *yargs }
inst
end
{% else %}
{{base.id}} {{type_name.id}} < {{mocked_type.id}}
{% end %}
include ::Spectator::Mocked
extend ::Spectator::StubbedType
@ -50,22 +79,22 @@ module Spectator
end
{% end %}
def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil
def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil
@_spectator_stubs.try &.delete(stub)
end
def _spectator_clear_stubs : Nil
def _spectator_clear_stubs : ::Nil
@_spectator_stubs = nil
end
private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub
private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub
class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
class_getter _spectator_calls : ::Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
getter _spectator_calls = [] of ::Spectator::MethodCall
# Returns the mock's name formatted for user output.
private def _spectator_stubbed_name : String
private def _spectator_stubbed_name : ::String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
@ -73,7 +102,7 @@ module Spectator
\{% end %}
end
private def self._spectator_stubbed_name : String
private def self._spectator_stubbed_name : ::String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Class Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
@ -120,7 +149,7 @@ module Spectator
macro inject(base, type_name, name = nil, **value_methods, &block)
{% begin %}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
{{base.id}} ::{{type_name.id}}
{{base.id}} {{"::".id unless type_name.id.starts_with?("::")}}{{type_name.id}}
include ::Spectator::Mocked
extend ::Spectator::StubbedType
@ -129,12 +158,12 @@ module Spectator
{% elsif base == :struct %}
@@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new
{% else %}
{% raise "Unsupported base type #{base} for injecting mock" %}
@@_spectator_mock_registry = ::Spectator::MockRegistry.new
{% end %}
private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub
private class_getter _spectator_stubs : ::Array(::Spectator::Stub) = [] of ::Spectator::Stub
class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
class_getter _spectator_calls : ::Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
private def _spectator_stubs
entry = @@_spectator_mock_registry.fetch(self) do
@ -143,11 +172,11 @@ module Spectator
entry.stubs
end
def _spectator_remove_stub(stub : ::Spectator::Stub) : Nil
def _spectator_remove_stub(stub : ::Spectator::Stub) : ::Nil
@@_spectator_mock_registry[self]?.try &.stubs.delete(stub)
end
def _spectator_clear_stubs : Nil
def _spectator_clear_stubs : ::Nil
@@_spectator_mock_registry.delete(self)
end
@ -169,7 +198,7 @@ module Spectator
end
# Returns the mock's name formatted for user output.
private def _spectator_stubbed_name : String
private def _spectator_stubbed_name : ::String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
@ -178,7 +207,7 @@ module Spectator
end
# Returns the mock's name formatted for user output.
private def self._spectator_stubbed_name : String
private def self._spectator_stubbed_name : ::String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Class Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% 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)
if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}")
else
Log.trace { "Fallback for #{call} - return self" }
self
@ -42,9 +42,9 @@ module Spectator
private def _spectator_abstract_stub_fallback(call : MethodCall, type)
if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
raise UnexpectedMessage.new("#{inspect} received unexpected message #{call}")
else
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.")
raise TypeCastError.new("#{inspect} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.")
end
end
@ -56,7 +56,7 @@ module Spectator
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
_spectator_record_call(%call)
Log.trace { "#{_spectator_stubbed_name} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" }
Log.trace { "#{inspect} got undefined method `#{%call}{% if call.block %} { ... }{% end %}`" }
self
end

View file

@ -126,7 +126,41 @@ module Spectator
{{method.body}}
end
{% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %}
{% original = "previous_def"
# Workaround for Crystal not propagating block with previous_def/super.
if method.accepts_block?
original += "("
if method.splat_index
method.args.each_with_index do |arg, i|
if i == method.splat_index
if arg.internal_name && arg.internal_name.size > 0
original += "*#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
elsif i > method.splat_index
original += "#{arg.name}: #{arg.internal_name}, "
else
original += "#{arg.internal_name}, "
end
end
else
method.args.each do |arg|
original += "#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
end
# If the block is captured (i.e. `&block` syntax), it must be passed along as an argument.
# Otherwise, use `yield` to forward the block.
captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0
method.block_arg.name
else
nil
end
original += "&#{captured_block}" if captured_block
original += ")"
original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block
end
original = original.id %}
{% # Reconstruct the method signature.
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
@ -145,7 +179,7 @@ module Spectator
::NamedTuple.new(
{% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
),
{% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %}
{% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new(
{% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
).merge({{method.double_splat}})
@ -158,12 +192,26 @@ module Spectator
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method.
_spectator_cast_stub_value(%stub, %call, typeof({{original}}),
{{ if method.return_type && method.return_type.resolve == NoReturn
{{ if rt = method.return_type
if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn
:no_return
elsif method.return_type && method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)
else
# Process as an enumerable type to reduce code repetition.
rt = rt.is_a?(Union) ? rt.types : [rt]
# Check if any types are nilable.
nilable = rt.any? do |t|
# These are all macro types that have the `resolve?` method.
(t.is_a?(TypeNode) || t.is_a?(Path) || t.is_a?(Generic) || t.is_a?(MetaClass)) &&
(resolved = t.resolve?).is_a?(TypeNode) && resolved <= Nil
end
if nilable
:nil
else
:raise
end
end
else
:raise
end }})
else
# Delegate missing stub behavior to concrete type.
@ -227,7 +275,42 @@ module Spectator
{{method.body}}
end
{% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %}
{% original = "previous_def"
# Workaround for Crystal not propagating block with previous_def/super.
if method.accepts_block?
original += "("
if method.splat_index
method.args.each_with_index do |arg, i|
if i == method.splat_index
if arg.internal_name && arg.internal_name.size > 0
original += "*#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
elsif i > method.splat_index
original += "#{arg.name}: #{arg.internal_name}"
else
original += "#{arg.internal_name}, "
end
end
else
method.args.each do |arg|
original += "#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
end
# If the block is captured (i.e. `&block` syntax), it must be passed along as an argument.
# Otherwise, use `yield` to forward the block.
captured_block = if method.block_arg && method.block_arg.name && method.block_arg.name.size > 0
method.block_arg.name
else
nil
end
original += "&#{captured_block}" if captured_block
original += ")"
original += " { |*_spectator_yargs| yield *_spectator_yargs }" unless captured_block
end
original = original.id %}
{% end %}
{% # Reconstruct the method signature.
@ -247,7 +330,7 @@ module Spectator
::NamedTuple.new(
{% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
),
{% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %}
{% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new(
{% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
).merge({{method.double_splat}})
@ -259,15 +342,25 @@ module Spectator
if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method.
{% if method.return_type %}
{% if rt = method.return_type %}
# Return type restriction takes priority since it can be a superset of the original implementation.
_spectator_cast_stub_value(%stub, %call, {{method.return_type}},
{{ if method.return_type.resolve == NoReturn
{{ if rt.is_a?(Path) && (resolved = rt.resolve?).is_a?(TypeNode) && resolved <= NoReturn
:no_return
elsif method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)
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 }})
{% elsif !method.abstract? %}
# The method isn't abstract, infer the type it returns without calling it.
@ -338,64 +431,93 @@ module Spectator
# Redefines all methods and ones inherited from its parents and mixins to support stubs.
private macro stub_type(type_name = @type)
{% type = type_name.resolve
# Reverse order of ancestors (there's currently no reverse method for ArrayLiteral).
count = type.ancestors.size
ancestors = type.ancestors.map_with_index { |_, i| type.ancestors[count - i - 1] } %}
{% for ancestor in ancestors %}
{% for method in ancestor.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
end %}
{{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
end
{% end %}
definitions = [] of Nil
scope = if type == @type
:previous_def
elsif type.module?
type.name
else
:super
end.id
{% for method in ancestor.class.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
end %}
default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
# Add entries for methods in the target type and its class type.
[[:self.id, type.class], [nil, type]].each do |(receiver, t)|
t.methods.each do |method|
definitions << {
type: t,
method: method,
scope: scope,
receiver: receiver,
}
end
end
{% end %}
{% end %}
{% for method in type.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
# Iterate through all ancestors and add their methods.
type.ancestors.each do |ancestor|
[[:self.id, ancestor.class], [nil, ancestor]].each do |(receiver, t)|
t.methods.each do |method|
# Skip methods already found to prevent redefining them multiple times.
unless definitions.any? do |d|
m = d[:method]
m.name == method.name &&
m.args == method.args &&
m.splat_index == method.splat_index &&
m.double_splat == method.double_splat &&
m.block_arg == method.block_arg
end
definitions << {
type: t,
method: method,
scope: :super.id,
receiver: receiver,
}
end
end
end
end
definitions = definitions.reject do |definition|
name = definition[:method].name
name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(name.symbolize)
end %}
{{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{"#{method.receiver}.".id if method.receiver}}{{method.name}}(
{% for definition in definitions %}
{% original_type = definition[:type]
method = definition[:method]
scope = definition[:scope]
receiver = definition[:receiver]
rewrite_args = method.accepts_block?
# Handle calling methods on other objects (primarily for mock modules).
if scope != :super.id && scope != :previous_def.id
if receiver == :self.id
scope = "#{scope}.#{method.name}".id
rewrite_args = true
else
scope = :super.id
end
end %}
# Redefinition of {{original_type}}{{"#".id}}{{method.name}}
{{(method.abstract? ? "abstract_stub abstract" : "default_stub").id}} {{method.visibility.id if method.visibility != :public}} def {{"#{receiver}.".id if receiver}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
{% unless method.abstract? %}
{% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
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)
{{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 %}
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 %}
{% if captured_block %}&{{captured_block}}{% end %}
){% if !captured_block && method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}{% end %}
end
{% end %}
{% end %}
end
# Utility macro for casting a stub (and its return value) to the correct type.
@ -415,29 +537,30 @@ module Spectator
# Get the value as-is from the stub.
# This will be compiled as a union of all known stubbed value types.
%value = {{stub}}.call({{call}})
%type = {{type}}
# Attempt to cast the value to the method's return type.
# If successful, which it will be in most cases, return it.
# The caller will receive a properly typed value without unions or other side-effects.
%cast = %value.as?({{type}})
if %cast.is_a?({{type}})
%cast
else
{% if fail_cast == :nil %}
nil
%cast
{% elsif fail_cast == :raise %}
# Check if nil was returned by the stub and if its okay to return it.
if %value.nil? && %type.nilable?
# Value was nil and nil is allowed to be returned.
%type.cast(%cast)
elsif %cast.nil?
# The stubbed value was something else entirely and cannot be cast to the return type.
# There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail.
%type = begin
%value.class.to_s
rescue
"<Unknown>"
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.")
else
# Types match and value can be returned as cast type.
%cast
end
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%type}`, but returned type must be `#{ {{type}} }`.")
{% else %}
{% raise "fail_cast must be :nil, :raise, or :no_return, but got: #{fail_cast}" %}
{% end %}
end
{% end %}
end
end

View file

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

View file

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

View file

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

View file

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

View file

@ -22,51 +22,106 @@ class Object
# ```
# require "spectator/should"
# ```
def should(matcher, message = nil)
def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.match(actual)
expectation = ::Spectator::Expectation.new(match_data, message: message)
expectation = ::Spectator::Expectation.new(match_data, location, message)
::Spectator::Harness.current.report(expectation)
end
# Asserts that some criteria defined by the matcher is satisfied.
# Allows a custom message to be used.
# Returns the expected value cast as the expected type, if the matcher is satisfied.
def should(matcher : ::Spectator::Matchers::TypeMatcher(U), message = nil, *, _file = __FILE__, _line = __LINE__) forall U
actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.match(actual)
expectation = ::Spectator::Expectation.new(match_data, location, message)
if ::Spectator::Harness.current.report(expectation)
return self if self.is_a?(U)
raise "Spectator bug: expected value should have cast to #{U}"
else
raise TypeCastError.new("Expected value should be a #{U}, but was actually #{self.class}")
end
end
# Works the same as `#should` except the condition is inverted.
# When `#should` succeeds, this method will fail, and vice-versa.
def should_not(matcher, message = nil)
def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.negated_match(actual)
expectation = ::Spectator::Expectation.new(match_data, message: message)
expectation = ::Spectator::Expectation.new(match_data, location, message)
::Spectator::Harness.current.report(expectation)
end
# Asserts that some criteria defined by the matcher is not satisfied.
# Allows a custom message to be used.
# Returns the expected value cast without the unexpected type, if the matcher is satisfied.
def should_not(matcher : ::Spectator::Matchers::TypeMatcher(U), message = nil, *, _file = __FILE__, _line = __LINE__) forall U
actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.negated_match(actual)
expectation = ::Spectator::Expectation.new(match_data, location, message)
if ::Spectator::Harness.current.report(expectation)
return self unless self.is_a?(U)
raise "Spectator bug: expected value should not be #{U}"
else
raise TypeCastError.new("Expected value is not expected to be a #{U}, but was actually #{self.class}")
end
end
# Asserts that some criteria defined by the matcher is not satisfied.
# Allows a custom message to be used.
# Returns the expected value cast as a non-nillable type, if the matcher is satisfied.
def should_not(matcher : ::Spectator::Matchers::NilMatcher, message = nil, *, _file = __FILE__, _line = __LINE__)
actual = ::Spectator::Value.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.negated_match(actual)
expectation = ::Spectator::Expectation.new(match_data, location, message)
if ::Spectator::Harness.current.report(expectation)
return self unless self.nil?
raise "Spectator bug: expected value should not be nil"
else
raise NilAssertionError.new("Expected value should not be nil.")
end
end
# Works the same as `#should` except that the condition check is postponed.
# The expectation is checked after the example finishes and all hooks have run.
def should_eventually(matcher, message = nil)
::Spectator::Harness.current.defer { should(matcher, message) }
def should_eventually(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
::Spectator::Harness.current.defer { should(matcher, message, _file: _file, _line: _line) }
end
# Works the same as `#should_not` except that the condition check is postponed.
# The expectation is checked after the example finishes and all hooks have run.
def should_never(matcher, message = nil)
::Spectator::Harness.current.defer { should_not(matcher, message) }
def should_never(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
::Spectator::Harness.current.defer { should_not(matcher, message, _file: _file, _line: _line) }
end
end
struct Proc(*T, R)
# Extension method to create an expectation for a block of code (proc).
# Depending on the matcher, the proc may be executed multiple times.
def should(matcher, message = nil)
def should(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
actual = ::Spectator::Block.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.match(actual)
expectation = ::Spectator::Expectation.new(match_data, message: message)
expectation = ::Spectator::Expectation.new(match_data, location, message)
::Spectator::Harness.current.report(expectation)
end
# Works the same as `#should` except the condition is inverted.
# When `#should` succeeds, this method will fail, and vice-versa.
def should_not(matcher, message = nil)
def should_not(matcher, message = nil, *, _file = __FILE__, _line = __LINE__)
actual = ::Spectator::Block.new(self)
location = ::Spectator::Location.new(_file, _line)
match_data = matcher.negated_match(actual)
expectation = ::Spectator::Expectation.new(match_data, message: message)
expectation = ::Spectator::Expectation.new(match_data, location, message)
::Spectator::Harness.current.report(expectation)
end
end

View file

@ -60,7 +60,7 @@ module Spectator
#
# A set of *metadata* can be used for filtering and modifying example behavior.
# For instance, adding a "pending" tag will mark tests as pending and skip execution.
def start_group(name, location = nil, metadata = Metadata.new) : Nil
def start_group(name, location = nil, metadata = nil) : Nil
Log.trace { "Start group: #{name.inspect} @ #{location}; metadata: #{metadata}" }
builder = ExampleGroupBuilder.new(name, location, metadata)
@ -86,7 +86,7 @@ module Spectator
#
# A set of *metadata* can be used for filtering and modifying example behavior.
# For instance, adding a "pending" tag will mark tests as pending and skip execution.
def start_iterative_group(collection, name, iterator = nil, location = nil, metadata = Metadata.new) : Nil
def start_iterative_group(collection, name, iterator = nil, location = nil, metadata = nil) : Nil
Log.trace { "Start iterative group: #{name} (#{typeof(collection)}) @ #{location}; metadata: #{metadata}" }
builder = IterativeExampleGroupBuilder.new(collection, name, iterator, location, metadata)
@ -127,7 +127,7 @@ module Spectator
# It will be yielded two arguments - the example created by this method, and the *context* argument.
# The return value of the block is ignored.
# It is expected that the test code runs when the block is called.
def add_example(name, location, context_builder, metadata = Metadata.new, &block : Example -> _) : Nil
def add_example(name, location, context_builder, metadata = nil, &block : Example -> _) : Nil
Log.trace { "Add example: #{name} @ #{location}; metadata: #{metadata}" }
current << ExampleBuilder.new(context_builder, block, name, location, metadata)
end
@ -144,7 +144,7 @@ module Spectator
# A set of *metadata* can be used for filtering and modifying example behavior.
# For instance, adding a "pending" tag will mark the test as pending and skip execution.
# A default *reason* can be given in case the user didn't provide one.
def add_pending_example(name, location, metadata = Metadata.new, reason = nil) : Nil
def add_pending_example(name, location, metadata = nil, reason = nil) : Nil
Log.trace { "Add pending example: #{name} @ #{location}; metadata: #{metadata}" }
current << PendingExampleBuilder.new(name, location, metadata, reason)
end

View file

@ -10,7 +10,9 @@ module Spectator
# Checks whether the node satisfies the filter.
def includes?(node) : Bool
node.metadata.any? { |key, value| key.to_s == @tag && (!@value || value == @value) }
return false unless metadata = node.metadata
metadata.any? { |key, value| key.to_s == @tag && (!@value || value == @value) }
end
end
end

View file

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

View file

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

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