Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5ce286e
Add failing test for options hash arity
calebhearth May 31, 2022
fc6c6d4
Add BuildsMethodSignature
calebhearth Jun 29, 2022
ba6f7c7
Plug in BuildsMethodSignature
calebhearth Jun 29, 2022
9c1d71e
Use eval instead of class_eval
calebhearth Nov 3, 2022
7a81192
Apply suggestions from code review
calebhearth Nov 10, 2022
def814a
live with this evil
searls Nov 16, 2022
23e6916
make all params & keyword params optional in our signatures
searls Nov 16, 2022
7353b13
committing crimes against ruby to get this thing passing again
searls Nov 17, 2022
3b0d269
Fix linting & tests for Ruby 3.0 by adding YET MORE EVAL oh no
searls Nov 17, 2022
49fd88b
avoid a ruby warning that's hard to shake now that we're doing eval b…
searls Nov 17, 2022
ac22915
No evidence this change was necessary
searls Nov 17, 2022
71bb11f
Outdated comment
searls Nov 17, 2022
f1b97ad
remove unit-of-work/transactional ivars
searls Nov 17, 2022
0d10f79
rename dependency, structure test more consistently with others
searls Nov 17, 2022
37decc3
Found a new edge case (implicit block args broke), wrote a failing te…
searls Nov 17, 2022
7baf4a8
Fixes the case of stubbing/verifying mocked methods that act on impli…
searls Nov 17, 2022
98029db
extract magic strings to constants
searls Nov 17, 2022
a8e9253
Now that the block param is always supplied and its name is always kn…
searls Nov 17, 2022
7496e98
Failing test for yet another edge case: using eval to define methods …
searls Nov 17, 2022
5a00de1
Makes the test pass by prepending everything we reference with __mock…
searls Nov 17, 2022
55d4d8a
I should maybe stop metaprogramming inside a heredoc
searls Nov 17, 2022
0f30710
start refactoring this logic -- just realized it introduces a bug I n…
searls Nov 17, 2022
ee389e2
Fix the issue of out-of-order parameters by selecting on all instead …
searls Nov 17, 2022
c594f69
lol this test wasn't running b/c the filename didn't end in _test
searls Nov 17, 2022
d99ccdb
Add assertions to this test and pull it into the SAFE suite
searls Nov 17, 2022
efe316d
wups
searls Nov 17, 2022
1c12118
detangle this black hole of density a bit
searls Nov 17, 2022
06029da
Update test/unit/stringifies_method_signature_test.rb
searls Nov 17, 2022
f53a733
Revert "Update test/unit/stringifies_method_signature_test.rb"
searls Nov 17, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/mocktail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require_relative "mocktail/replaces_type"
require_relative "mocktail/resets_state"
require_relative "mocktail/simulates_argument_error"
require_relative "mocktail/stringifies_method_signature"
require_relative "mocktail/value"
require_relative "mocktail/verifies_call"
require_relative "mocktail/version"
Expand Down
42 changes: 26 additions & 16 deletions lib/mocktail/imitates_type/makes_double/declares_dry_class.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
require_relative "declares_dry_class/reconstructs_call"

module Mocktail
class DeclaresDryClass
def initialize
@handles_dry_call = HandlesDryCall.new
@raises_neato_no_method_error = RaisesNeatoNoMethodError.new
@transforms_params = TransformsParams.new
@stringifies_method_signature = StringifiesMethodSignature.new
end

def declare(type, instance_methods)
Expand Down Expand Up @@ -39,24 +42,31 @@ def initialize(*args, **kwargs, &blk)
private

def define_double_methods!(dry_class, type, instance_methods)
handles_dry_call = @handles_dry_call
instance_methods.each do |method|
dry_class.undef_method(method) if dry_class.method_defined?(method)

