├── .rspec ├── .gitignore ├── Gemfile ├── lib └── rspec │ ├── resembles_json_matchers │ ├── version.rb │ ├── resembles_nil_matcher.rb │ ├── resembles_string_matcher.rb │ ├── matcherizer.rb │ ├── resembles_boolean_matcher.rb │ ├── resembles_class_matcher.rb │ ├── resembles_array_matcher.rb │ ├── resembles_route_matcher.rb │ ├── helpers.rb │ ├── string_indent.rb │ ├── resembles_numeric_matcher.rb │ ├── resembles_date_matcher.rb │ ├── resembles_any_of_matcher.rb │ ├── resembles_matcher.rb │ ├── json_matcher.rb │ ├── attribute_matcher.rb │ ├── resembles_hash_matcher.rb │ └── attribute_differ.rb │ └── resembles_json_matchers.rb ├── Rakefile ├── gemfiles ├── .bundle │ └── config ├── rails_5.gemfile ├── rails_6.gemfile ├── rails_5.gemfile.lock └── rails_6.gemfile.lock ├── Appraisals ├── .travis.yml ├── spec ├── matchers_spec.rb ├── spec_helper.rb ├── examples_spec.rb └── matchers │ ├── resembles_any_of_matcher_spec.rb │ ├── attribute_matcher_spec.rb │ └── json_matcher_spec.rb ├── LICENSE.txt ├── bin └── rspec ├── Changelog.md ├── Guardfile ├── rspec-resembles_json_matchers.gemspec ├── .rubocop.yml ├── examples └── example_spec.rb └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .rubocop-* 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in rspec-json_api_matchers.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rspec 4 | module ResemblesJsonMatchers 5 | VERSION = "0.9.1" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task :default => :spec 9 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | BUNDLE_DEPLOYMENT: "true" 4 | BUNDLE_PATH: "/home/rando/Code/personal/rspec-resembles_json_matchers/vendor/bundle" 5 | BUNDLE_FROZEN: "true" 6 | -------------------------------------------------------------------------------- /gemfiles/rails_5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 5.2.0" 6 | gem "rspec", "~> 3.6.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.0.0" 6 | gem "rspec", "~> 3.9.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "rails-5" do 4 | gem "rails", "~> 5.2.0" 5 | gem "rspec", "~> 3.6.0" 6 | end 7 | 8 | appraise "rails-6" do 9 | gem "rails", "~> 6.0.0" 10 | gem "rspec", "~> 3.9.0" 11 | end 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - "2.5" 4 | - "2.6" 5 | - "2.7" 6 | before_install: gem update --system && gem install bundler 7 | gemfile: 8 | - gemfiles/rails_5.gemfile 9 | - gemfiles/rails_6.gemfile 10 | matrix: 11 | fast_finish: true 12 | notifications: 13 | email: false 14 | -------------------------------------------------------------------------------- /spec/matchers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "spec_helper" 4 | 5 | RSpec.describe RSpec::ResemblesJsonMatchers do 6 | subject do 7 | Class.new { include RSpec::ResemblesJsonMatchers }.new 8 | end 9 | 10 | specify { expect(subject.match_json({})).to be_a_matcher } 11 | specify { expect(subject.resemble_json({})).to be_a_matcher } 12 | 13 | # Support 14 | 15 | matcher :be_a_matcher do 16 | match do |actual| 17 | actual.respond_to? :matches? 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "rspec/resembles_json_matchers" 5 | 6 | require "awesome_print" 7 | require "pry-byebug" 8 | require "active_support/core_ext/string/strip" 9 | 10 | RSpec.configure do |config| 11 | config.example_status_persistence_file_path = "tmp/failing_examples.txt" 12 | 13 | config.filter_run_when_matching :focus 14 | config.filter_run_including focus: true 15 | config.run_all_when_everything_filtered = true 16 | 17 | # config.fail_fast = true 18 | end 19 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | bundle_binstub = File.expand_path("../bundle", __FILE__) 12 | load(bundle_binstub) if File.file?(bundle_binstub) 13 | 14 | require "pathname" 15 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 16 | Pathname.new(__FILE__).realpath) 17 | 18 | require "rubygems" 19 | require "bundler/setup" 20 | 21 | load Gem.bin_path("rspec-core", "rspec") 22 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_nil_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesNilMatcher 5 | def self.can_match?(nillish) 6 | nillish.nil? 7 | end 8 | 9 | attr_reader :expected 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def description 16 | "resemble boolean #{@expected.inspect}" 17 | end 18 | 19 | def matches?(actual) 20 | actual.nil? 21 | end 22 | 23 | def expected_formatted 24 | @expected 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_string_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesStringMatcher 5 | def self.can_match?(string) 6 | string.is_a? String 7 | end 8 | 9 | attr_reader :expected 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def description 16 | "resemble text #{@expected.inspect}" 17 | end 18 | 19 | # TODO make sure the lengths are kinda the same? Levenschtien distances? 20 | def matches?(actual) 21 | self.class.can_match?(actual) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/examples_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "spec_helper" 4 | require "rspec/resembles_json_matchers" 5 | 6 | RSpec.describe "my json response document" do 7 | include RSpec::ResemblesJsonMatchers 8 | 9 | subject(:response_document) do 10 | { 11 | author: "Paul", 12 | gems_published: 42, 13 | created_at: "2016-01-01T00:00:00Z" 14 | } 15 | end 16 | 17 | it { 18 | expect(subject).to match_json( 19 | { 20 | author: "Paul", 21 | gems_published: be > 40, 22 | created_at: iso8601_timestamp 23 | } 24 | ) 25 | } 26 | end 27 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/matcherizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | module Matcherizer 5 | def matcherize(expected) 6 | if matcher? expected 7 | expected 8 | 9 | elsif expected.is_a?(Hash) 10 | RSpec::ResemblesJsonMatchers::JsonMatcher.new(expected) 11 | 12 | elsif expected.respond_to? :=== 13 | RSpec::Matchers::BuiltIn::Match.new(expected) 14 | 15 | else 16 | RSpec::Matchers::BuiltIn::Eq.new(expected) 17 | end 18 | end 19 | 20 | def matcher?(obj) 21 | obj.respond_to?(:matches?) && obj.respond_to?(:description) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_boolean_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesBooleanMatcher 5 | def self.can_match?(bool) 6 | bool.is_a?(TrueClass) || bool.is_a?(FalseClass) 7 | end 8 | 9 | attr_reader :expected 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def description 16 | "resemble boolean #{@expected.inspect}" 17 | end 18 | 19 | def matches?(actual) 20 | actual.is_a?(TrueClass) || actual.is_a?(FalseClass) 21 | end 22 | 23 | def expected_formatted 24 | @expected 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_class_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesClassMatcher 5 | def self.can_match?(klass) 6 | klass.is_a? Class 7 | end 8 | 9 | attr_reader :expected 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def description 16 | "resemble #{@expected}" 17 | end 18 | 19 | def matches?(actual) 20 | @actual = actual 21 | actual.is_a? @expected 22 | end 23 | 24 | def expected_formatted 25 | @expected 26 | end 27 | 28 | def failure_message 29 | "#{@actual.inspect} does not resemble a #{@expected}" 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_array_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesArrayMatcher 5 | include Helpers 6 | 7 | def self.can_match?(array) 8 | array.is_a? Array 9 | end 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def description 16 | "resemble #{@expected.inspect}" 17 | end 18 | 19 | def matches?(actual) 20 | actual.is_a?(Array) && actual.all? do |a| 21 | expected_matchers.any? do |e| 22 | e.matches? a 23 | end 24 | end 25 | end 26 | 27 | def expected_matchers 28 | @expected.map { |e| matcherize(e) } 29 | end 30 | 31 | def failure_message; end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_route_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesRouteMatcher 5 | ROUTE_REGEX = %r{\A/.*:}.freeze 6 | def self.can_match?(route_string) 7 | defined?(ActionDispatch) && 8 | route_string.is_a?(String) && 9 | route_string.start_with?("/") && 10 | route_string.include?(":") 11 | end 12 | 13 | attr_reader :expected 14 | 15 | def initialize(expected_route) 16 | @expected = expected_route 17 | end 18 | 19 | def description 20 | "resemble route #{@expected.inspect}" 21 | end 22 | 23 | def matches?(actual) 24 | path = ActionDispatch::Journey::Path::Pattern.from_string(@expected) 25 | actual =~ path.to_regexp 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module RSpec::ResemblesJsonMatchers 6 | module Helpers 7 | # Returns string composed of the specified clauses with proper 8 | # spacing between them. Empty and nil clauses are ignored. 9 | def sentencize(*clauses) 10 | clauses 11 | .flatten 12 | .compact 13 | .reject(&:empty?) 14 | .map(&:strip) 15 | .join(" ") 16 | end 17 | 18 | def matcherize(expected) 19 | if matcher?(expected) 20 | expected 21 | else 22 | RSpec::ResemblesJsonMatchers.resembles_matcher_for(expected).new(expected) 23 | end 24 | end 25 | 26 | def matcher?(obj) 27 | obj.respond_to?(:matches?) && obj.respond_to?(:description) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/string_indent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec 4 | module ResemblesJsonMatchers 5 | module StringIndent 6 | def indent!(amount, indent_string = nil, indent_empty_lines = false) 7 | indent_string = indent_string || self[/^[ \t]/] || " " 8 | re = indent_empty_lines ? /^/ : /^(?!$)/ 9 | gsub!(re, indent_string * amount) 10 | end 11 | 12 | def indent(amount, indent_string = nil, indent_empty_lines = false) 13 | dup.tap { |me| me.indent!(amount, indent_string, indent_empty_lines) } 14 | end 15 | end 16 | end 17 | end 18 | 19 | # String#indent was added in ActiveSupport 4 and appears here in order to support ActiveSupport 3 20 | begin 21 | require "active_support/core_ext/string/indent" # indent 22 | rescue LoadError 23 | String.include RSpec::ResemblesJsonMatchers::StringIndent 24 | end 25 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_numeric_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesNumericMatcher 5 | def self.can_match?(number) 6 | number.is_a?(Numeric) 7 | end 8 | 9 | attr_reader :expected 10 | 11 | def initialize(expected) 12 | @expected = expected 13 | end 14 | 15 | def description 16 | "resemble the number #{@expected.inspect}" 17 | end 18 | 19 | # TODO Make sure int/float matches? Numbers are within an order of magnitude? 20 | def matches?(actual) 21 | @actual = actual 22 | self.class.can_match?(actual) 23 | end 24 | 25 | def expected_formatted 26 | @expected 27 | end 28 | 29 | def to_json(*_args) 30 | @expected 31 | end 32 | 33 | def failure_message 34 | "#{@actual.inspect} does not resemble a number" 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # 0.9.0 2 | 3 | - ResemblesBoolean doesn't care about the value of the boolean, only that it 4 | is a boolean. 5 | - ResemblesAnyOf with non-homogeneous arrays no longer prints errors when the 6 | overall array matched successfully. 7 | 8 | # 0.8.0 9 | 10 | - ResemblesJson now fails if there are unexpected keys in the actual hash 11 | 12 | # 0.7.3 13 | 14 | - Add a few more missing render methods 15 | - Only try to call indifferent_access on Hashes 16 | 17 | # 0.7.2 18 | 19 | - Expose `expected` in the nil matcher so the description can be rendered 20 | 21 | # 0.7.1 22 | 23 | - Handle rendering when matcher value is null 24 | 25 | # 0.7.0 26 | 27 | - Change the output to be a diff-like view rather than verbose prose 28 | 29 | # 0.5.2 30 | 31 | - Fix an error that was included in master by mistake 32 | 33 | # 0.5.1 (yanked) 34 | 35 | - Add support for Rails 3.2 by monkey-patching String#index if it's not already 36 | 37 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_date_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "time" 4 | 5 | module RSpec::ResemblesJsonMatchers 6 | class ResemblesDateMatcher 7 | DATE_REGEX = /\A\d{4}-\d{2}-\d{2}\Z/.freeze 8 | ISO8601_REGEX = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z/.freeze 9 | def self.can_match?(date_or_str) 10 | case date_or_str 11 | when Date, Time, DateTime 12 | true 13 | when DATE_REGEX, ISO8601_REGEX 14 | true 15 | when String 16 | begin 17 | Time.iso8601(date_or_str) 18 | true 19 | rescue ArgumentError 20 | false 21 | end 22 | end 23 | end 24 | 25 | attr_reader :expected 26 | 27 | def initialize(expected) 28 | @expected = expected 29 | # @expected = expected.is_a?(String) ? Time.parse(expected) : expected 30 | end 31 | 32 | def description 33 | "resemble date #{@expected.inspect}" 34 | end 35 | 36 | def matches?(actual) 37 | @actual = actual 38 | self.class.can_match?(actual) 39 | end 40 | 41 | def failure_message 42 | "#{@actual.inspect} does not resemble a Date or Timestamp" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_any_of_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/array/wrap" 4 | 5 | module RSpec::ResemblesJsonMatchers 6 | class ResemblesAnyOfMatcher 7 | include Helpers 8 | 9 | def self.can_match?(array) 10 | array.is_a? Array 11 | end 12 | 13 | attr_reader :expected, :actual, :original_expected 14 | 15 | def initialize(expected) 16 | @original_expected = expected 17 | @expected = expected.map { |e| matcherize(e) } 18 | end 19 | 20 | def matches?(actual) 21 | @actual = Array.wrap(actual) 22 | @matched = @actual.all? do |a| 23 | expected_matchers.any? { |m| attempted_matchers << m; m.matches? a } 24 | end 25 | end 26 | 27 | def matched? 28 | @matched 29 | end 30 | 31 | def description 32 | if @expected.size == 1 33 | "have every item #{expected_matchers.first.description}" 34 | else 35 | "have every item match one of:\n#{expected_formatted}" 36 | end 37 | end 38 | 39 | def failure_message; end 40 | 41 | def expected_matchers 42 | @expected 43 | end 44 | 45 | def attempted_matchers 46 | @attempted_matchers ||= [] 47 | end 48 | 49 | def expected_formatted 50 | out = +"" 51 | out << expected_matchers.map do |v| 52 | "should #{v.description}".indent(2) 53 | end.join("\n") 54 | out << "\n" 55 | out 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/array/wrap" 4 | 5 | module RSpec::ResemblesJsonMatchers 6 | def resembles(expected) 7 | ResemblesMatcher.new(expected) 8 | end 9 | 10 | class ResemblesMatcher 11 | def initialize(expected, inclusion: nil) 12 | @expected = expected 13 | 14 | candidate_class = if inclusion == :any 15 | ResemblesAnyOfMatcher 16 | elsif inclusion == :all 17 | ResemblesAllOfMatcher 18 | else 19 | candidates.detect { |candidate| candidate.can_match?(@expected) } 20 | end 21 | 22 | fail "Could not find an acceptable resembles matcher for #{@expected.inspect}" unless candidate_class 23 | 24 | @candidate = candidate_class.new(expected) 25 | end 26 | 27 | def description 28 | @candidate.description 29 | end 30 | 31 | def matches?(actual) 32 | @actual = actual 33 | @candidate&.matches?(actual) 34 | end 35 | 36 | def failure_message(negated: false) 37 | "expected #{@actual.inspect} to #{negated ? 'not ' : ''}#{description}" 38 | end 39 | 40 | def negative_failure_message 41 | failure_message(negated: true) 42 | end 43 | 44 | def candidates; end 45 | end 46 | 47 | class ResemblesAllOfMatcher 48 | end 49 | 50 | RSpec.configure { |c| c.include self } 51 | end 52 | -------------------------------------------------------------------------------- /spec/matchers/resembles_any_of_matcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../spec_helper" 4 | 5 | RSpec.describe RSpec::ResemblesJsonMatchers::ResemblesAnyOfMatcher do 6 | subject(:matcher) { RSpec::ResemblesJsonMatchers::ResemblesAnyOfMatcher.new(matcher_candidates) } 7 | 8 | context "when every item in the given array matches one of the matchers" do 9 | let(:given) { [1, 2, "foo"] } 10 | let(:matcher_candidates) { [be_kind_of(Integer), "foo"] } 11 | 12 | specify { expect(matcher).to be_matches(given) } 13 | end 14 | 15 | context "when all the items in the given array do not match all of the matchers" do 16 | let(:given) { [1, 2] } 17 | let(:matcher_candidates) { [be_kind_of(Integer), "foo"] } 18 | 19 | specify { expect(matcher).to be_matches(given) } 20 | end 21 | 22 | context "when not every item in the given array matches the matchers" do 23 | let(:given) { [1, 2, "foo"] } 24 | let(:matcher_candidates) { [be_kind_of(Integer)] } 25 | 26 | specify { expect(matcher).not_to be_matches(given) } 27 | end 28 | 29 | describe "#description" do 30 | context "when there is only one matcher provded" do 31 | let(:matcher_candidates) { [be_kind_of(Integer)] } 32 | 33 | specify { expect(matcher.description).to eq "have every item be a kind of Integer" } 34 | end 35 | 36 | context "when multiple matchers are provided" do 37 | let(:matcher_candidates) { [be_kind_of(Integer), "foo"] } 38 | 39 | specify { 40 | expect(matcher.description).to eq <<-TXT.strip_heredoc 41 | have every item match one of: 42 | should be a kind of Integer 43 | should resemble text "foo" 44 | TXT 45 | } 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rspec/resembles_json_matchers/version" 4 | require "rspec/resembles_json_matchers/string_indent" 5 | 6 | module RSpec 7 | module ResemblesJsonMatchers 8 | require "rspec/resembles_json_matchers/helpers" 9 | require "rspec/resembles_json_matchers/attribute_matcher" 10 | require "rspec/resembles_json_matchers/json_matcher" 11 | 12 | require "rspec/resembles_json_matchers/resembles_any_of_matcher" 13 | require "rspec/resembles_json_matchers/resembles_route_matcher" 14 | require "rspec/resembles_json_matchers/resembles_date_matcher" 15 | require "rspec/resembles_json_matchers/resembles_numeric_matcher" 16 | require "rspec/resembles_json_matchers/resembles_string_matcher" 17 | require "rspec/resembles_json_matchers/resembles_boolean_matcher" 18 | require "rspec/resembles_json_matchers/resembles_nil_matcher" 19 | require "rspec/resembles_json_matchers/resembles_class_matcher" 20 | 21 | def iso8601_timestamp 22 | match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/) 23 | end 24 | 25 | def match_json(*a) 26 | JsonMatcher.new(*a) 27 | end 28 | alias resemble_json match_json 29 | 30 | def resembles(*a) 31 | RSpec::ResemblesJsonMatchers.resembles_matcher_for(*a).new(*a) 32 | end 33 | alias resemble resembles 34 | 35 | def self.resembles_matcher_candidates 36 | # Order matters 37 | @resembles_matcher_candidates ||= [ 38 | JsonMatcher, 39 | ResemblesAnyOfMatcher, 40 | ResemblesRouteMatcher, 41 | ResemblesDateMatcher, 42 | ResemblesNumericMatcher, 43 | ResemblesStringMatcher, 44 | ResemblesBooleanMatcher, 45 | ResemblesNilMatcher, 46 | ResemblesClassMatcher 47 | ].freeze 48 | end 49 | 50 | def self.resembles_matcher_for(expected, **_a) 51 | resembles_matcher_candidates.detect { |candidate| candidate.can_match?(expected) } || RSpec::Matchers::BuiltIn::Eq 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A sample Guardfile 4 | # More info at https://github.com/guard/guard#readme 5 | 6 | ## Uncomment and set this to only include directories you want to watch 7 | # directories %w(app lib config test spec features) \ 8 | # .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")} 9 | 10 | ## Note: if you are using the `directories` clause above and you are not 11 | ## watching the project directory ('.'), then you will want to move 12 | ## the Guardfile to a watched dir and symlink it back, e.g. 13 | # 14 | # $ mkdir config 15 | # $ mv Guardfile config/ 16 | # $ ln -s config/Guardfile . 17 | # 18 | # and, you'll have to watch "config/Guardfile" instead of "Guardfile" 19 | 20 | # Note: The cmd option is now required due to the increasing number of ways 21 | # rspec may be run, below are examples of the most common uses. 22 | # * bundler: 'bundle exec rspec' 23 | # * bundler binstubs: 'bin/rspec' 24 | # * spring: 'bin/rspec' (This will use spring if running and you have 25 | # installed the spring binstubs per the docs) 26 | # * zeus: 'zeus rspec' (requires the server to be started separately) 27 | # * 'just' rspec: 'rspec' 28 | 29 | guard :rspec, 30 | # cmd: "bin/rspec --no-profile --fail-fast", 31 | cmd: "bin/rspec --no-profile", 32 | failed_mode: :focus, 33 | run_all: { cmd: "bin/rspec --no-profile --tag ~type:feature" }, # skip feature specs on "all" 34 | notification: true, 35 | all_on_start: false, 36 | all_on_pass: false do 37 | require "guard/rspec/dsl" 38 | dsl = Guard::RSpec::Dsl.new(self) 39 | 40 | # Feel free to open issues for suggestions and improvements 41 | 42 | # RSpec files 43 | rspec = dsl.rspec 44 | watch(rspec.spec_helper) { rspec.spec_dir } 45 | watch(rspec.spec_support) { rspec.spec_dir } 46 | watch(rspec.spec_files) 47 | 48 | # Ruby files 49 | ruby = dsl.ruby 50 | dsl.watch_spec_files_for(ruby.lib_files) 51 | 52 | watch(%r{^lib/rspec/resembles_json_matchers/(.+_matcher)\.rb$}) do |m| 53 | "spec/matchers/#{m[1]}_spec.rb" 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/json_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/hash/keys" # stringify_keys 4 | require "json" 5 | 6 | require "rspec/matchers" 7 | require "rspec/resembles_json_matchers/attribute_differ" 8 | 9 | module RSpec::ResemblesJsonMatchers 10 | class JsonMatcher 11 | include RSpec::Matchers::Composable 12 | include Helpers 13 | 14 | def self.can_match?(hash) 15 | hash.is_a? Hash 16 | end 17 | 18 | attr_reader :expected 19 | 20 | def initialize(expected_json) 21 | @expected = expected_json.try(:deep_stringify_keys) 22 | end 23 | 24 | def matches?(actual_json) 25 | @actual = actual_json.try(:deep_stringify_keys) 26 | # Can't use #all? because it stops on the first false 27 | all_passed = true 28 | expected_matchers.each do |_key, matcher| 29 | result = matcher.matches?(actual) 30 | all_passed &&= result 31 | end 32 | all_passed 33 | end 34 | 35 | def description 36 | # TODO Figure out how to discover the right indent level 37 | "have json that looks like\n#{expected_formatted.indent(2)}" 38 | end 39 | 40 | def failure_message 41 | AttributeDiffer.new(self).to_s 42 | end 43 | 44 | def to_json(*_args) 45 | failure_message 46 | end 47 | 48 | def expected_matchers 49 | @expected_matchers ||= {}.tap do |hsh| 50 | (expected.keys + actual.keys).uniq.each do |key| 51 | expected_value = matcherize(expected[key]) 52 | hsh[key.to_s] = 53 | if !expected.key?(key) then ExtraAttributeMatcher.new(key, expected_value) 54 | elsif !actual.key?(key) then MissingAttributeMatcher.new(key, expected_value) 55 | else 56 | AttributeMatcher.new(key, expected_value) 57 | end 58 | end 59 | end 60 | end 61 | 62 | def expected_formatted 63 | out = +"{\n" 64 | out << expected_matchers.map do |k, v| 65 | %{"#{k}": #{RSpec::Support::ObjectFormatter.format(v.expected_value)}}.indent(2) 66 | end.join(",\n") 67 | out << "\n}" 68 | end 69 | 70 | def actual 71 | @actual ||= {} 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /rspec-resembles_json_matchers.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "rspec/resembles_json_matchers/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "rspec-resembles_json_matchers" 9 | spec.version = Rspec::ResemblesJsonMatchers::VERSION 10 | spec.authors = ["Paul Sadauskas"] 11 | spec.email = ["psadauskas@gmail.com"] 12 | 13 | spec.summary = "Helpful matchers for comparing JSON documents." 14 | spec.homepage = "https://github.com/paul/rspec-resembles_json_matchers" 15 | 16 | # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or 17 | # delete this section to allow pushing this gem to any host. 18 | # if spec.respond_to?(:metadata) 19 | # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'" 20 | # else 21 | # raise "RubyGems 2.0 or newer is required to protect against public gem pushes." 22 | # end 23 | 24 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_development_dependency "bundler", "~> 2.0" 30 | spec.add_development_dependency "rake" 31 | spec.add_development_dependency "rspec", "~> 3.0" 32 | 33 | spec.add_development_dependency "appraisal" 34 | spec.add_development_dependency "awesome_print" 35 | spec.add_development_dependency "guard" 36 | spec.add_development_dependency "guard-rspec" 37 | spec.add_development_dependency "pry-byebug" 38 | spec.add_development_dependency "pry-state" 39 | spec.add_development_dependency "rubocop" 40 | spec.add_development_dependency "rubocop-performance" 41 | spec.add_development_dependency "rubocop-rspec" 42 | 43 | spec.add_runtime_dependency "activesupport", ">= 3.0" # For core extensions 44 | spec.add_runtime_dependency "rspec", ">= 3.0", "< 4.0.0.a" 45 | spec.add_runtime_dependency "rspec-expectations", ">= 3.0", "< 4.0.0.a" 46 | spec.add_runtime_dependency "rspec-support", ">= 3.0", "< 4.0.0.a" 47 | end 48 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/attribute_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/hash/indifferent_access" 4 | require "active_support/core_ext/object/try" 5 | 6 | module RSpec::ResemblesJsonMatchers 7 | class AttributeMatcher 8 | include RSpec::ResemblesJsonMatchers::Helpers 9 | Undefined = Object.new # TODO use Dry::Core::Constants::Undefined 10 | 11 | attr_reader :attribute_name, :expected, :document 12 | 13 | def initialize(attribute_name, expected = NullMatcher.new) 14 | @attribute_name, @expected = attribute_name, expected 15 | end 16 | 17 | def description 18 | sentencize "have attribute #{attribute_name.inspect} #{expected.description}" 19 | end 20 | 21 | def matches?(document) 22 | @document = document.try(:with_indifferent_access) 23 | 24 | @matched = document.key?(attribute_name) && expected.matches?(actual_value) 25 | end 26 | 27 | def failure_message 28 | %{Expected attribute #{attribute_name.inspect} to #{value_matcher.description}, but it was #{actual_value.inspect}} 29 | end 30 | 31 | def matched? 32 | @matched 33 | end 34 | 35 | def value_matcher 36 | @expected 37 | end 38 | 39 | def expected_value 40 | value_matcher.expected 41 | end 42 | 43 | def actual_value 44 | document.fetch(attribute_name, nil) 45 | end 46 | 47 | NullMatcher = Class.new do 48 | def matches?(*_args) 49 | true 50 | end 51 | 52 | def failure_message 53 | "" 54 | end 55 | alias_method :failure_message_for_should, :failure_message 56 | 57 | def description 58 | "be present" 59 | end 60 | 61 | def ===(_other) 62 | true 63 | end 64 | end 65 | end 66 | 67 | class MissingAttributeMatcher < AttributeMatcher 68 | def matches?(document) 69 | @document = document.try(:with_indifferent_access) 70 | false 71 | end 72 | 73 | def failure_message 74 | "Document had is missing attribute #{attribute_name.inspect}" 75 | end 76 | 77 | def description 78 | "be present" 79 | end 80 | end 81 | 82 | class ExtraAttributeMatcher < AttributeMatcher 83 | def matches?(document) 84 | @document = document.try(:with_indifferent_access) 85 | false 86 | end 87 | 88 | def failure_message 89 | "Document had unexpected attribute #{attribute_name.inspect}" 90 | end 91 | 92 | def description 93 | "not be present" 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/resembles_hash_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec::ResemblesJsonMatchers 4 | class ResemblesHashMatcher 5 | include Helpers 6 | 7 | def self.can_match?(hash) 8 | hash.is_a? Hash 9 | end 10 | 11 | attr_reader :expected 12 | 13 | def initialize(expected) 14 | @expected = expected 15 | @failed_matches = {} 16 | @matched_keys = [] 17 | @matched_matchers = [] 18 | end 19 | 20 | def matches?(actual) 21 | @actual = actual 22 | expected_matchers.each do |expected_key, value_matcher| 23 | @matched_keys << expected_key 24 | 25 | attr_matcher = AttributeMatcher.new(expected_key, value_matcher) 26 | match = attr_matcher.matches?(actual) 27 | 28 | if match 29 | @matched_matchers << value_matcher 30 | else 31 | @failed_matches[expected_key] = value_matcher unless match 32 | end 33 | end 34 | 35 | actual.each_key { |k| unmatched_matchers.delete(k) } 36 | 37 | failed_matches.none? and 38 | unmatched_keys.none? and 39 | unmatched_matchers.none? 40 | end 41 | 42 | def description 43 | "resemble json\n" + expected_formatted.indent(2) 44 | end 45 | 46 | def failure_message 47 | "failed because\n" + [ 48 | pretty_failed_matches.indent(2), 49 | pretty_unmatched_keys.indent(2), 50 | pretty_unmatched_matchers.indent(2) 51 | ].join("\n") + "\n" 52 | end 53 | 54 | protected 55 | 56 | def expected_matchers 57 | @expected_matchers ||= {}.tap do |hsh| 58 | expected.each do |key, value_matcher| 59 | hsh[key.to_s] = matcherize(value_matcher) 60 | end 61 | end 62 | end 63 | 64 | attr_reader :failed_matches 65 | 66 | def unmatched_keys 67 | @actual.keys - @matched_keys 68 | end 69 | 70 | def unmatched_matchers 71 | @unmatched_matchers ||= expected_matchers.dup 72 | end 73 | 74 | def expected_formatted 75 | out = "{\n" 76 | out << expected_matchers.map do |k, v| 77 | %{ "%s": %s} % [k, v.expected_formatted] 78 | end.join(",\n") 79 | out << "\n}\n" 80 | end 81 | 82 | def pretty_failed_matches 83 | failed_matches.map do |k, matcher| 84 | next unless @actual.key?(k) # Covered by the unmatched matchers messages 85 | 86 | matcher_failure_message = 87 | matcher.failure_message 88 | .gsub("(compared using ==)", "") # From the equality matcher, ugly in this context 89 | .strip 90 | .indent(2) 91 | 92 | "attribute #{k.to_s.inspect}:\n#{matcher_failure_message}" 93 | end.join("\n") + "\n" 94 | end 95 | 96 | def pretty_unmatched_keys 97 | unmatched_keys.map do |key| 98 | "attribute #{key.to_s.inspect}:\n is present, but no matcher provided to match it" 99 | end.join("\n") + "\n" 100 | end 101 | 102 | def pretty_unmatched_matchers 103 | unmatched_matchers.map do |key, _matcher| 104 | "attribute #{key.to_s.inspect}:\n has a matcher defined, but that attribute was not provided" 105 | end.join("\n") + "\n" 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/matchers/attribute_matcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe RSpec::ResemblesJsonMatchers::AttributeMatcher do 6 | let(:example_hash) do 7 | { 8 | nil: nil, 9 | true: true, 10 | false: false, 11 | string: "string", 12 | number: 42, 13 | hash: { a: "b" }, 14 | array: ["a", "b"], 15 | "string_key": "string_key", 16 | symbol_key: :symbol_key 17 | } 18 | end 19 | 20 | describe "matching with just a key provided" do 21 | subject(:matcher) { described_class.new(key) } 22 | 23 | context "with a key that exists" do 24 | let(:key) { :string } 25 | 26 | specify { expect(matcher).to be_matches(example_hash) } 27 | 28 | context "even when the value is nil" do 29 | let(:key) { :nil } 30 | 31 | specify { expect(matcher).to be_matches(example_hash) } 32 | end 33 | 34 | context "even when the value is false" do 35 | let(:key) { :false } 36 | 37 | specify { expect(matcher).to be_matches(example_hash) } 38 | end 39 | end 40 | 41 | context "with a key that does not exist" do 42 | let(:key) { :does_not_exist } 43 | 44 | specify { expect(matcher).not_to be_matches(example_hash) } 45 | end 46 | end 47 | 48 | describe "matching both a key and value matcher" do 49 | subject(:matcher) { described_class.new(key, value_matcher) } 50 | 51 | context "when both the key and value_matcher match" do 52 | let(:key) { :true } 53 | let(:value_matcher) { be true } 54 | 55 | specify { expect(matcher).to be_matches(example_hash) } 56 | 57 | context "even when the value to match is nil" do 58 | let(:key) { :nil } 59 | let(:value_matcher) { be_nil } 60 | 61 | specify { expect(matcher).to be_matches(example_hash) } 62 | end 63 | end 64 | 65 | context "when the value matcher fails" do 66 | let(:key) { :true } 67 | let(:value_matcher) { be false } 68 | 69 | specify { expect(matcher).not_to be_matches(example_hash) } 70 | end 71 | 72 | context "when the key does not exist" do 73 | let(:key) { :does_not_exist } 74 | let(:value_matcher) { be_truthy } 75 | 76 | specify { expect(matcher).not_to be_matches(example_hash) } 77 | end 78 | end 79 | 80 | describe "#description" do 81 | context "with just a key provided" do 82 | let(:matcher) { described_class.new(key) } 83 | let(:key) { :true } 84 | 85 | subject(:description) { matcher.description } 86 | 87 | specify { expect(description).to eq "have attribute :true be present" } 88 | end 89 | 90 | context "with key and value matcher provided" do 91 | let(:matcher) { described_class.new(key, value_matcher) } 92 | let(:key) { :true } 93 | let(:value_matcher) { be_true } 94 | 95 | subject(:description) { matcher.description } 96 | 97 | specify { expect(description).to start_with "have attribute :true" } 98 | specify { expect(description).to end_with value_matcher.description } 99 | end 100 | end 101 | 102 | describe "#failure_message" do 103 | subject(:failure_message) { matcher.failure_message } 104 | 105 | before { matcher.matches? example_hash } 106 | 107 | context "when the document has the attribute, but it doesn't match" do 108 | let(:value_matcher) { be_true } 109 | let(:key) { :false } 110 | let(:matcher) { described_class.new(key, value_matcher) } 111 | 112 | specify { expect(failure_message).to eq "Expected attribute :false to be true, but it was false" } 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - https://relaxed.ruby.style/rubocop.yml 3 | 4 | require: 5 | - rubocop-rspec 6 | - rubocop-performance 7 | 8 | AllCops: 9 | DisplayCopNames: true 10 | DisplayStyleGuide: true 11 | TargetRubyVersion: 2.6 12 | 13 | Exclude: 14 | - "vendor/**/*" 15 | - "spec/fixtures/**/*" 16 | - "bin/**/*" 17 | 18 | Layout: 19 | Severity: error 20 | Lint: 21 | Severity: error 22 | 23 | Layout/HashAlignment: 24 | EnforcedHashRocketStyle: table 25 | EnforcedColonStyle: table 26 | Layout/LineLength: 27 | Enabled: true 28 | Max: 140 29 | Lint/AmbiguousBlockAssociation: 30 | Exclude: 31 | - "spec/**/*" # `expect { }.to change { }` is fine 32 | Lint/BooleanSymbol: 33 | Exclude: 34 | - "spec/**/*" # Symbols like :true get used in these test quite a bit 35 | Lint/ShadowingOuterLocalVariable: 36 | # Shadowing outer local variables with block parameters is often useful to 37 | # not reinvent a new name for the same thing, it highlights the relation 38 | # between the outer variable and the parameter. The cases where it's actually 39 | # confusing are rare, and usually bad for other reasons already, for example 40 | # because the method is too long. 41 | Enabled: false 42 | Metrics/BlockLength: 43 | Exclude: 44 | - Gemfile 45 | - Guardfile 46 | - shared_context 47 | ExcludedMethods: 48 | - configure 49 | - context 50 | - define 51 | - describe 52 | - it 53 | - namespace 54 | - specify 55 | - task 56 | - shared_examples_for 57 | - shared_context 58 | - feature 59 | Metrics/ClassLength: 60 | Exclude: 61 | - "spec/**/*_spec.rb" 62 | Naming/RescuedExceptionsVariableName: 63 | PreferredName: ex 64 | Naming/FileName: 65 | Enabled: false 66 | Naming/MethodName: 67 | Enabled: false # welcome to the visitor pattern 68 | Naming/MethodParameterName: 69 | Enabled: false 70 | Style/ClassAndModuleChildren: 71 | Enabled: false 72 | Style/EmptyLiteral: 73 | Enabled: false 74 | Style/FormatStringToken: 75 | Enabled: false 76 | Style/FrozenStringLiteralComment: 77 | Enabled: true 78 | Style/HashEachMethods: 79 | Enabled: true 80 | Style/HashSyntax: 81 | Exclude: 82 | - Rakefile 83 | - "**/*.rake" 84 | Style/HashTransformKeys: 85 | Enabled: true 86 | Style/HashTransformValues: 87 | Enabled: true 88 | Style/MethodCallWithoutArgsParentheses: 89 | Enabled: true 90 | Style/NumericLiterals: 91 | Enabled: false 92 | Style/StringLiterals: 93 | Enabled: true 94 | EnforcedStyle: double_quotes 95 | Style/SymbolArray: 96 | MinSize: 4 97 | 98 | # 0.81 99 | Lint/RaiseException: 100 | Enabled: true 101 | Lint/StructNewOverride: 102 | Enabled: false 103 | 104 | # 0.82 105 | Layout/SpaceAroundMethodCallOperator: 106 | Enabled: true 107 | Style/ExponentialNotation: 108 | Enabled: true 109 | 110 | # 0.83 111 | Layout/EmptyLinesAroundAttributeAccessor: 112 | Enabled: true 113 | Style/SlicingWithRange: 114 | Enabled: true 115 | 116 | # 0.84 117 | Lint/DeprecatedOpenSSLConstant: 118 | Enabled: true 119 | 120 | # Rspec 121 | Capybara/FeatureMethods: 122 | Enabled: false 123 | RSpec/ContextWording: 124 | Enabled: false 125 | RSpec/DescribeClass: 126 | Enabled: false 127 | RSpec/DescribedClass: 128 | Enabled: false 129 | RSpec/ExampleLength: 130 | Max: 20 131 | RSpec/ExampleWording: 132 | Enabled: false 133 | RSpec/ExpectChange: 134 | EnforcedStyle: block 135 | RSpec/FilePath: 136 | Enabled: false 137 | RSpec/ImplicitExpect: 138 | Enabled: false 139 | RSpec/LeadingSubject: 140 | Enabled: false 141 | RSpec/MessageSpies: 142 | Enabled: false 143 | RSpec/MultipleExpectations: 144 | Max: 4 145 | RSpec/NestedGroups: 146 | Max: 4 147 | RSpec/NotToNot: 148 | Enabled: false 149 | RSpec/ExpectInHook: 150 | Enabled: false 151 | RSpec/LetSetup: 152 | Enabled: false 153 | RSpec/NamedSubject: 154 | Enabled: false 155 | 156 | -------------------------------------------------------------------------------- /examples/example_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../spec/spec_helper" 4 | 5 | RSpec.describe "The resembles json matcher" do 6 | include RSpec::ResemblesJsonMatchers 7 | 8 | before :suite do 9 | puts <<~WARNING 10 | NOTE: some of these are expected to fail, they are meant to demonstrate 11 | the output generated by failing specs 12 | WARNING 13 | end 14 | 15 | describe "a basic json document" do 16 | let(:document) do 17 | { 18 | "@id": "/posts/2016/test1", 19 | "@type": "Post", 20 | "title": "Hello World!", 21 | "body": "lorem ipsum", 22 | "created_at": "2016-03-01T00:03:42", 23 | "published_at": "2016-03-10T15:35:00" 24 | } 25 | end 26 | 27 | specify do 28 | expect(document).to resemble_json( 29 | { 30 | "@id": "/posts/:year/:title", 31 | "@type": eq("Post"), 32 | "title": "Hello World!", 33 | "body": "lorem ipsum", 34 | "created_at": "2016-03-01T00:03:42", 35 | "published_at": "2016-03-10T15:35:00" 36 | } 37 | ) 38 | end 39 | 40 | context "with several attributes that failed to match" do 41 | specify do 42 | expect(document).to resemble_json( 43 | { 44 | "@id": "/posts/:year/:title", 45 | "@type": eq("PostCollection"), 46 | "title": 42.0, 47 | "body": "lorem ipsum", 48 | "created_at": "2016-03-01T00:03:42", 49 | "published_at": "2016-03-10T15:35:00" 50 | } 51 | ) 52 | end 53 | end 54 | 55 | context "when the matcher is missing a field that is present in the document" do 56 | specify do 57 | expect(document).to resemble_json( 58 | { 59 | "@id": "/posts/:year/:title", 60 | "@type": eq("Post"), 61 | "body": "lorem ipsum", 62 | "created_at": "2016-03-01T00:03:42", 63 | "published_at": "2016-03-10T15:35:00" 64 | } 65 | ) 66 | end 67 | end 68 | 69 | context "when the document is missing a field that is present in the matcher" do 70 | specify do 71 | expect(document).to resemble_json( 72 | { 73 | "@id": "/posts/:year/:title", 74 | "@type": eq("Post"), 75 | "title": "Hello World!", 76 | "body": "lorem ipsum", 77 | "created_at": "2016-03-01T00:03:42", 78 | "published_at": "2016-03-10T15:35:00", 79 | "updated_at": "2016-03-10T15:40:00" 80 | } 81 | ) 82 | end 83 | end 84 | end 85 | 86 | describe "complex nested json documents" do 87 | let(:document) do 88 | { 89 | "@id": "/posts", 90 | "@type": "PostCollection", 91 | "nextPage": "/posts?page=2", 92 | "members": [ 93 | { 94 | "@id": "/posts/2016/test1", 95 | "@type": "Post", 96 | "title": "Hello World!", 97 | "body": "lorem ipsum", 98 | "created_at": "2016-03-01T00:03:42", 99 | "published_at": "2016-03-10T15:35:00" 100 | } 101 | ] 102 | } 103 | end 104 | 105 | specify do 106 | expect(document).to resemble_json( 107 | { 108 | "@id": "/posts", 109 | "@type": eq("PostCollection"), 110 | "nextPage": "/posts?page=2", 111 | "members": [ 112 | { 113 | "@id": "/posts/:year/:title", 114 | "@type": eq("Post"), 115 | "title": "Hello World!", 116 | "body": "lorem ipsum", 117 | "created_at": "2016-03-01T00:03:42", 118 | "published_at": "2016-03-10T15:35:00" 119 | } 120 | ] 121 | } 122 | ) 123 | end 124 | end 125 | 126 | describe "empty nested documents" do 127 | let(:document) do 128 | { 129 | "@id": "/posts/2016/test1", 130 | "@type": "Post", 131 | "title": "Hello World!", 132 | "body": "lorem ipsum", 133 | "created_at": "2016-03-01T00:03:42", 134 | "published_at": "2016-03-10T15:35:00" 135 | } 136 | end 137 | 138 | specify do 139 | expect(document).to resemble_json( 140 | { 141 | "@id": "/posts/:year/:title", 142 | "@type": eq("Post"), 143 | "title": "Hello World!", 144 | "body": "lorem ipsum", 145 | "author": { 146 | "@id": "/authors/:id", 147 | "name": eq("Paul") 148 | }, 149 | "created_at": "2016-03-01T00:03:42", 150 | "published_at": "2016-03-10T15:35:00" 151 | } 152 | ) 153 | end 154 | end 155 | 156 | describe "empty nested array documents" do 157 | let(:document) do 158 | { 159 | "@id": "/posts", 160 | "@type": "PostCollection", 161 | "nextPage": "/posts?page=2" 162 | } 163 | end 164 | 165 | specify do 166 | expect(document).to resemble_json( 167 | { 168 | "@id": "/posts", 169 | "@type": eq("PostCollection"), 170 | "nextPage": "/posts?page=2", 171 | "members": [ 172 | { 173 | "@id": "/posts/:year/:title", 174 | "@type": eq("Post"), 175 | "title": "Hello World!", 176 | "body": "lorem ipsum", 177 | "created_at": "2016-03-01T00:03:42", 178 | "published_at": "2016-03-10T15:35:00" 179 | } 180 | ] 181 | } 182 | ) 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/rspec/resembles_json_matchers/attribute_differ.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/inflector" 4 | 5 | module RSpec::ResemblesJsonMatchers 6 | class AttributeDiffer 7 | def initialize(matcher) 8 | @matcher = matcher 9 | end 10 | 11 | def to_s 12 | @buffer = StringIO.new 13 | @buffer.puts NEUTRAL_COLOR + "Diff:" 14 | render(@matcher) 15 | @buffer.string 16 | end 17 | 18 | private 19 | 20 | def render(matcher, **opts) 21 | class_name = ActiveSupport::Inflector.demodulize(matcher.class.to_s) 22 | method_name = :"render_#{class_name}" 23 | send method_name, matcher, **opts 24 | end 25 | 26 | def render_JsonMatcher(matcher, prefix: "", starts_on_newline: false, **opts) 27 | @buffer.print prefix if starts_on_newline 28 | @buffer.print NORMAL_COLOR unless prefix.include?("-") 29 | @buffer.puts "{" 30 | matcher.expected_matchers.each do |key, attr_matcher| 31 | last = (matcher.expected_matchers.keys.last == key) 32 | render(attr_matcher, prefix: prefix, last: last, **opts) 33 | end 34 | if matcher.actual.nil? 35 | @buffer.print REMOVED_COLOR 36 | if prefix.include? "-" 37 | @buffer.print prefix + "}" 38 | else 39 | @buffer.print prefix + "- }" 40 | end 41 | else 42 | @buffer.print NORMAL_COLOR unless prefix.include?("-") 43 | @buffer.print prefix + "}" 44 | end 45 | end 46 | 47 | def render_AttributeMatcher(matcher, prefix: "", last: false) 48 | if matcher.matched? 49 | @buffer.print NORMAL_COLOR 50 | @buffer.print prefix + " " + "#{matcher.attribute_name.to_json}: " 51 | render(matcher.value_matcher, prefix: prefix + " ") 52 | elsif nested_matcher?(matcher.value_matcher) 53 | @buffer.print NORMAL_COLOR 54 | @buffer.print prefix + " " + "#{matcher.attribute_name.to_json}: " 55 | render(matcher.value_matcher, prefix: prefix + " ") 56 | else 57 | @buffer.print REMOVED_COLOR 58 | @buffer.print prefix 59 | if prefix.include? "-" 60 | @buffer.print " " 61 | else 62 | @buffer.print "- " 63 | end 64 | @buffer.print "#{matcher.attribute_name.to_json}: " 65 | render(matcher.value_matcher, prefix: prefix + " ") 66 | @buffer.print NORMAL_COLOR 67 | @buffer.print(",") unless last 68 | @buffer.puts 69 | @buffer.print ADDED_COLOR 70 | @buffer.print prefix + "+ #{matcher.attribute_name.to_json}: " 71 | render(matcher.actual_value, prefix: prefix + " ") 72 | @buffer.print NORMAL_COLOR 73 | end 74 | @buffer.print(",") unless last 75 | @buffer.puts 76 | end 77 | 78 | def render_MissingAttributeMatcher(matcher, prefix: "", last: false) 79 | prefix += (prefix.include?("-") ? " " : "- ") 80 | @buffer.print REMOVED_COLOR 81 | @buffer.print prefix + "#{matcher.attribute_name.to_json}: " 82 | render(matcher.value_matcher, prefix: prefix) 83 | @buffer.print(",") unless last 84 | @buffer.puts 85 | end 86 | 87 | def render_ExtraAttributeMatcher(matcher, prefix: "", last: false) 88 | prefix += "+ " 89 | @buffer.print ADDED_COLOR 90 | @buffer.print prefix + matcher.attribute_name.to_json + ": " 91 | render(matcher.actual_value, prefix: prefix) 92 | @buffer.print "," unless last 93 | @buffer.puts 94 | end 95 | 96 | def render_ResemblesAnyOfMatcher(matcher, prefix: "", **_opts) 97 | @buffer.puts "[" 98 | if matcher.matched? 99 | matcher.original_expected.each do |item| 100 | @buffer.print JSON.pretty_generate(item).indent(1, NORMAL_COLOR + prefix + "- ") 101 | last = (matcher.original_expected.last == item) 102 | @buffer.print(",") unless last 103 | @buffer.puts 104 | end 105 | elsif matcher.actual.nil? || matcher.actual.empty? 106 | matcher.expected.each do |expected_matcher| 107 | render expected_matcher, prefix: prefix + " ", starts_on_newline: true 108 | last = (matcher.expected.last == expected_matcher) 109 | @buffer.print(",") unless last 110 | @buffer.puts 111 | end 112 | else 113 | matcher.attempted_matchers.each do |attempted_matcher| 114 | render attempted_matcher, prefix: prefix + " ", starts_on_newline: true 115 | last = (matcher.attempted_matchers.last == attempted_matcher) 116 | @buffer.print(",") unless last 117 | @buffer.puts 118 | end 119 | end 120 | @buffer.print prefix + "]" 121 | end 122 | 123 | def render_ResemblesBooleanMatcher(matcher, **_opts) 124 | @buffer.print matcher.expected.to_json 125 | end 126 | 127 | def render_ResemblesStringMatcher(matcher, **_opts) 128 | @buffer.print matcher.expected.to_json 129 | end 130 | 131 | def render_ResemblesDateMatcher(matcher, **_opts) 132 | @buffer.print matcher.expected.to_json 133 | end 134 | 135 | def render_ResemblesNumericMatcher(matcher, **_opts) 136 | @buffer.print matcher.expected.to_json 137 | end 138 | 139 | def render_ResemblesClassMatcher(matcher, **_opts) 140 | @buffer.print matcher.expected.inspect 141 | end 142 | 143 | def render_ResemblesNilMatcher(_matcher, **_opts) 144 | @buffer.print "null" 145 | end 146 | 147 | def render_ResemblesRouteMatcher(matcher, **_opts) 148 | @buffer.print matcher.expected.inspect 149 | end 150 | 151 | def method_missing(method_name, *args, &block) 152 | if method_name.to_s.start_with?("render_") 153 | raise NoMethodError, method_name if method_name.to_s.end_with?("Matcher") 154 | 155 | @buffer.print RSpec::Support::ObjectFormatter.format(args.first) 156 | else 157 | super 158 | end 159 | end 160 | 161 | def respond_to_missing?(method_name, _include_private = false) 162 | method_name.to_s.start_with?("render_") 163 | end 164 | 165 | def nested_matcher?(matcher) 166 | matcher.is_a?(JsonMatcher) || matcher.is_a?(ResemblesAnyOfMatcher) 167 | end 168 | 169 | NORMAL_COLOR = "\e[0m" 170 | REMOVED_COLOR = "\e[31m" # Red 171 | ADDED_COLOR = "\e[32m" # Green 172 | NEUTRAL_COLOR = "\e[34m" # Blue 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /gemfiles/rails_5.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | rspec-resembles_json_matchers (0.9.0) 5 | activesupport (>= 3.0) 6 | rspec (>= 3.0, < 4.0.0.a) 7 | rspec-expectations (>= 3.0, < 4.0.0.a) 8 | rspec-support (>= 3.0, < 4.0.0.a) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (5.2.4.3) 14 | actionpack (= 5.2.4.3) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | actionmailer (5.2.4.3) 18 | actionpack (= 5.2.4.3) 19 | actionview (= 5.2.4.3) 20 | activejob (= 5.2.4.3) 21 | mail (~> 2.5, >= 2.5.4) 22 | rails-dom-testing (~> 2.0) 23 | actionpack (5.2.4.3) 24 | actionview (= 5.2.4.3) 25 | activesupport (= 5.2.4.3) 26 | rack (~> 2.0, >= 2.0.8) 27 | rack-test (>= 0.6.3) 28 | rails-dom-testing (~> 2.0) 29 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 30 | actionview (5.2.4.3) 31 | activesupport (= 5.2.4.3) 32 | builder (~> 3.1) 33 | erubi (~> 1.4) 34 | rails-dom-testing (~> 2.0) 35 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 36 | activejob (5.2.4.3) 37 | activesupport (= 5.2.4.3) 38 | globalid (>= 0.3.6) 39 | activemodel (5.2.4.3) 40 | activesupport (= 5.2.4.3) 41 | activerecord (5.2.4.3) 42 | activemodel (= 5.2.4.3) 43 | activesupport (= 5.2.4.3) 44 | arel (>= 9.0) 45 | activestorage (5.2.4.3) 46 | actionpack (= 5.2.4.3) 47 | activerecord (= 5.2.4.3) 48 | marcel (~> 0.3.1) 49 | activesupport (5.2.4.3) 50 | concurrent-ruby (~> 1.0, >= 1.0.2) 51 | i18n (>= 0.7, < 2) 52 | minitest (~> 5.1) 53 | tzinfo (~> 1.1) 54 | appraisal (2.2.0) 55 | bundler 56 | rake 57 | thor (>= 0.14.0) 58 | arel (9.0.0) 59 | ast (2.4.0) 60 | awesome_print (1.8.0) 61 | builder (3.2.4) 62 | byebug (9.1.0) 63 | coderay (1.1.2) 64 | concurrent-ruby (1.1.6) 65 | crass (1.0.6) 66 | diff-lcs (1.3) 67 | erubi (1.9.0) 68 | ffi (1.9.18) 69 | formatador (0.2.5) 70 | globalid (0.4.2) 71 | activesupport (>= 4.2.0) 72 | guard (2.14.1) 73 | formatador (>= 0.2.4) 74 | listen (>= 2.7, < 4.0) 75 | lumberjack (~> 1.0) 76 | nenv (~> 0.1) 77 | notiffany (~> 0.0) 78 | pry (>= 0.9.12) 79 | shellany (~> 0.0) 80 | thor (>= 0.18.1) 81 | guard-compat (1.2.1) 82 | guard-rspec (4.7.3) 83 | guard (~> 2.1) 84 | guard-compat (~> 1.1) 85 | rspec (>= 2.99.0, < 4.0) 86 | i18n (1.8.2) 87 | concurrent-ruby (~> 1.0) 88 | listen (3.1.5) 89 | rb-fsevent (~> 0.9, >= 0.9.4) 90 | rb-inotify (~> 0.9, >= 0.9.7) 91 | ruby_dep (~> 1.2) 92 | loofah (2.5.0) 93 | crass (~> 1.0.2) 94 | nokogiri (>= 1.5.9) 95 | lumberjack (1.0.12) 96 | mail (2.7.1) 97 | mini_mime (>= 0.1.1) 98 | marcel (0.3.3) 99 | mimemagic (~> 0.3.2) 100 | method_source (0.9.0) 101 | mimemagic (0.3.5) 102 | mini_mime (1.0.2) 103 | mini_portile2 (2.4.0) 104 | minitest (5.14.1) 105 | nenv (0.3.0) 106 | nio4r (2.5.2) 107 | nokogiri (1.10.9) 108 | mini_portile2 (~> 2.4.0) 109 | notiffany (0.1.1) 110 | nenv (~> 0.1) 111 | shellany (~> 0.0) 112 | parallel (1.19.1) 113 | parser (2.7.1.3) 114 | ast (~> 2.4.0) 115 | pry (0.11.3) 116 | coderay (~> 1.1.0) 117 | method_source (~> 0.9.0) 118 | pry-byebug (3.5.1) 119 | byebug (~> 9.1) 120 | pry (~> 0.10) 121 | pry-state (0.1.10) 122 | pry (>= 0.9.10, < 0.12.0) 123 | rack (2.2.2) 124 | rack-test (1.1.0) 125 | rack (>= 1.0, < 3) 126 | rails (5.2.4.3) 127 | actioncable (= 5.2.4.3) 128 | actionmailer (= 5.2.4.3) 129 | actionpack (= 5.2.4.3) 130 | actionview (= 5.2.4.3) 131 | activejob (= 5.2.4.3) 132 | activemodel (= 5.2.4.3) 133 | activerecord (= 5.2.4.3) 134 | activestorage (= 5.2.4.3) 135 | activesupport (= 5.2.4.3) 136 | bundler (>= 1.3.0) 137 | railties (= 5.2.4.3) 138 | sprockets-rails (>= 2.0.0) 139 | rails-dom-testing (2.0.3) 140 | activesupport (>= 4.2.0) 141 | nokogiri (>= 1.6) 142 | rails-html-sanitizer (1.3.0) 143 | loofah (~> 2.3) 144 | railties (5.2.4.3) 145 | actionpack (= 5.2.4.3) 146 | activesupport (= 5.2.4.3) 147 | method_source 148 | rake (>= 0.8.7) 149 | thor (>= 0.19.0, < 2.0) 150 | rainbow (3.0.0) 151 | rake (12.3.0) 152 | rb-fsevent (0.10.2) 153 | rb-inotify (0.9.10) 154 | ffi (>= 0.5.0, < 2) 155 | rexml (3.2.4) 156 | rspec (3.6.0) 157 | rspec-core (~> 3.6.0) 158 | rspec-expectations (~> 3.6.0) 159 | rspec-mocks (~> 3.6.0) 160 | rspec-core (3.6.0) 161 | rspec-support (~> 3.6.0) 162 | rspec-expectations (3.6.0) 163 | diff-lcs (>= 1.2.0, < 2.0) 164 | rspec-support (~> 3.6.0) 165 | rspec-mocks (3.6.0) 166 | diff-lcs (>= 1.2.0, < 2.0) 167 | rspec-support (~> 3.6.0) 168 | rspec-support (3.6.0) 169 | rubocop (0.84.0) 170 | parallel (~> 1.10) 171 | parser (>= 2.7.0.1) 172 | rainbow (>= 2.2.2, < 4.0) 173 | rexml 174 | rubocop-ast (>= 0.0.3) 175 | ruby-progressbar (~> 1.7) 176 | unicode-display_width (>= 1.4.0, < 2.0) 177 | rubocop-ast (0.0.3) 178 | parser (>= 2.7.0.1) 179 | rubocop-performance (1.6.0) 180 | rubocop (>= 0.71.0) 181 | rubocop-rspec (1.39.0) 182 | rubocop (>= 0.68.1) 183 | ruby-progressbar (1.10.1) 184 | ruby_dep (1.5.0) 185 | shellany (0.0.1) 186 | sprockets (4.0.0) 187 | concurrent-ruby (~> 1.0) 188 | rack (> 1, < 3) 189 | sprockets-rails (3.2.1) 190 | actionpack (>= 4.0) 191 | activesupport (>= 4.0) 192 | sprockets (>= 3.0.0) 193 | thor (0.20.0) 194 | thread_safe (0.3.6) 195 | tzinfo (1.2.7) 196 | thread_safe (~> 0.1) 197 | unicode-display_width (1.7.0) 198 | websocket-driver (0.7.2) 199 | websocket-extensions (>= 0.1.0) 200 | websocket-extensions (0.1.4) 201 | 202 | PLATFORMS 203 | ruby 204 | 205 | DEPENDENCIES 206 | appraisal 207 | awesome_print 208 | bundler (~> 2.0) 209 | guard 210 | guard-rspec 211 | pry-byebug 212 | pry-state 213 | rails (~> 5.2.0) 214 | rake 215 | rspec (~> 3.6.0) 216 | rspec-resembles_json_matchers! 217 | rubocop 218 | rubocop-performance 219 | rubocop-rspec 220 | 221 | BUNDLED WITH 222 | 2.1.4 223 | -------------------------------------------------------------------------------- /gemfiles/rails_6.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | rspec-resembles_json_matchers (0.9.0) 5 | activesupport (>= 3.0) 6 | rspec (>= 3.0, < 4.0.0.a) 7 | rspec-expectations (>= 3.0, < 4.0.0.a) 8 | rspec-support (>= 3.0, < 4.0.0.a) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (6.0.3) 14 | actionpack (= 6.0.3) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | actionmailbox (6.0.3) 18 | actionpack (= 6.0.3) 19 | activejob (= 6.0.3) 20 | activerecord (= 6.0.3) 21 | activestorage (= 6.0.3) 22 | activesupport (= 6.0.3) 23 | mail (>= 2.7.1) 24 | actionmailer (6.0.3) 25 | actionpack (= 6.0.3) 26 | actionview (= 6.0.3) 27 | activejob (= 6.0.3) 28 | mail (~> 2.5, >= 2.5.4) 29 | rails-dom-testing (~> 2.0) 30 | actionpack (6.0.3) 31 | actionview (= 6.0.3) 32 | activesupport (= 6.0.3) 33 | rack (~> 2.0, >= 2.0.8) 34 | rack-test (>= 0.6.3) 35 | rails-dom-testing (~> 2.0) 36 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 37 | actiontext (6.0.3) 38 | actionpack (= 6.0.3) 39 | activerecord (= 6.0.3) 40 | activestorage (= 6.0.3) 41 | activesupport (= 6.0.3) 42 | nokogiri (>= 1.8.5) 43 | actionview (6.0.3) 44 | activesupport (= 6.0.3) 45 | builder (~> 3.1) 46 | erubi (~> 1.4) 47 | rails-dom-testing (~> 2.0) 48 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 49 | activejob (6.0.3) 50 | activesupport (= 6.0.3) 51 | globalid (>= 0.3.6) 52 | activemodel (6.0.3) 53 | activesupport (= 6.0.3) 54 | activerecord (6.0.3) 55 | activemodel (= 6.0.3) 56 | activesupport (= 6.0.3) 57 | activestorage (6.0.3) 58 | actionpack (= 6.0.3) 59 | activejob (= 6.0.3) 60 | activerecord (= 6.0.3) 61 | marcel (~> 0.3.1) 62 | activesupport (6.0.3) 63 | concurrent-ruby (~> 1.0, >= 1.0.2) 64 | i18n (>= 0.7, < 2) 65 | minitest (~> 5.1) 66 | tzinfo (~> 1.1) 67 | zeitwerk (~> 2.2, >= 2.2.2) 68 | appraisal (2.2.0) 69 | bundler 70 | rake 71 | thor (>= 0.14.0) 72 | ast (2.4.0) 73 | awesome_print (1.8.0) 74 | builder (3.2.4) 75 | byebug (11.1.3) 76 | coderay (1.1.3) 77 | concurrent-ruby (1.1.6) 78 | crass (1.0.6) 79 | diff-lcs (1.3) 80 | erubi (1.9.0) 81 | ffi (1.12.2) 82 | formatador (0.2.5) 83 | globalid (0.4.2) 84 | activesupport (>= 4.2.0) 85 | guard (2.16.2) 86 | formatador (>= 0.2.4) 87 | listen (>= 2.7, < 4.0) 88 | lumberjack (>= 1.0.12, < 2.0) 89 | nenv (~> 0.1) 90 | notiffany (~> 0.0) 91 | pry (>= 0.9.12) 92 | shellany (~> 0.0) 93 | thor (>= 0.18.1) 94 | guard-compat (1.2.1) 95 | guard-rspec (4.7.3) 96 | guard (~> 2.1) 97 | guard-compat (~> 1.1) 98 | rspec (>= 2.99.0, < 4.0) 99 | i18n (1.8.2) 100 | concurrent-ruby (~> 1.0) 101 | listen (3.2.1) 102 | rb-fsevent (~> 0.10, >= 0.10.3) 103 | rb-inotify (~> 0.9, >= 0.9.10) 104 | loofah (2.5.0) 105 | crass (~> 1.0.2) 106 | nokogiri (>= 1.5.9) 107 | lumberjack (1.2.5) 108 | mail (2.7.1) 109 | mini_mime (>= 0.1.1) 110 | marcel (0.3.3) 111 | mimemagic (~> 0.3.2) 112 | method_source (0.9.2) 113 | mimemagic (0.3.5) 114 | mini_mime (1.0.2) 115 | mini_portile2 (2.4.0) 116 | minitest (5.14.1) 117 | nenv (0.3.0) 118 | nio4r (2.5.2) 119 | nokogiri (1.10.9) 120 | mini_portile2 (~> 2.4.0) 121 | notiffany (0.1.3) 122 | nenv (~> 0.1) 123 | shellany (~> 0.0) 124 | parallel (1.19.1) 125 | parser (2.7.1.3) 126 | ast (~> 2.4.0) 127 | pry (0.11.3) 128 | coderay (~> 1.1.0) 129 | method_source (~> 0.9.0) 130 | pry-byebug (3.8.0) 131 | byebug (~> 11.0) 132 | pry (~> 0.10) 133 | pry-state (0.1.10) 134 | pry (>= 0.9.10, < 0.12.0) 135 | rack (2.2.2) 136 | rack-test (1.1.0) 137 | rack (>= 1.0, < 3) 138 | rails (6.0.3) 139 | actioncable (= 6.0.3) 140 | actionmailbox (= 6.0.3) 141 | actionmailer (= 6.0.3) 142 | actionpack (= 6.0.3) 143 | actiontext (= 6.0.3) 144 | actionview (= 6.0.3) 145 | activejob (= 6.0.3) 146 | activemodel (= 6.0.3) 147 | activerecord (= 6.0.3) 148 | activestorage (= 6.0.3) 149 | activesupport (= 6.0.3) 150 | bundler (>= 1.3.0) 151 | railties (= 6.0.3) 152 | sprockets-rails (>= 2.0.0) 153 | rails-dom-testing (2.0.3) 154 | activesupport (>= 4.2.0) 155 | nokogiri (>= 1.6) 156 | rails-html-sanitizer (1.3.0) 157 | loofah (~> 2.3) 158 | railties (6.0.3) 159 | actionpack (= 6.0.3) 160 | activesupport (= 6.0.3) 161 | method_source 162 | rake (>= 0.8.7) 163 | thor (>= 0.20.3, < 2.0) 164 | rainbow (3.0.0) 165 | rake (13.0.1) 166 | rb-fsevent (0.10.4) 167 | rb-inotify (0.10.1) 168 | ffi (~> 1.0) 169 | rexml (3.2.4) 170 | rspec (3.9.0) 171 | rspec-core (~> 3.9.0) 172 | rspec-expectations (~> 3.9.0) 173 | rspec-mocks (~> 3.9.0) 174 | rspec-core (3.9.2) 175 | rspec-support (~> 3.9.3) 176 | rspec-expectations (3.9.2) 177 | diff-lcs (>= 1.2.0, < 2.0) 178 | rspec-support (~> 3.9.0) 179 | rspec-mocks (3.9.1) 180 | diff-lcs (>= 1.2.0, < 2.0) 181 | rspec-support (~> 3.9.0) 182 | rspec-support (3.9.3) 183 | rubocop (0.84.0) 184 | parallel (~> 1.10) 185 | parser (>= 2.7.0.1) 186 | rainbow (>= 2.2.2, < 4.0) 187 | rexml 188 | rubocop-ast (>= 0.0.3) 189 | ruby-progressbar (~> 1.7) 190 | unicode-display_width (>= 1.4.0, < 2.0) 191 | rubocop-ast (0.0.3) 192 | parser (>= 2.7.0.1) 193 | rubocop-performance (1.6.0) 194 | rubocop (>= 0.71.0) 195 | rubocop-rspec (1.39.0) 196 | rubocop (>= 0.68.1) 197 | ruby-progressbar (1.10.1) 198 | shellany (0.0.1) 199 | sprockets (4.0.0) 200 | concurrent-ruby (~> 1.0) 201 | rack (> 1, < 3) 202 | sprockets-rails (3.2.1) 203 | actionpack (>= 4.0) 204 | activesupport (>= 4.0) 205 | sprockets (>= 3.0.0) 206 | thor (1.0.1) 207 | thread_safe (0.3.6) 208 | tzinfo (1.2.7) 209 | thread_safe (~> 0.1) 210 | unicode-display_width (1.7.0) 211 | websocket-driver (0.7.2) 212 | websocket-extensions (>= 0.1.0) 213 | websocket-extensions (0.1.4) 214 | zeitwerk (2.3.0) 215 | 216 | PLATFORMS 217 | ruby 218 | 219 | DEPENDENCIES 220 | appraisal 221 | awesome_print 222 | bundler (~> 2.0) 223 | guard 224 | guard-rspec 225 | pry-byebug 226 | pry-state 227 | rails (~> 6.0.0) 228 | rake 229 | rspec (~> 3.9.0) 230 | rspec-resembles_json_matchers! 231 | rubocop 232 | rubocop-performance 233 | rubocop-rspec 234 | 235 | BUNDLED WITH 236 | 2.1.4 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RSpec::ResemblesJsonMatchers 2 | 3 | [![Gem Version](https://badge.fury.io/rb/rspec-json_api_matchers.svg)](https://badge.fury.io/rb/rspec-json_api_matchers)[![Build Status](https://travis-ci.org/paul/rspec-json_api_matchers.svg?branch=master)](https://travis-ci.org/paul/rspec-json_api_matchers) 4 | 5 | 6 | This gem provides a set of matchers that make testing JSON documents (actually the hashes parsed from them) simpler and more elegant. 7 | 8 | ## `resemble` Matcher 9 | 10 | Oftentimes when testing your JSON API responses, you don't care about the actual values matching exactly, just that they "resemble" your expected values. This gem provides a variety of matchers to just get close: 11 | 12 | ### Numbers 13 | 14 | Anything that's a Ruby `Numeric` will match: 15 | 16 | ```ruby 17 | # These pass 18 | expect(10).to resemble 42 19 | expect(3.14).to resemble 42 20 | expect(10).to resemble 42.4 21 | 22 | # These fail 23 | expect("string").to resemble 42 24 | expect(Time.now).to resemble 42 25 | ``` 26 | 27 | I haven't needed it yet, but I'm open to discussing if more accurate matches would be needed. For example: 28 | 29 | * Does 1_000_000_000 "resemble" 1? 30 | * Does a float "resemble" an integer? 31 | 32 | 33 | ### Dates/Times 34 | 35 | Anything that is a Ruby `Date`/`Time`/`DateTime`, or a string that can be parsed by `Time.iso8601` will match: 36 | 37 | ```ruby 38 | # These pass 39 | expect(Time.now).to resemble "2018-01-01T00:00:00Z" 40 | expect(Time.now.iso8601).to resemble "2018-01-01T00:00:00Z" 41 | 42 | # These fail 43 | expect("Some string").to resemble "2018-01-01T00:00:00Z" 44 | ``` 45 | 46 | Open questions: 47 | 48 | * Does `"2018-01-01T00:00:00-0700"` "resemble" `"2018-01-01T00:00:00Z"`? That is, should it ensure the timezone matches? 49 | * Do non-ISO8601 datetimes "resemble" ISO8601 ones? 50 | * Is there a permissible time range? Does the year 1600 "resemble" 2017? Does `"0000-00-00T00:00:00Z"`? Does `Time.at(0)`? 51 | 52 | ### Rails routes 53 | 54 | If you're using Rails (specifically `ActionDispatch`), we can check that routes resemble each other: 55 | 56 | ```ruby 57 | # These pass 58 | expect("/posts/1").to resemble posts_path(post) 59 | expect("/posts/1000000").to resemble posts_path(post) 60 | expect("https://example.com/posts/1").to resemble posts_path(post) 61 | 62 | # These fail 63 | expect("/users/1").to resemble posts_path(post) 64 | expect("Some string").to resemble posts_path(post) 65 | ``` 66 | 67 | ### Strings 68 | 69 | Any string that didn't match one of the other resembles matchers will match: 70 | 71 | ```ruby 72 | # These pass 73 | expect("Some string").to resemble "some other string" 74 | expect("").to resemble "some other string" 75 | expect("a" * 100_000).to resemble "some other string" 76 | 77 | # These fail 78 | expect(42).to resemble "42" 79 | expect(Time.now).to resemble "Time.now 80 | 81 | ``` 82 | 83 | Open questions: 84 | 85 | * Should there be some heuristic to decide if a string resembles another? Does length matter? 86 | 87 | ## `#resemble_json/match_json` 88 | 89 | The resembles matchers are nice on their own, but their power shines when used with the `resembles_json` matcher. This allows you to write an example JSON document in your spec, and match it against the output from a request. Any values in the "expected" document that aren't already matchers will be converted to the best `resembles` matcher. You can write plain json documents as the expected, or be explicit by specifying matchers. 90 | 91 | 92 | ### Example Usage 93 | 94 | ```ruby 95 | RSpec.describe "a basic json document" do 96 | # This would probably actually come from something like `JSON.parse(response.body)` 97 | let(:document) do 98 | { 99 | "@id": "/posts/2016/test1", 100 | "@type": "Post", 101 | "title": "Hello World!", 102 | "body": "lorem ipsum", 103 | "created_at": "2016-03-01T00:03:42", 104 | "published_at": "2016-03-10T15:35:00" 105 | } 106 | end 107 | 108 | specify do 109 | expect(document).to resemble_json( 110 | { 111 | "@id": post_path(post), # resembles route 112 | "@type": eq("Post"), # using an explicit matcher to match exactly 113 | "title": match(/^Hello/), # another explicit matcher 114 | "body": "lorem ipsum", # resembles string 115 | "created_at": "2016-03-01T00:03:42", # resembles time 116 | "published_at": post.published_at # Also resembles time 117 | } 118 | ) 119 | end 120 | end 121 | ``` 122 | 123 | It provides good descriptions if you run `rspec` with `--format=documentation`: 124 | 125 | ``` 126 | a basic json document 127 | should resemble json 128 | { 129 | "@id": /posts/:year/:title, 130 | "@type": "Post", 131 | "title": Hello World!, 132 | "body": lorem ipsum, 133 | "created_at": "2016-03-01T00:03:42", 134 | "published_at": "2016-03-10T15:35:00" 135 | } 136 | ``` 137 | 138 | It also provides a failure message as a diff of the JSON object: 139 | 140 | ``` 141 | 1) The resembles json matcher a basic json document with several attributes that failed to match should have json that looks like 142 | { 143 | "@id": "/posts/:year/:title", 144 | "@type": "PostCollection", 145 | "title": 42.0, 146 | "body": "lorem ipsum", 147 | "created_at": "2016-03-01T00:03:42", 148 | "published_at": "2016-03-10T15:35:00" 149 | } 150 | Failure/Error: 151 | expect(document).to resemble_json( 152 | { 153 | "@id": "/posts/:year/:title", 154 | "@type": eq("PostCollection"), 155 | "title": 42.0, 156 | "body": "lorem ipsum", 157 | "created_at": "2016-03-01T00:03:42", 158 | "published_at": "2016-03-10T15:35:00" 159 | } 160 | ) 161 | 162 | { 163 | "@id": "/posts/:year/:title", 164 | - "@type": eq "PostCollection", 165 | + "@type": "Post", 166 | - "title": 42.0, 167 | + "title": "Hello World!", 168 | "body": "lorem ipsum", 169 | "created_at": "2016-03-01T00:03:42", 170 | "published_at": "2016-03-10T15:35:00" 171 | } 172 | # ./examples/example_spec.rb:40:in `block (4 levels) in ' 173 | ``` 174 | 175 | It can also handle nested JSON documents and Arrays, showing the proper diffs. See the `./examples` directory, but it works pretty much how you'd expect. 176 | 177 | # Installation 178 | 179 | Add this line to your application's Gemfile: 180 | 181 | ```ruby 182 | gem 'rspec-json_api_matchers' 183 | ``` 184 | 185 | And then execute: 186 | 187 | $ bundle 188 | 189 | Or install it yourself as: 190 | 191 | $ gem install rspec-json_api_matchers 192 | 193 | # Contributing 194 | 195 | Bug reports and pull requests are welcome on GitHub at https://github.com/paul/rspec-json_api_matchers. 196 | 197 | ## License 198 | 199 | 200 | [![WTFPL](http://www.wtfpl.net/wp-content/uploads/2012/12/wtfpl-badge-4.png)][http://www.wtfpl.net/] 201 | -------------------------------------------------------------------------------- /spec/matchers/json_matcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | require "time" 6 | require "active_support/core_ext/hash" # Hash#except 7 | 8 | RSpec.describe RSpec::ResemblesJsonMatchers::JsonMatcher do 9 | subject(:matcher) { described_class.new(expected) } 10 | 11 | let(:result) { matcher.matches?(document) } 12 | let(:failure_message) { result; strip_colors(matcher.failure_message) } 13 | 14 | let(:document) do 15 | { 16 | id: 1, 17 | name: "Paul", 18 | website: "http://sadauskas.com", 19 | created_at: "2017-01-01T00:00:00Z" 20 | } 21 | end 22 | 23 | let(:expected) do 24 | { 25 | id: 1, 26 | name: "Paul", 27 | website: "http://sadauskas.com", 28 | created_at: "2017-01-01T00:00:00Z" 29 | } 30 | end 31 | 32 | context "when expected is a regular json document" do 33 | it "should match the document" do 34 | expect(result).to be_truthy 35 | end 36 | 37 | it "should include the document in the description" do 38 | expect(matcher.description.strip).to eq(<<~TXT.strip) 39 | have json that looks like 40 | { 41 | "id": 1, 42 | "name": "Paul", 43 | "website": "http://sadauskas.com", 44 | "created_at": "2017-01-01T00:00:00Z" 45 | } 46 | TXT 47 | end 48 | 49 | context "when one of the matchers fails" do 50 | let(:document) { super().merge(id: "some string") } 51 | 52 | it "should not match the document" do 53 | expect(result).to be_falsey 54 | end 55 | 56 | it "should have a failure message with a diff containing the mismatch field" do 57 | matcher.matches?(document) 58 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 59 | Diff: 60 | { 61 | - "id": 1, 62 | + "id": "some string", 63 | "name": "Paul", 64 | "website": "http://sadauskas.com", 65 | "created_at": "2017-01-01T00:00:00Z" 66 | } 67 | TXT 68 | end 69 | end 70 | 71 | context "when the document is missing a field" do 72 | let(:document) { super().except(:website) } 73 | 74 | it "should not match the document" do 75 | expect(result).to be_falsey 76 | end 77 | 78 | it "should have a failure message with a diff containing the mismatch field" do 79 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 80 | Diff: 81 | { 82 | "id": 1, 83 | "name": "Paul", 84 | - "website": "http://sadauskas.com", 85 | "created_at": "2017-01-01T00:00:00Z" 86 | } 87 | TXT 88 | end 89 | end 90 | 91 | context "when the document has an extra field" do 92 | let(:document) { super().merge(email: "paul@sadauskas.com") } 93 | 94 | it "should not match the document" do 95 | expect(result).to be_falsey 96 | end 97 | 98 | it "should have a failure message with a diff containing the mismatch field" do 99 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 100 | Diff: 101 | { 102 | "id": 1, 103 | "name": "Paul", 104 | "website": "http://sadauskas.com", 105 | "created_at": "2017-01-01T00:00:00Z", 106 | + "email": "paul@sadauskas.com" 107 | } 108 | TXT 109 | end 110 | end 111 | 112 | context "nested documents" do 113 | let(:document) do 114 | { 115 | title: "hello", 116 | author: { 117 | name: 42 118 | } 119 | } 120 | end 121 | 122 | let(:expected) do 123 | { 124 | title: "hello", 125 | author: { 126 | name: "Paul" 127 | } 128 | } 129 | end 130 | 131 | it "should not match the document" do 132 | expect(result).to be_falsey 133 | end 134 | 135 | it "should have a failure message with a diff containing the mismatch field" do 136 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 137 | Diff: 138 | { 139 | "title": "hello", 140 | "author": { 141 | - "name": "Paul" 142 | + "name": 42 143 | } 144 | } 145 | TXT 146 | end 147 | 148 | context "when nested document is missing entirely" do 149 | let(:document) do 150 | { 151 | title: "hello" 152 | } 153 | end 154 | 155 | it "should not match the document" do 156 | expect(result).to be_falsey 157 | end 158 | 159 | it "should have a failure message with a diff containing the missing object" do 160 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 161 | Diff: 162 | { 163 | "title": "hello", 164 | - "author": { 165 | - "name": "Paul" 166 | - } 167 | } 168 | TXT 169 | end 170 | end 171 | end 172 | 173 | context "nested array of documents" do 174 | let(:document) do 175 | { 176 | "@context": "/_contexts/Collection.jsonld", 177 | "@id": "/users", 178 | "@type": "UserCollection", 179 | "member": [ 180 | { 181 | "@context": "/_contexts/Collection.jsonld", 182 | "@id": "/users", 183 | "@type": "UserCollection", 184 | "name": 42, 185 | "created_at": "2017-01-01T00:00:00Z" 186 | } 187 | ] 188 | } 189 | end 190 | 191 | let(:expected) do 192 | { 193 | "@context": "/_contexts/Collection.jsonld", 194 | "@id": "/users", 195 | "@type": "UserCollection", 196 | "member": [ 197 | { 198 | "@context": "/_contexts/Collection.jsonld", 199 | "@id": "/users", 200 | "@type": "UserCollection", 201 | "name": "Paul", 202 | "created_at": "2017-01-01T00:00:00Z" 203 | } 204 | ] 205 | } 206 | end 207 | 208 | it "should not match the document" do 209 | expect(result).to be_falsey 210 | end 211 | 212 | it "should have a failure message with a diff containing the mismatch field" do 213 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 214 | Diff: 215 | { 216 | "@context": "/_contexts/Collection.jsonld", 217 | "@id": "/users", 218 | "@type": "UserCollection", 219 | "member": [ 220 | { 221 | "@context": "/_contexts/Collection.jsonld", 222 | "@id": "/users", 223 | "@type": "UserCollection", 224 | - "name": "Paul", 225 | + "name": 42, 226 | "created_at": "2017-01-01T00:00:00Z" 227 | } 228 | ] 229 | } 230 | TXT 231 | end 232 | end 233 | end 234 | 235 | context "when expected has matchers in it" do 236 | let(:expected) do 237 | { 238 | id: Integer, 239 | name: match(/Paul/), 240 | website: eq("http://example.com"), 241 | created_at: Time.parse("2018-01-01T00:00:00Z") 242 | } 243 | end 244 | 245 | it "should match the document" do 246 | expect(result).to be_falsey 247 | end 248 | 249 | it "should have a pretty description" do 250 | expect(matcher.description.strip).to eq(<<~TXT.strip) 251 | have json that looks like 252 | { 253 | "id": Integer, 254 | "name": /Paul/, 255 | "website": "http://example.com", 256 | "created_at": 2018-01-01 00:00:00.000000000 +0000 257 | } 258 | TXT 259 | end 260 | 261 | it "should print a diff of the failures" do 262 | matcher.matches?(document) 263 | expect(strip_colors(failure_message)).to eq(<<~TXT.strip) 264 | Diff: 265 | { 266 | "id": Integer, 267 | "name": match /Paul/, 268 | - "website": eq "http://example.com", 269 | + "website": "http://sadauskas.com", 270 | "created_at": "2018-01-01 00:00:00 UTC" 271 | } 272 | TXT 273 | end 274 | end 275 | 276 | def strip_colors(text) 277 | text.gsub(/\e\[\d+m/, "").strip 278 | end 279 | end 280 | --------------------------------------------------------------------------------