Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sorbet support (initial) #3

Draft
wants to merge 28 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 23 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
29 changes: 29 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,33 @@ 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

Gem `sorbet-runtime` does not load signatures for stdlib types (Integer, String, etc...) into runtime.
Checking types for Integer, String, etc is only available through `rbs typecheck` command which uses [custom ruby binary](https://github.com/sorbet/sorbet/blob/master/docs/running-compiled-code.md).

Therefore, you should consider changing `raise_on_missing_types` to `false` if you use Sorbet.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe, we can somehow distinguish stdlib types from custom types, so we can ignore only missing stdlib types? Any ideas?

Copy link
Author

@iurev iurev Mar 16, 2023

Choose a reason for hiding this comment

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

Thanks for the suggestion, it should be easy to implement.

# sorbet.rb
def sig_is_missing(method_call, raise_on_missing)
  return unless raise_on_missing
  return unless method_call.receiver_class < T::Sig # By adding this line to check if the class uses signatures
  raise MissingSignature, RAISE_ON_MISSING_MESSAGE
end

I should also mention that I missed an important part of the problem here and I should update the readme to include this information.

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 for example.

However, many signatures are declared inside .rbi files, like 1) signatures for stdlib and core types and 2) signatures for most libraries including rails. Unfortunately, these types cannot be loaded into runtime at the moment. Therefore, it's not possible to type check their mocks yet.

Copy link
Author

Choose a reason for hiding this comment

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

I also updated readme to make the explanation more understandable.
link to the comment

I can add this commit to the master branch too.


```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 +399,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: 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
24 changes: 24 additions & 0 deletions lib/mock_suey/sorbet_rspec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require "sorbet-runtime"

# 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)
unless is_mocked
return T::Configuration.send(:call_validation_error_handler_default, signature, opts)
end

# 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

return T::Configuration.send(:call_validation_error_handler_default, 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
24 changes: 24 additions & 0 deletions spec/fixtures/rspec/double_sorbet_fixture.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

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

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
Loading