Compare commits

...

35 Commits

Author SHA1 Message Date
Michael Miller 287758e6af
Update README to point at v0.12.0 2024-02-03 08:05:02 -07:00
Michael Miller f39ceb8eba
Release v0.12.0 2024-02-03 07:56:57 -07:00
Michael Miller 9b1d400ee1
Update CHANGELOG 2024-01-27 11:29:11 -07:00
Michael Miller edb20e5b2f
Additional handling when comparing ranges against unexpected types 2024-01-27 11:25:59 -07:00
Michael Miller 526a998e41
Shorten compare_values case statements 2024-01-27 11:25:25 -07:00
Michael Miller 556d4783bf
Support case equality of tuples, arrays, named tuples, and hashes in stub argument matching 2024-01-27 11:18:10 -07:00
Michael Miller b5fbc96195
Allow matchers to be used in case equality 2024-01-27 11:17:19 -07:00
Michael Miller 5520999b6d
Add spec for GitHub issue 55
https://github.com/icy-arctic-fox/spectator/issues/55
2024-01-27 11:16:57 -07:00
Michael Miller 4a630b1ebf
Bump version to v0.11.7 2023-10-16 17:34:49 -06:00
Michael Miller d72895fe10
Merge branch 'stufro-fix-readme-mocking-example' 2023-05-21 09:58:19 -06:00
Stuart Frost 04f151fddf Fix mocking example in README.md 2023-05-19 19:39:22 +01:00
Michael Miller 9cbb5d2cf7
Workaround issue using Box with union
Addresses issue found relating to https://gitlab.com/arctic-fox/spectator/-/issues/81
See https://github.com/crystal-lang/crystal/issues/11839
2023-03-27 18:37:50 -06:00
Mike Miller 3852606b28 Merge branch 'gh-49' into 'master'
Fix splat argument expansion in method redefinition

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

2
.gitignore vendored
View File

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

View File

