Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6dd84fb
add editorconfig
Mar 6, 2023
d22a223
Add basic sorbet integration
Mar 7, 2023
019e9ef
raise_on_missing in sorbet.rb
Mar 7, 2023
3518df8
Improve Sorbet integration: blocks support, integration tests and readme
Mar 10, 2023
12f43a9
remove pry gem
Mar 10, 2023
f704ff4
test message for MethodMissing for sorbet
Mar 11, 2023
a6f93a2
small refactoring: change T::Private::Methods... to T::Utils
Mar 11, 2023
fe22912
fix: passing unrelated double into sig methods
Mar 11, 2023
46ad16f
Fix recursion and write more integration tests
Mar 11, 2023
c7f6159
TypeChecks::Sorbet refactoring
Mar 11, 2023
3502874
specify required gems versions
Mar 11, 2023
a2615a4
fix singleton classes in sorbet
Mar 11, 2023
3930a0c
add tests for Sorbet WithoutRuntime.sig
Mar 11, 2023
e1201a4
add tests for Sorbet .on_failure(:log)
Mar 11, 2023
00fc30c
Update README.md
iurev Mar 16, 2023
e54a28d
Update lib/mock_suey/rspec/proxy_method_invoked.rb
iurev Mar 16, 2023
8752f4a
Add basic sorbet integration
Mar 7, 2023
4adac91
specify required gems versions
Mar 11, 2023
cde36ce
Add basic sorbet integration
Mar 7, 2023
ba6de3b
specify required gems versions
Mar 11, 2023
1350dde
fix rubocop
Mar 16, 2023
18ac177
fix Gemfile
Mar 16, 2023
0b6b714
Update lib/mock_suey/type_checks/sorbet.rb
palkan Mar 16, 2023
f8b740f
temporary require ruby.rb from tests
Mar 17, 2023
28f235a
fix specs for ruby <= 3.0 by removing LOAD_PATH
Mar 18, 2023
16f2ffd
fix rubocop warning
Mar 18, 2023
d1fe510
fix sorbet call_validation_error_handler
Mar 17, 2023
24e99b2
update readme
Mar 17, 2023
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
10 changes: 10 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ source "https://rubygems.org"
gem "debug", platform: :mri
gem "rbs", "< 3.0"
gem "rspec"
gem 'sorbet-runtime', require: false

