-
-
Notifications
You must be signed in to change notification settings - Fork 135
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
This commit introduces an RSpec extension for Dry::Monads, providing: - Matchers for success, failure, some, and none monad types - Constructors for creating monad instances in specs - Support for catching missing constants in spec files
- Loading branch information
1 parent
342cc3c
commit 4e58fcf
Showing
9 changed files
with
391 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
# frozen_string_literal: true | ||
|
||
Dry::Monads.extend(Dry::Core::Extensions) | ||
|
||
Dry::Monads.register_extension(:rspec) do | ||
require "dry/monads/extensions/rspec" | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,187 @@ | ||
# frozen_string_literal: true | ||
|
||
debug_inspector_available = | ||
begin | ||
require "debug_inspector" | ||
defined?(DebugInspector) | ||
rescue LoadError | ||
false | ||
end | ||
|
||
module Dry | ||
module Monads | ||
module RSpec | ||
module Matchers | ||
extend ::RSpec::Matchers::DSL | ||
|
||
{ | ||
failure: { | ||
expected_classes: [ | ||
::Dry::Monads::Result::Failure, | ||
::Dry::Monads::Maybe::None, | ||
::Dry::Monads::Try::Error | ||
], | ||
extract_value: :failure.to_proc | ||
}, | ||
success: { | ||
expected_classes: [ | ||
::Dry::Monads::Result::Success, | ||
::Dry::Monads::Maybe::Some, | ||
::Dry::Monads::Try::Value | ||
], | ||
extract_value: :value!.to_proc | ||
}, | ||
some: { | ||
expected_classes: [ | ||
::Dry::Monads::Maybe::Some | ||
], | ||
extract_value: :value!.to_proc | ||
} | ||
}.each do |name, args| | ||
args => { expected_classes:, extract_value: } | ||
expected_constructors = expected_classes.map(&:name).map do |c| | ||
c.split("::").last | ||
end | ||
|
||
matcher :"be_#{name}" do |expected = Undefined| | ||
match do |actual| | ||
if expected_classes.any? { |klass| actual.is_a?(klass) } | ||
exact_match = actual.is_a?(expected_classes[0]) | ||
|
||
if exact_match && block_arg | ||
block_arg.call(extract_value.call(actual)) | ||
elsif Undefined.equal?(expected) | ||
true | ||
elsif exact_match | ||
extract_value.call(actual) == expected | ||
else | ||
false | ||
end | ||
else | ||
false | ||
end | ||
end | ||
|
||
failure_message do |actual| | ||
if expected_classes.none? { |klass| actual.is_a?(klass) } | ||
if expected_classes.size > 1 | ||
"expected #{actual.inspect} to be one of the following values: " \ | ||
"#{expected_constructors.join(", ")}, but it's #{actual.class}" | ||
else | ||
"expected #{actual.inspect} to be a #{expected_constructors[0]} value, " \ | ||
"but it's #{actual.class}" | ||
end | ||
elsif actual.is_a?(expected_classes[0]) && block_arg | ||
"expected #{actual.inspect} to have a value satisfying the given block" | ||
else | ||
"expected #{actual.inspect} to have value #{expected.inspect}, " \ | ||
"but it was #{extract_value.call(actual).inspect}" | ||
end | ||
end | ||
|
||
failure_message_when_negated do |actual| | ||
if expected_classes.size > 1 | ||
"expected #{actual.inspect} to not be one of the following values: " \ | ||
"#{expected_constructors.join(", ")}, but it is" | ||
else | ||
"expected #{actual.inspect} to not be a #{expected_constructors[0]} value, " \ | ||
"but it is" | ||
end | ||
end | ||
end | ||
end | ||
|
||
matcher :be_none do | ||
match do |actual| | ||
actual.is_a?(::Dry::Monads::Maybe::None) | ||
end | ||
|
||
failure_message do |actual| | ||
"expected #{actual.inspect} to be none" | ||
end | ||
|
||
failure_message_when_negated do |actual| | ||
"expected #{actual.inspect} to not be none" | ||
end | ||
end | ||
end | ||
|
||
Constructors = Monads[:result, :maybe] | ||
|
||
CONSTANTS = %i[Success Failure Some None].to_set | ||
|
||
NESTED_CONSTANTS = CONSTANTS.to_set { |c| "::#{c}" } | ||
|
||
class << self | ||
def resolve_constant_name(name) | ||
if CONSTANTS.include?(name) | ||
name | ||
elsif NESTED_CONSTANTS.any? { |c| name.to_s.end_with?(c) } | ||
name[/::(\w+)$/, 1].to_sym | ||
else | ||
nil | ||
end | ||
end | ||
|
||
def name_to_const(name) | ||
case name | ||
in :Success | ||
Dry::Monads::Result::Success | ||
in :Failure | ||
Dry::Monads::Result::Failure | ||
in :Some | ||
Dry::Monads::Maybe::Some | ||
in :None | ||
Dry::Monads::Maybe::None | ||
end | ||
end | ||
end | ||
end | ||
end | ||
end | ||
|
||
catch_missing_const = Module.new do | ||
if debug_inspector_available | ||
def const_missing(name) | ||
const_name = Dry::Monads::RSpec.resolve_constant_name(name) | ||
|
||
if const_name | ||
DebugInspector.open do |dc| | ||
if dc.frame_binding(2).receiver.is_a?(RSpec::Core::ExampleGroup) | ||
Dry::Monads::RSpec.name_to_const(const_name) | ||
else | ||
super | ||
end | ||
end | ||
else | ||
super | ||
end | ||
end | ||
else | ||
def const_missing(name) | ||
const_name = Dry::Monads::RSpec.resolve_constant_name(name) | ||
|
||
if const_name && caller_locations(1, 1).first.path.end_with?("_spec.rb") | ||
Dry::Monads::RSpec.name_to_const(const_name) | ||
else | ||
super | ||
end | ||
end | ||
end | ||
|
||
define_method(:include) do |*modules| | ||
super(*modules).tap do | ||
modules.each do |m| | ||
m.extend(catch_missing_const) unless m.frozen? | ||
end | ||
end | ||
end | ||
end | ||
|
||
Object.extend(catch_missing_const) | ||
|
||
RSpec.configure do |config| | ||
config.include Dry::Monads::RSpec::Matchers | ||
config.include Dry::Monads::RSpec::Constructors | ||
config.extend(catch_missing_const) | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
# frozen_string_literal: true | ||
|
||
require_relative "../fixtures/rspec_ext_helper" | ||
|
||
RSpec.describe "RSpec extension" do | ||
before(:all) do | ||
Dry::Monads.load_extensions(:rspec) | ||
end | ||
|
||
it "adds constructors and matchers" do | ||
expect(Success(1)).to be_success | ||
expect(Success(1)).to be_success(1) | ||
expect(Success(1)).not_to be_success(2) | ||
expect(Failure(1)).to be_failure | ||
expect(Failure(1)).to be_failure(1) | ||
expect(Failure(1)).not_to be_failure(2) | ||
expect(Some(1)).to be_some | ||
expect(Some(1)).to be_some(1) | ||
expect(Some(1)).not_to be_some(2) | ||
expect(None()).to be_none | ||
expect(None()).not_to be_some | ||
expect(None()).not_to be_some(1) | ||
end | ||
|
||
it "catches missing constants" do | ||
expect(Success(1)).to be_a(Success) | ||
expect(Failure(1)).to be_a(Failure) | ||
expect(Some(1)).to be_a(Some) | ||
expect(None()).to be_a(None) | ||
end | ||
|
||
context "patten matching" do | ||
example "with result" do | ||
success = Success(1) | ||
success => Success(value) | ||
expect(value).to eql(1) | ||
|
||
case Success(nested: Failure(:value)) | ||
in Success(nested: Success(value)) | ||
raise "unexpected" | ||
in Success(nested: Failure(value)) | ||
expect(value).to eql(:value) | ||
end | ||
end | ||
|
||
example "with maybe" do | ||
maybe = Some(1) | ||
maybe => Some(value) | ||
expect(value).to eql(1) | ||
|
||
case Some(nested: None()) | ||
in Some(nested: Some(value)) | ||
raise "unexpected" | ||
in Some(nested: None()) | ||
nil | ||
end | ||
end | ||
end | ||
|
||
context "with missing constant" do | ||
let(:operation) do | ||
require_relative "../fixtures/missing_constant" | ||
|
||
MissingConstant.new | ||
end | ||
|
||
it "raises an error when it comes from a non-spec file" do | ||
expect { operation.(1) }.to raise_error(NameError) | ||
end | ||
|
||
context "inline class" do | ||
let(:operation) do | ||
Class.new { | ||
def call(value) | ||
Success[value] | ||
end | ||
}.new | ||
end | ||
|
||
# we use debug_inspector to check if the error comes from a non-spec context | ||
it "raises an error when it comes from a non-spec context" do | ||
expect { operation.(1) }.to raise_error(NameError) | ||
end | ||
end | ||
|
||
context "helper" do | ||
include RSpecExtHelper | ||
|
||
it "makes success" do | ||
expect(make_success(1)).to be_success | ||
expect(make_success(1)).to be_success([1]) | ||
end | ||
end | ||
end | ||
|
||
describe Dry::Monads::RSpec::Matchers, aggregate_failures: false do | ||
context "error messages" do | ||
example "success" do | ||
expect { | ||
expect(Success(1)).to be_success(2) | ||
}.to raise_error("expected Success(1) to have value 2, but it was 1") | ||
|
||
expect { | ||
expect(1).to be_success | ||
}.to raise_error( | ||
"expected 1 to be one of the following values: Success, " \ | ||
"Some, Value, but it's Integer" | ||
) | ||
|
||
expect { | ||
expect(Success(1)).to be_success { |value| value.even? } | ||
}.to raise_error("expected Success(1) to have a value satisfying the given block") | ||
|
||
expect { | ||
expect(Success(1)).not_to be_success | ||
}.to raise_error( | ||
"expected Success(1) to not be one of the following values: Success, " \ | ||
"Some, Value, but it is" | ||
) | ||
end | ||
|
||
example "failure" do | ||
expect { | ||
expect(Failure(1)).to be_failure(2) | ||
}.to raise_error("expected Failure(1) to have value 2, but it was 1") | ||
|
||
expect { | ||
expect(1).to be_failure | ||
}.to raise_error( | ||
"expected 1 to be one of the following values: Failure, " \ | ||
"None, Error, but it's Integer" | ||
) | ||
|
||
expect { | ||
expect(Failure(1)).to be_failure { |value| value.even? } | ||
}.to raise_error("expected Failure(1) to have a value satisfying the given block") | ||
|
||
expect { | ||
expect(Failure(1)).not_to be_failure | ||
}.to raise_error( | ||
"expected Failure(1) to not be one of the following values: Failure, " \ | ||
"None, Error, but it is" | ||
) | ||
end | ||
|
||
example "some" do | ||
expect { | ||
expect(Some(1)).to be_some(2) | ||
}.to raise_error("expected Some(1) to have value 2, but it was 1") | ||
|
||
expect { | ||
expect(1).to be_some | ||
}.to raise_error("expected 1 to be a Some value, but it's Integer") | ||
|
||
expect { | ||
expect(Some(1)).to be_some { |value| value.even? } | ||
}.to raise_error("expected Some(1) to have a value satisfying the given block") | ||
|
||
expect { | ||
expect(Some(1)).not_to be_some | ||
}.to raise_error("expected Some(1) to not be a Some value, but it is") | ||
end | ||
|
||
example "none" do | ||
expect { | ||
expect(Some()).to be_none | ||
}.to raise_error("expected Some() to be none") | ||
|
||
expect { | ||
expect(1).to be_none | ||
}.to raise_error("expected 1 to be none") | ||
end | ||
end | ||
end | ||
end |
Oops, something went wrong.