@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.12.0] - 2024-02-03
### Added
- Added ability to use matchers for case equality. [#55](https://github.com/icy-arctic-fox/spectator/issues/55)
- Added support for nested case equality when checking arguments with Array, Tuple, Hash, and NamedTuple.
### Fixed
- Fixed some issues with the `be_within` matcher when used with expected and union types.
## [0.11.7] - 2023-10-16
### Fixed
- Fix memoized value (`let`) with a union type causing segfault. [#81](https://gitlab.com/arctic-fox/spectator/-/issues/81)
## [0.11.6] - 2023-01-26
### Added
- Added ability to cast types using the return value from expect/should statements with a type matcher.
- Added support for string interpolation in context names/labels.
### Fixed
- Fix invalid syntax (unterminated call) when recording calls to stubs with an un-named splat. [#51](https://github.com/icy-arctic-fox/spectator/issues/51)
- Fix malformed method signature when using named splat with keyword arguments in mocked type. [#49](https://github.com/icy-arctic-fox/spectator/issues/49)
### Changed
- Expectations using 'should' syntax report file and line where the 'should' keyword is instead of the test start.
- Add non-captured block argument in preparation for Crystal 1.8.0.
## [0.11.5] - 2022-12-18
### Added
- Added support for mock modules and types that include mocked modules.
@ -433,7 +458,10 @@ This has been changed so that it compiles and raises an error at runtime with a
First version ready for public use.
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...master
[Unreleased]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.12.0...master
[0.12.0]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.7...v0.12.0
[0.11.7]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.6...v0.11.7
[0.11.6]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.5...v0.11.6
[0.11.5]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.4...v0.11.5
[0.11.4]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.3...v0.11.4
[0.11.3]: https://gitlab.com/arctic-fox/spectator/-/compare/v0.11.2...v0.11.3

View File

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

View File

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

View File

@ -0,0 +1,70 @@
require "../spec_helper"
Spectator.describe "Expect Type", :smoke do
context "with expect syntax" do
it "ensures a type is cast" do
value = 42.as(String | Int32)
expect(value).to be_a(String | Int32)
expect(value).to compile_as(String | Int32)
value = expect(value).to be_a(Int32)
expect(value).to eq(42)
expect(value).to be_a(Int32)
expect(value).to compile_as(Int32)
expect(value).to_not respond_to(:downcase)
end
it "ensures a type is not nil" do
value = 42.as(Int32?)
expect(value).to be_a(Int32?)
expect(value).to compile_as(Int32?)
value = expect(value).to_not be_nil
expect(value).to eq(42)
expect(value).to be_a(Int32)
expect(value).to compile_as(Int32)
expect { value.not_nil! }.to_not raise_error(NilAssertionError)
end
it "removes types from a union" do
value = 42.as(String | Int32)
expect(value).to be_a(String | Int32)
expect(value).to compile_as(String | Int32)
value = expect(value).to_not be_a(String)
expect(value).to eq(42)
expect(value).to be_a(Int32)
expect(value).to compile_as(Int32)
expect(value).to_not respond_to(:downcase)
end
end
context "with should syntax" do
it "ensures a type is cast" do
value = 42.as(String | Int32)
value.should be_a(String | Int32)
value = value.should be_a(Int32)
value.should eq(42)
value.should be_a(Int32)
value.should compile_as(Int32)
value.should_not respond_to(:downcase)
end
it "ensures a type is not nil" do
value = 42.as(Int32?)
value.should be_a(Int32?)
value = value.should_not be_nil
value.should eq(42)
value.should be_a(Int32)
value.should compile_as(Int32)
expect { value.not_nil! }.to_not raise_error(NilAssertionError)
end
it "removes types from a union" do
value = 42.as(String | Int32)
value.should be_a(String | Int32)
value = value.should_not be_a(String)
value.should eq(42)
value.should be_a(Int32)
value.should compile_as(Int32)
value.should_not respond_to(:downcase)
end
end
end

View File

@ -0,0 +1,22 @@
require "../spec_helper"
Spectator.describe "Interpolated Label", :smoke do
let(foo) { "example" }
let(bar) { "context" }
it "interpolates #{foo} labels" do |example|
expect(example.name).to eq("interpolates example labels")
end
context "within a #{bar}" do
let(foo) { "multiple" }
it "interpolates context labels" do |example|
expect(example.group.name).to eq("within a context")
end
it "interpolates #{foo} levels" do |example|
expect(example.name).to eq("interpolates multiple levels")
end
end
end

View File

@ -0,0 +1,6 @@
require "../spec_helper"
# https://github.com/icy-arctic-fox/spectator/issues/49
Spectator.describe "GitHub Issue #49" do
# mock File
end

View File

@ -0,0 +1,48 @@
require "../spec_helper"
Spectator.describe "GitHub Issue #55" do
GROUP_NAME = "CallCenter"
let(name) { "TimeTravel" }
let(source) { "my.time.travel.experiment" }
class Analytics(T)
property start_time = Time.local
property end_time = Time.local
def initialize(@brain_talker : T)
end
def instrument(*, name, source, &)
@brain_talker.send(payload: {
:group => GROUP_NAME,
:name => name,
:source => source,
:start => start_time,
:end => end_time,
}, action: "analytics")
end
end
double(:brain_talker, send: nil)
let(brain_talker) { double(:brain_talker) }
let(analytics) { Analytics.new(brain_talker) }
it "tracks the time it takes to run the block" do
analytics.start_time = expected_start_time = Time.local
expected_end_time = expected_start_time + 10.seconds
analytics.end_time = expected_end_time + 0.5.seconds # Offset to ensure non-exact match.
analytics.instrument(name: name, source: source) do
end
expect(brain_talker).to have_received(:send).with(payload: {
:group => GROUP_NAME,
:name => name,
:source => source,
:start => expected_start_time,
:end => be_within(1.second).of(expected_end_time),
}, action: "analytics")
end
end

View File

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

View File

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

@ -297,7 +297,7 @@ Spectator.describe Spectator::Double do
arg
end
stub def self.baz(arg)
stub def self.baz(arg, &)
yield
end
end

View File

@ -364,7 +364,7 @@ Spectator.describe Spectator::Mock do
arg
end
def self.baz(arg)
def self.baz(arg, &)
yield
end
@ -929,7 +929,7 @@ Spectator.describe Spectator::Mock do
arg
end
def self.baz(arg)
def self.baz(arg, &)
yield
end
end

View File

@ -259,7 +259,7 @@ Spectator.describe Spectator::NullDouble do
arg
end
stub def self.baz(arg)
stub def self.baz(arg, &)
yield
end
end

View File

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

View File

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

View File

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

View File

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

View File

@ -118,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
@ -164,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
@ -184,7 +184,7 @@ module Spectator
end
# Yields this example and all parent groups.
def ascend
def ascend(&)
node = self
while node
yield node
@ -279,7 +279,7 @@ module Spectator
# The block given to this method will be executed within the test context.
#
# TODO: Benchmark compiler performance using this method versus client-side casting in a proc.
protected def with_context(klass)
protected def with_context(klass, &)
context = @example.cast_context(klass)
with context yield
end

View File

@ -87,7 +87,7 @@ module Spectator
delegate size, unsafe_fetch, to: @nodes
# Yields this group and all parent groups.
def ascend
def ascend(&)
group = self
while group
yield group

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ module Spectator
end
# Utility method for comparing two tuples considering special types.
private def compare_tuples(a : Tuple, b : Tuple)
private def compare_tuples(a : Tuple | Array, b : Tuple | Array)
return false if a.size != b.size
a.zip(b) do |a_value, b_value|
@ -18,14 +18,14 @@ module Spectator
# Utility method for comparing two tuples considering special types.
# Supports nilable tuples (ideal for splats).
private def compare_tuples(a : Tuple?, b : Tuple?)
private def compare_tuples(a : Tuple? | Array?, b : Tuple? | Array?)
return false if a.nil? ^ b.nil?
compare_tuples(a.not_nil!, b.not_nil!)
end
# Utility method for comparing two named tuples ignoring order.
private def compare_named_tuples(a : NamedTuple, b : NamedTuple)
private def compare_named_tuples(a : NamedTuple | Hash, b : NamedTuple | Hash)
a.each do |k, v1|
v2 = b.fetch(k) { return false }
return false unless compare_values(v1, v2)
@ -45,11 +45,14 @@ module Spectator
when Range
# Ranges can only be matched against if their right side is comparable.
# Ensure the right side is comparable, otherwise compare directly.
if b.is_a?(Comparable(typeof(b)))
a === b
else
a == b
end
return a === b if b.is_a?(Comparable(typeof(b)))
a == b
when Tuple, Array
return compare_tuples(a, b) if b.is_a?(Tuple) || b.is_a?(Array)
a === b
when NamedTuple, Hash
return compare_named_tuples(a, b) if b.is_a?(NamedTuple) || b.is_a?(Hash)
a === b
else
a === b
end

View File

@ -133,13 +133,12 @@ module Spectator
if method.splat_index
method.args.each_with_index do |arg, i|
if i == method.splat_index
original += '*'
if arg.internal_name && arg.internal_name.size > 0
original += "#{arg.internal_name}, "
original += "*#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
elsif i > method.splat_index
original += "#{arg.name}: #{arg.internal_name}"
original += "#{arg.name}: #{arg.internal_name}, "
else
original += "#{arg.internal_name}, "
end
@ -180,7 +179,7 @@ module Spectator
::NamedTuple.new(
{% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
),
{% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %}
{% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new(
{% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
).merge({{method.double_splat}})
@ -283,9 +282,8 @@ module Spectator
if method.splat_index
method.args.each_with_index do |arg, i|
if i == method.splat_index
original += '*'
if arg.internal_name && arg.internal_name.size > 0
original += "#{arg.internal_name}, "
original += "*#{arg.internal_name}, "
end
original += "**#{method.double_splat}, " if method.double_splat
elsif i > method.splat_index
@ -332,7 +330,7 @@ module Spectator
::NamedTuple.new(
{% for arg, i in method.args %}{% if !method.splat_index || i < method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
),
{% if method.splat_index && (splat = method.args[method.splat_index].internal_name) %}{{splat.symbolize}}, {{splat}},{% end %}
{% if method.splat_index && !(splat = method.args[method.splat_index].internal_name).empty? %}{{splat.symbolize}}, {{splat}},{% end %}
::NamedTuple.new(
{% for arg, i in method.args %}{% if method.splat_index && i > method.splat_index %}{{arg.internal_name.stringify}}: {{arg.internal_name}}, {% end %}{% end %}
).merge({{method.double_splat}})
@ -539,6 +537,7 @@ module Spectator
# Get the value as-is from the stub.
# This will be compiled as a union of all known stubbed value types.
%value = {{stub}}.call({{call}})
%type = {{type}}
# Attempt to cast the value to the method's return type.
# If successful, which it will be in most cases, return it.
@ -549,12 +548,12 @@ module Spectator
%cast
{% elsif fail_cast == :raise %}
# Check if nil was returned by the stub and if its okay to return it.
if %value.nil? && {{type}}.nilable?
if %value.nil? && %type.nilable?
# Value was nil and nil is allowed to be returned.
%cast.as({{type}})
%type.cast(%cast)
elsif %cast.nil?
# The stubbed value was something else entirely and cannot be cast to the return type.
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{ {{type}} }`.")
raise TypeCastError.new("#{_spectator_stubbed_name} received message #{ {{call}} } and is attempting to return a `#{%value.class}`, but returned type must be `#{%type}`.")
else
# Types match and value can be returned as cast type.
%cast

View File

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

View File

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

View File

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

View File

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

104
test.cr
View File

@ -1,104 +0,0 @@
require "./src/spectator"
module Thing
def self.original_method
:original
end
def self.default_method
:original
end
def self.stubbed_method(_value = 42)
:original
end
macro finished
def self.debug
{% begin %}puts "Methods: ", {{@type.methods.map &.name.stringify}} of String{% end %}
{% begin %}puts "Class Methods: ", {{@type.class.methods.map &.name.stringify}} of String{% end %}
end
end
end
Thing.debug
# Spectator::Mock.define_subtype(:module, Thing, MockThing, default_method: :default) do
# stub def stubbed_method(_value = 42)
# :stubbed
# end
# end
# Spectator.describe "Mock modules" do
# 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.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

7
util/nightly.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env sh
set -e
readonly image=crystallang/crystal:nightly
readonly code=/project
docker run -it -v "$PWD:${code}" -w "${code}" "${image}" crystal spec "$@"