gemspec

Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A collection of tools to keep mocks in line with real objects.
- [Installation](#installation)
- [Typed doubles](#typed-doubles)
- [Using with RBS](#using-with-rbs)
- [Using with Sorbet](#using-with-sorbet)
- [Typed doubles limitations](#typed-doubles-limitations)
- [Mock context](#mock-context)
- [Auto-generated type signatures and post-run checks](#auto-generated-type-signatures-and-post-run-checks)
Expand Down Expand Up @@ -84,6 +85,39 @@ Typed doubles rely on the type signatures being defined. What if you don't have

2) Auto-generating types on-the-fly from the real call traces (see below).

### Using with Sorbet

To use Mock Suey with Sorbet, configure it as follows:

```ruby
MockSuey.configure do |config|
config.type_check = :sorbet
end
```

Make sure that `sorbet` and `sorbet-runtime` gem are present in the bundle according to the [sorbet instruction](https://sorbet.org/docs/adopting#step-1-install-dependencies).
That's it! Now all mocked methods are type-checked.

### raise_on_missing_types

If a signature is described in a `.rb` file, it will be used by `sorbet-runtime` and type checking will be available.
One of the gems that is using sorbet signatures is [ShopifyAPI](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth.rb) for example.

However, many signatures are declared inside `.rbi` files, like 1) signatures for [stdlib and core types](https://github.com/sorbet/sorbet/tree/master/rbi/core) and 2) signatures for most libraries including [rails](https://github.com/chanzuckerberg/sorbet-rails/blob/master/sorbet/rbi/sorbet-typed/lib/activerecord/all/activerecord.rbi).
Unfortunately, these types cannot be loaded into runtime at the moment.
It's not possible to type check their mocks yet.

Checking types defined in .rbi files is only available through `rbs typecheck` command which uses [custom ruby binary](https://github.com/sorbet/sorbet/blob/master/docs/running-compiled-code.md).

You should consider changing `raise_on_missing_types` to `false` if you use Sorbet.

```ruby
MockSuey.configure do |config|
config.type_check = :sorbet
config.raise_on_missing_types = false
end
```

## Mock context

Mock context is a re-usable mocking/stubbing configuration. Keeping a _library of mocks_
Expand Down Expand Up @@ -371,6 +405,7 @@ The gem is available as open source under the terms of the [MIT License](http://

[the-talk]: https://evilmartians.com/events/weaving-and-seaming-mocks
[rbs]: https://github.com/ruby/rbs
[sorbet]: https://github.com/sorbet/sorbet
[fixturama]: https://github.com/nepalez/fixturama
[bogus]: https://github.com/psyho/bogus
[compact]: https://github.com/robwold/compact
2 changes: 1 addition & 1 deletion lib/mock_suey/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module MockSuey
class Configuration
# No freezing this const to allow third-party libraries
# to integrate with mock_suey
TYPE_CHECKERS = %w[ruby]
TYPE_CHECKERS = %w[ruby sorbet]

attr_accessor :debug,
:logger,
Expand Down
2 changes: 2 additions & 0 deletions lib/mock_suey/method_call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class MethodCall < Struct.new(
:return_value,
:has_kwargs,
:metadata,
:mocked_obj,
:block,
keyword_init: true
)
def initialize(**)
Expand Down
2 changes: 1 addition & 1 deletion lib/mock_suey/mock_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def initialize(contract, msg)
def captured_calls_message(calls)
calls.map do |call|
contract.args_pattern.map.with_index do |arg, i|
(ANYTHING == arg) ? "_" : call.arguments[i].inspect
(arg == ANYTHING) ? "_" : call.arguments[i].inspect
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just out of curiosity: why you swapped these two arguments of == operator?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reversed these arguments because rubocop showed me a warning Style/YodaCondition. link to the rule.

It looks a bit more logical to put the arg first: "argument equals constant" vs "constant equals argument".
Anyway, I don't mind both kinds of comparations. It is easy to read both of them.

end.join(", ").then do |args_desc|
" (#{args_desc}) -> #{call.return_value.class}"
end
Expand Down
2 changes: 2 additions & 0 deletions lib/mock_suey/rspec/proxy_method_invoked.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ def proxy_method_invoked(obj, *args, &block)
end

method_call = MockSuey::MethodCall.new(
mocked_obj: obj,
receiver_class:,
method_name:,
arguments: args,
block:,
metadata: {example: ::RSpec.current_example}
)

Expand Down
27 changes: 27 additions & 0 deletions lib/mock_suey/sorbet_rspec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require "sorbet-runtime"

error_handler = T::Configuration.instance_variable_get(:@call_validation_error_handler)
error_handler ||= proc do |signature, opts|
return T::Configuration.send(:call_validation_error_handler_default, signature, opts)
end

# Let methods with sig receive double/instance_double arguments
T::Configuration.call_validation_error_handler = lambda do |signature, opts|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reminds me this RSpec Sorbet integration: https://github.com/samuelgiles/rspec-sorbet/blob/4d0e6479453b3b4ef36d2d6a2df8282ae559368b/lib/rspec/sorbet/doubles.rb#L104

Are we doing something similar here? Maybe, we can rely on the rspec-sorbet gem? (That's something we can consider in the future)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It uses a similar approach and calls the same method T::Configuration.call_validation_error_handler from Sorbet.

The first problem I see here is that they may not work together at the moment. The reason for that is this line:
T::Configuration.send(:call_validation_error_handler_default, signature, opts)
Most probably, it will not call the handler defined in the gem rspec-sorbet

I will try to reproduce the error and write a comment about the result below. Anyway, it should be easy to fix. RSpec Sorbet uses a correct approach to override this method.

It should be possible to integrate RSpec Sorbet gem if this is needed I guess.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I fixed this issue in another branch: link to the commit
I will add this change to the master branch if you don't mind.

is_mocked = opts[:value].is_a?(RSpec::Mocks::Double) || opts[:value].is_a?(RSpec::Mocks::VerifyingDouble)
return error_handler.call(signature, opts) unless is_mocked

# https://github.com/rspec/rspec-mocks/blob/main/lib/rspec/mocks/verifying_double.rb
# https://github.com/rspec/rspec-mocks/blob/v3.12.3/lib/rspec/mocks/test_double.rb
doubled_class = if opts[:value].is_a? RSpec::Mocks::Double
doubled_class_name = opts[:value].instance_variable_get :@name
Kernel.const_get(doubled_class_name)
elsif opts[:value].is_a? RSpec::Mocks::VerifyingDouble
opts[:value].instance_variable_get(:@doubled_module).send(:object)
end
are_related = doubled_class <= opts[:type].raw_type
return if are_related

error_handler.call(signature, opts)
end
83 changes: 83 additions & 0 deletions lib/mock_suey/type_checks/sorbet.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

gem "sorbet-runtime", "~> 0.5"
require "sorbet-runtime"
require "set"
require "pathname"

require "mock_suey/sorbet_rspec"
require "mock_suey/ext/instance_class"

module MockSuey
module TypeChecks
using Ext::InstanceClass

class Sorbet
RAISE_ON_MISSING_MESSAGE = "Please, set raise_on_missing_types to false to disable this error. Details: https://github.com/test-prof/mock-suey#raise_on_missing_types"

def initialize(load_dirs: [])
@load_dirs = Array(load_dirs)
end

def typecheck!(method_call, raise_on_missing: false)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a room for improvement: the variable method_call is passed too many times.
Therefore, it should be better to extract these functions into a class or a structure MethodCheck, where method_call (and mocked_obj, arguments, method_name, etc...) will be available as instance variables.

original_method_sig = get_original_method_sig(method_call)
return sig_is_missing(raise_on_missing) unless original_method_sig

unbound_mocked_method = get_unbound_mocked_method(method_call)
override_mocked_method_to_avoid_recursion(method_call)

validate_call!(
instance: method_call.mocked_obj,
original_method: unbound_mocked_method,
method_sig: original_method_sig,
args: method_call.arguments,
blk: method_call.block
)
end

private

def validate_call!(instance:, original_method:, method_sig:, args:, blk:)
T::Private::Methods::CallValidation.validate_call(
instance,
original_method,
method_sig,
args,
blk
)
end

def get_unbound_mocked_method(method_call)
method_call.mocked_obj.method(method_call.method_name).unbind
end

def get_original_method_sig(method_call)
unbound_original_method = get_unbound_original_method(method_call)
T::Utils.signature_for_method(unbound_original_method)
end

def get_unbound_original_method(method_call)
method_name = method_call.method_name
mocked_obj = method_call.mocked_obj
is_a_class = mocked_obj.is_a? Class

if is_a_class
method_call.mocked_obj.method(method_name)
else
method_call.receiver_class.instance_method(method_name)
end
end

def sig_is_missing(raise_on_missing)
raise MissingSignature, RAISE_ON_MISSING_MESSAGE if raise_on_missing
end

def override_mocked_method_to_avoid_recursion(method_call)
method_name = method_call.method_name
mocked_obj = method_call.mocked_obj
return_value = method_call.return_value
mocked_obj.define_singleton_method(method_name) { |*args, &block| return_value }
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining a singleton method on a mocked object might cause a problem. It's better to either .dup or create a new empty object for the purpose of holding this method.

end
end
end
end
27 changes: 27 additions & 0 deletions spec/cases/typed_double_sorbet_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

describe "Sorbet integration tests" do
context "RSpec" do
let(:env) { {"TYPED_SORBET" => "true"} }

it "has no affect on simple double" do
status, output = run_rspec("double_sorbet", env: env)

expect(status).to be_success
expect(output).to include("6 examples, 0 failures")
end

context "instance_double_sorbet" do
it "enhances instance_double without extensions" do
status, output = run_rspec("instance_double_sorbet", env: env)

expect(status).not_to be_success
expect(output).to include("16 examples")
expect(output).to include("5 failures")
expect(output).to include("AccountantSorbet#tax_rate_for")
expect(output).to include("AccountantSorbet#net_pay")
expect(output).to include("AccountantSorbet#tax_for")
end
end
end
end
2 changes: 0 additions & 2 deletions spec/fixtures/rspec/double_fixture.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# frozen_string_literal: true

$LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__)

require_relative "./spec_helper"
require_relative "../shared/tax_calculator"
require_relative "tax_calculator_spec"
Expand Down
22 changes: 22 additions & 0 deletions spec/fixtures/rspec/double_sorbet_fixture.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

require_relative "./spec_helper"
require_relative "../shared/tax_calculator_sorbet"
require_relative "tax_calculator_sorbet_spec"

describe AccountantSorbet do
before do
allow(tax_calculator).to receive(:for_income).and_return(42)
allow(tax_calculator).to receive(:tax_rate_for).and_return(10.0)
allow(tax_calculator).to receive(:for_income).with(-10).and_return(TaxCalculator::Result.new(0))
end

let(:tax_calculator) { double("TaxCalculatorSorbet") }

include_examples "accountant", AccountantSorbet do
it "incorrect" do
# NOTE: in fact, sorbet-runtine also checks for type errors for ALL types
expect { subject.net_pay("incorrect") }.to raise_error(TypeError)
end
end
end
2 changes: 0 additions & 2 deletions spec/fixtures/rspec/instance_double_fixture.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# frozen_string_literal: true

$LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__)

require_relative "./spec_helper"
require_relative "../shared/tax_calculator"
require_relative "tax_calculator_spec"
Expand Down
Loading