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 87da93e
Show file tree
Hide file tree
Showing 8 changed files with 341 additions and 0 deletions.
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
218 changes: 218 additions & 0 deletions lib/dry/monads/extensions/rspec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# 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]

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
95 changes: 95 additions & 0 deletions spec/extensions/rspec_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 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
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
9 changes: 9 additions & 0 deletions spec/fixtures/rspec_ext_helper.rb
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
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
SPEC_ROOT = Pathname(__FILE__).dirname

begin
require "debug"
require "pry"
require "pry-byebug"
rescue LoadError
Expand Down

0 comments on commit 87da93e

Please sign in to comment.