Skip to content

Commit

Permalink
Add RSpec extension (closes #182)
Browse files Browse the repository at this point in the history
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
flash-gordon committed Feb 25, 2025
1 parent 342cc3c commit 4e58fcf
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 1 deletion.
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ gemspec

group :tools do
gem "benchmark-ips"
gem "debug"
gem "irb"
end

group :docs do
Expand All @@ -17,5 +19,6 @@ group :docs do
end

group :test do
gem "debug_inspector"
gem "dry-types"
end
1 change: 1 addition & 0 deletions lib/dry/monads.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "dry/monads/constants"
require "dry/monads/errors"
require "dry/monads/registry"
require "dry/monads/extensions"

module Dry
# Common, idiomatic monads for Ruby
Expand Down
7 changes: 7 additions & 0 deletions lib/dry/monads/extensions.rb
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
187 changes: 187 additions & 0 deletions lib/dry/monads/extensions/rspec.rb
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
175 changes: 175 additions & 0 deletions spec/extensions/rspec_spec.rb
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
Loading

0 comments on commit 4e58fcf

Please sign in to comment.