-
-
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 3435833
Showing
9 changed files
with
350 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,158 @@ | ||
# 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: { | ||
klass: ::Dry::Monads::Result::Failure, | ||
constructor_name: "Failure", | ||
extract_value: :failure.to_proc | ||
}, | ||
success: { | ||
klass: ::Dry::Monads::Result::Success, | ||
constructor_name: "Success", | ||
extract_value: :value!.to_proc | ||
}, | ||
some: { | ||
klass: ::Dry::Monads::Maybe::Some, | ||
constructor_name: "Some", | ||
extract_value: :value!.to_proc | ||
} | ||
}.each do |name, args| | ||
args => { klass:, constructor_name:, extract_value: } | ||
|
||
matcher :"be_#{name}" do |expected = Undefined| | ||
match do |actual| | ||
if actual.is_a?(klass) | ||
if block_arg | ||
block_arg.call(extract_value.call(actual)) | ||
elsif Undefined.equal?(expected) | ||
true | ||
else | ||
extract_value.call(actual) == expected | ||
end | ||
else | ||
false | ||
end | ||
end | ||
|
||
failure_message do |actual| | ||
if !actual.is_a?(klass) | ||
"expected #{actual.inspect} to be a #{constructor_name} " \ | ||
"value, but it's #{actual.class}" | ||
elsif 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| | ||
"expected #{actual.inspect} to not be a #{constructor_name} value" | ||
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 | ||
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,163 @@ | ||
# 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 a Success 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 a Success value") | ||
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 a Failure value, 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 a Failure value") | ||
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") | ||
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 |
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 | ||
|
||
class MissingConstant | ||
def call(value) | ||
Success[value] | ||
end | ||
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,9 @@ | ||
# frozen_string_literal: true | ||
|
||
Dry::Monads.load_extensions(:rspec) | ||
|
||
module RSpecExtHelper | ||
def make_success(value) | ||
Success[value] | ||
end | ||
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
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