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 + diff --git a/Gemfile b/Gemfile index 87da9d7..9e25fc8 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ source "https://rubygems.org" gem "debug", platform: :mri gem "rbs", "< 3.0" gem "rspec" +gem 'sorbet-runtime', require: false gemspec diff --git a/README.md b/README.md index f10b226..712dc4f 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,39 @@ Typed doubles rely on the type signatures being defined. What if you don't have 2) Auto-generating types on-the-fly from the real call traces (see below). +### Using with Sorbet + +To use Mock Suey with Sorbet, configure it as follows: + +```ruby +MockSuey.configure do |config| + config.type_check = :sorbet +end +``` + +Make sure that `sorbet` and `sorbet-runtime` gem are present in the bundle according to the [sorbet instruction](https://sorbet.org/docs/adopting#step-1-install-dependencies). +That's it! Now all mocked methods are type-checked. + +### raise_on_missing_types + +If a signature is described in a `.rb` file, it will be used by `sorbet-runtime` and type checking will be available. +One of the gems that is using sorbet signatures is [ShopifyAPI](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/auth.rb) for example. + +However, many signatures are declared inside `.rbi` files, like 1) signatures for [stdlib and core types](https://github.com/sorbet/sorbet/tree/master/rbi/core) and 2) signatures for most libraries including [rails](https://github.com/chanzuckerberg/sorbet-rails/blob/master/sorbet/rbi/sorbet-typed/lib/activerecord/all/activerecord.rbi). +Unfortunately, these types cannot be loaded into runtime at the moment. +It's not possible to type check their mocks yet. + +Checking types defined in .rbi files is only available through `rbs typecheck` command which uses [custom ruby binary](https://github.com/sorbet/sorbet/blob/master/docs/running-compiled-code.md). + +You should consider changing `raise_on_missing_types` to `false` if you use Sorbet. + +```ruby +MockSuey.configure do |config| + config.type_check = :sorbet + config.raise_on_missing_types = false +end +``` + ## Mock context Mock context is a re-usable mocking/stubbing configuration. Keeping a _library of mocks_ @@ -371,6 +405,7 @@ The gem is available as open source under the terms of the [MIT License](http:// [the-talk]: https://evilmartians.com/events/weaving-and-seaming-mocks [rbs]: https://github.com/ruby/rbs +[sorbet]: https://github.com/sorbet/sorbet [fixturama]: https://github.com/nepalez/fixturama [bogus]: https://github.com/psyho/bogus [compact]: https://github.com/robwold/compact 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 f066c6d..c6d65ca 100644 --- a/lib/mock_suey/method_call.rb +++ b/lib/mock_suey/method_call.rb @@ -12,6 +12,8 @@ class MethodCall < Struct.new( :return_value, :has_kwargs, :metadata, + :mocked_obj, + :block, keyword_init: true ) def initialize(**) 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 diff --git a/lib/mock_suey/rspec/proxy_method_invoked.rb b/lib/mock_suey/rspec/proxy_method_invoked.rb index 01df972..07313f5 100644 --- a/lib/mock_suey/rspec/proxy_method_invoked.rb +++ b/lib/mock_suey/rspec/proxy_method_invoked.rb @@ -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} ) diff --git a/lib/mock_suey/sorbet_rspec.rb b/lib/mock_suey/sorbet_rspec.rb new file mode 100644 index 0000000..c8619a7 --- /dev/null +++ b/lib/mock_suey/sorbet_rspec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "sorbet-runtime" + +error_handler = T::Configuration.instance_variable_get(:@call_validation_error_handler) +error_handler ||= proc do |signature, opts| + return T::Configuration.send(:call_validation_error_handler_default, signature, opts) +end + +# Let methods with sig receive double/instance_double arguments +T::Configuration.call_validation_error_handler = lambda do |signature, opts| + is_mocked = opts[:value].is_a?(RSpec::Mocks::Double) || opts[:value].is_a?(RSpec::Mocks::VerifyingDouble) + return error_handler.call(signature, opts) unless is_mocked + + # https://github.com/rspec/rspec-mocks/blob/main/lib/rspec/mocks/verifying_double.rb + # https://github.com/rspec/rspec-mocks/blob/v3.12.3/lib/rspec/mocks/test_double.rb + doubled_class = if opts[:value].is_a? RSpec::Mocks::Double + doubled_class_name = opts[:value].instance_variable_get :@name + Kernel.const_get(doubled_class_name) + elsif opts[:value].is_a? RSpec::Mocks::VerifyingDouble + opts[:value].instance_variable_get(:@doubled_module).send(:object) + end + are_related = doubled_class <= opts[:type].raw_type + return if are_related + + error_handler.call(signature, opts) +end diff --git a/lib/mock_suey/type_checks/sorbet.rb b/lib/mock_suey/type_checks/sorbet.rb new file mode 100644 index 0000000..e14ce18 --- /dev/null +++ b/lib/mock_suey/type_checks/sorbet.rb @@ -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) + 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 } + end + end + 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..079809f --- /dev/null +++ b/spec/cases/typed_double_sorbet_spec.rb @@ -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 diff --git a/spec/fixtures/rspec/double_fixture.rb b/spec/fixtures/rspec/double_fixture.rb index f9e11b7..2c4a52b 100644 --- a/spec/fixtures/rspec/double_fixture.rb +++ b/spec/fixtures/rspec/double_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" require_relative "tax_calculator_spec" diff --git a/spec/fixtures/rspec/double_sorbet_fixture.rb b/spec/fixtures/rspec/double_sorbet_fixture.rb new file mode 100644 index 0000000..87aace1 --- /dev/null +++ b/spec/fixtures/rspec/double_sorbet_fixture.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "./spec_helper" +require_relative "../shared/tax_calculator_sorbet" +require_relative "tax_calculator_sorbet_spec" + +describe AccountantSorbet do + before do + allow(tax_calculator).to receive(:for_income).and_return(42) + allow(tax_calculator).to receive(:tax_rate_for).and_return(10.0) + allow(tax_calculator).to receive(:for_income).with(-10).and_return(TaxCalculator::Result.new(0)) + end + + let(:tax_calculator) { double("TaxCalculatorSorbet") } + + include_examples "accountant", AccountantSorbet do + it "incorrect" do + # NOTE: in fact, sorbet-runtine also checks for type errors for ALL types + expect { subject.net_pay("incorrect") }.to raise_error(TypeError) + end + end +end diff --git a/spec/fixtures/rspec/instance_double_fixture.rb b/spec/fixtures/rspec/instance_double_fixture.rb index ed023ff..406e04b 100644 --- a/spec/fixtures/rspec/instance_double_fixture.rb +++ b/spec/fixtures/rspec/instance_double_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" 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 new file mode 100644 index 0000000..85206de --- /dev/null +++ b/spec/fixtures/rspec/instance_double_sorbet_fixture.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative "./spec_helper" +require_relative "../shared/tax_calculator_sorbet" +require_relative "tax_calculator_sorbet_spec" + +describe AccountantSorbet do + let!(:tax_calculator) { instance_double("TaxCalculatorSorbet") } + let!(:accountant) { AccountantSorbet.new(tax_calculator: tax_calculator) } + + 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 + + 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 + + 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 + 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/mock_context_fixture.rb b/spec/fixtures/rspec/mock_context_fixture.rb index 8daf3f7..e35a7e2 100644 --- a/spec/fixtures/rspec/mock_context_fixture.rb +++ b/spec/fixtures/rspec/mock_context_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" require_relative "tax_calculator_spec" 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/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" 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 new file mode 100644 index 0000000..c5f69d7 --- /dev/null +++ b/spec/fixtures/shared/tax_calculator_sorbet.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "sorbet-runtime" +require_relative "./tax_calculator" + +class TaxCalculatorSorbet < TaxCalculator + extend T::Sig + + sig { params(val: Integer).returns(T.nilable(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 + end + + 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 + + sig { params(val: Integer).returns(Integer).on_failure(:log) } + def simple_test_log_on_error(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 + 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) + # intentionally incorrect + 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(Float) } + 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 efd4791..8210498 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_obj: target ) end @@ -52,7 +53,25 @@ receiver_class: TestHash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + 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 @@ -67,7 +86,8 @@ receiver_class: Hash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + mocked_obj: target ) end @@ -82,7 +102,8 @@ receiver_class: Hash, method_name: :key?, arguments: ["x"], - return_value: true + return_value: true, + mocked_obj: target ) end @@ -95,7 +116,8 @@ receiver_class: TestRegexp.singleton_class, method_name: :escape, arguments: ["foo"], - return_value: "bar" + return_value: "bar", + mocked_obj: TestRegexp ) end @@ -111,13 +133,15 @@ expect(mcalls.first).to have_attributes( receiver_class: TestHash, method_name: :initialize, - arguments: [] + arguments: [], + mocked_obj: TestHash ) expect(mcalls.last).to have_attributes( receiver_class: TestHash, method_name: :[], arguments: ["a"], - return_value: 10 + return_value: 10, + mocked_obj: hash_double ) end end diff --git a/spec/support/integration_helpers.rb b/spec/support/integration_helpers.rb index b122e12..8a10c06 100644 --- a/spec/support/integration_helpers.rb +++ b/spec/support/integration_helpers.rb @@ -10,14 +10,17 @@ 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" + env = env.dup + env["CI"] = ENV["CI"] 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 new file mode 100644 index 0000000..142aec0 --- /dev/null +++ b/spec/type_checks/sorbet_spec.rb @@ -0,0 +1,349 @@ +# 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 without signatures" do + 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], + mocked_obj: target + ) + end + + it "when raise_on_missing false" do + allow(target).to receive(:simple_test_no_sig).and_return(333) + 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(333) + mcall = create_mcall(target) + + expect do + checker.typecheck!(mcall, raise_on_missing: true) + end.to raise_error(MockSuey::TypeChecks::MissingSignature, /.*set raise_on_missing_types to false.*/) + end + 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) + + 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 + 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, + return_value: "333" + ) + + 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 + + describe "for mocked class methods" do + describe "initialize" do + let(:target) { instance_double("AccountantSorbet") } + + it "called correctly" do + allow(target).to receive(:initialize) + + 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 + 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], + mocked_obj: target, + return_value: 333 + ) + + 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 + + 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, + 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 + 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, + return_value: 333 + ) + + 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 +end