dry_class.define_method method, ->(*args, **kwargs, &block) {
Debug.guard_against_mocktail_accidentally_calling_mocks_if_debugging!
handles_dry_call.handle(Call.new(
singleton: false,
double: self,
original_type: type,
dry_type: dry_class,
method: method,
original_method: type.instance_method(method),
args: args,
kwargs: kwargs,
block: block
))
parameters = type.instance_method(method).parameters
signature = @transforms_params.transform(Call.new, params: parameters)
method_signature = @stringifies_method_signature.stringify(signature)
__mocktail_closure = {
dry_class: dry_class,
type: type,
method: method,
original_method: type.instance_method(method),
signature: signature
}

dry_class.define_method method,
eval(<<-RUBBY, binding, __FILE__, __LINE__ + 1) # standard:disable Security/Eval
->#{method_signature} do
::Mocktail::Debug.guard_against_mocktail_accidentally_calling_mocks_if_debugging!
::Mocktail::HandlesDryCall.new.handle(::Mocktail::ReconstructsCall.new.reconstruct(
double: self,
call_binding: __send__(:binding),
default_args: (__send__(:binding).local_variable_defined?(:__mocktail_default_args) ? __send__(:binding).local_variable_get(:__mocktail_default_args) : {}),
**__mocktail_closure
))
end
RUBBY
end
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
module Mocktail
class ReconstructsCall
def reconstruct(double:, call_binding:, default_args:, dry_class:, type:, method:, original_method:, signature:)
Call.new(
singleton: false,
double: double,
original_type: type,
dry_type: dry_class,
method: method,
original_method: original_method,
args: args_for(signature, call_binding, default_args),
kwargs: kwargs_for(signature, call_binding, default_args),
block: call_binding.local_variable_get(signature.block_param || ::Mocktail::Signature::DEFAULT_BLOCK_PARAM)
)
end

private

def args_for(signature, call_binding, default_args)
arg_names, rest_name = non_default_args(signature.positional_params, default_args)

arg_values = arg_names.map { |p| call_binding.local_variable_get(p) }
rest_value = call_binding.local_variable_get(rest_name) if rest_name

arg_values + (rest_value || [])
end

def kwargs_for(signature, call_binding, default_args)
kwarg_names, kwrest_name = non_default_args(signature.keyword_params, default_args)

kwarg_values = kwarg_names.to_h { |p| [p, call_binding.local_variable_get(p)] }
kwrest_value = call_binding.local_variable_get(kwrest_name) if kwrest_name

kwarg_values.merge(kwrest_value || {})
end

def non_default_args(params, default_args)
named_args = params.allowed
.reject { |p| default_args&.key?(p) }
rest_arg = if params.rest && !default_args&.key?(params.rest)
params.rest
end

[named_args, rest_arg]
end
end
end
10 changes: 4 additions & 6 deletions lib/mocktail/simulates_argument_error/transforms_params.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@

module Mocktail
class TransformsParams
def transform(dry_call)
params = dry_call.original_method.parameters

def transform(dry_call, params: dry_call.original_method.parameters)
Signature.new(
positional_params: Params.new(
all: params.select { |t, _|
[:req, :opt, :rest].any? { |param_type| Bind.call(t, :==, param_type) }
}.map { |_, name| name },
required: params.select { |t, _| Bind.call(t, :==, :req) }.map { |_, n| n },
optional: params.select { |t, _| Bind.call(t, :==, :opt) }.map { |_, n| n },
rest: params.find { |t, _| Bind.call(t, :==, :rest) } & [1]
rest: params.find { |t, _| Bind.call(t, :==, :rest) }&.last
),
positional_args: dry_call.args,

Expand All @@ -22,11 +20,11 @@ def transform(dry_call)
}.map { |_, name| name },
required: params.select { |t, _| Bind.call(t, :==, :keyreq) }.map { |_, n| n },
optional: params.select { |t, _| Bind.call(t, :==, :key) }.map { |_, n| n },
rest: params.find { |t, _| Bind.call(t, :==, :keyrest) } & [1]
rest: params.find { |t, _| Bind.call(t, :==, :keyrest) }&.last
),
keyword_args: dry_call.kwargs,

block_param: params.find { |t, _| Bind.call(t, :==, :block) } & [1],
block_param: params.find { |t, _| Bind.call(t, :==, :block) }&.last,
block_arg: dry_call.block
)
end
Expand Down
45 changes: 45 additions & 0 deletions lib/mocktail/stringifies_method_signature.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
module Mocktail
class StringifiesMethodSignature
def stringify(signature)
positional_params = positional(signature)
keyword_params = keyword(signature)
block_param = block(signature)

"(#{[positional_params, keyword_params, block_param].compact.join(", ")})"
end

private

def positional(signature)
params = signature.positional_params.all.map do |name|
if signature.positional_params.allowed.include?(name)
"#{name} = ((__mocktail_default_args ||= {})[:#{name}] = nil)"
elsif signature.positional_params.rest == name
"*#{(name == :*) ? Signature::DEFAULT_REST_ARGS : name}"
end
end.compact

params.join(", ") if params.any?
end

def keyword(signature)
params = signature.keyword_params.all.map do |name|
if signature.keyword_params.allowed.include?(name)
"#{name}: ((__mocktail_default_args ||= {})[:#{name}] = nil)"
elsif signature.keyword_params.rest == name
"**#{(name == :**) ? Signature::DEFAULT_REST_KWARGS : name}"
end
end.compact

params.join(", ") if params.any?
end

def block(signature)
if signature.block_param && signature.block_param != :&
"&#{signature.block_param}"
else
"&#{Signature::DEFAULT_BLOCK_PARAM}"
end
end
end
end
5 changes: 4 additions & 1 deletion lib/mocktail/value/signature.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ class Signature < Struct.new(
:block_arg,
keyword_init: true
)
DEFAULT_REST_ARGS = "args"
DEFAULT_REST_KWARGS = "kwargs"
DEFAULT_BLOCK_PARAM = "blk"
end

