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 21, 2025
1 parent 342cc3c commit 826e737
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 0 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,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
191 changes: 191 additions & 0 deletions lib/dry/monads/extensions/rspec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# 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
class BeSuccessMatcher
def initialize(value = Undefined)
@value = value
end

def matches?(actual)
if Undefined.equal?(@value)
actual.success?
else
actual.eql?(Result::Success.new(@value))
end
end

def failure_message
if Undefined.equal?(@value)
"expected #{actual.inspect} to be a success"
else
"expected #{actual.inspect} to be a success with value #{@value.inspect}"
end
end

def failure_message_when_negated
if Undefined.equal?(@value)
"expected #{actual.inspect} to not be a success"
else
"expected #{actual.inspect} to not be a success with value #{@value.inspect}"
end
end
end

class BeFailureMatcher
def initialize(value = Undefined)
@value = value
end

def matches?(actual)
if Undefined.equal?(@value)
actual.failure?
else
actual.eql?(Result::Failure.new(@value))
end
end

def be_failure
BeFailureMatcher.new
end

def failure_message
if Undefined.equal?(@value)
"expected #{actual.inspect} to be a failure"
else
"expected #{actual.inspect} to be a failure with value #{@value.inspect}"
end
end

def failure_message_when_negated
if Undefined.equal?(@value)
"expected #{actual.inspect} to not be a failure"
else
"expected #{actual.inspect} to not be a failure with value #{@value.inspect}"
end
end
end

class BeSomeMatcher
def initialize(value = Undefined)
@value = value
end

def matches?(actual)
if Undefined.equal?(@value)
actual.some?
else
actual.eql?(Maybe::Some.new(@value))
end
end

def failure_message
if Undefined.equal?(@value)
"expected #{actual.inspect} to be a some"
else
"expected #{actual.inspect} to be a some with value #{@value.inspect}"
end
end

def failure_message_when_negated
if Undefined.equal?(@value)
"expected #{actual.inspect} to not be a some"
else
"expected #{actual.inspect} to not be a some with value #{@value.inspect}"
end
end
end

class BeNoneMatcher
def matches?(actual)
actual.none?
end

def failure_message
"expected #{actual.inspect} to be a none"
end

def failure_message_when_negated
"expected #{actual.inspect} to not be a none"
end
end

def be_success(value = Undefined)
BeSuccessMatcher.new(value)
end

def be_failure(value = Undefined)
BeFailureMatcher.new(value)
end

def be_some(value = Undefined)
BeSomeMatcher.new(value)
end

def be_none
BeNoneMatcher.new
end
end

Constructors = Monads[:result, :maybe]
end
end
end

RSpec.configure do |config|
config.include Dry::Monads::RSpec::Matchers
config.include Dry::Monads::RSpec::Constructors
end

catch_missing_const = Module.new do
constants = %i[Success Failure Some None].to_set

name_to_const = proc do |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

if debug_inspector_available
define_method(:const_missing) do |name|
if constants.include?(name) && caller_locations(1, 1).first.path.end_with?("_spec.rb")
DebugInspector.open do |dc|
if dc.frame_binding(2).receiver.is_a?(RSpec::Core::ExampleGroup)
name_to_const.(name)
else
super(name)
end
end
else
super(name)
end
end
else
define_method(:const_missing) do |name|
if constants.include?(name) && caller_locations(1, 1).first.path.end_with?("_spec.rb")
name_to_const.(name)
else
super(name)
end
end
end
end

Object.extend(catch_missing_const)
84 changes: 84 additions & 0 deletions spec/extensions/rspec_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

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
end
end
7 changes: 7 additions & 0 deletions spec/fixtures/missing_constant.rb
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

0 comments on commit 826e737

Please sign in to comment.