From 6dd84fbf43bdffdb0a9f18b59c235b1db64c3912 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Mon, 6 Mar 2023 19:47:01 +0300 Subject: [PATCH 01/28] add editorconfig --- .editorconfig | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..842ce7b --- /dev/null +++ b/.editorconfig @@ -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 + From d22a223ad893e8ec478bb8f2d533019e4e115b99 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Tue, 7 Mar 2023 17:52:45 +0300 Subject: [PATCH 02/28] Add basic sorbet integration --- Gemfile | 3 + lib/mock_suey/method_call.rb | 1 + lib/mock_suey/rspec/proxy_method_invoked.rb | 1 + lib/mock_suey/type_checks/sorbet.rb | 36 ++++++++ spec/fixtures/shared/tax_calculator_sorbet.rb | 13 +++ spec/rspec/proxy_method_invoked_hook_spec.rb | 21 +++-- spec/type_checks/sorbet_spec.rb | 84 +++++++++++++++++++ 7 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 lib/mock_suey/type_checks/sorbet.rb create mode 100644 spec/fixtures/shared/tax_calculator_sorbet.rb create mode 100644 spec/type_checks/sorbet_spec.rb diff --git a/Gemfile b/Gemfile index 87da9d7..312df73 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,9 @@ source "https://rubygems.org" gem "debug", platform: :mri gem "rbs", "< 3.0" gem "rspec" +gem 'sorbet', require: false +gem 'sorbet-runtime', require: false +gem 'tapioca', require: false gemspec diff --git a/lib/mock_suey/method_call.rb b/lib/mock_suey/method_call.rb index f066c6d..4ad2a65 100644 --- a/lib/mock_suey/method_call.rb +++ b/lib/mock_suey/method_call.rb @@ -12,6 +12,7 @@ class MethodCall < Struct.new( :return_value, :has_kwargs, :metadata, + :mocked_instance, keyword_init: true ) def initialize(**) diff --git a/lib/mock_suey/rspec/proxy_method_invoked.rb b/lib/mock_suey/rspec/proxy_method_invoked.rb index 01df972..7acf394 100644 --- a/lib/mock_suey/rspec/proxy_method_invoked.rb +++ b/lib/mock_suey/rspec/proxy_method_invoked.rb @@ -29,6 +29,7 @@ def proxy_method_invoked(obj, *args, &block) end method_call = MockSuey::MethodCall.new( + mocked_instance: obj, receiver_class:, method_name:, arguments: args, diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb new file mode 100644 index 0000000..e363c10 --- /dev/null +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "set" +require "pathname" + +require "mock_suey/ext/instance_class" + +module MockSuey + module TypeChecks + using Ext::InstanceClass + + class Sorbet + def initialize(load_dirs: []) + @load_dirs = Array(load_dirs) + end + + def typecheck!(call_obj, raise_on_missing: false) + method_name = call_obj.method_name + mocked_instance = call_obj.mocked_instance + unbound_mocked_method = mocked_instance.method(method_name).unbind + args = call_obj.arguments + + unbound_original_method = call_obj.receiver_class.instance_method(method_name) + original_method_sig = T::Private::Methods.signature_for_method(unbound_original_method) + + T::Private::Methods::CallValidation.validate_call( + mocked_instance, + unbound_mocked_method, + original_method_sig, + args, + nil + ) + end + end + end +end diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb new file mode 100644 index 0000000..712d69b --- /dev/null +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "sorbet-runtime" +require_relative "./tax_calculator" + +class TaxCalculatorSorbet < TaxCalculator + extend T::Sig + + sig { params(val: Integer).returns(Integer) } + def simple_test(val) + val + end +end diff --git a/spec/rspec/proxy_method_invoked_hook_spec.rb b/spec/rspec/proxy_method_invoked_hook_spec.rb index efd4791..8526b99 100644 --- a/spec/rspec/proxy_method_invoked_hook_spec.rb +++ b/spec/rspec/proxy_method_invoked_hook_spec.rb @@ -37,7 +37,8 @@ receiver_class: TestHash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + mocked_instance: target ) end @@ -52,7 +53,8 @@ receiver_class: TestHash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + mocked_instance: target ) end @@ -67,7 +69,8 @@ receiver_class: Hash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + mocked_instance: target ) end @@ -82,7 +85,8 @@ receiver_class: Hash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + mocked_instance: target ) end @@ -95,7 +99,8 @@ receiver_class: TestRegexp.singleton_class, method_name: :escape, arguments: ["foo"], - return_value: "bar" + return_value: "bar", + mocked_instance: TestRegexp ) end @@ -111,13 +116,15 @@ expect(mcalls.first).to have_attributes( receiver_class: TestHash, method_name: :initialize, - arguments: [] + arguments: [], + mocked_instance: TestHash ) expect(mcalls.last).to have_attributes( receiver_class: TestHash, method_name: :[], arguments: ["a"], - return_value: 10 + return_value: 10, + mocked_instance: hash_double ) end end diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb new file mode 100644 index 0000000..7bb88d7 --- /dev/null +++ b/spec/type_checks/sorbet_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "mock_suey/type_checks/sorbet" +require_relative "../fixtures/shared/tax_calculator_sorbet" + +describe MockSuey::TypeChecks::Sorbet do + subject(:checker) { described_class.new } + + context "with signatures" do + let(:target) { instance_double("TaxCalculatorSorbet") } + + subject(:checker) do + described_class.new + end + + describe "type check argument type" do + it "when correct" do + allow(target).to receive(:simple_test).and_return(120) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: [120], + return_value: 120, + mocked_instance: target + ) + + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "when incorrect" do + allow(target).to receive(:simple_test).and_return(120) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: ["120"], + return_value: 120, + mocked_instance: target + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /Parameter.*val.*Expected.*Integer.*got.*String.*/) + end + end + + describe "type check return type" do + it "when correct" do + allow(target).to receive(:simple_test).and_return(120) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: [120], + return_value: 120, + mocked_instance: target + ) + + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "when incorrect" do + allow(target).to receive(:simple_test).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: [120], + return_value: 120, + mocked_instance: target + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /.*Return value.*Expected.*Integer.*got.*String.*/) + end + end + end +end From 019e9efee0d5e52b66f257c55ec57e0ecafcbaac Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Tue, 7 Mar 2023 18:12:23 +0300 Subject: [PATCH 03/28] raise_on_missing in sorbet.rb --- lib/mock_suey/type_checks/sorbet.rb | 5 ++++ spec/fixtures/shared/tax_calculator_sorbet.rb | 4 +++ spec/type_checks/sorbet_spec.rb | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index e363c10..cee8982 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -23,6 +23,11 @@ def typecheck!(call_obj, raise_on_missing: false) unbound_original_method = call_obj.receiver_class.instance_method(method_name) original_method_sig = T::Private::Methods.signature_for_method(unbound_original_method) + unless original_method_sig + raise MissingSignature, "No signature found for #{call_obj.method_desc}" if raise_on_missing + return + end + T::Private::Methods::CallValidation.validate_call( mocked_instance, unbound_mocked_method, diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb index 712d69b..67157e9 100644 --- a/spec/fixtures/shared/tax_calculator_sorbet.rb +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -10,4 +10,8 @@ class TaxCalculatorSorbet < TaxCalculator def simple_test(val) val end + + def simple_test_no_sig(val) + val + end end diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index 7bb88d7..dc3bd30 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -13,6 +13,36 @@ described_class.new end + describe "type check without signatures" do + def create_mcall(target) + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test_no_sig, + arguments: [120], + return_value: 120, + mocked_instance: target + ) + end + + it "when raise_on_missing false" do + allow(target).to receive(:simple_test_no_sig).and_return(120) + mcall = create_mcall(target) + + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "when raise_on_missing true" do + allow(target).to receive(:simple_test_no_sig).and_return(120) + mcall = create_mcall(target) + + expect do + checker.typecheck!(mcall, raise_on_missing: true) + end.to raise_error(MockSuey::TypeChecks::MissingSignature) + end + end + describe "type check argument type" do it "when correct" do allow(target).to receive(:simple_test).and_return(120) From 3518df8acb9b7d7efdb6a6e64a800cebb1398c54 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Fri, 10 Mar 2023 22:14:20 +0300 Subject: [PATCH 04/28] Improve Sorbet integration: blocks support, integration tests and readme --- Gemfile | 1 + README.md | 29 ++ lib/mock_suey/core.rb | 2 +- lib/mock_suey/method_call.rb | 3 +- lib/mock_suey/rspec/proxy_method_invoked.rb | 3 +- lib/mock_suey/sorbet_rspec.rb | 24 ++ lib/mock_suey/type_checks/sorbet.rb | 34 ++- spec/cases/typed_double_sorbet_spec.rb | 37 +++ spec/fixtures/rspec/double_sorbet_fixture.rb | 24 ++ .../rspec/instance_double_sorbet_fixture.rb | 36 +++ .../rspec/shared_examples/account_examples.rb | 4 +- spec/fixtures/rspec/spec_helper.rb | 1 + spec/fixtures/shared/tax_calculator_sorbet.rb | 57 ++++ spec/rspec/proxy_method_invoked_hook_spec.rb | 31 +- spec/support/integration_helpers.rb | 7 +- spec/type_checks/sorbet_spec.rb | 273 ++++++++++++++---- 16 files changed, 489 insertions(+), 77 deletions(-) create mode 100644 lib/mock_suey/sorbet_rspec.rb create mode 100644 spec/cases/typed_double_sorbet_spec.rb create mode 100644 spec/fixtures/rspec/double_sorbet_fixture.rb create mode 100644 spec/fixtures/rspec/instance_double_sorbet_fixture.rb diff --git a/Gemfile b/Gemfile index 312df73..d4e05a3 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false gem 'tapioca', require: false +gem 'pry' gemspec diff --git a/README.md b/README.md index f10b226..6736af2 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 MockSuey 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. + +```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_ @@ -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 diff --git a/lib/mock_suey/core.rb b/lib/mock_suey/core.rb index 301ae45..30004f4 100644 --- a/lib/mock_suey/core.rb +++ b/lib/mock_suey/core.rb @@ -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, diff --git a/lib/mock_suey/method_call.rb b/lib/mock_suey/method_call.rb index 4ad2a65..c6d65ca 100644 --- a/lib/mock_suey/method_call.rb +++ b/lib/mock_suey/method_call.rb @@ -12,7 +12,8 @@ class MethodCall < Struct.new( :return_value, :has_kwargs, :metadata, - :mocked_instance, + :mocked_obj, + :block, keyword_init: true ) def initialize(**) diff --git a/lib/mock_suey/rspec/proxy_method_invoked.rb b/lib/mock_suey/rspec/proxy_method_invoked.rb index 7acf394..81ef1ac 100644 --- a/lib/mock_suey/rspec/proxy_method_invoked.rb +++ b/lib/mock_suey/rspec/proxy_method_invoked.rb @@ -29,10 +29,11 @@ def proxy_method_invoked(obj, *args, &block) end method_call = MockSuey::MethodCall.new( - mocked_instance: obj, + mocked_obj: obj, receiver_class:, method_name:, arguments: args, + block: block, metadata: {example: ::RSpec.current_example} ) diff --git a/lib/mock_suey/sorbet_rspec.rb b/lib/mock_suey/sorbet_rspec.rb new file mode 100644 index 0000000..d60518e --- /dev/null +++ b/lib/mock_suey/sorbet_rspec.rb @@ -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| + 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 + + T::Configuration.call_validation_error_handler_default(signature, opts) +end diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index cee8982..3eb4e14 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true +gem "sorbet-runtime" +require "sorbet-runtime" require "set" require "pathname" +require_relative "../sorbet_rspec" require "mock_suey/ext/instance_class" module MockSuey @@ -10,30 +13,43 @@ 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!(call_obj, raise_on_missing: false) - method_name = call_obj.method_name - mocked_instance = call_obj.mocked_instance - unbound_mocked_method = mocked_instance.method(method_name).unbind - args = call_obj.arguments + def typecheck!(method_call, raise_on_missing: false) + method_name = method_call.method_name + mocked_obj = method_call.mocked_obj + is_singleton = method_call.receiver_class.singleton_class? + is_a_class = mocked_obj.is_a? Class + unbound_mocked_method = if is_singleton + mocked_obj.instance_method(method_name) + else + mocked_obj.method(method_name).unbind + end + args = method_call.arguments - unbound_original_method = call_obj.receiver_class.instance_method(method_name) + unbound_original_method = if is_a_class + mocked_obj.method(method_name) + else + method_call.receiver_class.instance_method(method_name) + end original_method_sig = T::Private::Methods.signature_for_method(unbound_original_method) unless original_method_sig - raise MissingSignature, "No signature found for #{call_obj.method_desc}" if raise_on_missing + raise MissingSignature, RAISE_ON_MISSING_MESSAGE if raise_on_missing return end + block = method_call.block T::Private::Methods::CallValidation.validate_call( - mocked_instance, + mocked_obj, unbound_mocked_method, original_method_sig, args, - nil + block ) end end diff --git a/spec/cases/typed_double_sorbet_spec.rb b/spec/cases/typed_double_sorbet_spec.rb new file mode 100644 index 0000000..683f820 --- /dev/null +++ b/spec/cases/typed_double_sorbet_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +describe "Typed double extension" 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" do + context "when signatures exist" 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("5 examples, 3 failures") + expect(output).to include("AccountantSorbet #tax_rate_for") + expect(output).to include("AccountantSorbet #net_pay") + expect(output).to include("AccountantSorbet#tax_for negative amount") + end + end + + context "when signatures do not exist" do + it "behaves as regular instance_double" do + status, output = run_rspec("instance_double", env: env) + + expect(status).not_to be_success + expect(output).to include("5 examples, 1 failure") + end + end + end + end +end diff --git a/spec/fixtures/rspec/double_sorbet_fixture.rb b/spec/fixtures/rspec/double_sorbet_fixture.rb new file mode 100644 index 0000000..05c9bee --- /dev/null +++ b/spec/fixtures/rspec/double_sorbet_fixture.rb @@ -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_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) + 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 diff --git a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb new file mode 100644 index 0000000..180b89f --- /dev/null +++ b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb @@ -0,0 +1,36 @@ +# 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_spec" + +describe AccountantSorbet do + let!(:tax_calculator) { + target = instance_double("TaxCalculatorSorbet") + allow(target).to receive(:tax_rate_for).and_return(10) + # FAILURE(typed): Return type is incorrect + allow(target).to receive(:for_income).and_return(42) + # FAILURE(contract): Return type doesn't match the passed arguments + allow(target).to receive(:for_income).with(-10).and_return(TaxCalculator::Result.new(0)) + target + } + let!(:accountant) { AccountantSorbet.new(tax_calculator: tax_calculator) } + + it "#net_pay" do + expect(subject.net_pay(89)).to eq 47 + end + + it "#tax_rate_for" do + # FAILURE(verified): Result in TaxCalucalor.tax_rate_for(40) calls, + # which doesn't match the parameters + expect(subject.tax_rate_for(40)).to eq(10) + end + + describe "#tax_for" do + specify "negative amount" do + expect(subject.tax_for(-10)).to eq(0) + end + end +end diff --git a/spec/fixtures/rspec/shared_examples/account_examples.rb b/spec/fixtures/rspec/shared_examples/account_examples.rb index d4756c5..efe8910 100644 --- a/spec/fixtures/rspec/shared_examples/account_examples.rb +++ b/spec/fixtures/rspec/shared_examples/account_examples.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -shared_examples "accountant" do - subject { Accountant.new(tax_calculator: tax_calculator) } +shared_examples "accountant" do |class_name = Accountant| + subject { class_name.new(tax_calculator: tax_calculator) } it "#net_pay" do expect(subject.net_pay(89)).to eq 47 diff --git a/spec/fixtures/rspec/spec_helper.rb b/spec/fixtures/rspec/spec_helper.rb index 430df61..c730d5c 100644 --- a/spec/fixtures/rspec/spec_helper.rb +++ b/spec/fixtures/rspec/spec_helper.rb @@ -6,6 +6,7 @@ config.debug = true config.store_mocked_calls = ENV["STORE_MOCKS"] == "true" config.type_check = :ruby if ENV["TYPED_DOUBLE"] == "true" + config.type_check = :sorbet if ENV["TYPED_SORBET"] == "true" config.signature_load_dirs = ENV["RBS_SIG_PATH"] config.auto_type_check = ENV["AUTO_TYPE_CHECK"] == "true" config.verify_mock_contracts = ENV["VERIFY_CONTRACTS"] == "true" diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb index 67157e9..fb646f8 100644 --- a/spec/fixtures/shared/tax_calculator_sorbet.rb +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -6,6 +6,37 @@ class TaxCalculatorSorbet < TaxCalculator extend T::Sig + sig { params(val: Integer).returns(Result) } + def for_income(val) + return if val < 0 + + tax_rate = tax_rate_for(value: val) + + Result.new((tax_rate * val).to_i, tax_rate) + end + + def tax_rate_for(value:) + self.class.tax_rate_for(value: value) + end + + def self.tax_rate_for(value:) + TAX_BRACKETS.keys.find { _1 > value }.then { TAX_BRACKETS[_1] } + end + + sig { params(val: Integer).returns(Integer) } + def self.class_method_test(val) + val + end + + def self.class_method_test_no_sig(val) + val + end + + sig { params(val: Integer, blk: T.proc.params(a: Integer).returns(String)).returns(String) } + def simple_block(val, &blk) + blk.call(val) + end + sig { params(val: Integer).returns(Integer) } def simple_test(val) val @@ -15,3 +46,29 @@ def simple_test_no_sig(val) val end end + +class AccountantSorbet < Accountant + extend T::Sig + + attr_reader :tax_calculator + + sig { params(tax_calculator: TaxCalculator).void } + def initialize(tax_calculator: TaxCalculator.new) + @tax_calculator = tax_calculator + end + + sig { params(val: Integer).returns(Integer) } + def net_pay(val) + val - tax_calculator.for_income(val) + end + + sig { params(val: Integer).returns(Integer) } + def tax_for(val) + tax_calculator.for_income(val).result + end + + sig { params(value: Integer).returns(Integer) } + def tax_rate_for(value) + tax_calculator.tax_rate_for(value:) + end +end diff --git a/spec/rspec/proxy_method_invoked_hook_spec.rb b/spec/rspec/proxy_method_invoked_hook_spec.rb index 8526b99..8210498 100644 --- a/spec/rspec/proxy_method_invoked_hook_spec.rb +++ b/spec/rspec/proxy_method_invoked_hook_spec.rb @@ -38,7 +38,7 @@ method_name: :key?, arguments: ["x"], return_value: true, - mocked_instance: target + mocked_obj: target ) end @@ -54,7 +54,24 @@ method_name: :key?, arguments: ["x"], return_value: true, - mocked_instance: target + mocked_obj: target + ) + end + + it "#instance_double with block" do + target = instance_double(TestHash) + allow(target).to receive(:each_key).and_yield("444") + + block = proc { |_| "333" } + expect(target.each_key { |n| n }).to eq("444") + + expect(mcalls.size).to eq(1) + expect(mcalls.first).to have_attributes( + receiver_class: TestHash, + method_name: :each_key, + arguments: [], + mocked_obj: target, + block: block ) end @@ -70,7 +87,7 @@ method_name: :key?, arguments: ["x"], return_value: true, - mocked_instance: target + mocked_obj: target ) end @@ -86,7 +103,7 @@ method_name: :key?, arguments: ["x"], return_value: true, - mocked_instance: target + mocked_obj: target ) end @@ -100,7 +117,7 @@ method_name: :escape, arguments: ["foo"], return_value: "bar", - mocked_instance: TestRegexp + mocked_obj: TestRegexp ) end @@ -117,14 +134,14 @@ receiver_class: TestHash, method_name: :initialize, arguments: [], - mocked_instance: TestHash + mocked_obj: TestHash ) expect(mcalls.last).to have_attributes( receiver_class: TestHash, method_name: :[], arguments: ["a"], return_value: 10, - mocked_instance: hash_double + mocked_obj: hash_double ) end end diff --git a/spec/support/integration_helpers.rb b/spec/support/integration_helpers.rb index b122e12..78f1832 100644 --- a/spec/support/integration_helpers.rb +++ b/spec/support/integration_helpers.rb @@ -10,14 +10,15 @@ module IntegrationHelpers "bundle exec ruby" end - RSPEC_STUB = File.join(__dir__, "../../bin/rspec") + ROOT_DIR = File.expand_path(".") + RSPEC_STUB = File.join(ROOT_DIR, "bin/rspec") def run_rspec(path, chdir: nil, success: true, env: {}, options: "") - command = "#{RUBY_RUNNER} #{RSPEC_STUB} #{options} #{path}_fixture.rb" + command = "#{RUBY_RUNNER} #{RSPEC_STUB} #{options} spec/fixtures/rspec/#{path}_fixture.rb" output, err, status = Open3.capture3( env, command, - chdir: chdir || File.expand_path("../../fixtures/rspec", __FILE__) + chdir: ROOT_DIR ) if ENV["DEBUG"] == "1" diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index dc3bd30..c282c0b 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -3,6 +3,7 @@ require "mock_suey/type_checks/sorbet" require_relative "../fixtures/shared/tax_calculator_sorbet" +# TODO: add singleton classes checks describe MockSuey::TypeChecks::Sorbet do subject(:checker) { described_class.new } @@ -14,18 +15,31 @@ end describe "type check without signatures" do - def create_mcall(target) + skip "type-checks core classes" do + # 'sorbet-runtime' does not include signatures for stdlib classes yet mcall = MockSuey::MethodCall.new( + receiver_class: Array, + method_name: :take, + arguments: ["first"], + mocked_obj: [] + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /Expected.*Integer.*got.*String.*/) + end + + def create_mcall(target) + MockSuey::MethodCall.new( receiver_class: TaxCalculatorSorbet, method_name: :simple_test_no_sig, arguments: [120], - return_value: 120, - mocked_instance: target + mocked_obj: target ) end it "when raise_on_missing false" do - allow(target).to receive(:simple_test_no_sig).and_return(120) + allow(target).to receive(:simple_test_no_sig).and_return(333) mcall = create_mcall(target) expect do @@ -34,7 +48,7 @@ def create_mcall(target) end it "when raise_on_missing true" do - allow(target).to receive(:simple_test_no_sig).and_return(120) + allow(target).to receive(:simple_test_no_sig).and_return(333) mcall = create_mcall(target) expect do @@ -44,70 +58,223 @@ def create_mcall(target) end describe "type check argument type" do - it "when correct" do - allow(target).to receive(:simple_test).and_return(120) + describe "for mocked instance methods" do + it "when correct" do + allow(target).to receive(:simple_test).and_return(333) - mcall = MockSuey::MethodCall.new( - receiver_class: TaxCalculatorSorbet, - method_name: :simple_test, - arguments: [120], - return_value: 120, - mocked_instance: target - ) + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: [120], + mocked_obj: target + ) - expect do - checker.typecheck!(mcall) - end.not_to raise_error + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "when incorrect" do + allow(target).to receive(:simple_test).and_return(333) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: ["120"], + mocked_obj: target + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /Parameter.*val.*Expected.*Integer.*got.*String.*/) + end + + it "when block is passed correctly" do + allow(target).to receive(:simple_block).and_yield("444") + block = proc { |_n| "333" } + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_block, + arguments: [123], + mocked_obj: target, + block: block + ) + + expect(checker.typecheck!(mcall)).to eq("333") + expect { checker.typecheck!(mcall) }.not_to raise_error + end + + it "when no block has been passed" do + allow(target).to receive(:simple_block).and_yield("444") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_block, + arguments: [123], + mocked_obj: target + ) + + expect { checker.typecheck!(mcall) }.to raise_error(TypeError) + end end - it "when incorrect" do - allow(target).to receive(:simple_test).and_return(120) + describe "for mocked class methods" do + describe "initialize" do + let(:target) { instance_double("AccountantSorbet") } - mcall = MockSuey::MethodCall.new( - receiver_class: TaxCalculatorSorbet, - method_name: :simple_test, - arguments: ["120"], - return_value: 120, - mocked_instance: target - ) + it "called correctly" do + allow(target).to receive(:initialize) - expect do - checker.typecheck!(mcall) - end.to raise_error(TypeError, /Parameter.*val.*Expected.*Integer.*got.*String.*/) + mcall = MockSuey::MethodCall.new( + receiver_class: AccountantSorbet, + method_name: :initialize, + arguments: [{tax_calculator: TaxCalculator.new}], + mocked_obj: target + ) + + expect { checker.typecheck!(mcall) }.not_to raise_error + end + + it "called incorrectly" do + allow(target).to receive(:initialize) + + mcall = MockSuey::MethodCall.new( + receiver_class: AccountantSorbet, + method_name: :initialize, + arguments: [{tax_calculator: "incorrect"}], + mocked_obj: target + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /.*Parameter.*tax_calculator.*Expected.*TaxCalculator.*got.*String.*/) + end + end + + it "when correct" do + allow(TaxCalculatorSorbet).to receive(:class_method_test).and_return(333) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :class_method_test, + arguments: [120], + mocked_obj: TaxCalculatorSorbet + ) + + expect { checker.typecheck!(mcall) }.not_to raise_error + end + + it "when incorrect" do + allow(TaxCalculatorSorbet).to receive(:class_method_test).and_return(333) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :class_method_test, + arguments: ["120"], + mocked_obj: TaxCalculatorSorbet + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /Parameter.*val.*Expected.*Integer.*got.*String.*/) + end end end describe "type check return type" do - it "when correct" do - allow(target).to receive(:simple_test).and_return(120) + describe "for mocked instance methods" do + it "when simple type is correct" do + allow(target).to receive(:simple_test).and_return(333) - mcall = MockSuey::MethodCall.new( - receiver_class: TaxCalculatorSorbet, - method_name: :simple_test, - arguments: [120], - return_value: 120, - mocked_instance: target - ) + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: [120], + mocked_obj: target + ) - expect do - checker.typecheck!(mcall) - end.not_to raise_error + expect(checker.typecheck!(mcall)).to eq(333) + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "when simple type is incorrect" do + allow(target).to receive(:simple_test).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test, + arguments: [120], + mocked_obj: target + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /.*Return value.*Expected.*Integer.*got.*String.*/) + end + + it "when custom class is incorrect" do + allow(target).to receive(:for_income).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :for_income, + arguments: [120], + mocked_obj: target + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /.*Return value.*Expected.*TaxCalculator::Result.*got.*String.*/) + end + + it "when custom class is correct" do + allow(target).to receive(:for_income).and_return(TaxCalculator::Result.new) + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :for_income, + arguments: [120], + mocked_obj: target + ) + + expect { checker.typecheck!(mcall) }.not_to raise_error + end end - it "when incorrect" do - allow(target).to receive(:simple_test).and_return("incorrect") + describe "for mocked class methods" do + it "when correct" do + allow(TaxCalculatorSorbet).to receive(:class_method_test).and_return(333) - mcall = MockSuey::MethodCall.new( - receiver_class: TaxCalculatorSorbet, - method_name: :simple_test, - arguments: [120], - return_value: 120, - mocked_instance: target - ) + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :class_method_test, + arguments: [120], + mocked_obj: TaxCalculatorSorbet + ) - expect do - checker.typecheck!(mcall) - end.to raise_error(TypeError, /.*Return value.*Expected.*Integer.*got.*String.*/) + expect(checker.typecheck!(mcall)).to eq(333) + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "when incorrect" do + allow(TaxCalculatorSorbet).to receive(:class_method_test).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :class_method_test, + arguments: [120], + mocked_obj: TaxCalculatorSorbet + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /.*Return value.*Expected.*Integer.*got.*String.*/) + end end end end From 12f43a9d7d578f3cb681304862c675b14ab17df3 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Fri, 10 Mar 2023 22:18:35 +0300 Subject: [PATCH 05/28] remove pry gem --- Gemfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Gemfile b/Gemfile index d4e05a3..312df73 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,6 @@ gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false gem 'tapioca', require: false -gem 'pry' gemspec From f704ff4772dc00d1affe3c42a0c3f068843e4f58 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 12:23:54 +0300 Subject: [PATCH 06/28] test message for MethodMissing for sorbet --- spec/type_checks/sorbet_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index c282c0b..d97396a 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -53,7 +53,7 @@ def create_mcall(target) expect do checker.typecheck!(mcall, raise_on_missing: true) - end.to raise_error(MockSuey::TypeChecks::MissingSignature) + end.to raise_error(MockSuey::TypeChecks::MissingSignature, /.*set raise_on_missing_types to false.*/) end end From a6f93a2c6e0afc5411d97b668da33cf497021db1 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 12:26:46 +0300 Subject: [PATCH 07/28] small refactoring: change T::Private::Methods... to T::Utils --- lib/mock_suey/type_checks/sorbet.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index 3eb4e14..117b726 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -36,7 +36,7 @@ def typecheck!(method_call, raise_on_missing: false) else method_call.receiver_class.instance_method(method_name) end - original_method_sig = T::Private::Methods.signature_for_method(unbound_original_method) + original_method_sig = T::Utils.signature_for_method(unbound_original_method) unless original_method_sig raise MissingSignature, RAISE_ON_MISSING_MESSAGE if raise_on_missing From fe22912700de4d7e80e7b20f4ff7adc1efb56dea Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 12:38:04 +0300 Subject: [PATCH 08/28] fix: passing unrelated double into sig methods --- lib/mock_suey/sorbet_rspec.rb | 2 +- spec/cases/typed_double_sorbet_spec.rb | 2 +- spec/fixtures/rspec/instance_double_sorbet_fixture.rb | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/mock_suey/sorbet_rspec.rb b/lib/mock_suey/sorbet_rspec.rb index d60518e..6236dc6 100644 --- a/lib/mock_suey/sorbet_rspec.rb +++ b/lib/mock_suey/sorbet_rspec.rb @@ -20,5 +20,5 @@ are_related = doubled_class <= opts[:type].raw_type return if are_related - T::Configuration.call_validation_error_handler_default(signature, opts) + return T::Configuration.send(:call_validation_error_handler_default, signature, opts) end diff --git a/spec/cases/typed_double_sorbet_spec.rb b/spec/cases/typed_double_sorbet_spec.rb index 683f820..992ce1d 100644 --- a/spec/cases/typed_double_sorbet_spec.rb +++ b/spec/cases/typed_double_sorbet_spec.rb @@ -17,7 +17,7 @@ status, output = run_rspec("instance_double_sorbet", env: env) expect(status).not_to be_success - expect(output).to include("5 examples, 3 failures") + expect(output).to include("6 examples, 3 failures") expect(output).to include("AccountantSorbet #tax_rate_for") expect(output).to include("AccountantSorbet #net_pay") expect(output).to include("AccountantSorbet#tax_for negative amount") diff --git a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb index 180b89f..6498c41 100644 --- a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb +++ b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb @@ -18,6 +18,13 @@ } let!(:accountant) { AccountantSorbet.new(tax_calculator: tax_calculator) } + it "sorbet_rspec" do + unrelated_double = instance_double("Array") + expect do + described_class.new(tax_calculator: unrelated_double) + end.to raise_error(TypeError, /.*Expected type TaxCalculator, got type RSpec::Mocks::InstanceVerifyingDouble.*/) + end + it "#net_pay" do expect(subject.net_pay(89)).to eq 47 end From 46ad16f960c1275c81fb7e1dadb9a2729ebc62d6 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 14:53:58 +0300 Subject: [PATCH 09/28] Fix recursion and write more integration tests --- lib/mock_suey/type_checks/sorbet.rb | 2 + spec/cases/typed_double_sorbet_spec.rb | 30 ++-- spec/fixtures/rspec/double_sorbet_fixture.rb | 4 +- .../rspec/instance_double_sorbet_fixture.rb | 132 +++++++++++++++--- .../rspec/tax_calculator_sorbet_spec.rb | 17 +++ spec/fixtures/shared/tax_calculator_sorbet.rb | 5 +- spec/type_checks/sorbet_spec.rb | 9 +- 7 files changed, 149 insertions(+), 50 deletions(-) create mode 100644 spec/fixtures/rspec/tax_calculator_sorbet_spec.rb diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index 117b726..29c2340 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -44,6 +44,8 @@ def typecheck!(method_call, raise_on_missing: false) end block = method_call.block + mocked_obj.define_singleton_method(method_name) { |*args, &block| method_call.return_value } + T::Private::Methods::CallValidation.validate_call( mocked_obj, unbound_mocked_method, diff --git a/spec/cases/typed_double_sorbet_spec.rb b/spec/cases/typed_double_sorbet_spec.rb index 992ce1d..079809f 100644 --- a/spec/cases/typed_double_sorbet_spec.rb +++ b/spec/cases/typed_double_sorbet_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Typed double extension" do +describe "Sorbet integration tests" do context "RSpec" do let(:env) { {"TYPED_SORBET" => "true"} } @@ -11,26 +11,16 @@ expect(output).to include("6 examples, 0 failures") end - context "instance_double" do - context "when signatures exist" do - it "enhances instance_double without extensions" do - status, output = run_rspec("instance_double_sorbet", env: env) + 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("6 examples, 3 failures") - expect(output).to include("AccountantSorbet #tax_rate_for") - expect(output).to include("AccountantSorbet #net_pay") - expect(output).to include("AccountantSorbet#tax_for negative amount") - end - end - - context "when signatures do not exist" do - it "behaves as regular instance_double" do - status, output = run_rspec("instance_double", env: env) - - expect(status).not_to be_success - expect(output).to include("5 examples, 1 failure") - end + 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 diff --git a/spec/fixtures/rspec/double_sorbet_fixture.rb b/spec/fixtures/rspec/double_sorbet_fixture.rb index 05c9bee..e86ab7a 100644 --- a/spec/fixtures/rspec/double_sorbet_fixture.rb +++ b/spec/fixtures/rspec/double_sorbet_fixture.rb @@ -4,12 +4,12 @@ require_relative "./spec_helper" require_relative "../shared/tax_calculator_sorbet" -require_relative "tax_calculator_spec" +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) + 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 diff --git a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb index 6498c41..86e7458 100644 --- a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb +++ b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb @@ -4,40 +4,126 @@ require_relative "./spec_helper" require_relative "../shared/tax_calculator_sorbet" -require_relative "tax_calculator_spec" +require_relative "tax_calculator_sorbet_spec" describe AccountantSorbet do - let!(:tax_calculator) { - target = instance_double("TaxCalculatorSorbet") - allow(target).to receive(:tax_rate_for).and_return(10) - # FAILURE(typed): Return type is incorrect - allow(target).to receive(:for_income).and_return(42) - # FAILURE(contract): Return type doesn't match the passed arguments - allow(target).to receive(:for_income).with(-10).and_return(TaxCalculator::Result.new(0)) - target - } + let!(:tax_calculator) { instance_double("TaxCalculatorSorbet") } let!(:accountant) { AccountantSorbet.new(tax_calculator: tax_calculator) } - it "sorbet_rspec" do - unrelated_double = instance_double("Array") - expect do - described_class.new(tax_calculator: unrelated_double) - end.to raise_error(TypeError, /.*Expected type TaxCalculator, got type RSpec::Mocks::InstanceVerifyingDouble.*/) + describe ".initialize checks handled by sorbet_rspec.rb" do + it "correct param" do + tc = TaxCalculatorSorbet.new + expect { described_class.new(tax_calculator: tc) }.not_to raise_error + end + it "correct double" do + tc = double("TaxCalculatorSorbet") + expect { described_class.new(tax_calculator: tc) }.not_to raise_error + end + it "correct instance double" do + tc = instance_double("TaxCalculatorSorbet") + expect { described_class.new(tax_calculator: tc) }.not_to raise_error + end + it "correct instance double from parent class" do + tc = instance_double("TaxCalculator") + expect { described_class.new(tax_calculator: tc) }.not_to raise_error + end + it "unrelated double" do + unrelated_double = instance_double("Array") + expect do + described_class.new(tax_calculator: unrelated_double) + end.to raise_error(TypeError, /.*Expected type TaxCalculator, got type RSpec::Mocks::InstanceVerifyingDouble.*/) + end end - it "#net_pay" do - expect(subject.net_pay(89)).to eq 47 + describe "#net_pay" do + describe "without mocks" do + let(:tax_calculator) { TaxCalculatorSorbet.new } + it "raises ruby error because the method is intentionally written incorrectly" do + expect { accountant.net_pay(10) }.to raise_error(TypeError, "TaxCalculator::Result can't be coerced into Integer") + expect { accountant.net_pay(10) }.not_to raise_error # intentionaly + end + end + + describe "with mocks" do + let!(:tax_calculator) do + target = instance_double("TaxCalculatorSorbet") + allow(target).to receive(:for_income).and_return(return_result) + target + end + describe "with incorrect return" do + let(:return_result) { Array } + it "raises error because the method is intentionally written incorrectly" do + expect { accountant.net_pay(10) }.to raise_error(TypeError) + expect { accountant.net_pay(10) }.not_to raise_error # intentionaly + end + end + describe "with correct return" do + let!(:return_result) { TaxCalculator::Result.new(3, 33) } + it "raises error because the method is intentionally written incorrectly" do + expect { accountant.net_pay(10) }.to raise_error(TypeError) + expect { accountant.net_pay(10) }.not_to raise_error # intentionaly + end + end + end end - it "#tax_rate_for" do - # FAILURE(verified): Result in TaxCalucalor.tax_rate_for(40) calls, - # which doesn't match the parameters - expect(subject.tax_rate_for(40)).to eq(10) + describe "#tax_rate_for" do + describe "without mocks" do + let(:tax_calculator) { TaxCalculatorSorbet.new } + it "succeeds" do + expect { accountant.tax_rate_for(10) }.not_to raise_error + end + end + + describe "with mocks" do + let!(:tax_calculator) do + target = TaxCalculatorSorbet.new + allow(target).to receive(:tax_rate_for).and_return(return_result) + target + end + describe "with incorrect return" do + let(:return_result) { "incorrect" } + it "fails with TypeError" do + expect { accountant.tax_rate_for(10) }.to raise_error(TypeError, /.*Return value.*Expected type Float, got type String.*/) + expect { accountant.tax_rate_for(10) }.not_to raise_error # intentionaly + end + end + describe "with correct return" do + let(:return_result) { 0.333 } + it "returns correct result" do + expect(accountant.tax_rate_for(10)).to eq(0.333) + end + end + end end describe "#tax_for" do - specify "negative amount" do - expect(subject.tax_for(-10)).to eq(0) + describe "without mocks" do + let(:tax_calculator) { TaxCalculatorSorbet.new } + it "succeeds" do + expect { accountant.tax_for(10) }.not_to raise_error + end + end + + describe "with mocks" do + let!(:tax_calculator) do + target = TaxCalculatorSorbet.new + allow(target).to receive(:tax_rate_for).and_return(return_result) + target + end + describe "with incorrect return" do + let(:return_result) { Array } + it "fails with NoMethodError because tax_rate_for does not have signature" do + expect { accountant.tax_for(10) }.to raise_error(NoMethodError) + expect { accountant.tax_for(10) }.not_to raise_error # intentionaly + end + end + describe "with correct return" do + let(:return_result) { 0.333 } + it "returns correct result" do + expect(accountant.tax_for(10)).to eq(3) + end + end end end end diff --git a/spec/fixtures/rspec/tax_calculator_sorbet_spec.rb b/spec/fixtures/rspec/tax_calculator_sorbet_spec.rb new file mode 100644 index 0000000..cdf662c --- /dev/null +++ b/spec/fixtures/rspec/tax_calculator_sorbet_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative "../shared/tax_calculator_sorbet" + +describe TaxCalculatorSorbet do + subject { described_class.new } + + describe "#for_income" do + specify "positive" do + expect(subject.for_income(89).result).to eq(19) + end + + specify "negative" do + expect(subject.for_income(-10)).to be_nil + end + end +end diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb index fb646f8..f2d5e4c 100644 --- a/spec/fixtures/shared/tax_calculator_sorbet.rb +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -6,7 +6,7 @@ class TaxCalculatorSorbet < TaxCalculator extend T::Sig - sig { params(val: Integer).returns(Result) } + sig { params(val: Integer).returns(T.nilable(Result)) } def for_income(val) return if val < 0 @@ -59,6 +59,7 @@ def initialize(tax_calculator: TaxCalculator.new) sig { params(val: Integer).returns(Integer) } def net_pay(val) + # intentionally incorrect val - tax_calculator.for_income(val) end @@ -67,7 +68,7 @@ def tax_for(val) tax_calculator.for_income(val).result end - sig { params(value: Integer).returns(Integer) } + sig { params(value: Integer).returns(Float) } def tax_rate_for(value) tax_calculator.tax_rate_for(value:) end diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index d97396a..d898c77 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -98,7 +98,8 @@ def create_mcall(target) method_name: :simple_block, arguments: [123], mocked_obj: target, - block: block + block: block, + return_value: "333" ) expect(checker.typecheck!(mcall)).to eq("333") @@ -191,7 +192,8 @@ def create_mcall(target) receiver_class: TaxCalculatorSorbet, method_name: :simple_test, arguments: [120], - mocked_obj: target + mocked_obj: target, + return_value: 333 ) expect(checker.typecheck!(mcall)).to eq(333) @@ -252,7 +254,8 @@ def create_mcall(target) receiver_class: TaxCalculatorSorbet, method_name: :class_method_test, arguments: [120], - mocked_obj: TaxCalculatorSorbet + mocked_obj: TaxCalculatorSorbet, + return_value: 333 ) expect(checker.typecheck!(mcall)).to eq(333) From c7f6159f87389cd7ce8b3f71de95ee9ee055c277 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:16:18 +0300 Subject: [PATCH 10/28] TypeChecks::Sorbet refactoring --- lib/mock_suey/type_checks/sorbet.rb | 72 +++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index 29c2340..8a13ffe 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -20,39 +20,71 @@ def initialize(load_dirs: []) end def typecheck!(method_call, raise_on_missing: false) + 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_name = method_call.method_name mocked_obj = method_call.mocked_obj + is_singleton = method_call.receiver_class.singleton_class? - is_a_class = mocked_obj.is_a? Class - unbound_mocked_method = if is_singleton + if is_singleton mocked_obj.instance_method(method_name) else mocked_obj.method(method_name).unbind end - args = method_call.arguments + end - unbound_original_method = if is_a_class - mocked_obj.method(method_name) + 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 - original_method_sig = T::Utils.signature_for_method(unbound_original_method) - - unless original_method_sig - raise MissingSignature, RAISE_ON_MISSING_MESSAGE if raise_on_missing - return - end - block = method_call.block + end - mocked_obj.define_singleton_method(method_name) { |*args, &block| method_call.return_value } + def sig_is_missing(raise_on_missing) + raise MissingSignature, RAISE_ON_MISSING_MESSAGE if raise_on_missing + end - T::Private::Methods::CallValidation.validate_call( - mocked_obj, - unbound_mocked_method, - original_method_sig, - args, - block - ) + 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 } end end end From 35028742411da281a34db6f444e03418e16d1e91 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:26:46 +0300 Subject: [PATCH 11/28] specify required gems versions --- Gemfile | 2 +- lib/mock_suey/type_checks/sorbet.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 312df73..7690f5a 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,7 @@ gem "rbs", "< 3.0" gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false -gem 'tapioca', require: false +gem "rspec" gemspec diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index 8a13ffe..0f4c0d2 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -gem "sorbet-runtime" +gem "sorbet-runtime", "~> 0.5" require "sorbet-runtime" require "set" require "pathname" From a2615a451369ad9ca9540b006aca30c837f7e03a Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:42:19 +0300 Subject: [PATCH 12/28] fix singleton classes in sorbet --- lib/mock_suey/type_checks/sorbet.rb | 10 +----- spec/fixtures/shared/tax_calculator_sorbet.rb | 9 +++++ spec/type_checks/sorbet_spec.rb | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index 0f4c0d2..d25df60 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -48,15 +48,7 @@ def validate_call!(instance:, original_method:, method_sig:, args:, blk:) end def get_unbound_mocked_method(method_call) - method_name = method_call.method_name - mocked_obj = method_call.mocked_obj - - is_singleton = method_call.receiver_class.singleton_class? - if is_singleton - mocked_obj.instance_method(method_name) - else - mocked_obj.method(method_name).unbind - end + method_call.mocked_obj.method(method_call.method_name).unbind end def get_original_method_sig(method_call) diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb index f2d5e4c..f81b2a8 100644 --- a/spec/fixtures/shared/tax_calculator_sorbet.rb +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -45,6 +45,15 @@ def simple_test(val) def simple_test_no_sig(val) val end + + class << self + extend T::Sig + + sig { params(val: Integer).returns(Integer) } + def self.singleton_test(val) + val + end + end end class AccountantSorbet < Accountant diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index d898c77..af0f78f 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -58,6 +58,40 @@ def create_mcall(target) end describe "type check argument type" do + describe "check for singleton classes" do + let(:sc) { TaxCalculatorSorbet.singleton_class } + + it "type-checks singleton classes" do + mcall = MockSuey::MethodCall.new( + receiver_class: sc, + method_name: :singleton_test, + arguments: [1], + return_value: 333, + mocked_obj: sc + ) + + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end + + it "type-checks mocked singleton classes" do + allow(sc).to receive(:singleton_test).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: sc, + method_name: :singleton_test, + arguments: [1], + return_value: sc.singleton_test(1), + mocked_obj: sc + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError, /Return value.*Expected.*Integer.*got.*String/) + end + end + describe "for mocked instance methods" do it "when correct" do allow(target).to receive(:simple_test).and_return(333) From 3930a0cc7b0a5f79ad113070f612339f2b1453d1 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:51:35 +0300 Subject: [PATCH 13/28] add tests for Sorbet WithoutRuntime.sig --- spec/fixtures/shared/tax_calculator_sorbet.rb | 5 +++++ spec/type_checks/sorbet_spec.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb index f81b2a8..23d7d8c 100644 --- a/spec/fixtures/shared/tax_calculator_sorbet.rb +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -46,6 +46,11 @@ def simple_test_no_sig(val) val end + T::Sig::WithoutRuntime.sig { params(val: Integer).returns(Integer) } + def simple_test_no_runtime(val) + val + end + class << self extend T::Sig diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index af0f78f..57351d8 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -278,6 +278,21 @@ def create_mcall(target) expect { checker.typecheck!(mcall) }.not_to raise_error end + + it "does not raise type error when without runtime" do + allow(target).to receive(:simple_test_no_runtime).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test_no_runtime, + arguments: [120], + mocked_obj: target + ) + + expect do + checker.typecheck!(mcall) + end.not_to raise_error + end end describe "for mocked class methods" do From e1201a4e603669972966228d7e8a6638da52d535 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:56:37 +0300 Subject: [PATCH 14/28] add tests for Sorbet .on_failure(:log) --- spec/fixtures/shared/tax_calculator_sorbet.rb | 5 +++++ spec/type_checks/sorbet_spec.rb | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/spec/fixtures/shared/tax_calculator_sorbet.rb b/spec/fixtures/shared/tax_calculator_sorbet.rb index 23d7d8c..c5f69d7 100644 --- a/spec/fixtures/shared/tax_calculator_sorbet.rb +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -51,6 +51,11 @@ def simple_test_no_runtime(val) val end + sig { params(val: Integer).returns(Integer).on_failure(:log) } + def simple_test_log_on_error(val) + val + end + class << self extend T::Sig diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index 57351d8..21c55ff 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -286,13 +286,30 @@ def create_mcall(target) receiver_class: TaxCalculatorSorbet, method_name: :simple_test_no_runtime, arguments: [120], - mocked_obj: target + mocked_obj: target, + return_value: "incorrect" ) expect do checker.typecheck!(mcall) end.not_to raise_error end + + it "raises error when added on_failure()" do + allow(target).to receive(:simple_test_log_on_error).and_return("incorrect") + + mcall = MockSuey::MethodCall.new( + receiver_class: TaxCalculatorSorbet, + method_name: :simple_test_log_on_error, + arguments: [120], + mocked_obj: target, + return_value: "incorrect" + ) + + expect do + checker.typecheck!(mcall) + end.to raise_error(TypeError) + end end describe "for mocked class methods" do From 00fc30cc8806bcf950446e1d29a317dac3017f6b Mon Sep 17 00:00:00 2001 From: Vitalii Yulieff Date: Thu, 16 Mar 2023 18:30:05 +0300 Subject: [PATCH 15/28] Update README.md Co-authored-by: Vladimir Dementyev --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6736af2..57351dd 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Typed doubles rely on the type signatures being defined. What if you don't have ### Using with Sorbet -To use MockSuey with Sorbet, configure it as follows: +To use Mock Suey with Sorbet, configure it as follows: ```ruby MockSuey.configure do |config| From e54a28d2c1d75bd35a528b058179e412fa4a9927 Mon Sep 17 00:00:00 2001 From: Vitalii Yulieff Date: Thu, 16 Mar 2023 19:01:20 +0300 Subject: [PATCH 16/28] Update lib/mock_suey/rspec/proxy_method_invoked.rb Co-authored-by: Vladimir Dementyev --- lib/mock_suey/rspec/proxy_method_invoked.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mock_suey/rspec/proxy_method_invoked.rb b/lib/mock_suey/rspec/proxy_method_invoked.rb index 81ef1ac..07313f5 100644 --- a/lib/mock_suey/rspec/proxy_method_invoked.rb +++ b/lib/mock_suey/rspec/proxy_method_invoked.rb @@ -33,7 +33,7 @@ def proxy_method_invoked(obj, *args, &block) receiver_class:, method_name:, arguments: args, - block: block, + block:, metadata: {example: ::RSpec.current_example} ) From 8752f4a9feef79bf01e372540222aa47d755bab4 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Tue, 7 Mar 2023 17:52:45 +0300 Subject: [PATCH 17/28] Add basic sorbet integration --- Gemfile | 3 +++ spec/type_checks/sorbet_spec.rb | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 7690f5a..e76f0ca 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,9 @@ gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false gem "rspec" +gem 'sorbet', require: false +gem 'sorbet-runtime', require: false +gem 'tapioca', require: false gemspec diff --git a/spec/type_checks/sorbet_spec.rb b/spec/type_checks/sorbet_spec.rb index 21c55ff..142aec0 100644 --- a/spec/type_checks/sorbet_spec.rb +++ b/spec/type_checks/sorbet_spec.rb @@ -3,7 +3,6 @@ require "mock_suey/type_checks/sorbet" require_relative "../fixtures/shared/tax_calculator_sorbet" -# TODO: add singleton classes checks describe MockSuey::TypeChecks::Sorbet do subject(:checker) { described_class.new } From 4adac918bcf79180b1faea7e02442ffd0fe60893 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:26:46 +0300 Subject: [PATCH 18/28] specify required gems versions --- Gemfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Gemfile b/Gemfile index e76f0ca..7690f5a 100644 --- a/Gemfile +++ b/Gemfile @@ -8,9 +8,6 @@ gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false gem "rspec" -gem 'sorbet', require: false -gem 'sorbet-runtime', require: false -gem 'tapioca', require: false gemspec From cde36ce5585f679adff2ea1b180507a673207265 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Tue, 7 Mar 2023 17:52:45 +0300 Subject: [PATCH 19/28] Add basic sorbet integration --- Gemfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Gemfile b/Gemfile index 7690f5a..e76f0ca 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,9 @@ gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false gem "rspec" +gem 'sorbet', require: false +gem 'sorbet-runtime', require: false +gem 'tapioca', require: false gemspec From ba6de3bf85d7aa7afe957265845ad7488e360cd0 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 11 Mar 2023 15:26:46 +0300 Subject: [PATCH 20/28] specify required gems versions --- Gemfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Gemfile b/Gemfile index e76f0ca..7690f5a 100644 --- a/Gemfile +++ b/Gemfile @@ -8,9 +8,6 @@ gem "rspec" gem 'sorbet', require: false gem 'sorbet-runtime', require: false gem "rspec" -gem 'sorbet', require: false -gem 'sorbet-runtime', require: false -gem 'tapioca', require: false gemspec From 1350ddec44e446fd1c7215a3d88521eab37e3d31 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Thu, 16 Mar 2023 21:00:35 +0300 Subject: [PATCH 21/28] fix rubocop --- .../rspec/instance_double_sorbet_fixture.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb index 86e7458..79bc142 100644 --- a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb +++ b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb @@ -38,6 +38,7 @@ describe "#net_pay" do describe "without mocks" do let(:tax_calculator) { TaxCalculatorSorbet.new } + it "raises ruby error because the method is intentionally written incorrectly" do expect { accountant.net_pay(10) }.to raise_error(TypeError, "TaxCalculator::Result can't be coerced into Integer") expect { accountant.net_pay(10) }.not_to raise_error # intentionaly @@ -50,15 +51,19 @@ allow(target).to receive(:for_income).and_return(return_result) target end + describe "with incorrect return" do let(:return_result) { Array } + it "raises error because the method is intentionally written incorrectly" do expect { accountant.net_pay(10) }.to raise_error(TypeError) expect { accountant.net_pay(10) }.not_to raise_error # intentionaly end end + describe "with correct return" do let!(:return_result) { TaxCalculator::Result.new(3, 33) } + it "raises error because the method is intentionally written incorrectly" do expect { accountant.net_pay(10) }.to raise_error(TypeError) expect { accountant.net_pay(10) }.not_to raise_error # intentionaly @@ -70,6 +75,7 @@ describe "#tax_rate_for" do describe "without mocks" do let(:tax_calculator) { TaxCalculatorSorbet.new } + it "succeeds" do expect { accountant.tax_rate_for(10) }.not_to raise_error end @@ -81,15 +87,19 @@ allow(target).to receive(:tax_rate_for).and_return(return_result) target end + describe "with incorrect return" do let(:return_result) { "incorrect" } + it "fails with TypeError" do expect { accountant.tax_rate_for(10) }.to raise_error(TypeError, /.*Return value.*Expected type Float, got type String.*/) expect { accountant.tax_rate_for(10) }.not_to raise_error # intentionaly end end + describe "with correct return" do let(:return_result) { 0.333 } + it "returns correct result" do expect(accountant.tax_rate_for(10)).to eq(0.333) end @@ -100,6 +110,7 @@ describe "#tax_for" do describe "without mocks" do let(:tax_calculator) { TaxCalculatorSorbet.new } + it "succeeds" do expect { accountant.tax_for(10) }.not_to raise_error end @@ -111,15 +122,19 @@ allow(target).to receive(:tax_rate_for).and_return(return_result) target end + describe "with incorrect return" do let(:return_result) { Array } + it "fails with NoMethodError because tax_rate_for does not have signature" do expect { accountant.tax_for(10) }.to raise_error(NoMethodError) expect { accountant.tax_for(10) }.not_to raise_error # intentionaly end end + describe "with correct return" do let(:return_result) { 0.333 } + it "returns correct result" do expect(accountant.tax_for(10)).to eq(3) end From 18ac1773cc53518647ace2ef3c10f8badc9c7954 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Thu, 16 Mar 2023 21:07:12 +0300 Subject: [PATCH 22/28] fix Gemfile --- Gemfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Gemfile b/Gemfile index 7690f5a..9e25fc8 100644 --- a/Gemfile +++ b/Gemfile @@ -5,9 +5,7 @@ source "https://rubygems.org" gem "debug", platform: :mri gem "rbs", "< 3.0" gem "rspec" -gem 'sorbet', require: false gem 'sorbet-runtime', require: false -gem "rspec" gemspec From 0b6b7142d4e294f86056966d0e4603acdc3fff81 Mon Sep 17 00:00:00 2001 From: Vladimir Dementyev Date: Thu, 16 Mar 2023 16:53:57 -0400 Subject: [PATCH 23/28] Update lib/mock_suey/type_checks/sorbet.rb --- lib/mock_suey/type_checks/sorbet.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb index d25df60..e14ce18 100644 --- a/lib/mock_suey/type_checks/sorbet.rb +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -5,7 +5,7 @@ require "set" require "pathname" -require_relative "../sorbet_rspec" +require "mock_suey/sorbet_rspec" require "mock_suey/ext/instance_class" module MockSuey From f8b740f95132a4704d6a27ce76907084633a4e6c Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Fri, 17 Mar 2023 20:10:29 +0300 Subject: [PATCH 24/28] temporary require ruby.rb from tests --- spec/fixtures/rspec/double_fixture.rb | 1 + spec/fixtures/rspec/instance_double_fixture.rb | 1 + spec/fixtures/rspec/mock_context_fixture.rb | 1 + spec/support/integration_helpers.rb | 2 ++ 4 files changed, 5 insertions(+) diff --git a/spec/fixtures/rspec/double_fixture.rb b/spec/fixtures/rspec/double_fixture.rb index f9e11b7..17cc879 100644 --- a/spec/fixtures/rspec/double_fixture.rb +++ b/spec/fixtures/rspec/double_fixture.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "mock_suey/type_checks/ruby" $LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) require_relative "./spec_helper" diff --git a/spec/fixtures/rspec/instance_double_fixture.rb b/spec/fixtures/rspec/instance_double_fixture.rb index ed023ff..ba71c1f 100644 --- a/spec/fixtures/rspec/instance_double_fixture.rb +++ b/spec/fixtures/rspec/instance_double_fixture.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "mock_suey/type_checks/ruby" $LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) require_relative "./spec_helper" diff --git a/spec/fixtures/rspec/mock_context_fixture.rb b/spec/fixtures/rspec/mock_context_fixture.rb index 8daf3f7..7808064 100644 --- a/spec/fixtures/rspec/mock_context_fixture.rb +++ b/spec/fixtures/rspec/mock_context_fixture.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require "mock_suey/type_checks/ruby" $LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) require_relative "./spec_helper" diff --git a/spec/support/integration_helpers.rb b/spec/support/integration_helpers.rb index 78f1832..8a10c06 100644 --- a/spec/support/integration_helpers.rb +++ b/spec/support/integration_helpers.rb @@ -15,6 +15,8 @@ module IntegrationHelpers def run_rspec(path, chdir: nil, success: true, env: {}, options: "") command = "#{RUBY_RUNNER} #{RSPEC_STUB} #{options} spec/fixtures/rspec/#{path}_fixture.rb" + env = env.dup + env["CI"] = ENV["CI"] output, err, status = Open3.capture3( env, command, From 28f235af57a3985bda3c3f5bf538cebaa68eef37 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 18 Mar 2023 15:46:39 +0300 Subject: [PATCH 25/28] fix specs for ruby <= 3.0 by removing LOAD_PATH --- spec/fixtures/rspec/double_fixture.rb | 3 --- spec/fixtures/rspec/double_sorbet_fixture.rb | 2 -- spec/fixtures/rspec/instance_double_fixture.rb | 3 --- spec/fixtures/rspec/instance_double_sorbet_fixture.rb | 2 -- spec/fixtures/rspec/mock_context_fixture.rb | 3 --- spec/fixtures/rspec/stored_mocked_calls_fixture.rb | 2 -- 6 files changed, 15 deletions(-) diff --git a/spec/fixtures/rspec/double_fixture.rb b/spec/fixtures/rspec/double_fixture.rb index 17cc879..2c4a52b 100644 --- a/spec/fixtures/rspec/double_fixture.rb +++ b/spec/fixtures/rspec/double_fixture.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require "mock_suey/type_checks/ruby" -$LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) - require_relative "./spec_helper" require_relative "../shared/tax_calculator" require_relative "tax_calculator_spec" diff --git a/spec/fixtures/rspec/double_sorbet_fixture.rb b/spec/fixtures/rspec/double_sorbet_fixture.rb index e86ab7a..87aace1 100644 --- a/spec/fixtures/rspec/double_sorbet_fixture.rb +++ b/spec/fixtures/rspec/double_sorbet_fixture.rb @@ -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_sorbet" require_relative "tax_calculator_sorbet_spec" diff --git a/spec/fixtures/rspec/instance_double_fixture.rb b/spec/fixtures/rspec/instance_double_fixture.rb index ba71c1f..406e04b 100644 --- a/spec/fixtures/rspec/instance_double_fixture.rb +++ b/spec/fixtures/rspec/instance_double_fixture.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require "mock_suey/type_checks/ruby" -$LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) - require_relative "./spec_helper" require_relative "../shared/tax_calculator" require_relative "tax_calculator_spec" diff --git a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb index 79bc142..85206de 100644 --- a/spec/fixtures/rspec/instance_double_sorbet_fixture.rb +++ b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb @@ -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_sorbet" require_relative "tax_calculator_sorbet_spec" diff --git a/spec/fixtures/rspec/mock_context_fixture.rb b/spec/fixtures/rspec/mock_context_fixture.rb index 7808064..e35a7e2 100644 --- a/spec/fixtures/rspec/mock_context_fixture.rb +++ b/spec/fixtures/rspec/mock_context_fixture.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require "mock_suey/type_checks/ruby" -$LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) - require_relative "./spec_helper" require_relative "../shared/tax_calculator" require_relative "tax_calculator_spec" diff --git a/spec/fixtures/rspec/stored_mocked_calls_fixture.rb b/spec/fixtures/rspec/stored_mocked_calls_fixture.rb index 0f8bdc8..ed52c24 100644 --- a/spec/fixtures/rspec/stored_mocked_calls_fixture.rb +++ b/spec/fixtures/rspec/stored_mocked_calls_fixture.rb @@ -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" From 16f2ffd9f10ff95dcc7671620bfd82fee362dfdf Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Sat, 18 Mar 2023 15:51:36 +0300 Subject: [PATCH 26/28] fix rubocop warning --- lib/mock_suey/mock_contract.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mock_suey/mock_contract.rb b/lib/mock_suey/mock_contract.rb index a31a47c..373d0ea 100644 --- a/lib/mock_suey/mock_contract.rb +++ b/lib/mock_suey/mock_contract.rb @@ -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 end.join(", ").then do |args_desc| " (#{args_desc}) -> #{call.return_value.class}" end From d1fe5106e1778b29bcf4436bfc9775f3210d56a1 Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Fri, 17 Mar 2023 19:03:27 +0300 Subject: [PATCH 27/28] fix sorbet call_validation_error_handler --- lib/mock_suey/sorbet_rspec.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/mock_suey/sorbet_rspec.rb b/lib/mock_suey/sorbet_rspec.rb index 6236dc6..c8619a7 100644 --- a/lib/mock_suey/sorbet_rspec.rb +++ b/lib/mock_suey/sorbet_rspec.rb @@ -2,12 +2,15 @@ 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| 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 + 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 @@ -20,5 +23,5 @@ are_related = doubled_class <= opts[:type].raw_type return if are_related - return T::Configuration.send(:call_validation_error_handler_default, signature, opts) + error_handler.call(signature, opts) end From 24e99b274c1a09e347929980c34f6b7d92c5bf7a Mon Sep 17 00:00:00 2001 From: Vitalii Iurev Date: Fri, 17 Mar 2023 19:30:02 +0300 Subject: [PATCH 28/28] update readme --- README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 57351dd..712dc4f 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,16 @@ 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). +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. -Therefore, you should consider changing `raise_on_missing_types` to `false` if you use Sorbet. +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|