class Params < Struct.new(
Expand All @@ -26,7 +29,7 @@ def initialize(**params)
end

def allowed
required + optional
all.select { |name| required.include?(name) || optional.include?(name) }
end

def rest?
Expand Down
File renamed without changes.
36 changes: 36 additions & 0 deletions test/safe/kwargs_vs_options_hash_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
require "test_helper"

class KwargsVsOptionsHashTest < Minitest::Test
include Mocktail::DSL

class Charity
def donate(amount:)
raise "Unimplemented"
end

def give(opts)
raise "Unimplemented"
end
end

def test_handles_kwargs
aclu = Mocktail.of(Charity)

stubs { |m| aclu.donate(amount: m.numeric) }.with { :receipt }

assert_equal :receipt, aclu.donate(amount: 100)
assert_nil aclu.donate(amount: "money?")
end

def test_handles_options_hashes
wbc = Mocktail.of(Charity)

stubs { wbc.give(to: "poor") }.with { :stringy_thanks }
stubs { wbc.give({to: :poor}) }.with { :symbol_thanks }

assert_equal :stringy_thanks, wbc.give(to: "poor")
assert_equal :stringy_thanks, wbc.give({to: "poor"})
assert_equal :symbol_thanks, wbc.give(to: :poor)
assert_equal :symbol_thanks, wbc.give({to: :poor})
end
end
51 changes: 51 additions & 0 deletions test/safe/stub_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -444,4 +444,55 @@ def do_stuff(arg, a:, &blk)

MSG
end

class Checkable
def check
raise "unimplemented"
end
end

class ValidatesThing
def initialize(checkable)
@checkable = checkable
end

def validate(thing)
@checkable.check { thing[:email].include?("@") }
end
end

# Methods that don't declare a block arg can still yield to a block, so
# we need to make sure that our generated signatures don't break that.
def test_implicit_block_arg
checkable = Mocktail.of(Checkable)
validates_thing = ValidatesThing.new(checkable)

stubs { checkable.check { |blk| blk.call == true } }.with { :valid }

stubs { checkable.check { |blk| blk.call != true } }.with { :invalid }

assert_equal validates_thing.validate({email: "foo@bar"}), :valid
assert_equal validates_thing.validate({email: "foobar"}), :invalid
end

class ThingWithBadlyOrderedArgs
def positional(a, b = nil, c = nil, d, e) # standard:disable Style/OptionalArguments
end
end

def test_out_of_order_args_work
thing = Mocktail.of(ThingWithBadlyOrderedArgs)

stubs { thing.positional(:a, :d, :e) }.with { :weird }
stubs { thing.positional(:a, :b, :d, :e) }.with { :less_weird }
stubs { thing.positional(:a, :b, :c, :d, :e) }.with { :even_less_weird }

assert_equal :weird, thing.positional(:a, :d, :e)
assert_equal :less_weird, thing.positional(:a, :b, :d, :e)
assert_equal :even_less_weird, thing.positional(:a, :b, :c, :d, :e)

assert_equal [:a, :d, :e], Mocktail.calls(thing, :positional)[0].args
assert_equal [:a, :b, :d, :e], Mocktail.calls(thing, :positional)[1].args
assert_equal [:a, :b, :c, :d, :e], Mocktail.calls(thing, :positional)[2].args
end
end
65 changes: 65 additions & 0 deletions test/unit/imitates_type/makes_double/declares_dry_class_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require "test_helper"

module Mocktail
class DeclaresDryClassTest < Minitest::Test
include Mocktail::DSL

def setup
@subject = DeclaresDryClass.new
end

class Fib
def lie(truth, lies:)
end
end

def test_calls_handle_dry_call_with_what_we_want
fake_fib_class = @subject.declare(Fib, [:lie])
fake_fib = fake_fib_class.new
handles_dry_call = Mocktail.of_next(HandlesDryCall)

fake_fib.lie("truth", lies: "lies")

verify {
handles_dry_call.handle(Call.new(
singleton: false,
double: fake_fib,
original_type: Fib,
dry_type: fake_fib_class,
method: :lie,
original_method: Fib.instance_method(:lie),
args: ["truth"],
kwargs: {lies: "lies"},
block: nil
))
}
end

class ExtremelyUnfortunateArgNames
def welp(binding, type, dry_class, method, signature)
end
end

def test_handles_args_with_unfortunate_names
fake_class = @subject.declare(ExtremelyUnfortunateArgNames, [:welp])
fake = fake_class.new
handles_dry_call = Mocktail.of_next(HandlesDryCall)

fake.welp(:a_binding, :a_type, :a_dry_class, :a_method, :a_signature)

verify {
handles_dry_call.handle(Call.new(
singleton: false,
double: fake,
original_type: ExtremelyUnfortunateArgNames,
dry_type: fake_class,
method: :welp,
original_method: ExtremelyUnfortunateArgNames.instance_method(:welp),
args: [:a_binding, :a_type, :a_dry_class, :a_method, :a_signature],
kwargs: {},
block: nil
))
}
end
end
end
Loading