Compare commits

...

115 Commits

Author SHA1 Message Date
Michael Miller d74a772f43
WIP replace mocks with Mocks shard 2023-12-26 18:46:19 -07:00
Michael Miller 0e3f626932
Specify Crystal version range 2023-10-17 17:09:09 -06:00
Michael Miller d45d5d4479
Bump Ameba version to 1.5 2023-10-17 17:08:58 -06: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
Michael Miller c256ef763e
Bump version to 0.11.4 2022-11-27 22:27:52 -07:00
Michael Miller 8efd38fbdd
Split Arguments class by functionality
Code changes for https://github.com/icy-arctic-fox/spectator/issues/47 caused a drastic increase in compilation times.
This improves compilation times by splitting concerns for arguments.
In one case, arguments are used for matching.
In the other, arguments are captured for comparison.
The second case has been moved to a FormalArguments class.
Theoretically, this reduces the complexity and combinations the compiler might be iterating.
2022-11-27 22:26:19 -07:00
Michael Miller 015d36ea4c
Work around strange cast/type checking issue
For some reason, line 421 (the responds to call check) excluded the stub's call type.
Luckily this line doesn't seem to be necessary anymore.
Removed the unecessary quick check.

The tests from spec/spectator/mocks/double_spec:88-96 were failing when they're the only tests in the file.
The non-matching stub wouldn't raise.
Stepping through, attempting to access the value would segfault.
This is because it accessed a stub with String instead of its real Int32 type.
Removing the aforementioned check fixes this.
2022-11-27 19:43:03 -07:00
Michael Miller 318e4eba89
Use shorter string when stub is treated as a message 2022-11-04 22:55:12 -06:00
Michael Miller e2cdc9e08e
Re-enable logger after catching exit
The logger is closed during at-exit hooks that get invoked by Kernel's exit method.
2022-11-04 22:10:59 -06:00
Michael Miller 60b5f151f1
Minor improvements to log output 2022-11-04 22:05:27 -06:00
Michael Miller 8b12262c62
Display <root> when to_s is called directly on the root group 2022-11-04 21:01:32 -06:00
Michael Miller 6e7d215f69
Add type annotations to to_s and inspect 2022-11-04 20:56:02 -06:00
Michael Miller 12eb2e9357
Avoid printing double contents from to_s 2022-11-04 20:35:43 -06:00
Michael Miller 1093571fbd
Add more info to stub.to_s 2022-11-04 20:34:52 -06:00
Michael Miller c00d2fe4e6
Update changelog 2022-11-04 16:57:06 -06:00
Michael Miller a6149b2671
Use `before` instead of `before_each` (same for after) 2022-11-04 16:56:03 -06:00
Michael Miller 4906dfae0d
Add short before/after hook name 2022-11-04 16:55:31 -06:00
Michael Miller 24fd7d1e91
Update Ameba 2022-10-28 18:14:53 -06:00
Michael Miller baff1de1d8
Update changelog
Implemented https://github.com/icy-arctic-fox/spectator/issues/46
2022-10-23 22:37:41 -06:00
Michael Miller 4dacaab6dc
Fix missing keyword arguments after splat 2022-10-23 22:36:20 -06:00
Michael Miller a31ffe3fa3
Fix argument capture
Fix issue added by 8959d28b38
2022-10-23 22:04:28 -06:00
Michael Miller c77da67341
Hide splat label in certain situations
Undefined double methods were reporting splat arguments, which is technically correct.
But for output in these cases, it makes more sense to show the exact calling args.
2022-10-23 21:56:37 -06:00
Michael Miller 8959d28b38
Cleaner call capture and logging for missing methods in doubles 2022-10-23 21:54:12 -06:00
Michael Miller 39e4f8e37a
Use `build` instead of `capture` for `none` 2022-10-23 21:53:24 -06:00
Michael Miller e2130d12d3
Implement arguments case equality
Implements https://github.com/icy-arctic-fox/spectator/issues/47
Some specs are failing and need to be resolved before the new feature is considered done.
2022-10-23 20:42:08 -06:00
Michael Miller 0177a678f9
Avoid shadowing variable 2022-10-23 20:40:56 -06:00
Michael Miller a728a037d4
Rename attributes 2022-10-23 15:37:55 -06:00
Michael Miller 163f94287e
Fix Arguments to_s 2022-10-23 15:27:39 -06:00
Michael Miller e38e3ecc32
Initial rework of arguments to support named positionals 2022-10-23 15:22:50 -06:00
Michael Miller 70d0009db5
Disable issue 47 test for now 2022-10-09 18:23:39 -06:00
Michael Miller d9082dab45
Test behavior and for leakages with allow syntax 2022-10-09 17:14:20 -06:00
Michael Miller b3aa2d62c0
Ensure stubs don't leak between examples 2022-10-09 16:59:39 -06:00
Michael Miller c6afa0adb3
Use different value than original 2022-10-09 16:58:56 -06:00
Michael Miller bc0a9c03c9
Remove runtime compilation tests
These may be readded later.
Right now they're failing because the GitHub issue 44 spec changes the behavior of Process.run.
The changes made by that spec shouldn't leak, but to fix correctly requires substantial changes.
These runtime tests provide little value right now and slow down testing.
2022-10-09 16:47:54 -06:00
Michael Miller 11e227b29f
Simplify method receiver conditional 2022-10-09 16:24:28 -06:00
Michael Miller 8e83edcc35
Simpler conditional block inclusion 2022-10-09 16:04:07 -06:00
Michael Miller 090c95b162
Ensure stubs defined with allow syntax are cleared 2022-10-09 15:48:00 -06:00
Michael Miller 2516803b0d
Add spec for GitHub issue 47
https://github.com/icy-arctic-fox/spectator/issues/47
2022-10-09 15:35:22 -06:00
Michael Miller e9d3f31ac3
Use harness' cleanup instead of defer 2022-10-09 15:32:32 -06:00
Michael Miller 5c910e5a85
Clear stubs defined with `expect().to receive()` syntax after test finishes 2022-10-09 13:57:28 -06:00
Michael Miller 25b9931002
Add ability to remove specific stubs 2022-10-09 13:38:29 -06:00
Michael Miller 422b0efa59
Update test to account for fix in Crystal 1.6
Keyword arguments cannot be used as a short-hand for positional arguments (yet).
https://github.com/icy-arctic-fox/spectator/issues/44
2022-10-09 12:33:31 -06:00
Michael Miller c1e1666449
Formatting 2022-10-08 14:05:53 -06:00
Michael Miller 4dfa5ccb6e
Prevent defining stubs on undefined methods in LazyDouble
In Crystal 1.6, a segfault would occur in the spec spec/spectator/mocks/lazy_double_spec.cr:238
I suspect this is a Crystal bug of some kind, but can't reduce it.
The methods produced by `method_missing` don't have a return type including Symbol.
Symbol is excluded from the union of return types (Int32 | String | Nil).
The program segfaults when calling a method on the actual value, which is a symbol.
It ultimately crashes when producing a failure message, which indicates the value it tested doesn't equal the expected value (a symbol of the same value).
Avoid this issue by preventing stubs on undefined/untyped methods.
2022-10-08 14:04:02 -06:00
Michael Miller 1998edbbb2
Release v0.11.3 2022-09-03 16:48:15 -06:00
Michael Miller 079272c9de
Add spec for custom matchers docs
Related: https://github.com/icy-arctic-fox/spectator/issues/46
2022-09-03 16:46:17 -06:00
Michael Miller ccdf9f124b
Add require statements and namespace Value
When defining a matcher outside of the `Spectator` module (custom matcher), `Value(ExpectedType)` can't be resolved.
I suspect this is a Crystal compiler bug, since a derived class should not affect lookups of parent classes like this.
Require statements are added to (hopefully) ensure `Spectator::Value` is defined for the initializer.
Related to https://github.com/icy-arctic-fox/spectator/issues/46
2022-09-03 16:46:17 -06:00
Michael Miller 7549351cce
Match tense of failure block component 2022-08-29 21:13:58 -06:00
Michael Miller 0505f210f9
Update CHANGELOG 2022-08-29 20:56:26 -06:00
Michael Miller 9d6d8de72f
Show error block for forced failure - `fail`
Fixes https://gitlab.com/arctic-fox/spectator/-/issues/78
2022-08-29 20:53:48 -06:00
Michael Miller 027521a7bc
ErrorResultBlock only needs the exception, not an ErrorResult 2022-08-29 18:00:32 -06:00
Michael Miller d10531430c
Bump v0.11.2 2022-08-07 15:23:05 -06:00
Michael Miller b5c61f9003
Change `-e` to use partial instead of exact match
Fixes https://gitlab.com/arctic-fox/spectator/-/issues/71
Fixes https://github.com/icy-arctic-fox/spectator/issues/45
2022-08-07 15:20:17 -06:00
Michael Miller 17a3ca3ac7
Fix https://gitlab.com/arctic-fox/spectator/-/issues/77 2022-08-07 14:58:09 -06:00
Michael Miller 02027cda53
Bump version 0.11.1 2022-07-18 19:48:43 -06:00
Michael Miller 18e9c1c35d
Workaround issue with Box.unbox causing segfault
Using Box.unbox on a nil value with a union type causes:

Invalid memory access (signal 11) at address 0x8

Related Crystal issue: https://github.com/crystal-lang/crystal/issues/11839
Fixes: https://gitlab.com/arctic-fox/spectator/-/issues/76
2022-07-18 19:47:34 -06:00
Michael Miller 95764140ee
Add spec for GitLab issue 51
https://gitlab.com/arctic-fox/spectator/-/issues/51
2022-07-14 22:01:33 -06:00
Michael Miller 61dee8d7db
Release v0.11.0 2022-07-14 20:51:48 -06:00
Michael Miller f4c5caa656
Update checklist 2022-07-14 20:50:28 -06:00
119 changed files with 2728 additions and 2451 deletions

2
.gitignore vendored
View File

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

View File

