├── .rspec ├── .yardopts ├── Gemfile ├── .gitignore ├── spec ├── spec_helper.rb ├── saharspec │ ├── its │ │ ├── with_spec.rb │ │ ├── block_spec.rb │ │ ├── call_spec.rb │ │ └── map_spec.rb │ ├── matchers │ │ ├── dont_spec.rb │ │ ├── eq_mutliline_spec.rb │ │ ├── be_json_spec.rb │ │ ├── ret_spec.rb │ │ └── send_message_spec.rb │ └── example_groups │ │ └── instant_context_spec.rb └── .rubocop.yml ├── lib ├── saharspec │ ├── version.rb │ ├── example_groups.rb │ ├── util.rb │ ├── matchers.rb │ ├── matchers │ │ ├── eq_multiline.rb │ │ ├── request_webmock.rb │ │ ├── dont.rb │ │ ├── ret.rb │ │ ├── be_json.rb │ │ └── send_message.rb │ ├── its.rb │ ├── its │ │ ├── with.rb │ │ ├── block_with.rb │ │ ├── call.rb │ │ ├── block.rb │ │ └── map.rb │ └── example_groups │ │ └── instant_context.rb └── saharspec.rb ├── .travis.yml ├── Rakefile ├── config └── rubocop-rspec.yml ├── .github └── workflows │ └── ci.yml ├── .rubocop_todo.yml ├── saharspec.gemspec ├── LICENSE.txt ├── .rubocop.yml ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | -r ./spec/spec_helper 2 | --color 3 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | --no-private 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp 2 | Gemfile.lock 3 | .yardoc 4 | pkg 5 | doc 6 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift 'lib' 2 | 3 | RSpec.configure do |config| 4 | config.raise_errors_for_deprecations! 5 | end 6 | -------------------------------------------------------------------------------- /spec/saharspec/its/with_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/its/with' 2 | 3 | RSpec.describe :it_with do 4 | subject { x + y } 5 | 6 | it_with(x: 1, y: 5) { is_expected.to eq 6 } 7 | end 8 | -------------------------------------------------------------------------------- /lib/saharspec/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | MAJOR = 0 5 | MINOR = 0 6 | PATCH = 10 7 | VERSION = [MAJOR, MINOR, PATCH].join('.') 8 | end 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | cache: bundler 2 | language: ruby 3 | rvm: 4 | - "2.3.0" 5 | - "2.4.0" 6 | - "2.5.0" 7 | - "2.6.0" 8 | - "2.7.0" 9 | - jruby-9.2 10 | install: 11 | - bundle install --retry=3 12 | script: 13 | - bundle exec rake 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'rubygems/tasks' 3 | Gem::Tasks.new 4 | 5 | require 'rspec/core/rake_task' 6 | RSpec::Core::RakeTask.new 7 | 8 | require 'rubocop/rake_task' 9 | RuboCop::RakeTask.new 10 | task default: %w[spec rubocop] 11 | -------------------------------------------------------------------------------- /spec/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ../.rubocop.yml 2 | 3 | Metrics/LineLength: 4 | Max: 120 5 | 6 | Style/BlockDelimiters: 7 | Enabled: false 8 | 9 | Style/Semicolon: 10 | Enabled: false 11 | 12 | Layout/MultilineMethodCallIndentation: 13 | Enabled: false 14 | -------------------------------------------------------------------------------- /config/rubocop-rspec.yml: -------------------------------------------------------------------------------- 1 | RSpec: 2 | Language: 3 | Examples: 4 | Regular: 5 | - its_block 6 | - its_call 7 | - its_map 8 | Skipped: 9 | - xits_block 10 | - xits_call 11 | - xits_map 12 | Focused: 13 | - fits_block 14 | - fits_call 15 | - fits_map 16 | -------------------------------------------------------------------------------- /lib/saharspec/example_groups.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | # Wrapper module for all RSpec additions that adjust example groups (`context`) creation. 5 | # 6 | # ## {InstantContext#instant_context #instant_context} 7 | # 8 | # ```ruby 9 | # subject { x + y } 10 | # 11 | # instant_context 'with numeric values', lets: {x: 1, y: 2} do 12 | # it { is_expected.to eq 3 } 13 | # end 14 | # ``` 15 | # 16 | module ExampleGroups 17 | end 18 | end 19 | 20 | require_relative 'example_groups/instant_context' 21 | -------------------------------------------------------------------------------- /lib/saharspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | defined?(RSpec) or 4 | fail 'RSpec is not present in the current environment, check that `rspec` ' \ 5 | 'is present in your Gemfile and is in the same group as `saharspec`' \ 6 | 7 | # Umbrella module for all Saharspec RSpec DRY-ing features. 8 | # 9 | # See {file:README.md} or {Its}, {Matchers}, and {ExampleGroups} separately. 10 | # 11 | module Saharspec 12 | end 13 | 14 | require_relative 'saharspec/its' 15 | require_relative 'saharspec/matchers' 16 | require_relative 'saharspec/example_groups' 17 | require_relative 'saharspec/util' 18 | -------------------------------------------------------------------------------- /lib/saharspec/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Util 5 | def multiline(string) 6 | # 1. for all lines looking like "|" -- remove this. 7 | # 2. remove trailing spaces 8 | # 3. preserve trailing spaces ending with "|", but remove the pipe 9 | # 4. remove one empty line before & after, allows prettier %Q{} 10 | # TODO: check if all lines start with "|"? 11 | string 12 | .gsub(/^ *\|/, '') 13 | .gsub(/ +$/, '') 14 | .gsub(/\|$/, '') 15 | .gsub(/(\A *\n|\n *\z)/, '') 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/saharspec/its/block_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/its/block' 2 | 3 | RSpec.describe :its_block do 4 | let(:relevant) { [] } 5 | let(:irrelevant) { [] } 6 | 7 | subject { relevant << 'foo' } 8 | 9 | its_block { is_expected.to change { relevant }.to(['foo']) } 10 | its_block { is_expected.not_to(change { irrelevant }) } 11 | 12 | describe 'metadata passing' do 13 | before(:each, :some_metadata) { relevant << 'bar' } 14 | 15 | its_block { is_expected.to change { relevant }.to(['foo']) } 16 | its_block(:some_metadata) { is_expected.to change { relevant }.to(%w[bar foo]) } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | name: build ${{ matrix.ruby }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby: [ '3.1', '3.2', '3.3', '3.4' ] 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | bundler-cache: true 24 | 25 | - name: RSpec 26 | run: bundle exec rspec 27 | 28 | - name: Rubocop 29 | run: bundle exec rubocop 30 | -------------------------------------------------------------------------------- /spec/saharspec/its/call_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/its/call' 2 | require 'saharspec/matchers/ret' 3 | 4 | RSpec.describe :its_call do 5 | context 'simple' do 6 | let(:array) { [] } 7 | subject { -> { array << 1 } } 8 | 9 | its_call { is_expected.not_to raise_error } 10 | its_call { is_expected.to change(array, :size).by(1) } 11 | end 12 | 13 | context 'with args' do 14 | let(:array) { [] } 15 | subject { ->(a) { array << a } } 16 | 17 | its_call(1) { is_expected.not_to raise_error } 18 | its_call(88) { is_expected.to change { array }.to([88]) } 19 | end 20 | 21 | context 'when method with keyword args (Ruby 2.7)' do 22 | def t(**args) 23 | args 24 | end 25 | 26 | subject { method(:t) } 27 | 28 | its_call(a: 1, b: 2) { is_expected.to ret(a: 1, b: 2) } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/saharspec/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | # All Saharspec matchers, when required, included into `RSpec::Matchers` namespace. 5 | # 6 | # See: 7 | # 8 | # * {RSpec::Matchers#dont #dont}: `expect { block }.to change(this).and dont.change(that)` 9 | # * {RSpec::Matchers#send_message #send_message}: `expect { block }.to send_message(File, :write)` 10 | # * {RSpec::Matchers#ret #ret}: `expect { block }.to ret value` 11 | # * {RSpec::Matchers#be_json #be_json}: `expect(response.body).to be_json('foo' => 'bar')` 12 | # * {RSpec::Matchers#eq_multiline #eq_multiline}: multiline equality akin to squiggly heredoc 13 | # 14 | module Matchers 15 | end 16 | end 17 | 18 | require_relative 'matchers/eq_multiline' 19 | require_relative 'matchers/send_message' 20 | require_relative 'matchers/ret' 21 | require_relative 'matchers/dont' 22 | require_relative 'matchers/be_json' 23 | -------------------------------------------------------------------------------- /spec/saharspec/matchers/dont_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/matchers/dont' 2 | 3 | RSpec.describe :dont do 4 | context 'simple' do 5 | it { expect(2).to dont.eq(1) } 6 | it { expect(1).not_to dont.eq(1) } 7 | end 8 | 9 | context 'with block' do 10 | let(:ary) { [] } 11 | it { expect {}.to dont.change(ary, :length) } 12 | it { expect { ary << 1 }.not_to dont.change(ary, :length).by(1) } 13 | end 14 | 15 | context 'composability' do 16 | it { expect(2).to dont.eq(1).and eq(2) } 17 | end 18 | 19 | context 'matcher-less' do 20 | it { 21 | expect { 22 | expect(2).to dont 23 | }.to raise_error(ArgumentError, /matcher to negate/) 24 | } 25 | end 26 | 27 | describe '#failure_message' do 28 | subject { matcher.failure_message } 29 | 30 | context 'properly uses nested negated message' do 31 | let(:matcher) { dont.raise_error } 32 | 33 | before { matcher === -> { raise ArgumentError, "TEST" } } 34 | 35 | it { is_expected.to match /^expected no Exception, got \#/ } 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2017-08-11 18:02:13 +0300 using RuboCop version 0.49.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | # Cop supports --auto-correct. 11 | # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. 12 | # SupportedStyles: aligned, indented, indented_relative_to_receiver 13 | Layout/MultilineMethodCallIndentation: 14 | Exclude: 15 | - 'lib/saharspec/get_webmock.rb' 16 | 17 | # Offense count: 2 18 | Lint/ShadowingOuterLocalVariable: 19 | Exclude: 20 | - 'lib/saharspec/get_webmock.rb' 21 | 22 | # Offense count: 1 23 | # Cop supports --auto-correct. 24 | # Configuration parameters: EnforcedStyle, SupportedStyles. 25 | # SupportedStyles: only_raise, only_fail, semantic 26 | Style/SignalException: 27 | Exclude: 28 | - 'lib/saharspec/get_webmock.rb' 29 | -------------------------------------------------------------------------------- /saharspec.gemspec: -------------------------------------------------------------------------------- 1 | require_relative 'lib/saharspec/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'saharspec' 5 | s.version = Saharspec::VERSION 6 | s.authors = ['Victor Shepelev'] 7 | s.email = 'zverok.offline@gmail.com' 8 | s.homepage = 'https://github.com/zverok/saharspec' 9 | 10 | s.summary = 'Several additions for DRYer RSpec code' 11 | s.licenses = ['MIT'] 12 | 13 | s.files = `git ls-files`.split($RS).reject do |file| 14 | file =~ /^(?: 15 | spec\/.* 16 | |Gemfile 17 | |Rakefile 18 | |\.rspec 19 | |\.gitignore 20 | |\.rubocop.yml 21 | |\.travis.yml 22 | )$/x 23 | end 24 | s.require_paths = ["lib"] 25 | 26 | s.required_ruby_version = '>= 3.1.0' 27 | 28 | s.add_development_dependency 'rubocop', '~> 1.76.0' 29 | s.add_development_dependency 'rspec', '>= 3.7.0' 30 | s.add_development_dependency 'rspec-its' 31 | s.add_development_dependency 'simplecov', '~> 0.9' 32 | s.add_development_dependency 'rake' 33 | s.add_development_dependency 'rubygems-tasks' 34 | s.add_development_dependency 'yard' 35 | #s.add_development_dependency 'coveralls' 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-18 Victor 'Zverok' Shepelev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/saharspec/matchers/eq_multiline.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../util' 4 | 5 | module Saharspec 6 | module Matchers 7 | # @private 8 | class EqMultiline < RSpec::Matchers::BuiltIn::Eq 9 | include Util 10 | def initialize(expected) 11 | super(multiline(expected)) 12 | end 13 | end 14 | end 15 | end 16 | 17 | module RSpec 18 | module Matchers 19 | # Allows to pretty test multiline strings with complex indentation (for example, results of 20 | # code generation). 21 | # 22 | # In provided string, removes first and last empty line, trailing spaces and leading spaces up 23 | # to `|` character. 24 | # 25 | # If you need to preserve trailing spaces, end them with another `|`. 26 | # 27 | # @example 28 | # require 'saharspec/matchers/eq_multiline' 29 | # 30 | # expect(some_code_gen).to eq_multiline(%{ 31 | # |def something 32 | # | a = 5 33 | # | a**2 34 | # |end 35 | # }) 36 | # 37 | # @param expected [String] 38 | def eq_multiline(expected) 39 | Saharspec::Matchers::EqMultiline.new(expected) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/saharspec/its/map_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/its/map' 2 | 3 | RSpec.describe :its_map do 4 | subject do 5 | %w[test me please] 6 | end 7 | 8 | its_map(:length) { is_expected.to eq [4, 2, 6] } 9 | 10 | context 'method chain' do 11 | its_map(:'reverse.upcase') { is_expected.to eq %w[TSET EM ESAELP] } 12 | end 13 | 14 | describe '[]' do 15 | context 'class responding to #[]' do 16 | let(:klass) { 17 | Class.new do 18 | def initialize(n) 19 | @n = n 20 | end 21 | 22 | def [](a, b) 23 | (a + b) * @n 24 | end 25 | end 26 | } 27 | 28 | subject { [klass.new(1), klass.new(2), klass.new(3)] } 29 | 30 | its_map([1, 2]) { is_expected.to eq [3, 6, 9] } 31 | end 32 | 33 | context 'nested hashes' do 34 | subject { [{a: {b: 1}}, {a: {b: 2}}, {a: {b: 3}}] } 35 | 36 | its_map(%i[a b]) { is_expected.to eq [1, 2, 3] } 37 | end 38 | end 39 | 40 | describe 'metadata passing' do 41 | before(:each, :some_metadata) { subject << 'foo' } 42 | 43 | its_map(:length) { is_expected.to eq [4, 2, 6] } 44 | its_map(:length, :some_metadata) { is_expected.to eq [4, 2, 6, 3] } 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/saharspec/matchers/eq_mutliline_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/matchers/eq_multiline' 2 | 3 | RSpec.describe :eq_multiline do 4 | describe 'simple' do 5 | specify { 6 | expect("test\nme\nplease").to eq_multiline(%{ 7 | |test 8 | |me 9 | |please 10 | }) 11 | } 12 | end 13 | 14 | describe 'before/after empty lines removal' do 15 | it 'removes one line' do 16 | expect("test\nme\nplease").to eq_multiline(%{ 17 | |test 18 | |me 19 | |please 20 | }) 21 | end 22 | 23 | it 'does not remove several lines' do 24 | expect("\ntest\nme\nplease\n").to eq_multiline(%{ 25 | | 26 | |test 27 | |me 28 | |please 29 | | 30 | }) 31 | end 32 | end 33 | 34 | describe 'trailing spaces preservation' do 35 | it 'removes trailing spaces' do 36 | expect("test\nme\nplease").to eq_multiline("|test \n|me \n|please") 37 | end 38 | 39 | it 'allows to mark not-to-remove spaces' do 40 | expect("test\nme \nplease").to eq_multiline(%{ 41 | |test 42 | |me | 43 | |please 44 | }) 45 | end 46 | end 47 | 48 | describe 'broken lines' do 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/saharspec/its.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | # Wrapper module for all `its_*` RSpec additions. 5 | # 6 | # ## {Map#its_map #its_map} 7 | # 8 | # ```ruby 9 | # subject { %w[1 2 3] } 10 | # its_map(:to_s) { is_expected.to eq [1, 2, 3] } 11 | # ``` 12 | # 13 | # ## {Call#its_call #its_call} 14 | # 15 | # ```ruby 16 | # subject { [1, 2, 3].method(:[]) } 17 | # its_call(2) { is_expected.to ret 3 } 18 | # its_call('foo') { is_expected.to raise_error } 19 | # ``` 20 | # 21 | # ## {Block#its_block #its_block} 22 | # 23 | # ```ruby 24 | # subject { something_action } 25 | # its_block { is_expected.not_to raise_error } 26 | # its_block { is_expected.to change(some, :value).by(1) } 27 | # ``` 28 | # 29 | # ## {With#it_with #it_with} 30 | # 31 | # ```ruby 32 | # subject { x + y } 33 | # it_with(x: 1, y: 2) { is_expected.to eq 3 } 34 | # ``` 35 | # 36 | # ## {BlockWith#its_block_with #its_block_with} 37 | # 38 | # ```ruby 39 | # subject { x + y } 40 | # its_block_with(x: 1, y: nil) { is_expected.to raise_error } 41 | # ``` 42 | # 43 | module Its 44 | end 45 | end 46 | 47 | require_relative 'its/map' 48 | require_relative 'its/block' 49 | require_relative 'its/call' 50 | -------------------------------------------------------------------------------- /lib/saharspec/its/with.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Its 5 | module With 6 | # Creates a nested context + example with `let` values defined from the arguments. 7 | # 8 | # @example 9 | # 10 | # subject { x + y } 11 | # 12 | # it_with(x: 1, y: 2) { is_expected.to eq 3 } 13 | # 14 | # # is equivalent to 15 | # 16 | # context "with x=1, y=2" do 17 | # let(:x) { 1 } 18 | # let(:y) { 2 } 19 | # 20 | # it { is_expected.to eq 3 } 21 | # end 22 | # 23 | # See also {Its::BlockWith#its_block_with #its_block_with} for a block form, and 24 | # {ExampleGroups::InstantContext#instant_context #instant_context} for inline `context`+`let` definitions. 25 | def it_with(**lets, &block) 26 | context "with #{lets.map { "#{_1}=#{_2.inspect}" }.join(', ')}" do 27 | lets.each do |name, val| 28 | let(name) { val } 29 | end 30 | example(nil, &block) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | 37 | RSpec.configure do |rspec| 38 | rspec.extend Saharspec::Its::With 39 | rspec.backtrace_exclusion_patterns << %r{/lib/saharspec/its/with} 40 | end 41 | 42 | RSpec::SharedContext.include Saharspec::Its::With 43 | -------------------------------------------------------------------------------- /spec/saharspec/matchers/be_json_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/matchers/be_json' 2 | 3 | RSpec.describe :be_json do 4 | describe 'argument-less' do 5 | it { expect('{}').to be_json } 6 | it { expect('{"foo": "bar"}').to be_json } 7 | it { expect('definitely not').not_to be_json } 8 | it { 9 | expect { expect('definitely not').to be_json } 10 | .to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected value to be a valid JSON/) 11 | } 12 | end 13 | 14 | describe 'with arguments' do 15 | it { expect('{"foo": "bar"}').to be_json('foo' => 'bar') } 16 | it { expect('{"foo": "baz"}').not_to be_json('foo' => 'bar') } 17 | it { expect('{"foo": 1, "bar": 2}').to be_json include('foo' => 1) } 18 | it { expect('{"foo": 2, "bar": 1}').not_to be_json include('foo' => 1) } 19 | it { 20 | expect { expect('{"foo": 2, "bar": 1}').to be_json include('foo' => 1) } 21 | .to raise_error(RSpec::Expectations::ExpectationNotMetError, /to be a valid JSON matching/) 22 | } 23 | 24 | it { expect('{"foo": 1, "bar": 2}').to be_json include('foo' => kind_of(Numeric)) } 25 | it { expect('{"foo": "2", "bar": 1}').not_to be_json include('foo' => kind_of(Numeric)) } 26 | 27 | it { expect('{"foo": "bar"}').to be_json_sym(foo: 'bar') } 28 | it { expect('{"foo": "baz"}').not_to be_json_sym(foo: 'bar') } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/saharspec/its/block_with.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Its 5 | module BlockWith 6 | # Creates a nested context + example with `let` values defined from the arguments, and 7 | # the subject treated as a block. 8 | # 9 | # @example 10 | # 11 | # subject { x + y } 12 | # 13 | # its_block_with(x: 1, y: nil) { is_expected.to raise_error } 14 | # 15 | # # is equivalent to 16 | # 17 | # context "with x=1, y=2" do 18 | # let(:x) { 1 } 19 | # let(:y) { nil } 20 | # 21 | # it { expect { subject }.to raise_error } 22 | # end 23 | # 24 | def its_block_with(**lets, &block) 25 | context "with #{lets.map { "#{_1}=#{_2.inspect}" }.join(', ')} as block" do 26 | lets.each do |name, val| 27 | let(name) { val } 28 | end 29 | 30 | def is_expected # rubocop:disable Lint/NestedMethodDefinition 31 | expect { subject } 32 | end 33 | 34 | example(nil, &block) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | 41 | RSpec.configure do |rspec| 42 | rspec.extend Saharspec::Its::BlockWith 43 | rspec.backtrace_exclusion_patterns << %r{/lib/saharspec/its/block_with} 44 | end 45 | 46 | RSpec::SharedContext.include Saharspec::Its::BlockWith 47 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | #require: rubocop-rspec 3 | 4 | AllCops: 5 | NewCops: enable 6 | Include: 7 | - 'lib/**/*' 8 | Exclude: 9 | - Gemfile 10 | - Rakefile 11 | - '*.gemspec' 12 | - 'tmp/**' 13 | - 'lib/saharspec/matchers/request_webmock.rb' 14 | - 'vendor/**/*' 15 | DisplayCopNames: true 16 | TargetRubyVersion: 3.1 17 | SuggestExtensions: false 18 | 19 | Layout/SpaceInsideHashLiteralBraces: 20 | EnforcedStyle: no_space 21 | 22 | Naming/PredicatePrefix: 23 | Enabled: false 24 | 25 | Metrics/BlockLength: 26 | Enabled: false 27 | 28 | Layout/LineLength: 29 | Max: 100 30 | AllowedPatterns: ['\#.*'] # ignore long comments 31 | 32 | Style/PercentLiteralDelimiters: 33 | PreferredDelimiters: 34 | default: '{}' 35 | 36 | Style/AndOr: 37 | EnforcedStyle: conditionals 38 | 39 | Style/Documentation: 40 | Enabled: false 41 | 42 | Style/SignalException: 43 | EnforcedStyle: semantic 44 | 45 | Style/FormatStringToken: 46 | Enabled: false 47 | 48 | Style/EmptyCaseCondition: 49 | Enabled: false 50 | 51 | Style/CaseEquality: 52 | Enabled: false 53 | 54 | Naming/MethodParameterName: 55 | Enabled: false 56 | 57 | # Should always be configured: 58 | Lint/RaiseException: 59 | Enabled: true 60 | 61 | Lint/StructNewOverride: 62 | Enabled: true 63 | 64 | Style/HashEachMethods: 65 | Enabled: true 66 | 67 | Style/HashTransformKeys: 68 | Enabled: true 69 | 70 | Style/HashTransformValues: 71 | Enabled: true 72 | 73 | Metrics/AbcSize: 74 | Max: 30 75 | 76 | Style/NumberedParametersLimit: 77 | Max: 2 78 | -------------------------------------------------------------------------------- /lib/saharspec/matchers/request_webmock.rb: -------------------------------------------------------------------------------- 1 | # TODO: PR to webmock itself?.. 2 | # 3 | # RSpec::Matchers.define :request_webmock do |url, method: :get| 4 | # match do |block| 5 | # WebMock.reset! 6 | # stub_request(method, url) 7 | # .tap { |req| req.with(@with_options) if @with_options && !@with_block } 8 | # .tap { |req| req.with(@with_options, &@with_block) if @with_block } 9 | # .tap { |req| req.to_return(@response) if @response } 10 | # block.call 11 | # matcher = have_requested(method, url) 12 | # .tap { |matcher| matcher.with(@with_options) if @with_options && !@with_block } 13 | # .tap { |matcher| matcher.with(@with_options, &@with_block) if @with_block } 14 | # expect(WebMock).to matcher 15 | # end 16 | # 17 | # chain :with do |options = {}, &block| 18 | # @with_options = options 19 | # @with_block = block 20 | # end 21 | # 22 | # chain :once do 23 | # times(1) 24 | # end 25 | # 26 | # chain :twice do 27 | # times(2) 28 | # end 29 | # 30 | # chain :times do |n| 31 | # @times = n 32 | # end 33 | # 34 | # chain :at_least_once do 35 | # at_least_times(1) 36 | # end 37 | # 38 | # chain :at_least_twice do 39 | # at_least_times(2) 40 | # end 41 | # 42 | # chain :at_least_times do |n| 43 | # @at_least_times = n 44 | # end 45 | # 46 | # chain :returning do |response| 47 | # @response = 48 | # case response 49 | # when String 50 | # {body: response} 51 | # when Hash 52 | # response 53 | # else 54 | # fail "Expected string or Hash of params, got #{response.inspect}" 55 | # end 56 | # end 57 | # 58 | # supports_block_expectations 59 | # end 60 | # 61 | -------------------------------------------------------------------------------- /lib/saharspec/its/call.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Its 5 | module Call 6 | # For `#call`-able subject, creates nested example where subject is called with arguments 7 | # provided, allowing to apply block matchers like `.to change(something)` or `.to raise_error` 8 | # to different calls in a DRY way. 9 | # 10 | # Also, plays really well with {RSpec::Matchers#ret #ret} block matcher. 11 | # 12 | # @example 13 | # let(:array) { %i[a b c] } 14 | # 15 | # describe '#[]' do 16 | # subject { array.method(:[]) } 17 | # 18 | # its_call(1) { is_expected.to ret :b } 19 | # its_call(1..-1) { is_expected.to ret %i[b c] } 20 | # its_call('foo') { is_expected.to raise_error TypeError } 21 | # end 22 | # 23 | # describe '#push' do 24 | # subject { array.method(:push) } 25 | # its_call(5) { is_expected.to change(array, :length).by(1) } 26 | # end 27 | # 28 | def its_call(*args, **kwargs, &block) 29 | # rubocop:disable Lint/NestedMethodDefinition 30 | describe("(#{args.map(&:inspect).join(', ')})") do 31 | let(:__call_subject) do 32 | subject.call(*args, **kwargs) 33 | end 34 | 35 | def is_expected 36 | expect { __call_subject } 37 | end 38 | 39 | example(nil, &block) 40 | end 41 | # rubocop:enable Lint/NestedMethodDefinition 42 | end 43 | end 44 | end 45 | end 46 | 47 | RSpec.configure do |rspec| 48 | rspec.extend Saharspec::Its::Call 49 | rspec.backtrace_exclusion_patterns << %r{/lib/saharspec/its/call} 50 | end 51 | 52 | RSpec::SharedContext.include Saharspec::Its::Call 53 | -------------------------------------------------------------------------------- /spec/saharspec/example_groups/instant_context_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/example_groups/instant_context' 2 | 3 | RSpec.describe :instant_context do 4 | let(:sum) { 5 + y } 5 | 6 | instant_context lets: {foo: 'bar'} do 7 | it { expect(foo).to eq 'bar' } 8 | 9 | instant_context 'when lets are nesting', lets: {foo: 'baz', baz: 'test'} do 10 | it { expect(baz).to eq 'test' } 11 | it { expect(foo).to eq 'baz' } 12 | end 13 | end 14 | 15 | instant_context 'when lets is adjusting the other let', lets: {y: 5} do 16 | it { expect(sum).to eq 10 } 17 | end 18 | 19 | describe 'titles' do 20 | instant_context lets: {x: 100, title: "OK"} do 21 | it { |ex| expect(ex.full_description).to eq 'instant_context titles with x=100, title="OK" ' } 22 | it { expect(self.class.description).to eq 'with x=100, title="OK"' } 23 | 24 | instant_context lets: {y: 200} do 25 | it { |ex| expect(ex.full_description).to eq 'instant_context titles with x=100, title="OK" with y=200 ' } 26 | it { expect(self.class.description).to eq 'with y=200' } 27 | end 28 | 29 | instant_context 'has a description', lets: {y: 200} do 30 | it { |ex| expect(ex.full_description).to eq 'instant_context titles with x=100, title="OK" has a description (with y=200) ' } 31 | it { expect(self.class.description).to eq 'has a description (with y=200)' } 32 | end 33 | 34 | instant_context lets: {x: 500} do 35 | it { |ex| expect(ex.full_description).to eq 'instant_context titles with x=100, title="OK" with x=500 ' } 36 | it { expect(self.class.description).to eq 'with x=500' } 37 | end 38 | end 39 | end 40 | 41 | describe 'metadata' do 42 | instant_context lets: {a: 1}, freeze: true do 43 | it { |ex| expect(ex.metadata).to include(freeze: true) } 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/saharspec/its/block.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Its 5 | module Block 6 | # Creates nested example that redefines implicit `is_expected` to use subject as a block. 7 | # 8 | # @example 9 | # 10 | # subject { calc_something(params) } 11 | # 12 | # # without its_block 13 | # context 'with this params' do 14 | # it { expect { subject }.to change(some, :value).by(1) } 15 | # end 16 | # 17 | # context 'with that params' do 18 | # it { expect { subject }.to raise_error(SomeError) } 19 | # end 20 | # 21 | # # with its_block 22 | # context 'with this params' do 23 | # its_block { is_expected.to change(some, :value).by(1) } 24 | # end 25 | # 26 | # context 'with that params' do 27 | # its_block { is_expected.to raise_error(SomeError) } 28 | # end 29 | # 30 | # @param options Options (metadata) that can be passed to usual RSpec example. 31 | # @param block [Proc] The test itself. Inside it, `is_expected` is a synonom 32 | # for `expect { subject }`. 33 | # 34 | def its_block(*options, &block) 35 | # rubocop:disable Lint/NestedMethodDefinition 36 | describe('as block') do 37 | # FIXME: Not necessary? (Previously, wrapped the subject in lambda, now just repeats it) 38 | let(:__call_subject) do 39 | subject 40 | end 41 | 42 | def is_expected 43 | expect { __call_subject } 44 | end 45 | 46 | example(nil, *options, &block) 47 | end 48 | # rubocop:enable Lint/NestedMethodDefinition 49 | end 50 | end 51 | end 52 | end 53 | 54 | RSpec.configure do |rspec| 55 | rspec.extend Saharspec::Its::Block 56 | rspec.backtrace_exclusion_patterns << %r{/lib/saharspec/its/block} 57 | end 58 | 59 | RSpec::SharedContext.include Saharspec::Its::Block 60 | -------------------------------------------------------------------------------- /lib/saharspec/example_groups/instant_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module ExampleGroups 5 | # Provides a shorter way to define a context and its `let` values in one statement. 6 | # 7 | # @example 8 | # subject { x + y } 9 | # 10 | # instant_context 'with numeric values', lets: {x: 1, y: 2} do 11 | # it { is_expected.to eq 3 } 12 | # end 13 | # # or, without an explicit description: 14 | # instant_context lets: {x: 1, y: 2} do 15 | # it { is_expected.to eq 3 } 16 | # end 17 | # 18 | # # is equivalent to 19 | # 20 | # context 'with numeric values (x=1, y=2)' do 21 | # let(:x) { 1 } 22 | # let(:y) { 2 } 23 | # 24 | # it { is_expected.to eq 3 } 25 | # end 26 | # 27 | # # without explicit description, it is equivalent to 28 | # context 'with x=1, y=2' do 29 | # # ... 30 | # end 31 | # 32 | # See also {Saharspec::Its::With#it_with #it_with} for a way to define just one example with 33 | # its `let`s. 34 | module InstantContext 35 | def instant_context(description = nil, lets:, **metadata, &block) 36 | full_description = "with #{lets.map { "#{_1}=#{_2.inspect}" }.join(', ')}" 37 | full_description = "#{description} (#{full_description})" if description 38 | absolute_path, line_number = caller_locations.first.then { [_1.absolute_path, _1.lineno] } 39 | 40 | context full_description, **metadata do 41 | # Tricking RSpec to think this context was defined where `instant_context` was called, 42 | # so `rspec that_spec.rb:123` knew it is related 43 | self.metadata.merge!(absolute_file_path: absolute_path, line_number: line_number) 44 | 45 | lets.each do |name, val| 46 | let(name) { val } 47 | end 48 | instance_eval(&block) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | 55 | RSpec.configure do |rspec| 56 | rspec.extend Saharspec::ExampleGroups::InstantContext 57 | rspec.backtrace_exclusion_patterns << %r{/lib/saharspec/example_groups/instant_context} 58 | end 59 | -------------------------------------------------------------------------------- /spec/saharspec/matchers/ret_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/matchers/ret' 2 | 3 | RSpec.describe :ret do 4 | context 'subject is a block' do 5 | subject { -> { 5 } } 6 | 7 | it { is_expected.to ret 5 } 8 | it { is_expected.not_to ret 3 } 9 | 10 | context 'with argument matchers' do 11 | it { is_expected.to ret kind_of(Numeric) } 12 | it { is_expected.to ret have_attributes(to_s: '5') } 13 | end 14 | end 15 | 16 | context 'diffability' do 17 | subject { -> { [{a: 1}, {b: 2}, {c: 3}] } } 18 | 19 | it { 20 | expect { is_expected.to ret [{a: 1}, {b: 2}, {c: 4}] } 21 | .to raise_error(RSpec::Expectations::ExpectationNotMetError, /Diff:/) 22 | } 23 | end 24 | 25 | describe '#failure_message' do 26 | before { matcher === -> { 3 } } 27 | 28 | let(:matcher) { ret(internal) } 29 | subject { matcher.failure_message } 30 | 31 | context 'simple value' do 32 | let(:internal) { 5 } 33 | 34 | it { is_expected.to eq 'expected to return 5, but returned 3' } 35 | end 36 | 37 | context 'RSpec matcher' do 38 | let(:internal) { be_a(String) } 39 | it { is_expected.to eq 'return value mismatch: expected 3 to be a kind of String' } 40 | end 41 | end 42 | 43 | describe '#failure_message_when_negated' do 44 | before { matcher === -> { 3 } } 45 | 46 | let(:matcher) { ret(internal) } 47 | subject { matcher.failure_message_when_negated } 48 | 49 | context 'simple value' do 50 | let(:internal) { 3 } 51 | 52 | it { is_expected.to eq 'expected not to return 3, but returned it' } 53 | end 54 | 55 | context 'RSpec matcher' do 56 | let(:internal) { be_a(Integer) } 57 | it { is_expected.to eq 'return value mismatch: expected 3 not to be a kind of Integer' } 58 | end 59 | end 60 | 61 | context 'subject responds to #call' do 62 | let(:cls) { 63 | Class.new { 64 | def call 65 | 5 66 | end 67 | } 68 | } 69 | subject { cls.new } 70 | 71 | it { is_expected.to ret 5 } 72 | it { is_expected.not_to ret 3 } 73 | end 74 | 75 | context 'subject is not callable' do 76 | specify { 77 | expect { expect(5).to ret 5 } 78 | .to raise_error(RSpec::Expectations::ExpectationNotMetError, /not callable/) 79 | } 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/saharspec/matchers/dont.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Matchers 5 | # @private 6 | class Not < RSpec::Matchers::BuiltIn::BaseMatcher 7 | def initialize(*) 8 | super 9 | @delegator = Delegator.new 10 | end 11 | 12 | def description 13 | "not #{@matcher.description}" 14 | end 15 | 16 | def match(_expected, actual) 17 | @matcher or fail ArgumentError, '`dont` matcher used without any matcher to negate. ' \ 18 | 'Usage: dont.other_matcher(args)' 19 | 20 | # https://www.rubydoc.info/github/rspec/rspec-expectations/RSpec%2FMatchers%2FMatcherProtocol:does_not_match%3F 21 | # In a negative expectation such as `expect(x).not_to foo`, RSpec will call 22 | # `foo.does_not_match?(x)` if this method is defined. If it's not defined it 23 | # will fall back to using `!foo.matches?(x)`. 24 | if @matcher.respond_to?(:does_not_match?) 25 | @matcher.does_not_match?(actual) 26 | else 27 | !@matcher.matches?(actual) 28 | end 29 | end 30 | 31 | def failure_message 32 | @matcher.failure_message_when_negated 33 | end 34 | 35 | def supports_block_expectations? 36 | @matcher.supports_block_expectations? 37 | end 38 | 39 | def method_missing(m, *a, &) 40 | if @matcher 41 | @matcher.send(m, *a, &) 42 | else 43 | @matcher = @delegator.send(m, *a, &) 44 | end 45 | 46 | self 47 | end 48 | 49 | def respond_to_missing?(method, include_private = false) 50 | if @matcher 51 | @matcher.respond_to?(method, include_private) 52 | else 53 | @delegator.respond_to_missing?(method, include_private) 54 | end 55 | end 56 | 57 | # ActiveSupport 7.1+ defines Object#with, and thus it doesn't go to `method_missing`. 58 | # So, matchers like `dont.send_message(...).with(...)` stop working correctly. 59 | # 60 | # This is dirty, but I don't see another way. 61 | if Object.instance_methods.include?(:with) 62 | def with(...) 63 | @matcher.with(...) 64 | end 65 | end 66 | 67 | class Delegator 68 | include RSpec::Matchers 69 | end 70 | end 71 | end 72 | end 73 | 74 | module RSpec 75 | module Matchers 76 | # Negates attached matcher, allowing creating negated matchers on the fly. 77 | # 78 | # While not being 100% grammatically correct, seems to be readable enough. 79 | # 80 | # @example 81 | # # before 82 | # RSpec.define_negated_matcher :not_change, :change 83 | # it { expect { code }.to do_stuff.and not_change(obj, :attr) } 84 | # 85 | # # after: no `define_negated_matcher` needed 86 | # it { expect { code }.to do_stuff.and dont.change(obj, :attr) } 87 | # 88 | def dont 89 | Saharspec::Matchers::Not.new 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/saharspec/its/map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Its 5 | module Map 6 | # Creates nested example which has current subject mapped 7 | # by specified attribute as its subject. 8 | # 9 | # @example 10 | # 11 | # # with attribute 12 | # subject { %w[test me please] } 13 | # its_map(:length) { is_expected.to eq [4, 2, 6] } 14 | # 15 | # # with attribute chain 16 | # its_map('reverse.upcase') { is_expected.to eq %w[TSET EM ESAELP] } 17 | # 18 | # # with Hash (or any other object responding to `#[]`) 19 | # subject { 20 | # [ 21 | # {title: 'Slaughterhouse Five', author: {first: 'Kurt', last: 'Vonnegut'}}, 22 | # {title: 'Hitchhickers Guide To The Galaxy', author: {first: 'Duglas', last: 'Adams'}} 23 | # ] 24 | # } 25 | # its_map([:title]) { are_expected.to eq ['Slaughterhouse Five', 'Hitchhickers Guide To The Galaxy'] } 26 | # # multiple attributes for nested hashes 27 | # its_map([:author, :last]) { are_expected.to eq ['Vonnegut', 'Adams'] } 28 | # 29 | # @param attribute [String, Symbol, Array] Attribute name (String or Symbol), attribute chain 30 | # (string separated with dots) or arguments to `#[]` method (Array) 31 | # @param options Other options that can be passed to usual RSpec example. 32 | # @param block [Proc] The test itself. Inside it, `is_expected` (or `are_expected`) is related to result 33 | # of `map`ping the subject. 34 | # 35 | def its_map(attribute, *options, &block) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 36 | # rubocop:disable Lint/NestedMethodDefinition 37 | # TODO: better desciption for different cases 38 | describe("map(&:#{attribute})") do 39 | let(:__its_map_subject) do 40 | if Array === attribute 41 | if subject.all? { |s| Hash === s } 42 | subject.map do |s| 43 | attribute.inject(s) { |inner, attr| inner[attr] } 44 | end 45 | else 46 | subject.map { |inner| inner[*attribute] } 47 | end 48 | else 49 | attribute_chain = attribute.to_s.split('.').map(&:to_sym) 50 | attribute_chain.inject(subject) do |inner_subject, attr| 51 | inner_subject.map(&attr) 52 | end 53 | end 54 | end 55 | 56 | def is_expected 57 | expect(__its_map_subject) 58 | end 59 | 60 | alias_method :are_expected, :is_expected 61 | 62 | options << {} unless options.last.is_a?(Hash) 63 | 64 | example(nil, *options, &block) 65 | end 66 | # rubocop:enable Lint/NestedMethodDefinition 67 | end 68 | end 69 | end 70 | end 71 | 72 | RSpec.configure do |rspec| 73 | rspec.extend Saharspec::Its::Map 74 | rspec.backtrace_exclusion_patterns << %r{/lib/saharspec/its/map} 75 | end 76 | 77 | RSpec::SharedContext.include Saharspec::Its::Map 78 | -------------------------------------------------------------------------------- /spec/saharspec/matchers/send_message_spec.rb: -------------------------------------------------------------------------------- 1 | require 'saharspec/matchers/send_message' 2 | 3 | RSpec.describe :send_message do 4 | let(:obj) { 5 | Object.new.tap { |o| 6 | def o.meth 7 | 10 8 | end 9 | } 10 | } 11 | 12 | context 'simple' do 13 | it { expect { obj.meth }.to send_message(obj, :meth) } 14 | it { expect {}.not_to send_message(obj, :meth) } 15 | it { expect { expect {}.to send_message(obj, :meth1) }.to raise_error(NoMethodError) } 16 | end 17 | 18 | context 'arguments' do 19 | it { expect { obj.meth(1, 2, 3) }.to send_message(obj, :meth).with(1, 2, 3) } 20 | it { expect { obj.meth(1, 3, 2) }.not_to send_message(obj, :meth).with(1, 2, 3) } 21 | it { expect { obj.meth([1, 3, 2]) }.to send_message(obj, :meth).with(array_including(2, 3)) } 22 | it { 23 | expect { obj.meth([1, 3, 2]) }.not_to send_message(obj, :meth).with(array_including(3, 4)) 24 | } 25 | 26 | xit 'checks count' 27 | end 28 | 29 | context 'return value' do 30 | specify { 31 | res = nil 32 | expect { res = obj.meth }.to send_message(obj, :meth).returning(5) 33 | expect(res).to eq 5 34 | } 35 | end 36 | 37 | context 'calling original' do 38 | specify { 39 | res = nil 40 | expect { res = obj.meth }.to send_message(obj, :meth).calling_original 41 | expect(res).to eq 10 42 | } 43 | end 44 | 45 | context 'number of times' do 46 | it { expect { obj.meth; obj.meth }.to send_message(obj, :meth).exactly(2).times } 47 | it { 48 | expect { 49 | expect { obj.meth; obj.meth }.to send_message(obj, :meth).exactly(3).times 50 | }.to raise_error(RSpec::Mocks::MockExpectationError) # exactly can't be tested with not_to 51 | } 52 | end 53 | 54 | context 'private methods' do 55 | let(:klass) { 56 | Object.new.tap { |o| 57 | def o.meth 58 | 10 59 | end 60 | 61 | o.send(:private, :meth) 62 | } 63 | } 64 | it { expect { obj.__send__(:meth) }.to send_message(obj, :meth) } 65 | end 66 | 67 | context 'several calls' do 68 | let(:obj1) { 69 | Object.new.tap { |o| 70 | def o.meth 71 | 10 72 | end 73 | } 74 | } 75 | let(:obj2) { 76 | Object.new.tap { |o| 77 | def o.meth 78 | 10 79 | end 80 | } 81 | } 82 | 83 | context 'composability' do 84 | it { 85 | expect { obj1.meth; obj2.meth } 86 | .to send_message(obj1, :meth) 87 | .and send_message(obj2, :meth) 88 | } 89 | 90 | context 'with dont.' do 91 | it { 92 | require 'saharspec/matchers/dont' 93 | 94 | expect { obj1.meth} 95 | .to send_message(obj1, :meth) 96 | .and dont.send_message(obj2, :meth) 97 | } 98 | end 99 | end 100 | 101 | context 'ordered' do 102 | it { 103 | expect { obj1.meth; obj2.meth } 104 | .to send_message(obj1, :meth).ordered 105 | .and send_message(obj2, :meth).ordered 106 | } 107 | 108 | it { 109 | expect { 110 | expect { obj2.meth; obj1.meth } 111 | .to send_message(obj1, :meth).ordered 112 | .and send_message(obj2, :meth).ordered 113 | }.to raise_error(/out of order/) 114 | } 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Saharspec history 2 | 3 | ## master 4 | 5 | * Drop support for Ruby < 3.1 6 | * ~~Fix `lets:` to really work :)~~ Remove `lets:` completely. 7 | * Alternative to `lets:` (too magical!), is `instant_context`: 8 | ```ruby 9 | instant_context lets: {a: 1} do 10 | # ... 11 | end 12 | 13 | # is the same as 14 | context "with a=1" do 15 | let(:a) { 1 } 16 | # ... 17 | end 18 | ``` 19 | * In the same spirit, created `it_with`/`its_blocks_with` additions: 20 | ```ruby 21 | subject { x + y } 22 | 23 | it_with(x: 1, y: 2) { is_expected.to eq 3 } 24 | 25 | # Same as: 26 | context 'with x=1, y=2' do 27 | let(:x) { 1 } 28 | let(:y) { 2 } 29 | 30 | it { is_expected.to eq 3 } 31 | end 32 | ``` 33 | * TODO: Rubocop cops suggesting the simplifications? 34 | * Add more "copies" of the `expect(...)`/`allow(...)` RSpec DSL to `send_message`: `at_least`/`twice`/`trice`/`yielding`. 35 | * A workaround for ActiveSupport 7.1 `Object#with` for `dont` matcher. 36 | 37 | ## 0.0.10 -- 2023-02-18 38 | 39 | * Add `lets:` metadata helper for DRYer defining of simple `let`s in multiple contexts; 40 | * Minimum supported Ruby version is 2.7 41 | * Add a handler for when `saharspec` is called before/without `rspec`, to provide an informative error message ([@Vagab](https://github.com/Vagab)) 42 | 43 | ## 0.0.9 -- 2022-05-17 44 | 45 | * Properly lint RSpec specs using `its_block`/`its_call`/`its_map` with `rubocop-rspec` >= 2.0 ([@ka8725](https://github.com/ka8725)) 46 | * Fix `its_block` and `its_call` to support RSpec 3.11 47 | 48 | ## 0.0.8 -- 2020-10-10 49 | 50 | * Better `dont` failure message (just use underlying matchers `failure_message_when_negated`) 51 | 52 | ## 0.0.7 -- 2020-04-11 53 | 54 | * Allow `its_call` to work properly with keyword args on Ruby 2.7 55 | 56 | ## 0.0.6 -- 2019-10-05 57 | 58 | * Fix `dont.send_message` combination behavior (and generally, behavior of `dont.` with matchers 59 | defining custom `does_not_matches?`); 60 | * Better `ret` matcher failure message when there is a complicated matcher on the right side (use 61 | its `failure_message` instead of `description`); 62 | * Update `send_message` matcher description to be more readable; 63 | * Drop Ruby 2.2 support. 64 | 65 | ## 0.0.5 -- 2018-03-03 66 | 67 | * `be_json` matcher; 68 | * make `ret` diffable. 69 | 70 | ## 0.0.4 -- 2017-11-07 71 | 72 | * Update `its_call` description generation, looks better with `rspec --format doc` 73 | 74 | ## 0.0.3 -- 2017-11-06 75 | 76 | * Introduce new `its`-family addition: `its_call(*args)` 77 | ```ruby 78 | let(:array) { %i[a b c] } 79 | subject { array.method(:delete_at) } 80 | its_call(1) { is_expected.to change(array, :length).by(-1) } 81 | ``` 82 | * Introduce new `ret` matcher ("block is expected to return"), useful in context when block is your 83 | primary subject: 84 | 85 | ```ruby 86 | let(:array) { %i[a b c] } 87 | subject { array.method(:delete_at) } 88 | its_call(1) { is_expected.to ret(:b).and change(array, :length).by(-1) } 89 | ``` 90 | * Introduce experimental `dont` matcher combiner (instead of `RSpec.define_negated_matcher`), used 91 | as `expect { something }.to dont.change { anything }`; 92 | * **Breaking**: rename `its_call`→`its_block` (because the name was needed for other means). 93 | 94 | ## 0.0.2 -- 2017-09-01 95 | 96 | * Make `send_message` composable; 97 | * Add `Util#multiline`. 98 | 99 | ## 0.0.1 -- 2017-08-14 100 | 101 | Initial! 102 | -------------------------------------------------------------------------------- /lib/saharspec/matchers/ret.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Matchers 5 | # @private 6 | class Ret 7 | include RSpec::Matchers::Composable 8 | 9 | attr_reader :actual, :expected 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def matches?(subject) 16 | @subject = subject 17 | return false unless subject.respond_to?(:call) 18 | 19 | @actual = subject.call 20 | @expected === @actual 21 | end 22 | 23 | def supports_block_expectations? 24 | true 25 | end 26 | 27 | def diffable? 28 | true 29 | end 30 | 31 | def description 32 | "return #{@expected.respond_to?(:description) ? @expected.description : @expected.inspect}" 33 | end 34 | 35 | def failure_message 36 | case 37 | when !@subject.respond_to?(:call) 38 | "expected to #{description}, but was not callable" 39 | when @expected.respond_to?(:failure_message) 40 | "return value mismatch: #{@expected.failure_message}" 41 | else 42 | "expected to #{description}, but returned #{@actual.inspect}" 43 | end 44 | end 45 | 46 | def failure_message_when_negated 47 | case 48 | when @expected.respond_to?(:failure_message_when_negated) 49 | "return value mismatch: #{@expected.failure_message_when_negated}" 50 | else 51 | "expected not to #{description}, but returned it" 52 | end 53 | end 54 | end 55 | end 56 | end 57 | 58 | module RSpec 59 | module Matchers 60 | # `ret` (short for `return`) checks if provided block **returns** value specified. 61 | # 62 | # It should be considered instead of simple value matchers (like `eq`) in the situations: 63 | # 64 | # 1. Several block behaviors tested in the same test, joined with `.and`, or in separate tests 65 | # 2. You test what some block or method returns with arguments, using 66 | # {Saharspec::Its::Call#its_call #its_call} 67 | # 68 | # Values are tested with `===`, which allows chaining other matchers and patterns to the check. 69 | # 70 | # @note 71 | # There is a case when `ret` fails: when it is _not the first_ in a chain of matchers joined 72 | # by `.and`. That's not exactly the matchers bug, that's how RSpec works (loses block's return 73 | # value passing the block between matchers) 74 | # 75 | # @example 76 | # # case 1: block is a subject 77 | # subject { -> { do_something } } 78 | # 79 | # it { is_expected.not_to raise_error } 80 | # it { is_expected.to change(some, :value).by(1) } 81 | # it { is_expected.to ret 8 } 82 | # 83 | # # or, joined: 84 | # specify { 85 | # expect { do_something }.to ret(8).and change(some, :value).by(1) 86 | # } 87 | # 88 | # # case 2: with arguments 89 | # subject { %i[a b c].method(:[]) } 90 | # 91 | # its_call(1) { is_expected.to ret :b } 92 | # its_call(1..-1) { is_expected.to ret %i[b c] } 93 | # its_call('foo') { is_expected.to raise_error TypeError } 94 | # 95 | # # Note, that values are tested with ===, which means all other matchers could be chained: 96 | # its_call(1) { is_expected.to ret instance_of(Symbol) } 97 | # its_call(1..-1) { is_expected.to ret instance_of(Array).and have_attributes(length: 2) } 98 | # 99 | def ret(expected) 100 | Saharspec::Matchers::Ret.new(expected) 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/saharspec/matchers/be_json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module Saharspec 6 | module Matchers 7 | # @private 8 | class BeJson 9 | include RSpec::Matchers::Composable 10 | include RSpec::Matchers # to have #match 11 | 12 | ANY = Object.new.freeze 13 | 14 | attr_reader :actual, :expected 15 | 16 | def initialize(expected, **parse_opts) 17 | @expected_matcher = @expected = expected 18 | 19 | # wrap to make be_json('foo' => matcher) work, too 20 | unless expected == ANY || expected.respond_to?(:matches?) 21 | @expected_matcher = match(expected) 22 | end 23 | @parse_opts = parse_opts 24 | end 25 | 26 | def matches?(json) 27 | @actual = JSON.parse(json, **@parse_opts) 28 | @expected_matcher == ANY || @expected_matcher === @actual 29 | rescue JSON::ParserError => e 30 | @parser_error = e 31 | false 32 | end 33 | 34 | def does_not_match?(*args) 35 | !matches?(*args) 36 | end 37 | 38 | def diffable? 39 | true 40 | end 41 | 42 | def description 43 | if @expected == ANY 44 | 'be a valid JSON string' 45 | else 46 | expected = @expected.respond_to?(:description) ? @expected.description : @expected 47 | "be a valid JSON matching (#{expected})" 48 | end 49 | end 50 | 51 | def failure_message 52 | failed = 53 | case 54 | when @parser_error 55 | "failed: #{@parser_error}" 56 | when @expected != ANY 57 | "was #{@actual}" 58 | end 59 | "expected value to #{description} but #{failed}" 60 | end 61 | 62 | def failure_message_when_negated 63 | 'expected value not to be parsed as JSON, but succeeded' 64 | end 65 | end 66 | end 67 | end 68 | 69 | module RSpec 70 | module Matchers 71 | # `be_json` checks if provided value is JSON, and optionally checks it contents. 72 | # 73 | # If you need to check against some hashes, it is more convenient to use `be_json_sym`, which 74 | # parses JSON with `symbolize_names: true`. 75 | # 76 | # @example 77 | # 78 | # expect('{}').to be_json # ok 79 | # expect('garbage').to be_json 80 | # # expected value to be a valid JSON string but failed: 765: unexpected token at 'garbage' 81 | # 82 | # expect('{"foo": "bar"}').to be_json('foo' => 'bar') # ok 83 | # expect('{"foo": "bar"}').to be_json_sym(foo: 'bar') # more convenient 84 | # 85 | # expect('{"foo": [1, 2, 3]').to be_json_sym(foo: array_including(3)) # nested matchers work 86 | # expect(something_large).to be_json_sym(include(meta: include(next_page: Integer))) 87 | # 88 | # @param expected Value or matcher to check JSON against. It should implement `#===` method, 89 | # so all standard and custom RSpec matchers work. 90 | def be_json(expected = Saharspec::Matchers::BeJson::ANY) 91 | Saharspec::Matchers::BeJson.new(expected) 92 | end 93 | 94 | # `be_json_sym` checks if value is a valid JSON and parses it with `symbolize_names: true`. This 95 | # way, it is convenient to check hashes content with Ruby's short symbolic keys syntax. 96 | # 97 | # See {#be_json_sym} for examples. 98 | # 99 | # @param expected Value or matcher to check JSON against. It should implement `#===` method, 100 | # so all standard and custom RSpec matchers work. 101 | def be_json_sym(expected = Saharspec::Matchers::BeJson::ANY) 102 | Saharspec::Matchers::BeJson.new(expected, symbolize_names: true) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/saharspec/matchers/send_message.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Saharspec 4 | module Matchers 5 | # @private 6 | class SendMessage 7 | include RSpec::Mocks::ExampleMethods 8 | include RSpec::Matchers::Composable 9 | 10 | def initialize(target, method) 11 | @target = target 12 | @method = method 13 | end 14 | 15 | # DSL 16 | def with(*arguments) 17 | @arguments = arguments 18 | self 19 | end 20 | 21 | def returning(*res) 22 | @res = [*res] 23 | self 24 | end 25 | 26 | def calling_original 27 | @call_original = true 28 | self 29 | end 30 | 31 | def exactly(n) 32 | @times = n 33 | self 34 | end 35 | 36 | def at_least(n) 37 | @at_least = n 38 | self 39 | end 40 | 41 | def at_most(n) 42 | @at_most = n 43 | self 44 | end 45 | 46 | def times 47 | fail NoMethodError unless @times || @at_least 48 | 49 | self 50 | end 51 | 52 | def once 53 | exactly(1) 54 | end 55 | 56 | def twice 57 | exactly(2) 58 | end 59 | 60 | def thrice 61 | exactly(3) 62 | end 63 | 64 | def ordered 65 | @ordered = true 66 | self 67 | end 68 | 69 | def yielding(*args, &block) 70 | @yield_args = args 71 | @yield_block = block 72 | self 73 | end 74 | 75 | # Matching 76 | def matches?(subject) 77 | run(subject) 78 | expect(@target).to expectation 79 | true 80 | end 81 | 82 | def does_not_match?(subject) 83 | run(subject) 84 | expect(@target).not_to expectation 85 | true 86 | end 87 | 88 | # Static properties 89 | def supports_block_expectations? 90 | true 91 | end 92 | 93 | def description 94 | format('send %p.%s', @target, @method) 95 | end 96 | 97 | def failure_message 98 | "expected #{description}, but sent nothing" 99 | end 100 | 101 | def failure_message_when_negated 102 | "expected not #{description}, but sent it" 103 | end 104 | 105 | private 106 | 107 | def run(subject) 108 | @target.respond_to?(@method, true) or 109 | fail NoMethodError, 110 | "undefined method `#{@method}' for#{@target.inspect}:#{@target.class}" 111 | allow(@target).to allower 112 | subject.call 113 | end 114 | 115 | def allower 116 | receive(@method).tap do |a| 117 | a.and_return(*@res) if @res 118 | a.and_call_original if @call_original 119 | end 120 | end 121 | 122 | def expectation 123 | have_received(@method).tap do |e| 124 | e.with(*@arguments) if @arguments 125 | e.exactly(@times).times if @times 126 | e.at_least(@at_least).times if @at_least 127 | e.at_most(@at_most).times if @at_most 128 | e.ordered if @ordered 129 | e.and_yield(*@yield_args, &@yield_block) if @yield_args 130 | end 131 | end 132 | end 133 | end 134 | end 135 | 136 | module RSpec 137 | module Matchers 138 | # Checks if the (block) subject sends specified message to specified object. 139 | # 140 | # @example 141 | # # before: 142 | # specify { 143 | # allow(double).to receive(:fetch) 144 | # code_being_tested 145 | # expect(double).to have_received(:fetch).with(something) 146 | # } 147 | # 148 | # # after: 149 | # require 'saharspec/matchers/send_message' 150 | # 151 | # it { expect { code_being_tested }.to send_message(double, :fetch).with(something) } 152 | # 153 | # # after + its_block 154 | # require 'saharspec/its/block' 155 | # 156 | # subject { code_being_tested } 157 | # its_block { is_expected.to send_message(double, :fetch).with(something) } 158 | # 159 | # @param target Object which expects message, double or real object 160 | # @param method [Symbol] Message being expected 161 | # 162 | # @return Instance of a matcher, allowing the following additional methods: 163 | # 164 | # * `once`, `twice`, `exactly(n).times`; 165 | # * `with(arguments)`; 166 | # * `calling_original`; 167 | # * `returning(response)`. 168 | # 169 | def send_message(target, method) 170 | Saharspec::Matchers::SendMessage.new(target, method) 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Saharspec: Specs DRY as Sahara 2 | 3 | **NOTE: The gem was renamed to [moarspec](https://github.com/zverok/moarspec) (for obvious reasons). Please update your gemfiles.** 4 | 5 | --- 6 | 7 | **saharspec** is a set of additions to RSpec. 8 | 9 | ## Usage 10 | 11 | Install it as a usual gem `saharspec` with `gem install` or `gem "saharspec"` in `:test` group of 12 | your `Gemfile`. 13 | 14 | Then, probably in your `spec_helper.rb` 15 | 16 | ```ruby 17 | require 'saharspec' 18 | # or feature-by-feature 19 | require 'saharspec/its/map' 20 | # or some part of a library 21 | require 'saharspec/its' 22 | ``` 23 | 24 | ## Parts 25 | 26 | ### Matchers 27 | 28 | Just a random matchers I've found useful in my studies. 29 | 30 | #### `send_message(object, method)` matcher 31 | 32 | ```ruby 33 | # before 34 | it { 35 | expect(Net::HTTP).to receive(:get).with('http://google.com').and_return('not this time') 36 | fetcher 37 | } 38 | 39 | # after 40 | require 'saharspec/matchers/send_message' 41 | 42 | it { 43 | expect { fetcher }.to send_message(Net::HTTP, :get).with('http://google.com').returning('not this time') 44 | } 45 | # after + its_block 46 | subject { fetcher } 47 | its_block { is_expected.to send_message(Net::HTTP, :get).with('http://google.com').returning('not this time') } 48 | ``` 49 | 50 | Note: there are [reasons](https://github.com/rspec/rspec-expectations/issues/934) why it is not in rspec-mocks, though, not very persuative for me. 51 | 52 | #### `expect { block }.to ret(value)` matcher 53 | 54 | Checks whether `#call`-able subject (block, method, command object), when called, return value matching 55 | to expected. 56 | 57 | Useful when this callable subject is your primary one: 58 | 59 | ```ruby 60 | # before: option 1. subject is value 61 | subject { 2 + x } 62 | 63 | context 'when numeric' do 64 | let(:x) { 3 } 65 | it { is_expected.to eq 5 } # DRY 66 | end 67 | 68 | context 'when incompatible' do 69 | let(:x) { '3' } 70 | it { expect { subject }.to raise_error } # not DRY 71 | end 72 | 73 | # option 2. subject is block 74 | subject { -> { 2 + x } } 75 | 76 | context 'when numeric' do 77 | let(:x) { 3 } 78 | it { expect(subject.call).to eq 5 } # not DRY 79 | end 80 | 81 | context 'when incompatible' do 82 | let(:x) { '3' } 83 | it { is_expected.to raise_error } # DRY 84 | end 85 | 86 | # after 87 | require 'saharspec/matchers/ret' 88 | 89 | subject { -> { 2 + x } } 90 | 91 | context 'when numeric' do 92 | let(:x) { 3 } 93 | it { is_expected.to ret 5 } # DRY: notice `ret` 94 | end 95 | 96 | context 'when incompatible' do 97 | let(:x) { '3' } 98 | it { is_expected.to raise_error } # DRY 99 | end 100 | ``` 101 | 102 | Plays really well with `its_call` shown below. 103 | 104 | #### `be_json(value)` and `be_json_sym(value)` matchers 105 | 106 | Simple matcher to check if string is valid JSON and optionally if it matches to expected values: 107 | 108 | ```ruby 109 | expect('{}').to be_json # ok 110 | expect('garbage').to be_json 111 | # expected value to be a valid JSON string but failed: 765: unexpected token at 'garbage' 112 | 113 | expect('{"foo": "bar"}').to be_json('foo' => 'bar') # ok 114 | 115 | # be_json_sym is more convenient to check with hash keys, parses JSON to symbols 116 | expect('{"foo": "bar"}').to be_json_sym(foo: 'bar') 117 | 118 | # nested matchers work, too 119 | expect('{"foo": [1, 2, 3]').to be_json_sym(foo: array_including(3)) 120 | 121 | # We need to go deeper! 122 | expect(something_large).to be_json_sym(include(meta: include(next_page: Integer))) 123 | ``` 124 | 125 | #### `eq_multiline(text)` matcher 126 | 127 | Dedicated to checking some multiline text generators. 128 | 129 | ```ruby 130 | # before: one option 131 | 132 | it { expect(generated_code).to eq("def method\n a = @b**2\n return a + @b\nend") } 133 | 134 | # before: another option 135 | it { 136 | expect(generated_code).to eq(%{def method 137 | a = @b**2 138 | return a + @b 139 | end}) 140 | } 141 | 142 | # after 143 | require 'saharspec/matchers/eq_multiline' 144 | 145 | it { 146 | expect(generated_code).to eq_multiline(%{ 147 | |def method 148 | | a = @b**2 149 | | return a + @b 150 | |end 151 | }) 152 | } 153 | ``` 154 | (empty lines before/after are removed, text deindented up to `|` sign) 155 | 156 | ### `dont`: matcher negation 157 | 158 | Allows to get rid of gazilliions of `define_negated_matcher`. `dont` is not 100% grammatically 159 | correct, yet short and readable enought. It just negates attached matcher. 160 | 161 | ```ruby 162 | # before 163 | RSpec.define_negated_matcher :not_change, :change 164 | 165 | it { expect { code }.to do_stuff.and not_change(obj, :attr) } 166 | 167 | # after: no `define_negated_matcher` needed 168 | require 'saharspec/matchers/dont' 169 | 170 | it { expect { code }.to do_stuff.and dont.change(obj, :attr) } 171 | ``` 172 | 173 | ### `its`-addons 174 | 175 | **Notice**: There are different opinions on usability/reasonability of `its(:attribute)` syntax, 176 | extracted from RSpec core and currently provided by [rspec-its](https://github.com/rspec/rspec-its) 177 | gem. Some find it (and a notion of description-less examples) bad practice. But if you are like me 178 | and love DRY-ness of it, probably you'll love those two ideas, taking `its`-syntax a bit further. 179 | 180 | #### `its_map` 181 | 182 | Like `rspec/its`, but for processing arrays: 183 | 184 | ```ruby 185 | subject { html_document.search('ul#menu > li') } 186 | 187 | # before 188 | it { expect(subject.map(&:text)).to all not_be_empty } 189 | 190 | # after 191 | require 'saharspec/its/map' 192 | 193 | its_map(:text) { are_expected.to all not_be_empty } 194 | ``` 195 | 196 | #### `its_block` 197 | 198 | Allows to DRY-ly refer to "block that calculates subject". 199 | 200 | ```ruby 201 | subject { some_operation_that_may_fail } 202 | 203 | # before 204 | context 'success' do 205 | it { is_expected.to eq 123 } 206 | end 207 | 208 | context 'fail' do 209 | it { expect { subject }.to raise_error(...) } 210 | end 211 | 212 | # after 213 | require 'saharspec/its/block' 214 | 215 | its_block { is_expected.to raise_error(...) } 216 | ``` 217 | 218 | #### `its_call` 219 | 220 | Allows to DRY-ly test callable object with different arguments. Plays well with forementioned `ret` 221 | matcher. 222 | 223 | Before: 224 | 225 | ```ruby 226 | # before 227 | describe '#delete_at' do 228 | let(:array) { %i[a b c] } 229 | 230 | it { expect(array.delete_at(1) }.to eq :b } 231 | it { expect(array.delete_at(8) }.to eq nil } 232 | it { expect { array.delete_at(1) }.to change(array, :length).by(-1) } 233 | it { expect { array.delete_at(:b) }.to raise_error TypeError } 234 | end 235 | 236 | # after 237 | require 'saharspec/its/call' 238 | 239 | describe '#delete_at' do 240 | let(:array) { %i[a b c] } 241 | 242 | subject { array.method(:delete_at) } 243 | 244 | its_call(1) { is_expected.to ret :b } 245 | its_call(1) { is_expected.to change(array, :length).by(-1) } 246 | its_call(8) { is_expected.to ret nil } 247 | its_call(:b) { is_expected.to raise_error TypeError } 248 | end 249 | ``` 250 | 251 | #### `it_with`/`its_block_with` 252 | 253 | Allows to adjust `let` values inline with `it` definition. Especially useful when there are many cases to test, and each one requires only one simple test with simple `let` replacements. 254 | 255 | ```ruby 256 | # before 257 | describe '#+' do 258 | subject { x + y } 259 | 260 | context 'with positive values' do 261 | let(:x) { 1 } 262 | let(:y) { 2 } 263 | 264 | it { is_expected.to eq 3 } 265 | end 266 | 267 | context 'with negative values' do 268 | # 4 more lines 269 | end 270 | 271 | context 'with non-numeric value' do 272 | # 4 more lines 273 | end 274 | end 275 | 276 | # after 277 | describe '#+' do 278 | subject { x + y } 279 | 280 | it_with(x: 1, y: 2) { is_expected.to eq 3 } 281 | it_with(x: 1, y: -2) { is_expected.to eq -1 } 282 | its_block_with(x: 1, y: nil) { is_expected.to raise_error } 283 | end 284 | ``` 285 | 286 | ### Example group helpers 287 | 288 | #### `instant_context` 289 | 290 | A shortcut for defining simple `let`s in the context description: 291 | 292 | ```ruby 293 | let(:user) { create(:user, role: role) } 294 | 295 | # before: a lot of code to say simple things: 296 | 297 | context 'when admin' do 298 | let(:role) { :admin } 299 | 300 | it { is_expected.to be_allowed } 301 | end 302 | 303 | context 'when user' do 304 | let(:role) { :user } 305 | 306 | it { is_expected.to be_denied } 307 | end 308 | 309 | # after 310 | 311 | instant_context 'when admin', lets: {role: :admin} do 312 | it { is_expected.to be_allowed } 313 | end 314 | 315 | instant_context 'when user', lets: {role: :user} do 316 | it { is_expected.to be_denied } 317 | end 318 | 319 | # you can also give empty descriptions, then they would be auto-generated 320 | 321 | # generates a context with description "role=:admin" 322 | instant_context lets: {role: :admin} do 323 | it { is_expected.to be_allowed } 324 | end 325 | ``` 326 | 327 | _Note: when each context has only one example, `it_with` (see above) is probably more convenient. `instant_context` shines when the contexts are numerous and simple, but have several examples each._ 328 | 329 | ### Linting with RuboCop RSpec 330 | 331 | `rubocop-rspec` fails to properly detect RSpec constructs that Saharspec defines (`its_call`, `its_block`, `its_map`). 332 | Make sure to use `rubocop-rspec` 2.0 or newer and add the following to your `.rubocop.yml`: 333 | 334 | ```yaml 335 | inherit_gem: 336 | saharspec: config/rubocop-rspec.yml 337 | ``` 338 | 339 | ## State & future 340 | 341 | I use all of the components of the library on daily basis. Probably, I will extend it with other 342 | ideas and findings from time to time (next thing that needs gemification is WebMock DRY-er, allowing 343 | code like `expect { code }.to request_webmock(url, params)` instead of preparing stubs and then 344 | checking them). Stay tuned. 345 | 346 | ## Author 347 | 348 | [Victor Shepelev](http://zverok.space/) 349 | 350 | ## License 351 | 352 | [MIT](https://github.com/zverok/saharspec/blob/master/LICENSE.txt). 353 | --------------------------------------------------------------------------------