@ -13,7 +13,7 @@ before_script:
spec:
script:
- crystal spec --error-on-warnings --junit_output=. spec/runtime_example_spec.cr spec/matchers/ spec/spectator/*.cr
- crystal spec --error-on-warnings --junit_output=. spec/matchers/ spec/spectator/*.cr
artifacts:
when: always
paths:

View File

@ -4,7 +4,70 @@ 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).
## [Unreleased]
## [0.11.7] - 2023-10-16
### Fixed
- Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81)
## [0.11.6] - 2023-01-26
### Added
- Added ability to cast types using the return value from expect/should statements with a type matcher.
- Added support for string interpolation in context names/labels.
### Fixed
- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. [#51](https://github.com/icy-arctic-fox/spectator/issues/51)
- Fix malformed method signature when using named splat with keyword arguments in mocked type. [#49](https://github.com/icy-arctic-fox/spectator/issues/49)
### Changed
- Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start.
- Add non-captured block argument in preparation for Crystal 1.8.0.
## [0.11.5] - 2022-12-18
### Added
- Added support for mock modules and types that include mocked modules.
### Fixed
- Fix macro logic to support free variables, 'self', and variants on stubbed methods. [#48](https://github.com/icy-arctic-fox/spectator/issues/48)
- Fix method stubs used on methods that capture blocks.
- Fix type name resolution for when using custom types in a mocked typed.
- Prevent comparing range arguments with non-compatible types in stubs. [#48](https://github.com/icy-arctic-fox/spectator/issues/48)
### Changed
- Simplify string representation of mock-related types.
- Remove unnecessary redefinitions of methods when adding stub functionality to a type.
- Allow metadata to be stored as nil to reduce overhead when tracking nodes without tags.
- Use normal equality (==) instead of case-equality (===) with proc arguments in stubs.
- Change stub value cast logic to avoid compiler bug. [#80](https://gitlab.com/arctic-fox/spectator/-/issues/80)
## [0.11.4] - 2022-11-27
### Added
- Add support for using named (keyword) arguments in place of positional arguments in stubs. [#47](https://github.com/icy-arctic-fox/spectator/issues/47)
- Add `before`, `after`, and `around` as aliases for `before_each`, `after_each`, and `around_each` respectively.
### Fixed
- Clear stubs defined with `expect().to receive()` syntax after test finishes to prevent leakage between tests.
- Ensure stubs defined with `allow().to receive()` syntax are cleared after test finishes when used inside a test (another leakage).
- Fix crash caused when logging is enabled after running an example that attempts to exit.
### Removed
- Removed support for stubbing undefined (untyped) methods in lazy doubles. Avoids possible segfault.
## [0.11.3] - 2022-09-03
### Fixed
- Display error block (failure message and stack trace) when using `fail`. [#78](https://gitlab.com/arctic-fox/spectator/-/issues/78)
- Defining a custom matcher outside of the `Spectator` namespace no longer produces a compilation error. [#46](https://github.com/icy-arctic-fox/spectator/issues/46)
## [0.11.2] - 2022-08-07
### Fixed
- `expect_raises` with block and no arguments produces compilation error. [#77](https://gitlab.com/arctic-fox/spectator/-/issues/77)
### Changed
- `-e` (`--example`) CLI option performs a partial match instead of exact match. [#71](https://gitlab.com/arctic-fox/spectator/-/issues/71) [#45](https://github.com/icy-arctic-fox/spectator/issues/45)
## [0.11.1] - 2022-07-18
### Fixed
- Workaround nilable type issue with memoized value. [#76](https://gitlab.com/arctic-fox/spectator/-/issues/76)
## [0.11.0] - 2022-07-14
### Changed
- Overhauled mock and double system. [#63](https://gitlab.com/arctic-fox/spectator/-/issues/63)
- Testing if `exit` is called no longer is done with stubs and catching the `Spectator::SystemExit` exception should be caught. [#29](https://github.com/icy-arctic-fox/spectator/issues/29)
@ -387,7 +450,15 @@ This has been changed so that it compiles and raises an error at runtime with a
First version ready for public use.
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...master
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...master
[0.11.7]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...v0.11.7
[0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6
[0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5
[0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4
[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3
[0.11.2]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.1...v0.11.2
[0.11.1]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.0...v0.11.1
[0.11.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.6...v0.11.0
[0.10.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.5...v0.10.6
[0.10.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.4...v0.10.5
[0.10.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.10.3...v0.10.4

View File

@ -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
```
@ -360,10 +360,10 @@ Items not marked as completed may have partial implementations.
- [X] Mocks (Stub real types) - `mock TYPE { }`
- [X] Doubles (Stand-ins for real types) - `double NAME { }`
- [X] Method stubs - `allow().to receive()`, `allow().to receive().and_return()`
- [X] Spies - `expect().to have_receive()`
- [ ] Message expectations - `expect().to receive().at_least()`
- [X] Argument expectations - `expect().to receive().with()`
- [ ] Message ordering - `expect().to receive().ordered`
- [X] Spies - `expect().to have_received()`
- [X] Message expectations - `expect().to have_received().at_least()`
- [X] Argument expectations - `expect().to have_received().with()`
- [ ] Message ordering - `expect().to have_received().ordered`
- [X] Null doubles
- [X] Runner
- [X] Fail fast

View File

@ -1,16 +1,20 @@
name: spectator
version: 0.11.0-alpha
version: 0.11.7
description: |
A feature-rich spec testing framework for Crystal with similarities to RSpec.
Feature-rich testing framework for Crystal inspired by RSpec.
authors:
- Michael Miller <icy.arctic.fox@gmail.com>
crystal: 1.5.0
crystal: ">= 1.6.0, < 1.11"
license: MIT
dependencies:
mocks:
github: icy-arctic-fox/mocks
development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 1.0.0
version: ~> 1.5.0

View File

@ -0,0 +1,91 @@
require "../spec_helper"
# https://gitlab.com/arctic-fox/spectator/-/wikis/Custom-Matchers
Spectator.describe "Custom Matchers Docs" do
context "value matcher" do
# Sub-type of Matcher to suit our needs.
# Notice this is a struct.
struct MultipleOfMatcher(ExpectedType) < Spectator::Matchers::ValueMatcher(ExpectedType)
# Short text about the matcher's purpose.
# This explains what condition satisfies the matcher.
# The description is used when the one-liner syntax is used.
def description : String
"is a multiple of #{expected.label}"
end
# Checks whether the matcher is satisfied with the expression given to it.
private def match?(actual : Spectator::Expression(T)) : Bool forall T
actual.value % expected.value == 0
end
# Message displayed when the matcher isn't satisfied.
# The message should typically only contain the test expression labels.
private def failure_message(actual : Spectator::Expression(T)) : String forall T
"#{actual.label} is not a multiple of #{expected.label}"
end
# Message displayed when the matcher isn't satisfied and is negated.
# This is essentially what would satisfy the matcher if it wasn't negated.
# The message should typically only contain the test expression labels.
private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T
"#{actual.label} is a multiple of #{expected.label}"
end
end
# The DSL portion of the matcher.
# This captures the test expression and creates an instance of the matcher.
macro be_a_multiple_of(expected)
%value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
MultipleOfMatcher.new(%value)
end
specify do
expect(9).to be_a_multiple_of(3)
# or negated:
expect(5).to_not be_a_multiple_of(2)
end
specify "failure messages" do
expect { expect(9).to be_a_multiple_of(5) }.to raise_error(Spectator::ExpectationFailed, "9 is not a multiple of 5")
expect { expect(6).to_not be_a_multiple_of(3) }.to raise_error(Spectator::ExpectationFailed, "6 is a multiple of 3")
end
end
context "standard matcher" do
struct OddMatcher < Spectator::Matchers::StandardMatcher
def description : String
"is odd"
end
private def match?(actual : Spectator::Expression(T)) : Bool forall T
actual.value % 2 == 1
end
private def failure_message(actual : Spectator::Expression(T)) : String forall T
"#{actual.label} is not odd"
end
private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T
"#{actual.label} is odd"
end
private def does_not_match?(actual : Spectator::Expression(T)) : Bool forall T
actual.value % 2 == 0
end
end
macro be_odd
OddMatcher.new
end
specify do
expect(9).to be_odd
expect(2).to_not be_odd
end
specify "failure messages" do
expect { expect(2).to be_odd }.to raise_error(Spectator::ExpectationFailed, "2 is not odd")
expect { expect(3).to_not be_odd }.to raise_error(Spectator::ExpectationFailed, "3 is odd")
end
end
end

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
@ -146,9 +249,9 @@ Spectator.describe "Mocks Docs" do
inst.something
end
it "leaks stubs to other examples" do
it "reverts to default stub for other examples" do
inst = mock(MyStruct)
expect(inst.something).to eq(7) # Previous stub was leaked.
expect(inst.something).to eq(5) # Default stub used instead of original behavior.
end
end
end

View File

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

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

0
spec/helpers/.gitkeep Normal file
View File

View File

@ -1,71 +0,0 @@
require "ecr"
require "json"
require "./result"
module Spectator::SpecHelpers
# Wrapper for compiling and running an example at runtime and getting a result.
class Example
# Creates the example.
# The *spec_helper_path* is the path to spec_helper.cr file.
# The name or ID of the example is given by *example_id*.
# Lastly, the source code for the example is given by *example_code*.
def initialize(@spec_helper_path : String, @example_id : String, @example_code : String)
end
# Instructs the Crystal compiler to compile the test.
# Returns an instance of `JSON::Any`.
# This will be the outcome and information about the test.
# Output will be suppressed for the test.
# If an error occurs while attempting to compile and run the test, an error will be raised.
def compile
# Create a temporary file containing the test.
with_tempfile do |source_file|
args = ["run", "--no-color", source_file, "--", "--json"]
Process.run(crystal_executable, args) do |process|
JSON.parse(process.output)
rescue JSON::ParseException
raise "Compilation of example #{@example_id} failed\n\n#{process.error.gets_to_end}"
end
end
end
# Same as `#compile`, but returns the result of the first example in the test.
# Returns a `SpectatorHelpers::Result` instance.
def result
output = compile
example = output["examples"][0]
Result.from_json_any(example)
end
# Constructs the string representation of the example.
# This produces the Crystal source code.
# *io* is the file handle to write to.
# The *dir* is the directory of the file being written to.
# This is needed to resolve the relative path to the spec_helper.cr file.
private def write(io, dir)
spec_helper_path = Path[@spec_helper_path].relative_to(dir) # ameba:disable Lint/UselessAssign
ECR.embed(__DIR__ + "/example.ecr", io)
end
# Creates a temporary file containing the compilable example code.
# Yields the path of the temporary file.
# Ensures the file is deleted after it is done being used.
private def with_tempfile
tempfile = File.tempfile("_#{@example_id}_spec.cr") do |file|
dir = File.dirname(file.path)
write(file, dir)
end
begin
yield tempfile.path
ensure
tempfile.delete
end
end
# Attempts to find the Crystal compiler on the system or raises an error.
private def crystal_executable
Process.find_executable("crystal") || raise("Could not find Crystal compiler")
end
end
end

View File

@ -1,5 +0,0 @@
require "<%= spec_helper_path %>"
Spectator.describe "<%= @example_id %>" do
<%= @example_code %>
end

View File

@ -1,28 +0,0 @@
module Spectator::SpecHelpers
# Information about an `expect` call in an example.
struct Expectation
# Indicates whether the expectation passed or failed.
getter? satisfied : Bool
# Message when the expectation failed.
# Only available when `#satisfied?` is false.
getter! message : String
# Additional information about the expectation.
# Only available when `#satisfied?` is false.
getter! values : Hash(String, String)
# Creates the expectation outcome.
def initialize(@satisfied, @message, @values)
end
# Extracts the expectation information from a `JSON::Any` object.
def self.from_json_any(object : JSON::Any)
satisfied = object["satisfied"].as_bool
message = object["failure"]?.try(&.as_s?)
values = object["values"]?.try(&.as_h?)
values = values.transform_values(&.as_s) if values
new(satisfied, message, values)
end
end
end

View File

@ -1,67 +0,0 @@
module Spectator::SpecHelpers
# Information about an example compiled and run at runtime.
struct Result
# Status of the example after running.
enum Outcome
Success
Failure
Error
Unknown
end
# Full name and description of the example.
getter name : String
# Status of the example after running.
getter outcome : Outcome
# List of expectations ran in the example.
getter expectations : Array(Expectation)
# Creates the result.
def initialize(@name, @outcome, @expectations)
end
# Checks if the example was successful.
def success?
outcome.success?
end
# :ditto:
def successful?
outcome.success?
end
# Checks if the example failed, but did not error.
def failure?
outcome.failure?
end
# Checks if the example encountered an error.
def error?
outcome.error?
end
# Extracts the result information from a `JSON::Any` object.
def self.from_json_any(object : JSON::Any)
name = object["description"].as_s
outcome = parse_outcome_string(object["status"].as_s)
expectations = if (list = object["expectations"].as_a?)
list.map { |e| Expectation.from_json_any(e) }
else
[] of Expectation
end
new(name, outcome, expectations)
end
# Converts a result string, such as "fail" to an enum value.
private def self.parse_outcome_string(string)
case string
when /pass/i then Outcome::Success
when /fail/i then Outcome::Failure
when /error/i then Outcome::Error
else Outcome::Unknown
end
end
end
end

View File

@ -9,12 +9,32 @@ Spectator.describe "GitHub Issue #44" do
let(command) { "ls -l" }
let(exception) { File::NotFoundError.new("File not found", file: "test.file") }
before_each do
expect(Process).to receive(:run).with(command, shell: true, output: :pipe).and_raise(exception)
context "with positional arguments" do
before_each do
pipe = Process::Redirect::Pipe
expect(Process).to receive(:run).with(command, nil, nil, false, true, pipe, pipe, pipe, nil).and_raise(exception)
end
it "must stub Process.run" do
expect do
Process.run(command, shell: true, output: :pipe) do |_process|
end
end.to raise_error(File::NotFoundError, "File not found")
end
end
skip "must stub Process.run", skip: "Method mock not applied" do
Process.run(command, shell: true, output: :pipe) do |_process|
# Original issue uses keyword arguments in place of positional arguments.
context "keyword arguments in place of positional arguments" do
before_each do
pipe = Process::Redirect::Pipe
expect(Process).to receive(:run).with(command, shell: true, output: pipe).and_raise(exception)
end
it "must stub Process.run" do
expect do
Process.run(command, shell: true, output: :pipe) do |_process|
end
end.to raise_error(File::NotFoundError, "File not found")
end
end
end

View File

@ -0,0 +1,18 @@
require "../spec_helper"
Spectator.describe "GitHub Issue #47" do
class Original
def foo(arg1, arg2)
# ...
end
end
mock Original
let(fake) { mock(Original) }
specify do
expect(fake).to receive(:foo).with("arg1", arg2: "arg2")
fake.foo("arg1", "arg2")
end
end

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,109 @@
require "../spec_helper"
module GitLabIssue51
class Foo
def call(str : String) : String?
""
end
def alt1_call(str : String) : String?
nil
end
def alt2_call(str : String) : String?
[str, nil].sample
end
end
class Bar
def call(a_foo) : Nil # Must add nil restriction here, otherwise a segfault occurs from returning the result of #alt2_call.
a_foo.call("")
a_foo.alt1_call("")
a_foo.alt2_call("")
end
end
end
Spectator.describe GitLabIssue51::Bar do
mock GitLabIssue51::Foo, call: "", alt1_call: "", alt2_call: ""
let(:foo) { mock(GitLabIssue51::Foo) }
subject(:call) { described_class.new.call(foo) }
describe "#call" do
it "invokes Foo#call" do
call
expect(foo).to have_received(:call)
end
it "invokes Foo#alt1_call" do
call
expect(foo).to have_received(:alt1_call)
end
it "invokes Foo#alt2_call" do
call
expect(foo).to have_received(:alt2_call)
end
describe "with an explicit return of nil" do
it "should invoke Foo#call?" do
allow(foo).to receive(:call).and_return(nil)
call
expect(foo).to have_received(:call)
end
it "invokes Foo#alt1_call" do
allow(foo).to receive(:alt1_call).and_return(nil)
call
expect(foo).to have_received(:alt1_call)
end
it "invokes Foo#alt2_call" do
allow(foo).to receive(:alt2_call).and_return(nil)
call
expect(foo).to have_received(:alt2_call)
end
end
describe "with returns set in before_each for all calls" do
before_each do
allow(foo).to receive(:call).and_return(nil)
allow(foo).to receive(:alt1_call).and_return(nil)
allow(foo).to receive(:alt2_call).and_return(nil)
end
it "should invoke Foo#call?" do
call
expect(foo).to have_received(:call)
end
it "should invoke Foo#alt1_call?" do
call
expect(foo).to have_received(:alt1_call)
end
it "should invoke Foo#alt2_call?" do
call
expect(foo).to have_received(:alt2_call)
end
end
describe "with returns set in before_each for alt calls only" do
before_each do
allow(foo).to receive(:alt1_call).and_return(nil)
allow(foo).to receive(:alt2_call).and_return(nil)
end
it "invokes Foo#alt1_call" do
call
expect(foo).to have_received(:alt1_call)
end
it "invokes Foo#alt2_call" do
call
expect(foo).to have_received(:alt2_call)
end
end
end
end

View File

@ -0,0 +1,6 @@
require "../spec_helper"
Spectator.describe "GitLab Issue #76" do
let(:value) { nil.as(Int32?) }
specify { expect(value).to be_nil }
end

View File

@ -0,0 +1,10 @@
require "../spec_helper"
# https://gitlab.com/arctic-fox/spectator/-/issues/77
Spectator.describe "GitLab Issue #77" do
it "fails" do
expect_raises do
raise "Error!"
end
end
end

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

@ -1,14 +1,14 @@
require "../spec_helper"
Spectator.describe Spectator::Matchers::ReceiveMatcher do
let(stub) { Spectator::NullStub.new(:test_method) }
let(stub) { Mocks::NilStub.new(:test_method) }
subject(matcher) { described_class.new(stub) }
let(args) { Spectator::Arguments.capture(1, "test", Symbol, foo: /bar/) }
let(args_stub) { Spectator::NullStub.new(:test_method, args) }
let(args) { Mocks::ArgumentsPattern.build(1, "test", Symbol, foo: /bar/) }
let(args_stub) { Mocks::NilStub.new(:test_method, args) }
let(args_matcher) { described_class.new(args_stub) }
let(no_args_stub) { Spectator::NullStub.new(:test_method, Spectator::Arguments.none) }
let(no_args_stub) { Mocks::NilStub.new(:test_method, Mocks::ArgumentsPattern.none) }
let(no_args_matcher) { described_class.new(no_args_stub) }
double(:dbl, test_method: nil, irrelevant: nil)
@ -169,7 +169,7 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do
end
context "with method calls" do
before_each do
before do
dbl.test_method
dbl.test_method(1, "wrong", :xyz, foo: "foobarbaz")
dbl.irrelevant("foo")
@ -289,7 +289,7 @@ Spectator.describe Spectator::Matchers::ReceiveMatcher do
pre_condition { expect(match_data).to be_a(failed_match) }
before_each do
before do
dbl.test_method
dbl.test_method(1, "test", :xyz, foo: "foobarbaz")
dbl.irrelevant("foo")

View File

@ -52,7 +52,7 @@ Spectator.describe "Explicit Subject" do
describe Array(Int32) do # TODO: Multiple arguments to describe/context.
subject { [] of Int32 }
before_each { subject.push(1, 2, 3) }
before { subject.push(1, 2, 3) }
it "has the prescribed elements" do
expect(subject).to eq([1, 2, 3])

View File

@ -1,58 +0,0 @@
require "./spec_helper"
# This is a meta test that ensures specs can be compiled and run at runtime.
# The purpose of this is to report an error if this process fails.
# Other tests will fail, but display a different name/description of the test.
# This clearly indicates that runtime testing failed.
#
# Runtime compilation is used to get output of tests as well as check syntax.
# Some specs are too complex to be ran normally.
# Additionally, this allows examples to easily check specific failure cases.
# Plus, it makes testing user-reported issues easy.
Spectator.describe "Runtime compilation", :slow, :compile do
given_example passing_example do
it "does something" do
expect(true).to be_true
end
end
it "can compile and retrieve the result of an example" do
expect(passing_example).to be_successful
end
it "can retrieve expectations" do
expect(passing_example.expectations).to_not be_empty
end
given_example failing_example do
it "does something" do
expect(true).to be_false
end
it "doesn't run" do
expect(true).to be_false
end
end
it "detects failed examples" do
expect(failing_example).to be_failure
end
given_example malformed_example do
it "does something" do
asdf
end
end
it "raises on compilation errors" do
expect { malformed_example }.to raise_error(/compilation/i)
end
given_expectation satisfied_expectation do
expect(true).to be_true
end
it "can compile and retrieve expectations" do
expect(satisfied_expectation).to be_satisfied
end
end

View File

@ -15,35 +15,3 @@ end
macro specify_fails(description = nil, &block)
it_fails {{description}} {{block}}
end
# Defines an example ("it" block) that is lazily compiled.
# When the example is referenced with *id*, it will be compiled and the results retrieved.
# The value returned by *id* will be a `Spectator::SpecHelpers::Result`.
# This allows the test result to be inspected.
macro given_example(id, &block)
let({{id}}) do
::Spectator::SpecHelpers::Example.new(
{{__FILE__}},
{{id.id.stringify}},
{{block.body.stringify}}
).result
end
end
# Defines an example ("it" block) that is lazily compiled.
# The "it" block must be omitted, as the block provided to this macro will be wrapped in one.
# When the expectation is referenced with *id*, it will be compiled and the result retrieved.
# The value returned by *id* will be a `Spectator::SpecHelpers::Expectation`.
# This allows an expectation to be inspected.
# Only the last expectation performed will be returned.
# An error is raised if no expectations ran.
macro given_expectation(id, &block)
let({{id}}) do
result = ::Spectator::SpecHelpers::Example.new(
{{__FILE__}},
{{id.id.stringify}},
{{"it do\n" + block.body.stringify + "\nend"}}
).result
result.expectations.last || raise("No expectations found from {{id.id}}")
end
end

View File

@ -0,0 +1,188 @@
require "../../../spec_helper"
Spectator.describe "Allow stub DSL" do
context "with a double" do
double(:dbl) do
# Ensure the original is never called.
stub abstract def foo : Nil
stub abstract def foo(arg) : Nil
stub abstract def value : Int32
end
let(dbl) { double(:dbl) }
# Ensure invocations don't leak between examples.
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage)
end
it "matches when a message is received" do
allow(dbl).to receive(:foo)
expect { dbl.foo }.to_not raise_error
end
it "returns the correct value" do
allow(dbl).to receive(:value).and_return(42)
expect(dbl.value).to eq(42)
end
it "matches when a message is received with matching arguments" do
allow(dbl).to receive(:foo).with(:bar)
expect { dbl.foo(:bar) }.to_not raise_error
end
it "raises when a message without arguments is received" do
allow(dbl).to receive(:foo).with(:bar)
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/)
end
it "raises when a message with different arguments is received" do
allow(dbl).to receive(:foo).with(:baz)
expect { dbl.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/)
end
end
context "with a class double" do
double(:dbl) do
# Ensure the original is never called.
stub abstract def self.foo : Nil
end
stub abstract def self.foo(arg) : Nil
end
stub abstract def self.value : Int32
42
end
end
let(dbl) { class_double(:dbl) }
# Ensure invocations don't leak between examples.
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage)
end
it "matches when a message is received" do
allow(dbl).to receive(:foo)
expect { dbl.foo }.to_not raise_error
end
it "returns the correct value" do
allow(dbl).to receive(:value).and_return(42)
expect(dbl.value).to eq(42)
end
it "matches when a message is received with matching arguments" do
allow(dbl).to receive(:foo).with(:bar)
expect { dbl.foo(:bar) }.to_not raise_error
end
it "raises when a message without arguments is received" do
allow(dbl).to receive(:foo).with(:bar)
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/)
end
it "raises when a message with different arguments is received" do
allow(dbl).to receive(:foo).with(:baz)
expect { dbl.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/)
end
end
context "with a mock" do
abstract class MyClass
abstract def foo : Int32
abstract def foo(arg) : Int32
end
mock(MyClass)
let(fake) { mock(MyClass) }
# Ensure invocations don't leak between examples.
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage)
end
it "matches when a message is received" do
allow(fake).to receive(:foo).and_return(42)
expect(fake.foo).to eq(42)
end
it "returns the correct value" do
allow(fake).to receive(:foo).and_return(42)
expect(fake.foo).to eq(42)
end
it "matches when a message is received with matching arguments" do
allow(fake).to receive(:foo).with(:bar).and_return(42)
expect(fake.foo(:bar)).to eq(42)
end
it "raises when a message without arguments is received" do
allow(fake).to receive(:foo).with(:bar)
expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage, /foo/)
end
it "raises when a message with different arguments is received" do
allow(fake).to receive(:foo).with(:baz)
expect { fake.foo(:bar) }.to raise_error(Spectator::UnexpectedMessage, /foo/)
end
end
context "with a class mock" do
class MyClass
def self.foo : Int32
42
end
def self.foo(arg) : Int32
42
end
end
mock(MyClass)
let(fake) { class_mock(MyClass) }
# Ensure invocations don't leak between examples.
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition { expect(fake.foo).to eq(42) }
it "matches when a message is received" do
allow(fake).to receive(:foo).and_return(0)
expect(fake.foo).to eq(0)
end
it "returns the correct value" do
allow(fake).to receive(:foo).and_return(0)
expect(fake.foo).to eq(0)
end
it "matches when a message is received with matching arguments" do
allow(fake).to receive(:foo).with(:bar).and_return(0)
expect(fake.foo(:bar)).to eq(0)
end
it "calls the original when a message without arguments is received" do
allow(fake).to receive(:foo).with(:bar)
expect(fake.foo).to eq(42)
end
it "calls the original when a message with different arguments is received" do
allow(fake).to receive(:foo).with(:baz)
expect(fake.foo(:bar)).to eq(42)
end
end
end

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
@ -312,7 +312,7 @@ Spectator.describe "Double DSL", :smoke do
let(override) { :override }
let(dbl) { double(:context_double, override: override) }
before_each { allow(dbl).to receive(:memoize).and_return(memoize) }
before { allow(dbl).to receive(:memoize).and_return(memoize) }
it "doesn't change predefined values" do
expect(dbl.predefined).to eq(:predefined)
@ -337,7 +337,7 @@ Spectator.describe "Double DSL", :smoke do
describe "class doubles" do
double(:class_double) do
abstract_stub def self.abstract_method
stub abstract def self.abstract_method
:abstract
end

View File

@ -14,6 +14,12 @@ Spectator.describe "Deferred stub expectation DSL" do
# Ensure invocations don't leak between examples.
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage)
dbl._spectator_clear_calls # Don't include previous call in results.
end
it "matches when a message is received" do
expect(dbl).to receive(:foo)
dbl.foo
@ -51,13 +57,13 @@ Spectator.describe "Deferred stub expectation DSL" do
context "with a class double" do
double(:dbl) do
# Ensure the original is never called.
abstract_stub def self.foo : Nil
stub abstract def self.foo : Nil
end
abstract_stub def self.foo(arg) : Nil
stub abstract def self.foo(arg) : Nil
end
abstract_stub def self.value : Int32
stub abstract def self.value : Int32
42
end
end
@ -67,6 +73,12 @@ Spectator.describe "Deferred stub expectation DSL" do
# Ensure invocations don't leak between examples.
pre_condition { expect(dbl).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect { dbl.foo }.to raise_error(Spectator::UnexpectedMessage)
dbl._spectator_clear_calls # Don't include previous call in results.
end
it "matches when a message is received" do
expect(dbl).to receive(:foo)
dbl.foo
@ -114,6 +126,12 @@ Spectator.describe "Deferred stub expectation DSL" do
# Ensure invocations don't leak between examples.
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect { fake.foo }.to raise_error(Spectator::UnexpectedMessage)
fake._spectator_clear_calls # Don't include previous call in results.
end
it "matches when a message is received" do
expect(fake).to receive(:foo).and_return(42)
fake.foo(:bar)
@ -166,14 +184,20 @@ Spectator.describe "Deferred stub expectation DSL" do
# Ensure invocations don't leak between examples.
pre_condition { expect(fake).to_not have_received(:foo), "Leaked method calls from previous examples" }
# Ensure stubs don't leak between examples.
pre_condition do
expect(fake.foo).to eq(42)
fake._spectator_clear_calls # Don't include previous call in results.
end
it "matches when a message is received" do
expect(fake).to receive(:foo).and_return(42)
expect(fake).to receive(:foo).and_return(0)
fake.foo(:bar)
end
it "returns the correct value" do
expect(fake).to receive(:foo).and_return(42)
expect(fake.foo).to eq(42)
expect(fake).to receive(:foo).and_return(0)
expect(fake.foo).to eq(0)
end
it "matches when a message isn't received" do
@ -181,12 +205,12 @@ Spectator.describe "Deferred stub expectation DSL" do
end
it "matches when a message is received with matching arguments" do
expect(fake).to receive(:foo).with(:bar).and_return(42)
expect(fake).to receive(:foo).with(:bar).and_return(0)
fake.foo(:bar)
end
it "matches when a message without arguments is received" do
expect(fake).to_not receive(:foo).with(:bar).and_return(42)
expect(fake).to_not receive(:foo).with(:bar).and_return(0)
fake.foo
end
@ -195,7 +219,7 @@ Spectator.describe "Deferred stub expectation DSL" do
end
it "matches when a message with arguments isn't received" do
expect(fake).to_not receive(:foo).with(:baz).and_return(42)
expect(fake).to_not receive(:foo).with(:baz).and_return(0)
fake.foo(:bar)
end
end

View File

@ -11,7 +11,7 @@ Spectator.describe "Mock DSL", :smoke do
args[1].as(Int32),
args[2].as(Int32),
},
args[3].as(Int32),
args[:kwarg].as(Int32),
{
x: args[:x].as(Int32),
y: args[:y].as(Int32),
@ -40,17 +40,17 @@ Spectator.describe "Mock DSL", :smoke do
arg
end
def method4 : Symbol
def method4(&) : Symbol
@_spectator_invocations << :method4
yield
end
def method5
def method5(&)
@_spectator_invocations << :method5
yield.to_i
end
def method6
def method6(&)
@_spectator_invocations << :method6
yield
end
@ -60,7 +60,7 @@ Spectator.describe "Mock DSL", :smoke do
{arg, args, kwarg, kwargs}
end
def method8(arg, *args, kwarg, **kwargs)
def method8(arg, *args, kwarg, **kwargs, &)
@_spectator_invocations << :method8
yield
{arg, args, kwarg, kwargs}
@ -80,7 +80,7 @@ Spectator.describe "Mock DSL", :smoke do
"stubbed"
end
stub def method4 : Symbol
stub def method4(&) : Symbol
yield
:block
end
@ -253,25 +253,25 @@ Spectator.describe "Mock DSL", :smoke do
end
# NOTE: Defining the stub here with a return type restriction, but no default implementation.
abstract_stub abstract def method4 : Symbol
stub abstract def method4 : Symbol
# 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
# NOTE: Defining the stub here with a return type restriction, but no default implementation.
abstract_stub abstract def method7(arg, *args, kwarg, **kwargs) : CapturedArguments
stub abstract def method7(arg, *args, kwarg, **kwargs) : CapturedArguments
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
abstract_stub abstract def method8(arg, *args, kwarg, **kwargs, &) : CapturedArguments
stub abstract def method8(arg, *args, kwarg, **kwargs, &) : CapturedArguments
end
subject(fake) { mock(AbstractClass) }
@ -373,20 +373,20 @@ Spectator.describe "Mock DSL", :smoke do
mock(AbstractClass) do
# NOTE: Abstract methods without a type restriction on the return value
# must be implemented with a type restriction.
abstract_stub abstract def method1 : String
stub abstract def method1 : String
# NOTE: Defining the stub here with a return type restriction, but no default implementation.
abstract_stub abstract def method4 : Symbol
stub abstract def method4 : Symbol
# 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
@ -449,25 +449,25 @@ Spectator.describe "Mock DSL", :smoke do
end
# NOTE: Defining the stub here with a return type restriction, but no default implementation.
abstract_stub abstract def method4 : Symbol
stub abstract def method4 : Symbol
# 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
# NOTE: Defining the stub here with a return type restriction, but no default implementation.
abstract_stub abstract def method7(arg, *args, kwarg, **kwargs) : CapturedArguments
stub abstract def method7(arg, *args, kwarg, **kwargs) : CapturedArguments
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
abstract_stub abstract def method8(arg, *args, kwarg, **kwargs, &) : CapturedArguments
stub abstract def method8(arg, *args, kwarg, **kwargs, &) : CapturedArguments
end
subject(fake) { mock(AbstractStruct) }
@ -569,20 +569,20 @@ Spectator.describe "Mock DSL", :smoke do
mock(AbstractStruct) do
# NOTE: Abstract methods without a type restriction on the return value
# must be implemented with a type restriction.
abstract_stub abstract def method1 : String
stub abstract def method1 : String
# NOTE: Defining the stub here with a return type restriction, but no default implementation.
abstract_stub abstract def method4 : Symbol
stub abstract def method4 : Symbol
# NOTE: Abstract methods that yield must have yield functionality defined in the method.
# This requires that yielding methods have a default implementation.
# Just providing `&` in the arguments gets dropped by the compiler unless `yield` is in the method definition.
stub def method5
stub def method5(&)
yield
end
# NOTE: Another quirk where a default implementation must be provided because `&` is dropped.
stub def method6 : Symbol
stub def method6(&) : Symbol
yield
end
end
@ -620,11 +620,11 @@ Spectator.describe "Mock DSL", :smoke do
:original
end
def method3
def method3(&)
yield
end
def method4 : Int32
def method4(&) : Int32
yield.to_i
end
@ -749,11 +749,11 @@ Spectator.describe "Mock DSL", :smoke do
:original
end
def method3
def method3(&)
yield
end
def method4 : Int32
def method4(&) : Int32
yield.to_i
end
@ -947,7 +947,7 @@ Spectator.describe "Mock DSL", :smoke do
let(override) { :override }
let(fake) { mock(Dummy, override: override) }
before_each { allow(fake).to receive(:memoize).and_return(memoize) }
before { allow(fake).to receive(:memoize).and_return(memoize) }
it "doesn't change predefined values" do
expect(fake.predefined).to eq(:predefined)
@ -994,7 +994,7 @@ Spectator.describe "Mock DSL", :smoke do
end
mock(Dummy) do
abstract_stub def self.abstract_method
stub abstract def self.abstract_method
:abstract
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
stub abstract 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

@ -9,5 +9,31 @@ Spectator.describe Spectator::Allow do
it "applies a stub" do
expect { alw.to(stub) }.to change { dbl.foo }.from(42).to(123)
end
context "leak" do
class Thing
def foo
42
end
end
mock Thing
getter(thing : Thing) { mock(Thing) }
# Workaround type restrictions requiring a constant.
def fake
class_mock(Thing).cast(thing)
end
specify do
expect { allow(fake).to(stub) }.to change { fake.foo }.from(42).to(123)
end
# This example must be run after the previous (random order may break this).
it "clears the stub after the example completes" do
expect { fake.foo }.to eq(42)
end
end
end
end

View File

@ -1,21 +1,15 @@
require "../../spec_helper"
Spectator.describe Spectator::Arguments do
subject(arguments) do
Spectator::Arguments.new(
subject(arguments) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) }
it "stores the arguments" do
expect(arguments).to have_attributes(
args: {42, "foo"},
kwargs: {bar: "baz", qux: 123}
)
end
it "stores the arguments" do
expect(arguments.args).to eq({42, "foo"})
end
it "stores the keyword arguments" do
expect(arguments.kwargs).to eq({bar: "baz", qux: 123})
end
describe ".capture" do
subject { Spectator::Arguments.capture(42, "foo", bar: "baz", qux: 123) }
@ -24,22 +18,20 @@ Spectator.describe Spectator::Arguments do
end
end
describe "#[]" do
context "with an index" do
it "returns a positional argument" do
aggregate_failures do
expect(arguments[0]).to eq(42)
expect(arguments[1]).to eq("foo")
end
describe "#[](index)" do
it "returns a positional argument" do
aggregate_failures do
expect(arguments[0]).to eq(42)
expect(arguments[1]).to eq("foo")
end
end
end
context "with a symbol" do
it "returns a named argument" do
aggregate_failures do
expect(arguments[:bar]).to eq("baz")
expect(arguments[:qux]).to eq(123)
end
describe "#[](symbol)" do
it "returns a keyword argument" do
aggregate_failures do
expect(arguments[:bar]).to eq("baz")
expect(arguments[:qux]).to eq(123)
end
end
end
@ -63,50 +55,79 @@ Spectator.describe Spectator::Arguments do
describe "#==" do
subject { arguments == other }
context "with equal arguments" do
let(other) { arguments }
context "with Arguments" do
context "with equal arguments" do
let(other) { arguments }
it "returns true" do
is_expected.to be_true
it { is_expected.to be_true }
end
context "with different arguments" do
let(other) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) }
it { is_expected.to be_false }
end
context "with the same kwargs in a different order" do
let(other) { Spectator::Arguments.new(arguments.args, {qux: 123, bar: "baz"}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(other) { Spectator::Arguments.new(arguments.args, {bar: "baz"}) }
it { is_expected.to be_false }
end
context "with an extra kwarg" do
let(other) { Spectator::Arguments.new(arguments.args, {bar: "baz", qux: 123, extra: 0}) }
it { is_expected.to be_false }
end
end
context "with different arguments" do
let(other) do
Spectator::Arguments.new(
args: {123, :foo, "bar"},
kwargs: {opt: "foobar"}
)
context "with FormalArguments" do
context "with equal arguments" do
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) }
it { is_expected.to be_true }
end
it "returns false" do
is_expected.to be_false
end
end
context "with different arguments" do
let(other) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, {opt: "foobar"}) }
context "with the same kwargs in a different order" do
let(other) do
Spectator::Arguments.new(
args: arguments.args,
kwargs: {qux: 123, bar: "baz"}
)
it { is_expected.to be_false }
end
it "returns true" do
is_expected.to be_true
end
end
context "with the same kwargs in a different order" do
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {qux: 123, bar: "baz"}) }
context "with a missing kwarg" do
let(other) do
Spectator::Arguments.new(
args: arguments.args,
kwargs: {bar: "baz"}
)
it { is_expected.to be_true }
end
it "returns false" do
is_expected.to be_false
context "with a missing kwarg" do
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz"}) }
it { is_expected.to be_false }
end
context "with an extra kwarg" do
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123, extra: 0}) }
it { is_expected.to be_false }
end
context "with different splat arguments" do
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, {bar: "baz", qux: 123}) }
it { is_expected.to be_false }
end
context "with mixed positional tuple types" do
let(other) { Spectator::FormalArguments.new({arg1: 42}, :splat, {"foo"}, {bar: "baz", qux: 123}) }
it { is_expected.to be_true }
end
end
end
@ -114,76 +135,149 @@ Spectator.describe Spectator::Arguments do
describe "#===" do
subject { pattern === arguments }
context "with equal arguments" do
let(pattern) { arguments }
context "with Arguments" do
context "with equal arguments" do
let(pattern) { arguments }
it "returns true" do
is_expected.to be_true
it { is_expected.to be_true }
end
context "with matching arguments" do
let(pattern) { Spectator::Arguments.new({Int32, /foo/}, {bar: /baz/, qux: Int32}) }
it { is_expected.to be_true }
end
context "with non-matching arguments" do
let(pattern) { Spectator::Arguments.new({Float64, /bar/}, {bar: /foo/, qux: "123"}) }
it { is_expected.to be_false }
end
context "with different arguments" do
let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) }
it { is_expected.to be_false }
end
context "with the same kwargs in a different order" do
let(pattern) { Spectator::Arguments.new(arguments.args, {qux: Int32, bar: /baz/}) }
it { is_expected.to be_true }
end
context "with an additional kwarg" do
let(pattern) { Spectator::Arguments.new(arguments.args, {bar: /baz/}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(pattern) { Spectator::Arguments.new(arguments.args, {bar: /baz/, qux: Int32, extra: 0}) }
it { is_expected.to be_false }
end
end
context "with different arguments" do
let(pattern) do
Spectator::Arguments.new(
args: {123, :foo, "bar"},
kwargs: {opt: "foobar"}
)
context "with FormalArguments" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) }
context "with equal arguments" do
let(pattern) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) }
it { is_expected.to be_true }
end
it "returns false" do
is_expected.to be_false
end
end
context "with matching arguments" do
let(pattern) { Spectator::Arguments.new({Int32, /foo/}, {bar: /baz/, qux: Int32}) }
context "with the same kwargs in a different order" do
let(pattern) do
Spectator::Arguments.new(
args: arguments.args,
kwargs: {qux: 123, bar: "baz"}
)
it { is_expected.to be_true }
end
it "returns true" do
is_expected.to be_true
end
end
context "with non-matching arguments" do
let(pattern) { Spectator::Arguments.new({Float64, /bar/}, {bar: /foo/, qux: "123"}) }
context "with a missing kwarg" do
let(pattern) do
Spectator::Arguments.new(
args: arguments.args,
kwargs: {bar: "baz"}
)
it { is_expected.to be_false }
end
it "returns false" do
is_expected.to be_false
end
end
context "with different arguments" do
let(pattern) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) }
context "with matching types and regex" do
let(pattern) do
Spectator::Arguments.new(
args: {Int32, /foo/},
kwargs: {bar: String, qux: 123}
)
it { is_expected.to be_false }
end
it "returns true" do
is_expected.to be_true
end
end
context "with the same kwargs in a different order" do
let(pattern) { Spectator::Arguments.new(arguments.positional, {qux: Int32, bar: /baz/}) }
context "with different types and regex" do
let(pattern) do
Spectator::Arguments.new(
args: {Symbol, /bar/},
kwargs: {bar: String, qux: 42}
)
it { is_expected.to be_true }
end
it "returns false" do
is_expected.to be_false
context "with an additional kwarg" do
let(pattern) { Spectator::Arguments.new(arguments.positional, {bar: /baz/}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(pattern) { Spectator::Arguments.new(arguments.positional, {bar: /baz/, qux: Int32, extra: 0}) }
it { is_expected.to be_false }
end
context "with different splat arguments" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) }
let(pattern) { Spectator::Arguments.new({Int32, /foo/, 5}, arguments.kwargs) }
it { is_expected.to be_false }
end
context "with matching mixed positional tuple types" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) }
let(pattern) { Spectator::Arguments.new({Int32, /foo/, 1, 2, 3}, arguments.kwargs) }
it { is_expected.to be_true }
end
context "with non-matching mixed positional tuple types" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, super.kwargs) }
let(pattern) { Spectator::Arguments.new({Float64, /bar/, 3, 2, Symbol}, arguments.kwargs) }
it { is_expected.to be_false }
end
context "with matching args spilling over into splat and mixed positional tuple types" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
let(pattern) { Spectator::Arguments.capture(Int32, /foo/, Symbol, Symbol, :z, bar: /baz/, qux: Int32) }
it { is_expected.to be_true }
end
context "with non-matching args spilling over into splat and mixed positional tuple types" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
let(pattern) { Spectator::Arguments.capture(Float64, /bar/, Symbol, String, :z, bar: /foo/, qux: Int32) }
it { is_expected.to be_false }
end
context "with matching mixed named positional and keyword arguments" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
let(pattern) { Spectator::Arguments.capture(/foo/, Symbol, :y, Symbol, arg1: Int32, bar: /baz/, qux: 123) }
it { is_expected.to be_true }
end
context "with non-matching mixed named positional and keyword arguments" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
let(pattern) { Spectator::Arguments.capture(5, Symbol, :z, Symbol, arg2: /foo/, bar: /baz/, qux: Int32) }
it { is_expected.to be_false }
end
context "with non-matching mixed named positional and keyword arguments" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
let(pattern) { Spectator::Arguments.capture(/bar/, String, :y, Symbol, arg1: 0, bar: /foo/, qux: Float64) }
it { is_expected.to be_false }
end
end
end

View File

@ -74,7 +74,7 @@ Spectator.describe Spectator::Double do
context "with abstract stubs and return type annotations" do
Spectator::Double.define(TestDouble) do
abstract_stub abstract def foo(value) : String
stub abstract def foo(value) : String
end
let(arguments) { Spectator::Arguments.capture(/foo/) }
@ -98,8 +98,8 @@ Spectator.describe Spectator::Double do
context "with nillable return type annotations" do
Spectator::Double.define(TestDouble) do
abstract_stub abstract def foo : String?
abstract_stub abstract def bar : Nil
stub abstract def foo : String?
stub abstract def bar : Nil
end
let(foo_stub) { Spectator::ValueStub.new(:foo, nil).as(Spectator::Stub) }
@ -116,7 +116,7 @@ Spectator.describe Spectator::Double do
context "with a method that uses NoReturn" do
Spectator::Double.define(NoReturnDouble) do
abstract_stub abstract def oops : NoReturn
stub abstract def oops : NoReturn
end
subject(dbl) { NoReturnDouble.new }
@ -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
@ -237,8 +233,8 @@ Spectator.describe Spectator::Double do
context "without common object methods" do
Spectator::Double.define(TestDouble) do
abstract_stub abstract def foo(value) : String
abstract_stub abstract def foo(value, & : -> _) : String
stub abstract def foo(value) : String
stub abstract def foo(value, & : -> _) : String
end
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
@ -301,7 +297,7 @@ Spectator.describe Spectator::Double do
arg
end
stub def self.baz(arg)
stub def self.baz(arg, &)
yield
end
end
@ -309,7 +305,7 @@ Spectator.describe Spectator::Double do
subject(dbl) { ClassDouble }
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
after_each { dbl._spectator_clear_stubs }
after { dbl._spectator_clear_stubs }
it "overrides an existing method" do
expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override)
@ -357,7 +353,7 @@ Spectator.describe Spectator::Double do
end
describe "._spectator_clear_stubs" do
before_each { dbl._spectator_define_stub(foo_stub) }
before { dbl._spectator_define_stub(foo_stub) }
it "removes previously defined stubs" do
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub)
@ -365,7 +361,7 @@ Spectator.describe Spectator::Double do
end
describe "._spectator_calls" do
before_each { dbl._spectator_clear_calls }
before { dbl._spectator_clear_calls }
# Retrieves symbolic names of methods called on a double.
def called_method_names(dbl)
@ -440,7 +436,7 @@ Spectator.describe Spectator::Double do
subject(dbl) { FooBarDouble.new }
let(stub) { Spectator::ValueStub.new(:foo, 5) }
before_each { dbl._spectator_define_stub(stub) }
before { dbl._spectator_define_stub(stub) }
it "removes previously defined stubs" do
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42)
@ -451,7 +447,7 @@ Spectator.describe Spectator::Double do
subject(dbl) { FooBarDouble.new }
let(stub) { Spectator::ValueStub.new(:foo, 5) }
before_each { dbl._spectator_define_stub(stub) }
before { dbl._spectator_define_stub(stub) }
# Retrieves symbolic names of methods called on a double.
def called_method_names(dbl)
@ -469,7 +465,7 @@ Spectator.describe Spectator::Double do
it "stores calls to non-stubbed methods" do
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
expect(called_method_names(dbl)).to eq(%i[baz])
expect(called_method_names(dbl)).to contain(:baz)
end
it "stores arguments for a call" do
@ -479,4 +475,68 @@ Spectator.describe Spectator::Double do
expect(call.arguments).to eq(args)
end
end
describe "#to_s" do
subject(string) { dbl.to_s }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
end
end
describe "#inspect" do
subject(string) { dbl.inspect }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "indicates it's a double" do
expect(string).to contain("Double")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
end
end

View File

@ -0,0 +1,325 @@
require "../../spec_helper"
Spectator.describe Spectator::FormalArguments do
subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
it "stores the arguments" do
expect(arguments).to have_attributes(
args: {arg1: 42, arg2: "foo"},
splat_name: :splat,
splat: {:x, :y, :z},
kwargs: {bar: "baz", qux: 123}
)
end
describe ".build" do
subject { Spectator::FormalArguments.build({arg1: 42, arg2: "foo"}, :splat, {1, 2, 3}, {bar: "baz", qux: 123}) }
it "stores the arguments and keyword arguments" do
is_expected.to have_attributes(
args: {arg1: 42, arg2: "foo"},
splat_name: :splat,
splat: {1, 2, 3},
kwargs: {bar: "baz", qux: 123}
)
end
context "without a splat" do
subject { Spectator::FormalArguments.build({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) }
it "stores the arguments and keyword arguments" do
is_expected.to have_attributes(
args: {arg1: 42, arg2: "foo"},
splat: nil,
kwargs: {bar: "baz", qux: 123}
)
end
end
end
describe "#[](index)" do
it "returns a positional argument" do
aggregate_failures do
expect(arguments[0]).to eq(42)
expect(arguments[1]).to eq("foo")
end
end
it "returns splat arguments" do
aggregate_failures do
expect(arguments[2]).to eq(:x)
expect(arguments[3]).to eq(:y)
expect(arguments[4]).to eq(:z)
end
end
context "with named positional arguments" do
subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
it "returns a positional argument" do
aggregate_failures do
expect(arguments[0]).to eq(42)
expect(arguments[1]).to eq("foo")
end
end
it "returns splat arguments" do
aggregate_failures do
expect(arguments[2]).to eq(:x)
expect(arguments[3]).to eq(:y)
expect(arguments[4]).to eq(:z)
end
end
end
end
describe "#[](symbol)" do
it "returns a keyword argument" do
aggregate_failures do
expect(arguments[:bar]).to eq("baz")
expect(arguments[:qux]).to eq(123)
end
end
context "with named positional arguments" do
subject(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
it "returns a positional argument" do
aggregate_failures do
expect(arguments[:arg1]).to eq(42)
expect(arguments[:arg2]).to eq("foo")
end
end
it "returns a keyword argument" do
aggregate_failures do
expect(arguments[:bar]).to eq("baz")
expect(arguments[:qux]).to eq(123)
end
end
end
end
describe "#to_s" do
subject { arguments.to_s }
it "formats the arguments" do
is_expected.to eq("(arg1: 42, arg2: \"foo\", *splat: {:x, :y, :z}, bar: \"baz\", qux: 123)")
end
context "when empty" do
let(arguments) { Spectator::FormalArguments.none }
it "returns (no args)" do
is_expected.to eq("(no args)")
end
end
context "with a splat and no arguments" do
let(arguments) { Spectator::FormalArguments.build(NamedTuple.new, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
it "omits the splat name" do
is_expected.to eq("(:x, :y, :z, bar: \"baz\", qux: 123)")
end
end
end
describe "#==" do
subject { arguments == other }
context "with Arguments" do
context "with equal arguments" do
let(other) { Spectator::Arguments.new(arguments.positional, arguments.kwargs) }
it { is_expected.to be_true }
end
context "with different arguments" do
let(other) { Spectator::Arguments.new({123, :foo, "bar"}, {opt: "foobar"}) }
it { is_expected.to be_false }
end
context "with the same kwargs in a different order" do
let(other) { Spectator::Arguments.new(arguments.positional, {qux: 123, bar: "baz"}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(other) { Spectator::Arguments.new(arguments.positional, {bar: "baz"}) }
it { is_expected.to be_false }
end
context "with an extra kwarg" do
let(other) { Spectator::Arguments.new(arguments.positional, {bar: "baz", qux: 123, extra: 0}) }
it { is_expected.to be_false }
end
end
context "with FormalArguments" do
context "with equal arguments" do
let(other) { arguments }
it { is_expected.to be_true }
end
context "with different arguments" do
let(other) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) }
it { is_expected.to be_false }
end
context "with the same kwargs in a different order" do
let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: 123, bar: "baz"}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz"}) }
it { is_expected.to be_false }
end
context "with an extra kwarg" do
let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: "baz", qux: 123, extra: 0}) }
it { is_expected.to be_false }
end
context "with different splat arguments" do
let(other) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) }
it { is_expected.to be_false }
end
context "with mixed positional tuple types" do
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, arguments.splat_name, arguments.splat, arguments.kwargs) }
it { is_expected.to be_true }
end
context "with mixed positional tuple types (flipped)" do
let(arguments) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
let(other) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, :splat, {:x, :y, :z}, {bar: "baz", qux: 123}) }
it { is_expected.to be_true }
end
end
end
describe "#===" do
subject { pattern === arguments }
context "with Arguments" do
let(arguments) { Spectator::Arguments.new({42, "foo"}, {bar: "baz", qux: 123}) }
context "with equal arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: 42, arg2: "foo"}, {bar: "baz", qux: 123}) }
it { is_expected.to be_true }
end
context "with matching arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/, qux: Int32}) }
it { is_expected.to be_true }
end
context "with non-matching arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, {bar: /foo/, qux: "123"}) }
it { is_expected.to be_false }
end
context "with different arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, {opt: "foobar"}) }
it { is_expected.to be_false }
end
context "with the same kwargs in a different order" do
let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {qux: Int32, bar: /baz/}) }
it { is_expected.to be_true }
end
context "with an additional kwarg" do
let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, {bar: /baz/, qux: Int32, extra: 0}) }
it { is_expected.to be_false }
end
end
context "with FormalArguments" do
context "with equal arguments" do
let(pattern) { arguments }
it { is_expected.to be_true }
end
context "with matching arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, :splat, {Symbol, Symbol, :z}, {bar: /baz/, qux: Int32}) }
it { is_expected.to be_true }
end
context "with non-matching arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, :splat, {String, Int32, :x}, {bar: /foo/, qux: "123"}) }
it { is_expected.to be_false }
end
context "with different arguments" do
let(pattern) { Spectator::FormalArguments.new({arg1: 123, arg2: :foo, arg3: "bar"}, :splat, {1, 2, 3}, {opt: "foobar"}) }
it { is_expected.to be_false }
end
context "with the same kwargs in a different order" do
let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {qux: Int32, bar: /baz/}) }
it { is_expected.to be_true }
end
context "with an additional kwarg" do
let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/}) }
it { is_expected.to be_true }
end
context "with a missing kwarg" do
let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, arguments.splat, {bar: /baz/, qux: Int32, extra: 0}) }
it { is_expected.to be_false }
end
context "with different splat arguments" do
let(pattern) { Spectator::FormalArguments.new(arguments.args, arguments.splat_name, {1, 2, 3}, arguments.kwargs) }
it { is_expected.to be_false }
end
context "with matching mixed positional tuple types" do
let(pattern) { Spectator::FormalArguments.new({arg1: Int32, arg2: /foo/}, arguments.splat_name, arguments.splat, arguments.kwargs) }
it { is_expected.to be_true }
end
context "with non-matching mixed positional tuple types" do
let(pattern) { Spectator::FormalArguments.new({arg1: Float64, arg2: /bar/}, arguments.splat_name, arguments.splat, arguments.kwargs) }
it { is_expected.to be_false }
end
end
end
end

View File

@ -235,16 +235,9 @@ Spectator.describe Spectator::LazyDouble do
end
context "with previously undefined methods" do
it "can stub methods" do
it "raises an error" do
stub = Spectator::ValueStub.new(:baz, :xyz)
dbl._spectator_define_stub(stub)
expect(dbl.baz).to eq(:xyz)
end
it "uses a stub only if an argument constraint is met" do
stub = Spectator::ValueStub.new(:baz, :xyz, Spectator::Arguments.capture(:right))
dbl._spectator_define_stub(stub)
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
expect { dbl._spectator_define_stub(stub) }.to raise_error(/stub/)
end
end
end
@ -253,27 +246,18 @@ Spectator.describe Spectator::LazyDouble do
subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") }
let(stub) { Spectator::ValueStub.new(:foo, 5) }
before_each { dbl._spectator_define_stub(stub) }
before { dbl._spectator_define_stub(stub) }
it "removes previously defined stubs" do
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42)
end
it "raises on methods without an implementation" do
stub = Spectator::ValueStub.new(:baz, :xyz)
dbl._spectator_define_stub(stub)
expect(dbl.baz).to eq(:xyz)
dbl._spectator_clear_stubs
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
end
end
describe "#_spectator_calls" do
subject(dbl) { Spectator::LazyDouble.new(foo: 42, bar: "baz") }
let(stub) { Spectator::ValueStub.new(:foo, 5) }
before_each { dbl._spectator_define_stub(stub) }
before { dbl._spectator_define_stub(stub) }
# Retrieves symbolic names of methods called on a double.
def called_method_names(dbl)
@ -291,7 +275,7 @@ Spectator.describe Spectator::LazyDouble do
it "stores calls to non-stubbed methods" do
expect { dbl.baz }.to raise_error(Spectator::UnexpectedMessage, /baz/)
expect(called_method_names(dbl)).to eq(%i[baz])
expect(called_method_names(dbl)).to contain(:baz)
end
it "stores arguments for a call" do
@ -301,4 +285,68 @@ Spectator.describe Spectator::LazyDouble do
expect(call.arguments).to eq(args)
end
end
describe "#to_s" do
subject(string) { dbl.to_s }
context "with a name" do
let(dbl) { Spectator::LazyDouble.new("dbl-name") }
it "indicates it's a double" do
expect(string).to contain("LazyDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
end
context "without a name" do
let(dbl) { Spectator::LazyDouble.new }
it "contains the double type" do
expect(string).to contain("LazyDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
end
end
describe "#inspect" do
subject(string) { dbl.inspect }
context "with a name" do
let(dbl) { Spectator::LazyDouble.new("dbl-name") }
it "contains the double type" do
expect(string).to contain("LazyDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
context "without a name" do
let(dbl) { Spectator::LazyDouble.new }
it "contains the double type" do
expect(string).to contain("LazyDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
end
end

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
@ -312,7 +388,7 @@ Spectator.describe Spectator::Mock do
let(mock) { MockThing }
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
after_each { mock._spectator_clear_stubs }
after { mock._spectator_clear_stubs }
it "overrides an existing method" do
expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override)
@ -367,8 +443,22 @@ Spectator.describe Spectator::Mock do
expect(restricted(mock)).to eq(:stub)
end
it "can reference its own type" do
new_mock = MockThing.new
stub = Spectator::ValueStub.new(:thing, new_mock)
mock._spectator_define_stub(stub)
expect(mock.thing).to be(new_mock)
end
it "can reference other types in the original namespace" do
other = OtherThing.new
stub = Spectator::ValueStub.new(:other, other)
mock._spectator_define_stub(stub)
expect(mock.other).to be(other)
end
describe "._spectator_clear_stubs" do
before_each { mock._spectator_define_stub(foo_stub) }
before { mock._spectator_define_stub(foo_stub) }
it "removes previously defined stubs" do
expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub)
@ -376,7 +466,7 @@ Spectator.describe Spectator::Mock do
end
describe "._spectator_calls" do
before_each { mock._spectator_clear_calls }
before { mock._spectator_clear_calls }
# Retrieves symbolic names of methods called on a mock.
def called_method_names(mock)
@ -401,6 +491,203 @@ Spectator.describe Spectator::Mock do
end
end
context "with a module" do
module Thing
# `extend self` cannot be used.
# The Crystal compiler doesn't report the methods as class methods when doing so.
def self.original_method
:original
end
def self.default_method
:original
end
def self.stubbed_method(_value = 42)
:original
end
end
Spectator::Mock.define_subtype(:module, Thing, MockThing) do
stub def self.stubbed_method(_value = 42)
:stubbed
end
end
let(mock) { MockThing }
after { mock._spectator_clear_stubs }
it "overrides an existing method" do
stub = Spectator::ValueStub.new(:original_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override)
end
it "doesn't affect other methods" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method }
end
it "replaces an existing default stub" do
stub = Spectator::ValueStub.new(:default_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override)
end
it "replaces an existing stubbed method" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override)
end
def restricted(thing : Thing.class)
thing.stubbed_method
end
it "can be used in type restricted methods" do
expect(restricted(mock)).to eq(:stubbed)
end
describe "._spectator_clear_stubs" do
before do
stub = Spectator::ValueStub.new(:original_method, :override)
mock._spectator_define_stub(stub)
end
it "removes previously defined stubs" do
expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original)
end
end
describe "._spectator_calls" do
before { mock._spectator_clear_calls }
# Retrieves symbolic names of methods called on a mock.
def called_method_names(mock)
mock._spectator_calls.map(&.method)
end
it "stores calls to original methods" do
expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method])
end
it "stores calls to default methods" do
expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method])
end
it "stores calls to stubbed methods" do
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method])
end
it "stores multiple calls to the same stub" do
mock.stubbed_method
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method])
end
it "stores arguments for a call" do
mock.stubbed_method(5)
args = Spectator::Arguments.capture(5)
call = mock._spectator_calls.first
expect(call.arguments).to eq(args)
end
end
end
context "with a mocked module included in a class" do
module Thing
def original_method
:original
end
def default_method
:original
end
def stubbed_method(_value = 42)
:original
end
end
Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do
stub def stubbed_method(_value = 42)
:stubbed
end
end
class IncludedMock
include MockThing
end
let(mock) { IncludedMock.new }
it "overrides an existing method" do
stub = Spectator::ValueStub.new(:original_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.original_method }.from(:original).to(:override)
end
it "doesn't affect other methods" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to_not change { mock.original_method }
end
it "replaces an existing default stub" do
stub = Spectator::ValueStub.new(:default_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.default_method }.to(:override)
end
it "replaces an existing stubbed method" do
stub = Spectator::ValueStub.new(:stubbed_method, :override)
expect { mock._spectator_define_stub(stub) }.to change { mock.stubbed_method }.to(:override)
end
def restricted(thing : Thing.class)
thing.default_method
end
describe "#_spectator_clear_stubs" do
before do
stub = Spectator::ValueStub.new(:original_method, :override)
mock._spectator_define_stub(stub)
end
it "removes previously defined stubs" do
expect { mock._spectator_clear_stubs }.to change { mock.original_method }.from(:override).to(:original)
end
end
describe "#_spectator_calls" do
before { mock._spectator_clear_calls }
# Retrieves symbolic names of methods called on a mock.
def called_method_names(mock)
mock._spectator_calls.map(&.method)
end
it "stores calls to original methods" do
expect { mock.original_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[original_method])
end
it "stores calls to default methods" do
expect { mock.default_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[default_method])
end
it "stores calls to stubbed methods" do
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[]).to(%i[stubbed_method])
end
it "stores multiple calls to the same stub" do
mock.stubbed_method
expect { mock.stubbed_method }.to change { called_method_names(mock) }.from(%i[stubbed_method]).to(%i[stubbed_method stubbed_method])
end
it "stores arguments for a call" do
mock.stubbed_method(5)
args = Spectator::Arguments.capture(5)
call = mock._spectator_calls.first
expect(call.arguments).to eq(args)
end
end
end
context "with a method that uses NoReturn" do
abstract class Thing
abstract def oops : NoReturn
@ -410,7 +697,7 @@ Spectator.describe Spectator::Mock do
let(mock) { MockThing.new }
after_each { mock._spectator_clear_stubs }
after { mock._spectator_clear_stubs }
it "raises a TypeCastError when using a value-based stub" do
stub = Spectator::ValueStub.new(:oops, nil).as(Spectator::Stub)
@ -461,7 +748,7 @@ Spectator.describe Spectator::Mock do
let(mock) { MockedClass.new }
# Necessary to clear stubs to prevent leakages between tests.
after_each { mock._spectator_clear_stubs }
after { mock._spectator_clear_stubs }
it "overrides responses from methods with keyword arguments" do
expect(mock.method1).to eq(123)
@ -571,8 +858,8 @@ Spectator.describe Spectator::Mock do
let(mock) { MockedStruct.new }
# Necessary to clear stubs to prevent leakages between tests.
after_each { mock._spectator_clear_stubs }
after_each { MockedStruct._spectator_invocations.clear }
after { mock._spectator_clear_stubs }
after { MockedStruct._spectator_invocations.clear }
it "overrides responses from methods with keyword arguments" do
expect(mock.method1).to eq(123)
@ -642,7 +929,7 @@ Spectator.describe Spectator::Mock do
arg
end
def self.baz(arg)
def self.baz(arg, &)
yield
end
end
@ -656,7 +943,7 @@ Spectator.describe Spectator::Mock do
let(mock) { Thing }
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
after_each { mock._spectator_clear_stubs }
after { mock._spectator_clear_stubs }
it "overrides an existing method" do
expect { mock._spectator_define_stub(foo_stub) }.to change { mock.foo }.from(:stub).to(:override)
@ -712,7 +999,7 @@ Spectator.describe Spectator::Mock do
end
describe "._spectator_clear_stubs" do
before_each { mock._spectator_define_stub(foo_stub) }
before { mock._spectator_define_stub(foo_stub) }
it "removes previously defined stubs" do
expect { mock._spectator_clear_stubs }.to change { mock.foo }.from(:override).to(:stub)
@ -720,7 +1007,7 @@ Spectator.describe Spectator::Mock do
end
describe "._spectator_calls" do
before_each { mock._spectator_clear_calls }
before { mock._spectator_clear_calls }
# Retrieves symbolic names of methods called on a mock.
def called_method_names(mock)
@ -756,7 +1043,7 @@ Spectator.describe Spectator::Mock do
let(mock) { NoReturnThing.new }
after_each { mock._spectator_clear_stubs }
after { mock._spectator_clear_stubs }
it "raises a TypeCastError when using a value-based stub" do
stub = Spectator::ValueStub.new(:oops, nil).as(Spectator::Stub)

View File

@ -50,7 +50,7 @@ Spectator.describe Spectator::NullDouble do
context "with abstract stubs and return type annotations" do
Spectator::NullDouble.define(TestDouble2) do
abstract_stub abstract def foo(value) : String
stub abstract def foo(value) : String
end
let(arguments) { Spectator::Arguments.capture(/foo/) }
@ -74,8 +74,8 @@ Spectator.describe Spectator::NullDouble do
context "with nillable return type annotations" do
Spectator::NullDouble.define(TestDouble) do
abstract_stub abstract def foo : String?
abstract_stub abstract def bar : Nil
stub abstract def foo : String?
stub abstract def bar : Nil
end
let(foo_stub) { Spectator::ValueStub.new(:foo, nil).as(Spectator::Stub) }
@ -92,7 +92,7 @@ Spectator.describe Spectator::NullDouble do
context "with a method that uses NoReturn" do
Spectator::NullDouble.define(NoReturnDouble) do
abstract_stub abstract def oops : NoReturn
stub abstract def oops : NoReturn
end
subject(dbl) { NoReturnDouble.new }
@ -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
@ -205,8 +202,8 @@ Spectator.describe Spectator::NullDouble do
context "without common object methods" do
Spectator::NullDouble.define(TestDouble) do
abstract_stub abstract def foo(value) : String
abstract_stub abstract def foo(value, & : -> _) : String
stub abstract def foo(value) : String
stub abstract def foo(value, & : -> _) : String
end
let(stub) { Spectator::ValueStub.new(:foo, "bar", arguments).as(Spectator::Stub) }
@ -262,7 +259,7 @@ Spectator.describe Spectator::NullDouble do
arg
end
stub def self.baz(arg)
stub def self.baz(arg, &)
yield
end
end
@ -270,7 +267,7 @@ Spectator.describe Spectator::NullDouble do
subject(dbl) { ClassDouble }
let(foo_stub) { Spectator::ValueStub.new(:foo, :override) }
after_each { dbl._spectator_clear_stubs }
after { dbl._spectator_clear_stubs }
it "overrides an existing method" do
expect { dbl._spectator_define_stub(foo_stub) }.to change { dbl.foo }.from(:stub).to(:override)
@ -318,7 +315,7 @@ Spectator.describe Spectator::NullDouble do
end
describe "._spectator_clear_stubs" do
before_each { dbl._spectator_define_stub(foo_stub) }
before { dbl._spectator_define_stub(foo_stub) }
it "removes previously defined stubs" do
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(:override).to(:stub)
@ -326,7 +323,7 @@ Spectator.describe Spectator::NullDouble do
end
describe "._spectator_calls" do
before_each { dbl._spectator_clear_calls }
before { dbl._spectator_clear_calls }
# Retrieves symbolic names of methods called on a double.
def called_method_names(dbl)
@ -401,7 +398,7 @@ Spectator.describe Spectator::NullDouble do
subject(dbl) { FooBarDouble.new }
let(stub) { Spectator::ValueStub.new(:foo, 5) }
before_each { dbl._spectator_define_stub(stub) }
before { dbl._spectator_define_stub(stub) }
it "removes previously defined stubs" do
expect { dbl._spectator_clear_stubs }.to change { dbl.foo }.from(5).to(42)
@ -412,7 +409,7 @@ Spectator.describe Spectator::NullDouble do
subject(dbl) { FooBarDouble.new }
let(stub) { Spectator::ValueStub.new(:foo, 5) }
before_each { dbl._spectator_define_stub(stub) }
before { dbl._spectator_define_stub(stub) }
# Retrieves symbolic names of methods called on a double.
def called_method_names(dbl)
@ -439,4 +436,68 @@ Spectator.describe Spectator::NullDouble do
expect(call.arguments).to eq(args)
end
end
describe "#to_s" do
subject(string) { dbl.to_s }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "indicates it's a double" do
expect(string).to contain("NullDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "contains the double type" do
expect(string).to contain("NullDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
end
end
describe "#inspect" do
subject(string) { dbl.inspect }
context "with a name" do
let(dbl) { FooBarDouble.new }
it "contains the double type" do
expect(string).to contain("NullDouble")
end
it "contains the double name" do
expect(string).to contain("dbl-name")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
context "without a name" do
let(dbl) { EmptyDouble.new }
it "contains the double type" do
expect(string).to contain("NullDouble")
end
it "contains \"Anonymous\"" do
expect(string).to contain("Anonymous")
end
it "contains the object ID" do
expect(string).to contain(dbl.object_id.to_s(16))
end
end
end
end

View File

@ -1,5 +1,6 @@
require "colorize"
require "log"
require "mocks"
require "./spectator/includes"
# Module that contains all functionality related to Spectator.

View File

@ -34,7 +34,7 @@ module Spectator
# Produces a string representation of the expression.
# This consists of the label (if one is available) and the value.
def to_s(io)
def to_s(io : IO) : Nil
if (label = @label)
io << label << ": "
end
@ -43,7 +43,7 @@ module Spectator
# Produces a detailed string representation of the expression.
# This consists of the label (if one is available) and the value.
def inspect(io)
def inspect(io : IO) : Nil
if (label = @label)
io << label << ": "
end

View File

@ -13,12 +13,12 @@ module Spectator
end
# Displays "anything".
def to_s(io)
def to_s(io : IO) : Nil
io << "anything"
end
# Displays "<anything>".
def inspect(io)
def inspect(io : IO) : Nil
io << "<anything>"
end
end

View File

@ -112,7 +112,7 @@ module Spectator
# Adds the example filter option to the parser.
private def example_option(parser, builder)
parser.on("-e", "--example STRING", "Run examples whose full nested names include STRING") do |pattern|
Log.debug { "Filtering for examples named '#{pattern}' (-e '#{pattern}')" }
Log.debug { "Filtering for examples containing '#{pattern}' (-e '#{pattern}')" }
filter = NameNodeFilter.new(pattern)
builder.add_node_filter(filter)
end

View File

@ -4,18 +4,23 @@
# This type is intentionally outside the `Spectator` module.
# The reason for this is to prevent name collision when using the DSL to define a spec.
abstract class SpectatorContext
# Evaluates the contents of a block within the scope of the context.
def eval(&)
with self yield
end
# Produces a dummy string to represent the context as a string.
# This prevents the default behavior, which normally stringifies instance variables.
# Due to the sheer amount of types Spectator can create
# and that the Crystal compiler instantiates a `#to_s` and/or `#inspect` for each of those types,
# an explosion in method instances can be created.
# The compile time is drastically reduced by using a dummy string instead.
def to_s(io)
def to_s(io : IO) : Nil
io << "Context"
end
# :ditto:
def inspect(io)
def inspect(io : IO) : Nil
io << "Context<" << self.class << '>'
end
end

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

@ -124,11 +124,21 @@ module Spectator::DSL
# This means that values defined by `let` and `subject` are available.
define_example_hook :before_each
# :ditto:
macro before(&block)
before_each {{block}}
end
# Defines a block of code that will be invoked after every example in the group.
# The block will be run in the context of the current running example.
# This means that values defined by `let` and `subject` are available.
define_example_hook :after_each
# :ditto:
macro after(&block)
after_each {{block}}
end
# Defines a block of code that will be invoked around every example in the group.
# The block will be run in the context of the current running example.
# This means that values defined by `let` and `subject` are available.
@ -139,6 +149,11 @@ module Spectator::DSL
# More code can run afterwards (in the block).
define_example_hook :around_each
# :ditto:
macro around(&block)
around_each {{block}}
end
# Defines a block of code that will be invoked before every example in the group.
# The block will be run in the context of the current running example.
# This means that values defined by `let` and `subject` are available.

View File

@ -790,7 +790,7 @@ module Spectator::DSL
# ```
# expect_raises { raise "foobar" }
# ```
macro expect_raises
macro expect_raises(&block)
expect {{block}}.to raise_error
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

@ -1,8 +1,10 @@
require "../mocks"
require "mocks/dsl/allow_syntax"
module Spectator::DSL
# Methods and macros for mocks and doubles.
module Mocks
include ::Mocks::DSL::AllowSyntax
# All defined double and mock types.
# Each tuple consists of the double name or mocked type,
# defined context (example group), and double type name relative to its context.
@ -31,20 +33,9 @@ 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
# Returns a new double that responds to undefined methods with itself.
# See: `NullDouble`
def as_null_object
{{null_double_type_name}}.new(@stubs)
end
{% if block %}{{block.body}}{% end %}
::Mocks::Double.define({{double_type_name}}, {{**value_methods}}) do
{{block.body if block}}
end
{% begin %}
# Define a matching null double type.
::Spectator::NullDouble.define({{null_double_type_name}}, {{name}}, {{**value_methods}}) {{block}}
{% end %}
end
# Instantiates a double.
@ -94,11 +85,11 @@ module Spectator::DSL
begin
%double = {% if found_tuple %}
{{found_tuple[2].id}}.new({{**value_methods}})
{{found_tuple[2].id}}.new({{found_tuple[0].id.stringify}}, {{**value_methods}})
{% else %}
::Spectator::LazyDouble.new({{name}}, {{**value_methods}})
{% end %}
::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset })
::Spectator::Harness.current?.try(&.cleanup { %double.__mocks.reset })
%double
end
end
@ -162,7 +153,7 @@ module Spectator::DSL
%stub{key} = ::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}})
%double._spectator_define_stub(%stub{key})
{% end %}
::Spectator::Harness.current?.try(&.cleanup { %double._spectator_reset })
::Spectator::Harness.current?.try(&.cleanup { %double.__mocks.reset })
%double
end
end
@ -218,24 +209,29 @@ module Spectator::DSL
# end
# ```
private macro def_mock(type, name = nil, **value_methods, &block)
{% # Construct a unique type name for the mock by using the number of defined types.
index = ::Spectator::DSL::Mocks::TYPES.size
mock_type_name = "Mock#{index}".id
{% resolved = type.resolve
# Construct a unique type name for the mock by using the number of defined types.
index = ::Spectator::DSL::Mocks::TYPES.size
# The type is nested under the original so that any type names from the original can be resolved.
mock_type_name = "Mock#{index}".id
# Store information about how the mock is defined and its context.
# This is important for constructing an instance of the mock later.
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, mock_type_name.symbolize}
# Store information about how the mock is defined and its context.
# This is important for constructing an instance of the mock later.
::Spectator::DSL::Mocks::TYPES << {type.id.symbolize, @type.name(generic_args: false).symbolize, "::#{resolved.name}::#{mock_type_name}".id.symbolize}
resolved = type.resolve
base = if resolved.class?
:class
elsif resolved.struct?
:struct
else
:module
end %}
base = if resolved.class?
:class
elsif resolved.struct?
:struct
else
:module
end %}
::Spectator::Mock.define_subtype({{base}}, {{type.id}}, {{mock_type_name}}, {{name}}, {{**value_methods}}) {{block}}
{% begin %}
{{base.id}} ::{{resolved.name}}
::Mocks::Mock.define({{mock_type_name}} < ::{{resolved.name}}, {{**value_methods}}) {{block}}
end
{% end %}
end
# Instantiates a mock.
@ -296,10 +292,10 @@ module Spectator::DSL
{% if found_tuple %}
{{found_tuple[2].id}}.new.tap do |%mock|
{% for key, value in value_methods %}
%stub{key} = ::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}})
%mock._spectator_define_stub(%stub{key})
%stub{key} = ::Mocks::ValueStub.new({{key.id.symbolize}}, {{value}})
%mock.__mocks.add_stub(%stub{key})
{% end %}
::Spectator::Harness.current?.try(&.cleanup { %mock._spectator_reset })
::Spectator::Harness.current?.try(&.cleanup { %mock.__mocks.reset })
end
{% else %}
{% raise "Type `#{type.id}` must be previously mocked before attempting to instantiate." %}
@ -372,8 +368,8 @@ module Spectator::DSL
begin
%mock = {{found_tuple[2].id}}
{% for key, value in value_methods %}
%stub{key} = ::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}})
%mock._spectator_define_stub(%stub{key})
%stub{key} = ::Mocks::ValueStub.new({{key.id.symbolize}}, {{value}})
%mock.__mocks.add_stub(%stub{key})
{% end %}
::Spectator::Harness.current?.try(&.cleanup { %mock._spectator_reset })
%mock
@ -426,77 +422,46 @@ 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}}
{% begin %}
{{base.id}} {{type.id}}
include ::Mocks::Stubbable::Automatic
{% for key, value in value_methods %}
stub_any_args {{key}} = {{value}}
{% end %}
{{block.body if block}}
end
{% end %}
end
# Targets a stubbable object (such as a mock or double) for operations.
# Constructs a stub for a method.
#
# The *stubbable* must be a `Stubbable` or `StubbedType`.
# This method is expected to be followed up with `.to receive()`.
# The *method* is the name of the method to stub.
#
# This is also the start of a fluent interface for defining stubs.
#
# Allow syntax:
# ```
# dbl = dbl(:foobar)
# allow(dbl).to receive(:foo).and_return(42)
# ```
def allow(stubbable : Stubbable | StubbedType)
::Spectator::Allow.new(stubbable)
end
# Helper method producing a compilation error when attempting to stub a non-stubbable object.
#
# Triggered in cases like this:
# ```
# allow(42).to receive(:to_s).and_return("123")
# ```
def allow(stubbable)
{% raise "Target of `allow()` must be stubbable (mock or double)." %}
end
# Begins the creation of a stub.
#
# The *method* is the name of the method being stubbed.
# It should not define any parameters, it should be just the method name as a literal symbol or string.
#
# Alone, this method returns a `NullStub`, which allows a stubbable object to return nil from a method.
# This macro is typically followed up with a method like `and_return` to change the stub's behavior.
#
# ```
# dbl = dbl(:foobar)
# allow(dbl).to receive(:foo)
# expect(dbl.foo).to be_nil
#
# allow(dbl).to receive(:foo).and_return(42)
# expect(dbl.foo).to eq(42)
# ```
#
# A block can be provided to be run every time the stub is invoked.
# The value returned by the block is returned by the stubbed method.
#
# ```
# dbl = dbl(:foobar)
# allow(dbl).to receive(:foo) { 42 }
# expect(dbl.foo).to eq(42)
# allow(dbl).to receive(:some_method)
# allow(dbl).to receive(:the_answer).and_return(42)
# ```
macro receive(method, *, _file = __FILE__, _line = __LINE__, &block)
{% if block %}
%proc = ->(%args : ::Spectator::AbstractArguments) {
{% if !block.args.empty? %}{{*block.args}} = %args {% end %}
{{block.body}}
}
::Spectator::ProcStub.new({{method.id.symbolize}}, %proc, location: ::Spectator::Location.new({{_file}}, {{_line}}))
::Mocks::ProcStub.new({{method.id.symbolize}}) {{block}}
{% else %}
::Spectator::NullStub.new({{method.id.symbolize}}, location: ::Spectator::Location.new({{_file}}, {{_line}}))
::Mocks::NilStub.new({{method.id.symbolize}})
{% end %}
end
# Returns empty arguments.
def no_args
::Spectator::Arguments.none
::Mocks::Arguments.none
end
# Indicates any arguments can be used (no constraint).
def any_args
::Spectator::Arguments.any
::Mocks::Arguments.any
end
end
end

View File

@ -11,12 +11,12 @@ module Spectator
end
# Calls the `error` method on *visitor*.
def accept(visitor)
def accept(visitor, &)
visitor.error(yield self)
end
# One-word description of the result.
def to_s(io)
def to_s(io : IO) : Nil
io << "error"
end

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
@ -103,8 +104,8 @@ module Spectator
# Returns the result of the execution.
# The result will also be stored in `#result`.
def run : Result
Log.debug { "Running example #{self}" }
Log.warn { "Example #{self} already ran" } if @finished
Log.debug { "Running example: #{self}" }
Log.warn { "Example already ran: #{self}" } if @finished
if pending?
Log.debug { "Skipping example #{self} - marked pending" }
@ -117,7 +118,7 @@ module Spectator
begin
@result = Harness.run do
if proc = @name_proc.as?(Proc(Example, String))
if proc = @name_proc
self.name = proc.call(self)
end
@ -142,8 +143,10 @@ module Spectator
group.call_before_each(self)
group.call_pre_condition(self)
end
Log.trace { "Running example code for: #{self}" }
@entrypoint.call(self)
@finished = true
Log.trace { "Finished running example code for: #{self}" }
if group = @group
group.call_post_condition(self)
group.call_after_each(self)
@ -161,7 +164,7 @@ module Spectator
# The context casted to an instance of *klass* is provided as a block argument.
#
# TODO: Benchmark compiler performance using this method versus client-side casting in a proc.
protected def with_context(klass)
protected def with_context(klass, &)
context = klass.cast(@context)
with context yield
end
@ -181,7 +184,7 @@ module Spectator
end
# Yields this example and all parent groups.
def ascend
def ascend(&)
node = self
while node
yield node
@ -191,7 +194,7 @@ module Spectator
# Constructs the full name or description of the example.
# This prepends names of groups this example is part of.
def to_s(io)
def to_s(io : IO) : Nil
name = @name
# Prefix with group's full name if the node belongs to a group.
@ -210,9 +213,9 @@ module Spectator
end
# Exposes information about the example useful for debugging.
def inspect(io)
def inspect(io : IO) : Nil
super
io << ' ' << result
io << " - " << result
end
# Creates the JSON representation of the example,
@ -276,7 +279,7 @@ module Spectator
# The block given to this method will be executed within the test context.
#
# TODO: Benchmark compiler performance using this method versus client-side casting in a proc.
protected def with_context(klass)
protected def with_context(klass, &)
context = @example.cast_context(klass)
with context yield
end
@ -286,7 +289,7 @@ module Spectator
# Constructs the full name or description of the example.
# This prepends names of groups this example is part of.
def to_s(io) : Nil
def to_s(io : IO) : Nil
@example.to_s(io)
end
end

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

@ -19,14 +19,14 @@ module Spectator
protected setter group : ExampleGroup?
define_hook before_all : ExampleGroupHook do
Log.trace { "Processing before_all hooks for #{self}" }
Log.trace { "Processing before_all hooks for: #{self}" }
@group.try &.call_before_all
before_all_hooks.each &.call_once
end
define_hook after_all : ExampleGroupHook, :prepend do
Log.trace { "Processing after_all hooks for #{self}" }
Log.trace { "Processing after_all hooks for: #{self}" }
after_all_hooks.each &.call_once if finished?
if group = @group
@ -35,21 +35,21 @@ module Spectator
end
define_hook before_each : ExampleHook do |example|
Log.trace { "Processing before_each hooks for #{self}" }
Log.trace { "Processing before_each hooks for: #{self}" }
@group.try &.call_before_each(example)
before_each_hooks.each &.call(example)
end
define_hook after_each : ExampleHook, :prepend do |example|
Log.trace { "Processing after_each hooks for #{self}" }
Log.trace { "Processing after_each hooks for: #{self}" }
after_each_hooks.each &.call(example)
@group.try &.call_after_each(example)
end
define_hook around_each : ExampleProcsyHook do |procsy|
Log.trace { "Processing around_each hooks for #{self}" }
Log.trace { "Processing around_each hooks for: #{self}" }
around_each_hooks.reverse_each { |hook| procsy = hook.wrap(procsy) }
if group = @group
@ -59,14 +59,14 @@ module Spectator
end
define_hook pre_condition : ExampleHook do |example|
Log.trace { "Processing pre_condition hooks for #{self}" }
Log.trace { "Processing pre_condition hooks for: #{self}" }
@group.try &.call_pre_condition(example)
pre_condition_hooks.each &.call(example)
end
define_hook post_condition : ExampleHook, :prepend do |example|
Log.trace { "Processing post_condition hooks for #{self}" }
Log.trace { "Processing post_condition hooks for: #{self}" }
post_condition_hooks.each &.call(example)
@group.try &.call_post_condition(example)
@ -79,7 +79,7 @@ module Spectator
# This group will be assigned to the parent *group* if it is provided.
# A set of *metadata* can be used for filtering and modifying example behavior.
def initialize(@name : Label = nil, @location : Location? = nil,
@group : ExampleGroup? = nil, @metadata : Metadata = Metadata.new)
@group : ExampleGroup? = nil, @metadata : Metadata? = nil)
# Ensure group is linked.
group << self if group
end
@ -87,7 +87,7 @@ module Spectator
delegate size, unsafe_fetch, to: @nodes
# Yields this group and all parent groups.
def ascend
def ascend(&)
group = self
while group
yield group
@ -112,11 +112,15 @@ module Spectator
# Constructs the full name or description of the example group.
# This prepends names of groups this group is part of.
def to_s(io)
# Prefix with group's full name if the node belongs to a group.
return unless parent = @group
def to_s(io : IO, *, nested = false) : Nil
unless parent = @group
# Display special string when called directly.
io << "<root>" unless nested
return
end
parent.to_s(io)
# Prefix with group's full name if the node belongs to a group.
parent.to_s(io, nested: true)
name = @name
# Add padding between the node names
@ -126,7 +130,7 @@ module Spectator
(parent.name?.is_a?(Symbol) && name.is_a?(String) &&
(name.starts_with?('#') || name.starts_with?('.')))
super
super(io)
end
# Adds the specified *node* to the group.

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

@ -42,7 +42,7 @@ module Spectator
# Produces the string representation of the hook.
# Includes the location and label if they're not nil.
def to_s(io)
def to_s(io : IO) : Nil
io << "example group hook"
if (label = @label)

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

@ -37,7 +37,7 @@ module Spectator
# Produces the string representation of the hook.
# Includes the location and label if they're not nil.
def to_s(io)
def to_s(io : IO) : Nil
io << "example hook"
if (label = @label)

View File

@ -39,7 +39,7 @@ module Spectator
# Produces the string representation of the hook.
# Includes the location and label if they're not nil.
def to_s(io)
def to_s(io : IO) : Nil
io << "example hook"
if (label = @label)

View File

@ -101,8 +101,8 @@ module Spectator
# Asserts that a method is called some point before the example completes.
@[AlwaysInline]
def to(stub : Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
def to(stub : ::Mocks::Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Mocks::Stubbable %}
to_eventually(stub, message)
end
@ -114,17 +114,32 @@ 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
{% raise "The syntax `expect(...).to_not receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
def to_not(stub : ::Mocks::Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to_not receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Mocks::Stubbable || T < ::Spectator::StubbedType %}
to_never(stub, message)
end
# :ditto:
@[AlwaysInline]
def not_to(stub : Stub, message = nil) : Nil
def not_to(stub : ::Mocks::Stub, message = nil) : Nil
to_not(stub, message)
end
@ -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
@ -143,11 +188,11 @@ module Spectator
end
# Asserts that a method is called some point before the example completes.
def to_eventually(stub : Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to_eventually receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
def to_eventually(stub : ::Mocks::Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to_eventually receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Mocks::Stubbable || T < ::Spectator::StubbedType %}
stubbable = @expression.value
unless stubbable._spectator_stub_for_method?(stub.method)
unless stubbable.__mocks.has_stub?(stub.method_name)
# Add stub without an argument constraint.
# Avoids confusing logic like this:
# ```
@ -156,13 +201,19 @@ module Spectator
# ```
# Notice that `#foo` is called, but with different arguments.
# Normally this would raise an error, but that should be prevented.
unconstrained_stub = stub.with(Arguments.any)
stubbable._spectator_define_stub(unconstrained_stub)
unconstrained_stub = stub.with(::Mocks::Arguments.any)
stubbable.__mocks.add_stub(unconstrained_stub)
end
stubbable._spectator_define_stub(stub)
# Apply the stub that is expected to be called.
stubbable.__mocks.add_stub(stub)
# Check if the stub was invoked after the test completes.
matcher = Matchers::ReceiveMatcher.new(stub)
to_eventually(matcher, message)
Harness.current.defer { to(matcher, message) }
# Prevent leaking stubs between tests.
Harness.current.cleanup { stubbable.__mocks.remove_stub(stub) }
end
# Asserts that some criteria defined by the matcher is eventually satisfied.
@ -173,11 +224,11 @@ module Spectator
end
# Asserts that a method is not called before the example completes.
def to_never(stub : Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to_never receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Spectator::Stubbable || T < ::Spectator::StubbedType %}
def to_never(stub : ::Mocks::Stub, message = nil) : Nil
{% raise "The syntax `expect(...).to_never receive(...)` requires the expression passed to `expect` be stubbable (a mock or double)" unless T < ::Mocks::Stubbable %}
stubbable = @expression.value
unless stubbable._spectator_stub_for_method?(stub.method)
unless stubbable.__mocks.find_stub(stub.method)
# Add stub without an argument constraint.
# Avoids confusing logic like this:
# ```
@ -187,17 +238,23 @@ module Spectator
# Notice that `#foo` is called, but with different arguments.
# Normally this would raise an error, but that should be prevented.
unconstrained_stub = stub.with(Arguments.any)
stubbable._spectator_define_stub(unconstrained_stub)
stubbable.__mocks.add_stub(unconstrained_stub)
end
stubbable._spectator_define_stub(stub)
# Apply the stub that could be called in case it is.
stubbable.__mocks.add_stub(stub)
# Check if the stub was invoked after the test completes.
matcher = Matchers::ReceiveMatcher.new(stub)
to_never(matcher, message)
Harness.current.defer { to_not(matcher, message) }
# Prevent leaking stubs between tests.
Harness.current.cleanup { stubbable.__mocks.remove_stub(stub) }
end
# :ditto:
@[AlwaysInline]
def never_to(stub : Stub, message = nil) : Nil
def never_to(stub : ::Mocks::Stub, message = nil) : Nil
to_never(stub, message)
end

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
@ -55,7 +55,7 @@ module Spectator
end
# One-word description of the result.
def to_s(io)
def to_s(io : IO) : Nil
io << "fail"
end

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

@ -16,7 +16,7 @@ module Spectator::Formatting::Components
end
# Writes the comment to the output.
def to_s(io)
def to_s(io : IO) : Nil
io << "# " << @content
end
end

View File

@ -7,36 +7,38 @@ module Spectator::Formatting::Components
# Displays information about an error result.
struct ErrorResultBlock < ResultBlock
# Creates the component.
def initialize(example : Example, index : Int32, @result : ErrorResult, subindex = 0)
def initialize(example : Example, index : Int32, @error : Exception, subindex = 0)
super(example, index, subindex)
end
# Content displayed on the second line of the block after the label.
private def subtitle
@result.error.message.try(&.each_line.first)
@error.message.try(&.each_line.first)
end
# Prefix for the second line of the block.
private def subtitle_label
"Error: ".colorize(:red)
case @error
when ExampleFailed then "Failure: "
else "Error: "
end.colorize(:red)
end
# Display error information.
private def content(io)
# Fetch the error and message.
error = @result.error
lines = error.message.try(&.lines)
lines = @error.message.try(&.lines)
# Write the error and message if available.
case
when lines.nil? then write_error_class(io, error)
when lines.size == 1 then write_error_message(io, error, lines.first)
when lines.size > 1 then write_multiline_error_message(io, error, lines)
else write_error_class(io, error)
when lines.nil? then write_error_class(io)
when lines.size == 1 then write_error_message(io, lines.first)
when lines.size > 1 then write_multiline_error_message(io, lines)
else write_error_class(io)
end
# Display the backtrace if it's available.
if backtrace = error.backtrace?
if backtrace = @error.backtrace?
indent { write_backtrace(io, backtrace) }
end
@ -44,24 +46,24 @@ module Spectator::Formatting::Components
end
# Display just the error type.
private def write_error_class(io, error)
private def write_error_class(io)
line(io) do
io << error.class.colorize(:red)
io << @error.class.colorize(:red)
end
end
# Display the error type and first line of the message.
private def write_error_message(io, error, message)
private def write_error_message(io, message)
line(io) do
io << "#{error.class}: ".colorize(:red)
io << "#{@error.class}: ".colorize(:red)
io << message
end
end
# Display the error type and its multi-line message.
private def write_multiline_error_message(io, error, lines)
private def write_multiline_error_message(io, lines)
# Use the normal formatting for the first line.
write_error_message(io, error, lines.first)
write_error_message(io, lines.first)
# Display additional lines after the first.
lines.skip(1).each do |entry|

View File

@ -9,7 +9,7 @@ module Spectator::Formatting::Components
end
# Produces output for running the previously specified example.
def to_s(io)
def to_s(io : IO) : Nil
io << "crystal spec "
# Use location for argument if it's available, since it's simpler.

View File

@ -10,7 +10,7 @@ module Spectator::Formatting::Components
end
# Produces the list of commands to run failed examples.
def to_s(io)
def to_s(io : IO) : Nil
io.puts "Failed examples:"
io.puts
@failures.each do |failure|

View File

@ -9,7 +9,7 @@ module Spectator::Formatting::Components
end
# Produces the output containing the profiling information.
def to_s(io)
def to_s(io : IO) : Nil
io << "Top "
io << @profile.size
io << " slowest examples ("

View File

@ -41,7 +41,7 @@ module Spectator::Formatting::Components
private abstract def content(io)
# Writes the component's output to the specified stream.
def to_s(io)
def to_s(io : IO) : Nil
title_line(io)
# Ident over to align with the spacing used by the index.
indent(index_digit_count + 2) do

View File

@ -15,7 +15,7 @@ module Spectator::Formatting::Components
# #:##:##
# # days #:##:##
# ```
def to_s(io)
def to_s(io : IO) : Nil
millis = @span.total_milliseconds
return format_micro(io, millis * 1000) if millis < 1

View File

@ -11,7 +11,7 @@ module Spectator::Formatting::Components
end
# Displays the stats.
def to_s(io)
def to_s(io : IO) : Nil
runtime(io)
totals(io)
if seed = @report.random_seed?

View File

@ -10,7 +10,7 @@ module Spectator::Formatting::Components
end
# Produces the output containing the profiling information.
def to_s(io)
def to_s(io : IO) : Nil
io << "# Top "
io << @profile.size
io << " slowest examples ("

View File

@ -31,7 +31,7 @@ module Spectator::Formatting::Components
end
# Writes the counts to the output.
def to_s(io)
def to_s(io : IO) : Nil
io << @examples << " examples, " << @failures << " failures"
if @errors > 0

View File

@ -63,15 +63,22 @@ module Spectator::Formatting
# Displays one or more blocks for a failed example.
# Each block is a failed expectation or error raised in the example.
private def dump_failed_example(example, index)
result = example.result.as?(ErrorResult)
# Retrieve the ultimate reason for failing.
error = example.result.as?(FailResult).try(&.error)
# Prevent displaying duplicated output from expectation.
# Display `ExampleFailed` but not `ExpectationFailed`.
error = nil if error.responds_to?(:expectation)
# Gather all failed expectations.
failed_expectations = example.result.expectations.select(&.failed?)
block_count = failed_expectations.size
block_count += 1 if result
block_count += 1 if error # Add an extra block for final error if it's significant.
# Don't use sub-index if there was only one problem.
if block_count == 1
if result
io.puts Components::ErrorResultBlock.new(example, index, result)
if error
io.puts Components::ErrorResultBlock.new(example, index, error)
else
io.puts Components::FailResultBlock.new(example, index, failed_expectations.first)
end
@ -79,7 +86,7 @@ module Spectator::Formatting
failed_expectations.each_with_index(1) do |expectation, subindex|
io.puts Components::FailResultBlock.new(example, index, expectation, subindex)
end
io.puts Components::ErrorResultBlock.new(example, index, result, block_count) if result
io.puts Components::ErrorResultBlock.new(example, index, error, block_count) if error
end
end
end

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

@ -38,7 +38,6 @@ require "./location"
require "./location_node_filter"
require "./matchers"
require "./metadata"
require "./mocks"
require "./name_node_filter"
require "./null_context"
require "./null_node_filter"

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

@ -59,7 +59,7 @@ module Spectator
# ```text
# FILE:LINE
# ```
def to_s(io)
def to_s(io : IO) : Nil
io << path << ':' << line
end
end

View File

@ -15,7 +15,7 @@ module Spectator::Matchers
extend self
# Text displayed when a method is undefined.
def inspect(io)
def inspect(io : IO) : Nil
io << "<Method undefined>"
end
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,6 +1,3 @@
require "../mocks/stub"
require "../mocks/stubbable"
require "../mocks/stubbed_type"
require "./matcher"
module Spectator::Matchers
@ -9,13 +6,13 @@ module Spectator::Matchers
alias Count = Range(Int32?, Int32?)
# Creates the matcher for expecting a method call matching a stub.
def initialize(@stub : Stub, @count : Count = Count.new(1, nil))
def initialize(@stub : Mocks::Stub, @count : Count = Count.new(1, nil))
end
# Creates the matcher for expecting a method call with any arguments.
# *expected* is an expression evaluating to the method name as a symbol.
def initialize(expected : Expression(Symbol))
stub = NullStub.new(expected.value).as(Stub)
stub = Mocks::NilStub.new(expected.value).as(Mocks::Stub)
initialize(stub)
end
@ -85,7 +82,7 @@ module Spectator::Matchers
end
# Actually performs the test against the expression (value or block).
def match(actual : Expression(Stubbable) | Expression(StubbedType)) : MatchData
def match(actual : Expression(Mocks::Stubbable)) : MatchData
stubbed = actual.value
calls = relevant_calls(stubbed)
if @count.includes?(calls.size)
@ -102,7 +99,7 @@ module Spectator::Matchers
end
# Performs the test against the expression (value or block), but inverted.
def negated_match(actual : Expression(Stubbable) | Expression(StubbedType)) : MatchData
def negated_match(actual : Expression(Mocks::Stubbable)) : MatchData
stubbed = actual.value
calls = relevant_calls(stubbed)
if @count.includes?(calls.size)
@ -135,7 +132,7 @@ module Spectator::Matchers
# Filtered list of method calls relevant to the matcher.
private def relevant_calls(stubbable)
stubbable._spectator_calls.select { |call| @stub === call }
stubbable.__mocks.calls.select { |call| @stub === call }
end
private def humanize_count
@ -148,11 +145,11 @@ module Spectator::Matchers
# Formatted list of method calls.
private def method_call_list(stubbable)
calls = stubbable._spectator_calls
calls = stubbable.__mocks.calls
if calls.empty?
"None"
else
calls.join("\n")
calls.join('\n')
end
end
end

View File

@ -1,3 +1,5 @@
require "../expression"
require "../value"
require "./standard_matcher"
module Spectator::Matchers
@ -22,7 +24,7 @@ module Spectator::Matchers
# Creates the value matcher.
# The expected value is stored for later use.
def initialize(@expected : Value(ExpectedType))
def initialize(@expected : ::Spectator::Value(ExpectedType))
end
# Additional information about the match failure.

View File

@ -1,7 +0,0 @@
require "./mocks/*"
module Spectator
# Functionality for mocking existing types.
module Mocks
end
end

View File

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

View File

@ -1,26 +0,0 @@
require "./stub"
require "./stubbable"
require "./stubbed_type"
module Spectator
# Targets a stubbable object.
#
# This type is effectively part of the mock DSL.
# It is primarily used in the mock DSL to provide this syntax:
# ```
# allow(dbl).to
# ```
struct Allow(T)
# Creates the stub target.
#
# The *target* must be a kind of `Stubbable` or `StubbedType`.
def initialize(@target : T)
{% raise "Target of `allow` must be stubbable (a mock or double)." unless T < Stubbable || T < StubbedType %}
end
# Applies a stub to the targeted stubbable object.
def to(stub : Stub) : Nil
@target._spectator_define_stub(stub)
end
end
end

View File

@ -1,91 +0,0 @@
require "./abstract_arguments"
module Spectator
# Arguments used in a method call.
#
# Can also be used to match arguments.
# *T* must be a `Tuple` type representing the positional arguments.
# *NT* must be a `NamedTuple` type representing the keyword arguments.
class Arguments(T, NT) < AbstractArguments
# Positional arguments.
getter args : T
# Keyword arguments.
getter kwargs : NT
# Creates arguments used in a method call.
def initialize(@args : T, @kwargs : NT)
end
# Constructs an instance from literal arguments.
def self.capture(*args, **kwargs) : AbstractArguments
new(args, kwargs).as(AbstractArguments)
end
# Instance of empty arguments.
class_getter none : AbstractArguments = capture
# Returns unconstrained arguments.
def self.any : AbstractArguments?
nil.as(AbstractArguments?)
end
# Returns the positional argument at the specified index.
def [](index : Int)
@args[index]
end
# Returns the specified named argument.
def [](arg : Symbol)
@kwargs[arg]
end
# Constructs a string representation of the arguments.
def to_s(io : IO) : Nil
return io << "(no args)" if args.empty? && kwargs.empty?
io << '('
# Add the positional arguments.
args.each_with_index do |arg, i|
io << ", " if i > 0
arg.inspect(io)
end
# Add the keyword arguments.
size = args.size + kwargs.size
kwargs.each_with_index(args.size) do |k, v, i|
io << ", " if 0 < i < size
io << k << ": "
v.inspect(io)
end
io << ')'
end
# Checks if this set of arguments and another are equal.
def ==(other : Arguments)
args == other.args && kwargs == other.kwargs
end
# Checks if another set of arguments matches this set of arguments.
def ===(other : Arguments)
args === other.args && named_tuples_match?(kwargs, other.kwargs)
end
# Checks if two named tuples match.
#
# Uses case equality (`===`) on every key-value pair.
# NamedTuple doesn't have a `===` operator, even though Tuple does.
private def named_tuples_match?(a : NamedTuple, b : NamedTuple)
return false if a.size != b.size
a.each do |k, v|
return false unless b.has_key?(k)
return false unless v === b[k]
end
true
end
end
end

View File

@ -1,191 +0,0 @@
require "./arguments"
require "./method_call"
require "./stub"
require "./stubbable"
require "./stubbed_name"
require "./stubbed_type"
require "./unexpected_message"
require "./value_stub"
module Spectator
# Stands in for an object for testing that a SUT calls expected methods.
#
# Handles all messages (method calls), but only responds to those configured.
# Methods called that were not configured will raise `UnexpectedMessage`.
# Doubles should be defined with the `#define` macro.
#
# Use `#_spectator_define_stub` to override behavior of a method in the double.
# Only methods defined in the double's type can have stubs.
# New methods are not defines when a stub is added that doesn't have a matching method name.
abstract class Double
include Stubbable
extend StubbedType
Log = Spectator::Log.for(self)
# Defines a test double type.
#
# The *type_name* is the name to give the class.
# Instances of the double can be named by providing a *name*.
# This can be a symbol, string, or even a type.
# See `StubbedName` for details.
#
# After the names, a collection of key-value pairs can be given to quickly define methods.
# Each key is the method name, and the corresponding value is the value returned by the method.
# These methods accept any arguments.
# Additionally, these methods can be overridden later with stubs.
#
# Lastly, a block can be provided to define additional methods and stubs.
# The block is evaluated in the context of the double's class.
#
# ```
# Double.define(SomeDouble, meth1: 42, meth2: "foobar") do
# stub abstract def meth3 : Symbol
#
# # Default implementation with a dynamic value.
# stub def meth4
# Time.utc
# end
# end
# ```
macro define(type_name, name = nil, **value_methods, &block)
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
class {{type_name.id}} < {{@type.name}}
{% for key, value in value_methods %}
default_stub def {{key.id}}(*%args, **%kwargs)
{{value}}
end
default_stub def {{key.id}}(*%args, **%kwargs, &)
{{key.id}}
end
{% end %}
{% if block %}{{block.body}}{% end %}
end
end
@calls = [] of MethodCall
private class_getter _spectator_stubs : Array(Stub) = [] of Stub
class_getter _spectator_calls : Array(MethodCall) = [] of MethodCall
# Creates the double.
#
# An initial set of *stubs* can be provided.
def initialize(@stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub)
end
# Creates the double.
#
# An initial set of stubs can be provided with *value_methods*.
def initialize(**value_methods)
@stubs = value_methods.map do |key, value|
ValueStub.new(key, value).as(Stub)
end
end
# Compares against another object.
#
# Always returns false.
# This method exists as a workaround to provide an alternative to `Object#same?`,
# which only accepts a `Reference` or `Nil`.
def same?(other) : Bool
false
end
# Defines a stub to change the behavior of a method in this double.
#
# NOTE: Defining a stub for a method not defined in the double's type has no effect.
protected def _spectator_define_stub(stub : Stub) : Nil
Log.debug { "Defined stub for #{_spectator_stubbed_name} #{stub}" }
@stubs.unshift(stub)
end
protected def _spectator_clear_stubs : Nil
Log.debug { "Clearing stubs for #{_spectator_stubbed_name}" }
@stubs.clear
end
private def _spectator_find_stub(call : MethodCall) : Stub?
Log.debug { "Finding stub for #{call}" }
stub = @stubs.find &.===(call)
Log.debug { stub ? "Found stub #{stub} for #{call}" : "Did not find stub for #{call}" }
stub
end
def _spectator_stub_for_method?(method : Symbol) : Bool
@stubs.any? { |stub| stub.method == method }
end
def _spectator_record_call(call : MethodCall) : Nil
@calls << call
end
def _spectator_calls
@calls
end
def _spectator_clear_calls : Nil
@calls.clear
end
# Returns the double's name formatted for user output.
private def _spectator_stubbed_name : String
{% if anno = @type.annotation(StubbedName) %}
"#<Double " + {{(anno[0] || :Anonymous.id).stringify}} + ">"
{% else %}
"#<Double Anonymous>"
{% end %}
end
private def self._spectator_stubbed_name : String
{% if anno = @type.annotation(StubbedName) %}
"#<Class Double " + {{(anno[0] || :Anonymous.id).stringify}} + ">"
{% else %}
"#<Class Double Anonymous>"
{% end %}
end
private def _spectator_stub_fallback(call : MethodCall, &)
Log.trace { "Fallback for #{call} - call original" }
yield
end
private def _spectator_stub_fallback(call : MethodCall, type, &)
_spectator_stub_fallback(call) { yield }
end
private def _spectator_abstract_stub_fallback(call : MethodCall)
Log.info do
break unless _spectator_stub_for_method?(call.method)
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
end
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
end
private def _spectator_abstract_stub_fallback(call : MethodCall, type)
_spectator_abstract_stub_fallback(call)
end
# "Hide" existing methods and methods from ancestors by overriding them.
macro finished
stub_type {{@type.name(generic_args: false)}}
end
# Handle all methods but only respond to configured messages.
# Raises an `UnexpectedMessage` error for non-configures messages.
macro method_missing(call)
Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" }
args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %})
call = ::Spectator::MethodCall.new({{call.name.symbolize}}, args)
_spectator_record_call(call)
raise ::Spectator::UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors".
end
end
end

View File

@ -1,49 +0,0 @@
require "../location"
require "./arguments"
require "./stub"
require "./stub_modifiers"
module Spectator
# Stub that raises an exception.
class ExceptionStub < Stub
# Invokes the stubbed implementation.
def call(call : MethodCall) : Nil
raise @exception
end
# Returns a new stub with constrained arguments.
def with_constraint(constraint : AbstractArguments?)
self.class.new(method, @exception, constraint, location)
end
# Creates the stub.
def initialize(method : Symbol, @exception : Exception, constraint : AbstractArguments? = nil, location : Location? = nil)
super(method, constraint, location)
end
end
module StubModifiers
# Returns a new stub that raises an exception.
def and_raise(exception : Exception)
ExceptionStub.new(method, exception, constraint, location)
end
# :ditto:
def and_raise(exception_class : Exception.class, message)
exception = exception_class.new(message)
and_raise(exception)
end
# :ditto:
def and_raise(message : String? = nil)
exception = Exception.new(message)
and_raise(exception)
end
# :ditto:
def and_raise(exception_class : Exception.class)
exception = exception_class.new
and_raise(exception)
end
end
end

View File

@ -1,82 +0,0 @@
require "../label"
require "./arguments"
require "./double"
require "./method_call"
require "./stub"
require "./value_stub"
module Spectator
# Stands in for an object for testing that a SUT calls expected methods.
#
# Handles all messages (method calls), but only responds to those configured.
# Methods called that were not configured will raise `UnexpectedMessage`.
#
# Use `#_spectator_define_stub` to override behavior of a method in the double.
# Only methods defined in the double's type can have stubs.
# New methods are not defines when a stub is added that doesn't have a matching method name.
class LazyDouble(Messages) < Double
@name : String?
def initialize(_spectator_double_name = nil, _spectator_double_stubs = [] of Stub, **@messages : **Messages)
@name = _spectator_double_name.try &.inspect
message_stubs = messages.map do |method, value|
ValueStub.new(method, value)
end
super(_spectator_double_stubs + message_stubs)
end
# Returns the double's name formatted for user output.
private def _spectator_stubbed_name : String
"#<LazyDouble #{@name || "Anonymous"}>"
end
private def _spectator_stub_fallback(call : MethodCall, &)
if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
else
Log.trace { "Fallback for #{call} - call original" }
yield
end
end
# Handles all messages.
macro method_missing(call)
Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" }
# Capture information about the call.
%args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %})
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
_spectator_record_call(%call)
# Attempt to find a stub that satisfies the method call and arguments.
if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original message.
\{% if Messages.keys.includes?({{call.name.symbolize}}) %}
_spectator_cast_stub_value(%stub, %call, \{{Messages[{{call.name.symbolize}}.id]}})
\{% else %}
# A method that was not defined during initialization was stubbed.
# Even though all stubs will have a #call method, the compiler doesn't seem to agree.
# Assert that it will (this should never fail).
raise TypeCastError.new("Stub has no value") unless %stub.responds_to?(:call)
# Return the value of the stub as-is.
# Might want to give a warning here, as this may produce a "bloated" union of all known stub types.
%stub.call(%call)
\{% end %}
else
# A stub wasn't found, invoke the fallback logic.
\{% if Messages.keys.includes?({{call.name.symbolize}}.id) %}
# Pass along the message type and a block to invoke it.
_spectator_stub_fallback(%call, \{{Messages[{{call.name.symbolize}}.id]}}) { @messages[{{call.name.symbolize}}] }
\{% else %}
# Message received for a methods that isn't stubbed nor defined when initialized.
_spectator_abstract_stub_fallback(%call)
nil # Necessary for compiler to infer return type as nil. Avoids runtime "can't execute ... `x` has no type errors".
\{% end %}
end
end
end
end

View File

@ -1,28 +0,0 @@
require "./abstract_arguments"
require "./arguments"
module Spectator
# Stores information about a call to a method.
class MethodCall
# Name of the method.
getter method : Symbol
# Arguments passed to the method.
getter arguments : AbstractArguments
# Creates a method call.
def initialize(@method : Symbol, @arguments : AbstractArguments = Arguments.none)
end
# Creates a method call by splatting its arguments.
def self.capture(method : Symbol, *args, **kwargs)
arguments = Arguments.new(args, kwargs).as(AbstractArguments)
new(method, arguments)
end
# Constructs a string containing the method name and arguments.
def to_s(io : IO) : Nil
io << '#' << method << arguments
end
end
end

View File

@ -1,190 +0,0 @@
require "./method_call"
require "./mocked"
require "./reference_mock_registry"
require "./stub"
require "./stubbed_name"
require "./stubbed_type"
require "./value_mock_registry"
require "./value_stub"
module Spectator
# Module providing macros for defining new mocks from existing types and injecting mock features into concrete types.
module Mock
# Defines a type that inherits from another, existing type.
# The newly defined subtype will have mocking functionality.
#
# Methods from the inherited type will be overridden to support stubs.
# *base* is the keyword for the type being defined - class or struct.
# *mocked_type* is the original type to inherit from.
# *type_name* is the name of the new type to define.
# An optional *name* of the mock can be provided.
# Any key-value pairs provided with *value_methods* are used as initial stubs for the mocked type.
#
# A block can be provided to define additional methods and stubs.
# The block is evaluated in the context of the derived type.
#
# ```
# Mock.define_subtype(:class, SomeType, meth1: 42, meth2: "foobar") do
# stub abstract def meth3 : Symbol
#
# # Default implementation with a dynamic value.
# stub def meth4
# Time.utc
# end
# end
# ```
macro define_subtype(base, mocked_type, type_name, name = nil, **value_methods, &block)
{% begin %}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
{{base.id}} {{type_name.id}} < {{mocked_type.id}}
include ::Spectator::Mocked
extend ::Spectator::StubbedType
{% begin %}
private getter(_spectator_stubs) do
[
{% for key, value in value_methods %}
::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}}),
{% end %}
] of ::Spectator::Stub
end
{% end %}
def _spectator_clear_stubs : Nil
@_spectator_stubs = nil
end
private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub
class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
getter _spectator_calls = [] of ::Spectator::MethodCall
# Returns the mock's name formatted for user output.
private def _spectator_stubbed_name : String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
"#<Mock {{mocked_type.id}}>"
\{% end %}
end
private def self._spectator_stubbed_name : String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Class Mock {{mocked_type.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
"#<Class Mock {{mocked_type.id}}>"
\{% end %}
end
macro finished
stub_type {{mocked_type.id}}
{% if block %}{{block.body}}{% end %}
end
end
{% end %}
end
# Injects mock functionality into an existing type.
#
# Generally this method of mocking should be avoiding.
# It modifies types being tested, the mock functionality won't exist outside of tests.
# This option should only be used when sub-types are not possible (e.g. concrete struct).
#
# Methods in the type will be overridden to support stubs.
# The original method functionality will still be accessible, but pass through mock code first.
# *base* is the keyword for the type being defined - class or struct.
# *type_name* is the name of the type to inject mock functionality into.
# This _must_ be full, resolvable path to the type.
# An optional *name* of the mock can be provided.
# Any key-value pairs provided with *value_methods* are used as initial stubs for the mocked type.
#
# A block can be provided to define additional methods and stubs.
# The block is evaluated in the context of the derived type.
#
# ```
# Mock.inject(:struct, SomeType, meth1: 42, meth2: "foobar") do
# stub abstract def meth3 : Symbol
#
# # Default implementation with a dynamic value.
# stub def meth4
# Time.utc
# end
# end
# ```
macro inject(base, type_name, name = nil, **value_methods, &block)
{% begin %}
{% if name %}@[::Spectator::StubbedName({{name}})]{% end %}
{{base.id}} ::{{type_name.id}}
include ::Spectator::Mocked
extend ::Spectator::StubbedType
{% if base == :class %}
@@_spectator_mock_registry = ::Spectator::ReferenceMockRegistry.new
{% elsif base == :struct %}
@@_spectator_mock_registry = ::Spectator::ValueMockRegistry(self).new
{% else %}
{% raise "Unsupported base type #{base} for injecting mock" %}
{% end %}
private class_getter _spectator_stubs : Array(::Spectator::Stub) = [] of ::Spectator::Stub
class_getter _spectator_calls : Array(::Spectator::MethodCall) = [] of ::Spectator::MethodCall
private def _spectator_stubs
entry = @@_spectator_mock_registry.fetch(self) do
_spectator_default_stubs
end
entry.stubs
end
def _spectator_clear_stubs : Nil
@@_spectator_mock_registry.delete(self)
end
def _spectator_calls
entry = @@_spectator_mock_registry.fetch(self) do
_spectator_default_stubs
end
entry.calls
end
private def _spectator_default_stubs
{% begin %}
[
{% for key, value in value_methods %}
::Spectator::ValueStub.new({{key.id.symbolize}}, {{value}}),
{% end %}
] of ::Spectator::Stub
{% end %}
end
# Returns the mock's name formatted for user output.
private def _spectator_stubbed_name : String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
"#<Mock {{type_name.id}}>"
\{% end %}
end
# Returns the mock's name formatted for user output.
private def self._spectator_stubbed_name : String
\{% if anno = @type.annotation(::Spectator::StubbedName) %}
"#<Class Mock {{type_name.id}} \"" + \{{(anno[0] || :Anonymous.id).stringify}} + "\">"
\{% else %}
"#<Class Mock {{type_name.id}}>"
\{% end %}
end
macro finished
stub_type {{type_name.id}}
{% if block %}{{block.body}}{% end %}
end
end
{% end %}
end
end
end

View File

@ -1,13 +0,0 @@
require "./method_call"
require "./stub"
module Spectator
# Stubs and calls for a mock.
private struct MockRegistryEntry
# Retrieves all stubs defined for a mock.
property stubs = [] of Stub
# Retrieves all calls to stubbed methods.
getter calls = [] of MethodCall
end
end

View File

@ -1,123 +0,0 @@
require "./method_call"
require "./stub"
require "./stubbable"
require "./unexpected_message"
module Spectator
# Mix-in used for mocked types.
#
# Bridges functionality between mocks and stubs
# Implements the abstracts methods from `Stubbable`.
#
# Types including this module will need to implement `#_spectator_stubs`.
# It should return a mutable list of stubs.
# This is used to store the stubs for the mocked type.
#
# Additionally, the `#_spectator_calls` (getter with no arguments) must be implemented.
# It should return a mutable list of method calls.
# This is used to store the calls to stubs for the mocked type.
module Mocked
include Stubbable
# Retrieves an mutable collection of stubs.
abstract def _spectator_stubs
def _spectator_define_stub(stub : ::Spectator::Stub) : Nil
_spectator_stubs.unshift(stub)
end
def _spectator_clear_stubs : Nil
_spectator_stubs.clear
end
private def _spectator_find_stub(call : ::Spectator::MethodCall) : ::Spectator::Stub?
_spectator_stubs.find &.===(call)
end
def _spectator_stub_for_method?(method : Symbol) : Bool
_spectator_stubs.any? { |stub| stub.method == method }
end
def _spectator_record_call(call : MethodCall) : Nil
_spectator_calls << call
end
def _spectator_calls(method : Symbol) : Enumerable(MethodCall)
_spectator_calls.select { |call| call.method == method }
end
def _spectator_clear_calls : Nil
_spectator_calls.clear
end
# Method called when a stub isn't found.
#
# The received message is captured in *call*.
# Yield to call the original method's implementation.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
def _spectator_stub_fallback(call : MethodCall, &)
if _spectator_stub_for_method?(call.method)
Spectator::Log.info do # FIXME: Don't log to top-level Spectator logger (use mock or double logger).
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
end
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
end
yield # Uninteresting message, allow through.
end
# Method called when a stub isn't found.
#
# The received message is captured in *call*.
# The expected return type is provided by *type*.
# Yield to call the original method's implementation.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
def _spectator_stub_fallback(call : MethodCall, type, &)
value = _spectator_stub_fallback(call) { yield }
begin
type.cast(value)
rescue TypeCastError
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `#{value.inspect}`, but returned type must be `#{type}`.")
end
end
# Method called when a stub isn't found.
#
# This is similar to `#_spectator_stub_fallback`,
# but called when the original (un-stubbed) method isn't available.
# The received message is captured in *call*.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
def _spectator_abstract_stub_fallback(call : MethodCall)
Spectator::Log.info do # FIXME: Don't log to top-level Spectator logger (use mock or double logger).
break unless _spectator_stub_for_method?(call.method)
"Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)."
end
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
end
# Method called when a stub isn't found.
#
# This is similar to `#_spectator_stub_fallback`,
# but called when the original (un-stubbed) method isn't available.
# The received message is captured in *call*.
# The expected return type is provided by *type*.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
def _spectator_abstract_stub_fallback(call : MethodCall, type)
value = _spectator_abstract_stub_fallback(call)
begin
type.cast(value)
rescue TypeCastError
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `#{value.inspect}`, but returned type must be `#{type}`.")
end
end
end
end

View File

@ -1,35 +0,0 @@
require "../location"
require "./arguments"
require "./stub_modifiers"
require "./typed_stub"
module Spectator
# Stub that responds with a multiple values in succession.
class MultiValueStub(T) < TypedStub(T)
# Invokes the stubbed implementation.
def call(call : MethodCall) : T
if @values.size == 1
@values.first
else
@values.shift
end
end
# Returns a new stub with constrained arguments.
def with_constraint(constraint : AbstractArguments?)
self.class.new(method, @values, constraint, location)
end
# Creates the stub.
def initialize(method : Symbol, @values : Array(T), constraint : AbstractArguments? = nil, location : Location? = nil)
super(method, constraint, location)
end
end
module StubModifiers
# Returns a new stub that returns multiple values in succession.
def and_return(value, *values)
MultiValueStub.new(method, [value, *values], constraint, location)
end
end
end

View File

@ -1,64 +0,0 @@
require "./double"
require "./method_call"
require "./stubbed_name"
require "./unexpected_message"
module Spectator
# Stands in for an object for testing that a SUT calls expected methods.
#
# Handles all messages (method calls), but only responds to those configured.
# Methods called that were not configured will return self.
# Doubles should be defined with the `#define` macro.
#
# Use `#_spectator_define_stub` to override behavior of a method in the double.
# Only methods defined in the double's type can have stubs.
# New methods are not defines when a stub is added that doesn't have a matching method name.
abstract class NullDouble < Double
# Returns the double's name formatted for user output.
private def _spectator_stubbed_name : String
{% if anno = @type.annotation(StubbedName) %}
"#<NullDouble " + {{(anno[0] || :Anonymous.id).stringify}} + ">"
{% else %}
"#<NullDouble Anonymous>"
{% end %}
end
private def _spectator_abstract_stub_fallback(call : MethodCall)
if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
else
Log.trace { "Fallback for #{call} - return self" }
self
end
end
# Specialization that matches when the return type matches self.
private def _spectator_abstract_stub_fallback(call : MethodCall, _type : self)
_spectator_abstract_stub_fallback(call)
end
# Default implementation that raises a `TypeCastError` since the return type isn't self.
private def _spectator_abstract_stub_fallback(call : MethodCall, type)
if _spectator_stub_for_method?(call.method)
Log.info { "Stubs are defined for #{call.method.inspect}, but none matched (no argument constraints met)." }
raise UnexpectedMessage.new("#{_spectator_stubbed_name} received unexpected message #{call}")
else
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{call} and is attempting to return `self`, but returned type must be `#{type}`.")
end
end
# Handles all undefined messages.
# Returns stubbed values if available, otherwise delegates to `#_spectator_abstract_stub_fallback`.
macro method_missing(call)
Log.trace { "Got undefined method `{{call.name}}({{*call.args}}{% if call.named_args %}{% unless call.args.empty? %}, {% end %}{{*call.named_args}}{% end %}){% if call.block %} { ... }{% end %}`" }
# Capture information about the call.
%args = ::Spectator::Arguments.capture({{call.args.splat(", ")}}{% if call.named_args %}{{*call.named_args}}{% end %})
%call = ::Spectator::MethodCall.new({{call.name.symbolize}}, %args)
_spectator_record_call(%call)
self
end
end
end

View File

@ -1,16 +0,0 @@
require "./typed_stub"
require "./value_stub"
module Spectator
# Stub that does nothing and returns nil.
class NullStub < TypedStub(Nil)
# Invokes the stubbed implementation.
def call(call : MethodCall) : Nil
end
# Returns a new stub with constrained arguments.
def with_constraint(constraint : AbstractArguments?)
self.class.new(method, constraint, location)
end
end
end

View File

@ -1,36 +0,0 @@
require "../location"
require "./arguments"
require "./typed_stub"
module Spectator
# Stub that responds with a value returned by calling a proc.
class ProcStub(T) < TypedStub(T)
# Invokes the stubbed implementation.
def call(call : MethodCall) : T
@proc.call(call.arguments)
end
# Returns a new stub with constrained arguments.
def with_constraint(constraint : AbstractArguments?)
self.class.new(method, @proc, constraint, location)
end
# Creates the stub.
def initialize(method : Symbol, @proc : Proc(AbstractArguments, T), constraint : AbstractArguments? = nil, location : Location? = nil)
super(method, constraint, location)
end
# Creates the stub.
def initialize(method : Symbol, constraint : AbstractArguments? = nil, location : Location? = nil, &block : Proc(AbstractArguments, T))
initialize(method, block, constraint, location)
end
end
module StubModifiers
# Returns a new stub with an argument constraint.
def with(*args, **kwargs, &block : AbstractArguments -> T) forall T
constraint = Arguments.new(args, kwargs).as(AbstractArguments)
ProcStub(T).new(method, block, constraint, location)
end
end
end

View File

@ -1,47 +0,0 @@
require "./mock_registry_entry"
require "./stub"
module Spectator
# Stores collections of stubs for mocked reference (class) types.
#
# This type is intended for all mocked reference types that have functionality "injected."
# That is, the type itself has mock functionality bolted on.
# Adding instance members should be avoided, for instance, it could mess up serialization.
# This registry works around that by mapping mocks (via their memory address) to a collection of stubs.
# Doing so prevents adding data to the mocked type.
class ReferenceMockRegistry
@entries : Hash(Void*, MockRegistryEntry)
# Creates an empty registry.
def initialize
@entries = Hash(Void*, MockRegistryEntry).new do |hash, key|
hash[key] = MockRegistryEntry.new
end
end
# Retrieves all stubs defined for a mocked object.
def [](object : Reference)
key = Box.box(object)
@entries[key]
end
# Retrieves all stubs defined for a mocked object.
#
# Yields to the block on the first retrieval.
# This allows a mock to populate the registry with initial stubs.
def fetch(object : Reference, & : -> Array(Stub))
key = Box.box(object)
@entries.fetch(key) do
entry = MockRegistryEntry.new
entry.stubs = yield
@entries[key] = entry
end
end
# Clears all stubs defined for a mocked object.
def delete(object : Reference) : Nil
key = Box.box(object)
@entries.delete(key)
end
end
end

View File

@ -1,38 +0,0 @@
require "./abstract_arguments"
require "./arguments"
require "./method_call"
require "./stub_modifiers"
module Spectator
# Untyped response to a method call (message).
abstract class Stub
include StubModifiers
# Name of the method this stub is for.
getter method : Symbol
# Arguments the method must have been called with to provide this response.
# Is nil when there's no constraint - only the method name must match.
getter constraint : AbstractArguments?
# Location the stub was defined.
getter location : Location?
# Creates the base of the stub.
def initialize(@method : Symbol, @constraint : AbstractArguments? = nil, @location : Location? = nil)
end
# Checks if a method call should receive the response from this stub.
def ===(call : MethodCall)
return false if method != call.method
return true unless constraint = @constraint
constraint === call.arguments
end
# String representation of the stub, formatted as a method call.
def to_s(io : IO) : Nil
io << "#" << method << (constraint || "(any args)")
end
end
end

View File

@ -1,21 +0,0 @@
require "./arguments"
module Spectator
# Mixin intended for `Stub` to return new, modified stubs.
module StubModifiers
# Returns a new stub of the same type with constrained arguments.
abstract def with_constraint(constraint : AbstractArguments?)
# :ditto:
@[AlwaysInline]
def with(constraint : AbstractArguments?)
with_constraint(constraint)
end
# :ditto:
def with(*args, **kwargs)
constraint = Arguments.new(args, kwargs).as(AbstractArguments)
self.with_constraint(constraint)
end
end
end

View File

@ -1,443 +0,0 @@
require "../dsl/reserved"
require "./arguments"
require "./method_call"
require "./stub"
require "./typed_stub"
module Spectator
# Mix-in for mocks and doubles providing method stubs.
#
# Macros in this module can override existing methods.
# Stubbed methods will look for stubs to evaluate in place of their original functionality.
# The primary macro of interest is `#stub`.
# The macros are intended to be called from within the type being stubbed.
#
# Types including this module must define `#_spectator_find_stub` and `#_spectator_stubbed_name`.
# These are internal, reserved method names by Spectator, hence the `_spectator` prefix.
# These methods can't (and shouldn't) be stubbed.
module Stubbable
# Attempts to find a stub that satisfies a method call.
#
# Returns a stub that matches the method *call*
# or nil if no stubs satisfy it.
abstract def _spectator_find_stub(call : MethodCall) : Stub?
# Utility method that looks for stubs for methods with the name specified.
abstract def _spectator_stub_for_method?(method : Symbol) : Bool
# Defines a stub to change the behavior of a method.
abstract def _spectator_define_stub(stub : Stub) : Nil
# Clears all previously defined stubs.
abstract def _spectator_clear_stubs : Nil
# Saves a call that was made to a stubbed method.
abstract def _spectator_record_call(call : MethodCall) : Nil
# Retrieves all previously saved calls.
abstract def _spectator_calls
# Clears all previously saved calls.
abstract def _spectator_clear_calls : Nil
# Method called when a stub isn't found.
#
# The received message is captured in *call*.
# Yield to call the original method's implementation.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_stub_fallback(call : MethodCall, &)
# Method called when a stub isn't found.
#
# The received message is captured in *call*.
# The expected return type is provided by *type*.
# Yield to call the original method's implementation.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_stub_fallback(call : MethodCall, type, &)
# Method called when a stub isn't found.
#
# This is similar to `#_spectator_stub_fallback`,
# but called when the original (un-stubbed) method isn't available.
# The received message is captured in *call*.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_abstract_stub_fallback(call : MethodCall)
# Method called when a stub isn't found.
#
# This is similar to `#_spectator_stub_fallback`,
# but called when the original (un-stubbed) method isn't available.
# The received message is captured in *call*.
# The expected return type is provided by *type*.
# The stubbed method returns the value returned by this method.
# This method can also raise an error if it's impossible to return something.
abstract def _spectator_abstract_stub_fallback(call : MethodCall, type)
# Utility method returning the stubbed type's name formatted for user output.
abstract def _spectator_stubbed_name : String
# Clears all previously defined calls and stubs.
def _spectator_reset : Nil
_spectator_clear_calls
_spectator_clear_stubs
end
# Redefines a method to accept stubs and provides a default response.
#
# The *method* must be a `Def`.
# That is, a normal looking method definition should follow the `default_stub` keyword.
#
# ```
# default_stub def stubbed_method
# "foobar"
# end
# ```
#
# The method cannot be abstract, as this method requires a default (fallback) response if a stub isn't provided.
#
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
# If no stub is found, then `#_spectator_stub_fallback` is called.
# The block provided to `#_spectator_stub_fallback` will invoke the default response.
# In other words, `#_spectator_stub_fallback` should yield if it's appropriate to return the default response.
private macro default_stub(method)
{% if method.is_a?(Def)
visibility = method.visibility
elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def)
visibility = method.visibility
method = method.exp
else
raise "`default_stub` requires a method definition"
end %}
{% raise "Cannot define a stub inside a method" if @def %}
{% raise "Default stub cannot be an abstract method" if method.abstract? %}
{% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %}
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
{{method.body}}
end
{% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %}
{% # Reconstruct the method signature.
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
# This chunk of code must reconstruct the method signature exactly as it was originally.
# If it doesn't match, it doesn't override the method and the stubbing won't work.
%}
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
# Capture information about the call.
%args = ::Spectator::Arguments.capture(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}{% end %}
)
%call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args)
_spectator_record_call(%call)
# Attempt to find a stub that satisfies the method call and arguments.
# Finding a suitable stub is delegated to the type including the `Stubbable` module.
if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method.
_spectator_cast_stub_value(%stub, %call, typeof({{original}}),
{{ if method.return_type && method.return_type.resolve == NoReturn
:no_return
elsif method.return_type && method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)
:nil
else
:raise
end }})
else
# Delegate missing stub behavior to concrete type.
_spectator_stub_fallback(%call, typeof({{original}})) do
# Use the default response for the method.
{{original}}
end
end
end
end
# Redefines a method to require stubs.
#
# This macro is similar to `#default_stub` but requires that a stub is defined for the method if it's called.
#
# The *method* should be a `Def`.
# That is, a normal looking method definition should follow the `stub` keyword.
#
# ```
# abstract_stub def stubbed_method
# "foobar"
# end
# ```
#
# The method being stubbed doesn't need to exist yet.
# Its body of the method passed to this macro is ignored.
# The method can be abstract.
# It should have a return type annotation, otherwise the compiled return type will probably end up as a giant union.
#
# ```
# abstract_stub abstract def stubbed_method : String
# ```
#
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
# If no stub is found, then `#_spectator_stub_fallback` or `#_spectator_abstract_stub_fallback` is called.
private macro abstract_stub(method)
{% if method.is_a?(Def)
visibility = method.visibility
elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def)
visibility = method.visibility
method = method.exp
else
raise "`abstract_stub` requires a method definition"
end %}
{% raise "Cannot define a stub inside a method" if @def %}
{% raise "Cannot stub method with reserved keyword as name - #{method.name}" if method.name.starts_with?("_spectator") || ::Spectator::DSL::RESERVED_KEYWORDS.includes?(method.name.symbolize) %}
{% # The logic in this macro follows mostly the same logic from `#default_stub`.
# The main difference is that this macro cannot access the original method being stubbed.
# It might exist or it might not.
# The method could also be abstract.
# For all intents and purposes, this macro defines logic that doesn't depend on an existing method.
%}
{% unless method.abstract? %}
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
{{method.body}}
end
{% original = "previous_def#{" { |*_spectator_yargs| yield *_spectator_yargs }".id if method.accepts_block?}".id %}
{% end %}
{% # Reconstruct the method signature.
# I wish there was a better way of doing this, but there isn't (at least not that I'm aware of).
# This chunk of code must reconstruct the method signature exactly as it was originally.
# If it doesn't match, it doesn't override the method and the stubbing won't work.
%}
{{visibility.id if visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
# Capture information about the call.
%args = ::Spectator::Arguments.capture(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg.internal_name}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}{% end %}
)
%call = ::Spectator::MethodCall.new({{method.name.symbolize}}, %args)
_spectator_record_call(%call)
# Attempt to find a stub that satisfies the method call and arguments.
# Finding a suitable stub is delegated to the type including the `Stubbable` module.
if %stub = _spectator_find_stub(%call)
# Cast the stub or return value to the expected type.
# This is necessary to match the expected return type of the original method.
{% if method.return_type %}
# Return type restriction takes priority since it can be a superset of the original implementation.
_spectator_cast_stub_value(%stub, %call, {{method.return_type}},
{{ if method.return_type.resolve == NoReturn
:no_return
elsif method.return_type.resolve <= Nil || method.return_type.is_a?(Union) && method.return_type.types.map(&.resolve).includes?(Nil)
:nil
else
:raise
end }})
{% elsif !method.abstract? %}
# The method isn't abstract, infer the type it returns without calling it.
_spectator_cast_stub_value(%stub, %call, typeof({{original}}))
{% else %}
# Stubbed method is abstract and there's no return type annotation.
# The value of the stub could be returned as-is.
# This may produce a "bloated" union of all known stub types,
# and generally causes more annoying problems.
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{%call} but cannot resolve the return type. Please add a return type restriction.")
{% end %}
else
# A stub wasn't found, invoke the type-specific fallback logic.
{% if method.return_type %}
# Pass along just the return type annotation.
_spectator_abstract_stub_fallback(%call, {{method.return_type}})
{% elsif !method.abstract? %}
_spectator_abstract_stub_fallback(%call, typeof({{original}}))
{% else %}
# Stubbed method is abstract and there's no type annotation.
_spectator_abstract_stub_fallback(%call)
{% end %}
end
end
end
# Redefines a method to require stubs.
#
# The *method* can be a `Def`.
# That is, a normal looking method definition should follow the `stub` keyword.
#
# ```
# stub def stubbed_method
# "foobar"
# end
# ```
#
# If the *method* is abstract, then a stub must be provided otherwise attempts to call the method will raise `UnexpectedMessage`.
#
# ```
# stub abstract def stubbed_method
# ```
#
# A `Call` can also be specified.
# In this case all methods in the stubbed type and its ancestors that match the call's signature are stubbed.
#
# ```
# stub stubbed_method(arg)
# ```
#
# The method being stubbed doesn't need to exist yet.
# Stubbed methods will call `#_spectator_find_stub` with the method call information.
# If no stub is found, then `#_spectator_stub_fallback` or `#_spectator_abstract_stub_fallback` is called.
macro stub(method)
{% raise "Cannot define a stub inside a method" if @def %}
{% if method.is_a?(Def) %}
{% if method.abstract? %}abstract_stub{% else %}default_stub{% end %} {{method}}
{% elsif method.is_a?(VisibilityModifier) && method.exp.is_a?(Def) %}
{% if method.exp.abstract? %}abstract_stub{% else %}default_stub{% end %} {{method}}
{% elsif method.is_a?(Call) %}
{% raise "Stub on `Call` unsupported." %}
{% else %}
{% raise "Unrecognized syntax for `stub` - #{method}" %}
{% end %}
end
# Redefines all methods and ones inherited from its parents and mixins to support stubs.
private macro stub_type(type_name = @type)
{% type = type_name.resolve
# Reverse order of ancestors (there's currently no reverse method for ArrayLiteral).
count = type.ancestors.size
ancestors = type.ancestors.map_with_index { |_, i| type.ancestors[count - i - 1] } %}
{% for ancestor in ancestors %}
{% for method in ancestor.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
end %}
{{(method.abstract? ? :abstract_stub : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
end
{% end %}
{% for method in ancestor.class.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
end %}
default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
super{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
end
{% end %}
{% end %}
{% for method in type.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
end %}
{{(method.abstract? ? :"abstract_stub abstract" : :default_stub).id}} {{method.visibility.id if method.visibility != :public}} def {{method.receiver && "#{method.receiver}.".id}}{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
{% unless method.abstract? %}
{% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
end
{% end %}
{% end %}
{% for method in type.class.methods.reject do |meth|
meth.name.starts_with?("_spectator") ||
::Spectator::DSL::RESERVED_KEYWORDS.includes?(meth.name.symbolize)
end %}
default_stub {{method.visibility.id if method.visibility != :public}} def self.{{method.name}}(
{% for arg, i in method.args %}{% if i == method.splat_index %}*{% end %}{{arg}}, {% end %}
{% if method.double_splat %}**{{method.double_splat}}, {% end %}
{% if method.block_arg %}&{{method.block_arg}}{% elsif method.accepts_block? %}&{% end %}
){% if method.return_type %} : {{method.return_type}}{% end %}{% if !method.free_vars.empty? %} forall {{method.free_vars.splat}}{% end %}
{% if type == @type %}previous_def{% else %}super{% end %}{% if method.accepts_block? %} { |*%yargs| yield *%yargs }{% end %}
end
{% end %}
end
# Utility macro for casting a stub (and it's return value) to the correct type.
#
# *stub* is the variable holding the stub.
# *call* is the variable holding the captured method call.
# *type* is the expected type to cast the value to.
# *fail_cast* indicates the behavior used when the value returned by the stub can't be cast to *type*.
# - `:nil` - return nil.
# - `:raise` - raise a `TypeCastError`.
# - `:no_return` - raise as no value should be returned.
private macro _spectator_cast_stub_value(stub, call, type, fail_cast = :nil)
# Attempt to cast the stub to the method's return type.
# If successful, return the value of the stub.
# This is a common usage where the return type is simple and matches the stub type exactly.
if %typed = {{stub}}.as?(::Spectator::TypedStub({{type}}))
%typed.call({{call}})
else
# The stub couldn't be easily cast to match the return type.
# Even though all stubs will have a `#call` method, the compiler doesn't seem to agree.
# Assert that it will (this should never fail).
raise TypeCastError.new("Stub has no value") unless {{stub}}.responds_to?(:call)
{% if fail_cast == :no_return %}
{{stub}}.call({{call}})
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a value, but it shouldn't have returned (`NoReturn`).")
{% else %}
# Get the value as-is from the stub.
# This will be compiled as a union of all known stubbed value types.
%value = {{stub}}.call({{call}})
# Attempt to cast the value to the method's return type.
# If successful, which it will be in most cases, return it.
# The caller will receive a properly typed value without unions or other side-effects.
if %cast = %value.as?({{type}})
%cast
else
{% if fail_cast == :nil %}
nil
{% elsif fail_cast == :raise %}
# The stubbed value was something else entirely and cannot be cast to the return type.
# There's something weird going on (compiler bug?) that sometimes causes this class lookup to fail.
%type = begin
%value.class.to_s
rescue
"<Unknown>"
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
end
end

Some files were not shown because too many files have changed in this diff Show More