├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin └── console ├── lib └── rspec │ ├── snapshot.rb │ └── snapshot │ ├── configuration.rb │ ├── default_serializer.rb │ ├── file_operator.rb │ ├── matchers.rb │ ├── matchers │ └── match_snapshot.rb │ ├── serializer_factory.rb │ └── version.rb ├── rspec-snapshot.gemspec └── spec ├── fixtures ├── non_existing_snapshots_dir │ └── custom_directory.snap └── snapshots │ ├── custom_directory.snap │ ├── do_not_update_existing_snapshot.snap │ └── update_existing_snapshot.snap ├── rspec └── snapshot │ ├── __snapshots__ │ ├── array.snap │ ├── captured_value.snap │ ├── custom_global_serializer.snap │ ├── custom_instance_serializer.snap │ ├── diff_snapshot.snap │ ├── do_not_update_existing_snapshot.snap │ ├── do_not_update_non_existing_snapshot.snap │ ├── example_diffable_object.snap │ ├── example_failure_message.snap │ ├── example_negated_failure_message.snap │ ├── failure_message_snapshot.snap │ ├── hash.snap │ ├── html.snap │ ├── negated_failure_message_snapshot.snap │ ├── nested_data_structure.snap │ ├── receive_with_match_snapshot.snap │ ├── receive_with_snapshot.snap │ ├── relative_directory.snap │ ├── update_existing_snapshot.snap │ └── update_non_existing_snapshot.snap │ ├── configuration_spec.rb │ ├── default_serializer_spec.rb │ ├── file_operator_spec.rb │ ├── matchers │ └── match_snapshot_spec.rb │ ├── matchers_spec.rb │ ├── serializer_factory_spec.rb │ └── version_spec.rb └── spec_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | pull_request: 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7'] 11 | defaults: 12 | run: 13 | shell: bash 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: ruby/setup-ruby@360dc864d5da99d54fcb8e9148c14a84b90d3e88 17 | with: 18 | ruby-version: ${{ matrix.ruby-version }} 19 | - run: gem install bundler:2.4.22 20 | - run: bundle install 21 | - run: bundle exec rubocop 22 | 23 | test: 24 | needs: lint 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | ruby-version: ['3.3', '3.2', '3.1', '3.0', '2.7'] 29 | defaults: 30 | run: 31 | shell: bash 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: ruby/setup-ruby@360dc864d5da99d54fcb8e9148c14a84b90d3e88 35 | with: 36 | ruby-version: ${{ matrix.ruby-version }} 37 | - run: gem install bundler:2.4.22 38 | - run: bundle install 39 | - run: cat Gemfile.lock 40 | - run: bundle exec rspec 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rake 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | NewCops: "enable" 7 | Exclude: 8 | - "Gemfile" 9 | - "Rakefile" 10 | - "bin/**/*" 11 | - "spec/fixtures/**/*" 12 | TargetRubyVersion: 2.5 13 | 14 | Layout/EmptyLinesAroundAttributeAccessor: 15 | Enabled: true 16 | Layout/LineLength: 17 | Max: 80 18 | Layout/SpaceAroundMethodCallOperator: 19 | Enabled: true 20 | 21 | Lint: 22 | Severity: error 23 | Lint/DeprecatedOpenSSLConstant: 24 | Enabled: true 25 | Lint/RaiseException: 26 | Enabled: true 27 | Lint/StructNewOverride: 28 | Enabled: true 29 | 30 | Metrics/BlockLength: 31 | Exclude: 32 | - "rspec-snapshot.gemspec" 33 | - "spec/**/*" 34 | 35 | RSpec/AnyInstance: 36 | Enabled: true 37 | RSpec/ContextWording: 38 | Prefixes: 39 | - when 40 | - with 41 | - without 42 | - and 43 | RSpec/ExampleLength: 44 | Enabled: true 45 | Max: 10 46 | RSpec/ExpectInHook: 47 | Enabled: true 48 | RSpec/FilePath: 49 | Enabled: true 50 | RSpec/InstanceVariable: 51 | Enabled: true 52 | RSpec/LeakyConstantDeclaration: 53 | Enabled: true 54 | RSpec/LetSetup: 55 | Enabled: true 56 | RSpec/MessageSpies: 57 | Enabled: true 58 | RSpec/MultipleExpectations: 59 | Enabled: true 60 | RSpec/MultipleMemoizedHelpers: 61 | Enabled: false 62 | RSpec/NamedSubject: 63 | Enabled: false # Disabled for preference 64 | RSpec/NestedGroups: 65 | Max: 6 66 | RSpec/ScatteredSetup: 67 | Enabled: true 68 | RSpec/SubjectStub: 69 | Enabled: true 70 | RSpec/VerifiedDoubles: 71 | Enabled: true 72 | 73 | Style/AccessModifierDeclarations: 74 | Enabled: false # Disabled since we follow Clean Code's newspaper code structure 75 | Style/DoubleNegation: 76 | Enabled: true 77 | Style/ExponentialNotation: 78 | Enabled: true 79 | Style/HashEachMethods: 80 | Enabled: true 81 | Style/HashTransformKeys: 82 | Enabled: true 83 | Style/HashTransformValues: 84 | Enabled: true 85 | Style/LineEndConcatenation: 86 | Enabled: false 87 | Style/MultilineTernaryOperator: 88 | Enabled: false # Disabled due to preference 89 | Style/SlicingWithRange: 90 | Enabled: true 91 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in rspec-snapshot.gemspec 4 | gemspec 5 | 6 | # Development dependencies 7 | gem 'bundler', '~> 2.3' 8 | gem 'byebug' 9 | gem 'pry-byebug' 10 | gem 'rake', '~> 13.0' 11 | gem 'rubocop', '~> 1.59' 12 | gem 'rubocop-rake', '~> 0.6.0' 13 | gem 'rubocop-rspec', '~> 2.16' 14 | gem 'simplecov' -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Wei Zhu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RSpec::Snapshot  2 | 3 | Adds snapshot testing to RSpec, inspired by [Jest](https://jestjs.io/). 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'rspec-snapshot' 11 | ``` 12 | 13 | And then execute: 14 | 15 | $ bundle install 16 | 17 | Or install it yourself as: 18 | 19 | $ gem install rspec-snapshot 20 | 21 | ## Usage 22 | 23 | The gem provides `match_snapshot` and `snapshot` RSpec matchers which take 24 | a snapshot name as an argument like: 25 | 26 | ```ruby 27 | # match_snapshot 28 | expect(generated_email).to match_snapshot('welcome_email') 29 | 30 | # match argument with snapshot 31 | expect(logger).to have_received(:info).with(snapshot('log message')) 32 | ``` 33 | 34 | When a test is run using a snapshot matcher and a snapshot file does not exist 35 | matching the passed name, the test value encountered will be serialized and 36 | stored in your snapshot directory as the file: `#{snapshot_name}.snap` 37 | 38 | When a test is run using a snapshot matcher and a snapshot file exists matching 39 | the passed name, then the test value encountered will be serialized and 40 | compared to the snapshot file contents. If the values match your test passes, 41 | otherwise it fails. 42 | 43 | ### Rails request testing 44 | 45 | ```ruby 46 | RSpec.describe 'Posts', type: :request do 47 | describe 'GET /posts' do 48 | it 'returns a list of post' do 49 | get posts_path 50 | 51 | expect(response.body).to match_snapshot('get_posts') 52 | end 53 | end 54 | end 55 | ``` 56 | 57 | ### Rails view testing 58 | 59 | ```ruby 60 | RSpec.describe 'widgets/index', type: :view do 61 | it 'displays all the widgets' do 62 | assign(:widgets, [ 63 | Widget.create!(:name => 'slicer'), 64 | Widget.create!(:name => 'dicer') 65 | ]) 66 | 67 | render 68 | 69 | expect(rendered).to match_snapshot('widgets/index') 70 | end 71 | end 72 | ``` 73 | 74 | ### UPDATE_SNAPSHOTS environment variable 75 | 76 | Occasionally you may want to regenerate all encountered snapshots for a set of 77 | tests. To do this, just set the UPDATE_SNAPSHOTS environment variable for your 78 | test command. 79 | 80 | Update all snapshots 81 | 82 | $ UPDATE_SNAPSHOTS=true bundle exec rspec 83 | 84 | Update snapshots for some subset of tests 85 | 86 | $ UPDATE_SNAPSHOTS=true bundle exec rspec spec/foo/bar 87 | 88 | ## Configuration 89 | 90 | Global configurations for rspec-snapshot are optional. Details below: 91 | 92 | ```ruby 93 | RSpec.configure do |config| 94 | # The default setting is `:relative`, which means snapshot files will be 95 | # created in a '__snapshots__' directory adjacent to the spec file where the 96 | # matcher is used. 97 | # 98 | # Set this value to put all snapshots in a fixed directory 99 | config.snapshot_dir = "spec/fixtures/snapshots" 100 | 101 | # Defaults to using the awesome_print gem to serialize values for snapshots 102 | # 103 | # Set this value to use a custom snapshot serializer 104 | config.snapshot_serializer = MyFavoriteSerializer 105 | end 106 | ``` 107 | 108 | ### Custom serializers 109 | 110 | By default, values to be stored as snapshots are serialized to human readable 111 | string form using the [awesome_print](https://github.com/awesome-print/awesome_print) gem. 112 | 113 | You can pass custom serializers to `rspec-snapshot` if you prefer. Pass a serializer class name to the global RSpec config, or to an individual 114 | matcher as a config option: 115 | 116 | ```ruby 117 | # Set a custom serializer for all tests 118 | RSpec.configure do |config| 119 | config.snapshot_serializer = MyCoolGeneralSerializer 120 | end 121 | 122 | # Set a custom serializer for this specific test 123 | expect(html_response).to( 124 | match_snapshot('html_response', { snapshot_serializer: MyAwesomeHTMLSerializer }) 125 | ) 126 | ``` 127 | 128 | Serializer classes are required to have one instance method `dump` which takes 129 | the value to be serialized and returns a string. 130 | 131 | ## Migration 132 | 133 | If you're updating to version 2.x.x from 1.x.x, you may need to update all your existing snapshots since the serialization method has changed. 134 | 135 | $ UPDATE_SNAPSHOTS=true bundle exec rspec 136 | 137 | ## Development 138 | 139 | ### Initial Setup 140 | 141 | Install a current version of ruby (> 2.5) and bundler. Then install gems 142 | 143 | $ bundle install 144 | 145 | ### Linting 146 | 147 | $ bundle exec rubocop 148 | 149 | ### Unit tests 150 | 151 | $ bundle exec rspec 152 | 153 | ### Interactive console with the gem code loaded 154 | 155 | $ bin/console 156 | 157 | ### Installing the gem locally 158 | 159 | $ bundle exec rake install 160 | 161 | ### Publishing a new gem version 162 | 163 | * Update the version number in `version.rb` 164 | * Ensure the changes to be published are merged to the master branch 165 | * Checkout the master branch locally 166 | * Run `bundle exec rake release`, which will: 167 | * create a git tag for the version 168 | * push git commits and tags 169 | * push the `.gem` file to [rubygems.org](https://rubygems.org). 170 | 171 | ## Contributing 172 | 173 | Bug reports and pull requests are welcome on GitHub at https://github.com/levinmr/rspec-snapshot. 174 | 175 | A big thanks to the original author [@yesmeck](https://github.com/yesmeck). 176 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task test: [:spec] 7 | task :default => :test 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "rspec/snapshot" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /lib/rspec/snapshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec' 4 | require 'rspec/snapshot/version' 5 | require 'rspec/snapshot/configuration' 6 | require 'rspec/snapshot/matchers' 7 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec 4 | # rubocop:disable Style/Documentation 5 | module Snapshot 6 | class Configuration 7 | def self.initialize_configuration(config) 8 | config.add_setting :snapshot_dir, default: :relative 9 | 10 | config.add_setting :snapshot_serializer, default: nil 11 | end 12 | end 13 | 14 | Configuration.initialize_configuration RSpec.configuration 15 | end 16 | # rubocop:enable Style/Documentation 17 | end 18 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/default_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'awesome_print' 4 | 5 | module RSpec 6 | module Snapshot 7 | # Serializes values in a human readable way for snapshots using the 8 | # awesome_print gem 9 | class DefaultSerializer 10 | # @param [*] value The value to serialize. 11 | # @return [String] The serialized value. 12 | def dump(value) 13 | value.ai(plain: true, indent: 2) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/file_operator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | 5 | module RSpec 6 | module Snapshot 7 | # Handles File IO for snapshots 8 | class FileOperator 9 | # Initializes the class instance, and creates the snapshot directory for 10 | # the current test if needed. 11 | # 12 | # @param [String] snapshot_name The name of the snapshot to read/write. 13 | # @param [Hash] metadata The RSpec metadata for the current test. 14 | def initialize(snapshot_name, metadata) 15 | snapshot_dir = snapshot_dir(metadata) 16 | @snapshot_path = File.join(snapshot_dir, "#{snapshot_name}.snap") 17 | create_snapshot_dir(@snapshot_path) 18 | end 19 | 20 | private def snapshot_dir(metadata) 21 | if RSpec.configuration.snapshot_dir == :relative 22 | File.dirname(metadata[:file_path]) << '/__snapshots__' 23 | else 24 | RSpec.configuration.snapshot_dir 25 | end 26 | end 27 | 28 | private def create_snapshot_dir(snapshot_dir) 29 | return if Dir.exist?(File.dirname(snapshot_dir)) 30 | 31 | FileUtils.mkdir_p(File.dirname(snapshot_dir)) 32 | end 33 | 34 | # @return [String] The snapshot file contents. 35 | def read 36 | file = File.new(@snapshot_path) 37 | value = file.read 38 | file.close 39 | value 40 | end 41 | 42 | # Writes the value to file, overwriting the file contents if either of the 43 | # following is true: 44 | # * The snapshot file does not already exist. 45 | # * The UPDATE_SNAPSHOTS environment variable is set. 46 | # 47 | # TODO: Do not write to file if running in CI mode. 48 | # 49 | # @param [String] snapshot_name The snapshot name. 50 | # @param [String] value The value to write to file. 51 | def write(value) 52 | return unless should_write? 53 | 54 | file = File.new(@snapshot_path, 'w+') 55 | file.write(value) 56 | RSpec.configuration.reporter.message( 57 | "Snapshot written: #{@snapshot_path}" 58 | ) 59 | file.close 60 | end 61 | 62 | private def should_write? 63 | file_does_not_exist? || update_snapshots? 64 | end 65 | 66 | private def update_snapshots? 67 | !!ENV.fetch('UPDATE_SNAPSHOTS', nil) 68 | end 69 | 70 | private def file_does_not_exist? 71 | !File.exist?(@snapshot_path) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/snapshot/matchers/match_snapshot' 4 | require 'rspec/snapshot/file_operator' 5 | require 'rspec/snapshot/serializer_factory' 6 | 7 | module RSpec 8 | module Snapshot 9 | # rubocop:disable Style/Documentation 10 | module Matchers 11 | def match_snapshot(snapshot_name, config = {}) 12 | MatchSnapshot.new(SerializerFactory.new(config).create, 13 | FileOperator.new(snapshot_name, 14 | RSpec.current_example.metadata)) 15 | end 16 | 17 | alias snapshot match_snapshot 18 | end 19 | # rubocop:enable Style/Documentation 20 | end 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.include RSpec::Snapshot::Matchers 25 | end 26 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/matchers/match_snapshot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec 4 | module Snapshot 5 | module Matchers 6 | # RSpec matcher for snapshot testing 7 | class MatchSnapshot 8 | attr_reader :actual, :expected 9 | 10 | # @param [#dump] serializer A class instance which responds to #dump to 11 | # convert test values to string for writing to snapshot files. 12 | # @param [FileOperator] file_operator Handles reading and writing the 13 | # snapshot file contents. 14 | def initialize(serializer, file_operator) 15 | @serializer = serializer 16 | @file_operator = file_operator 17 | end 18 | 19 | # @param [*] actual The received test value to compare to a snapshot. 20 | # @return [Boolean] True if the serialized actual value matches the 21 | # snapshot contents, false otherwise. 22 | def matches?(actual) 23 | @actual = serialize(actual) 24 | 25 | write_snapshot(@actual) 26 | 27 | @expected = read_snapshot 28 | 29 | @actual == @expected 30 | end 31 | 32 | # === is the method called when matching an argument 33 | alias === matches? 34 | alias match matches? 35 | 36 | private def serialize(value) 37 | return value if value.is_a?(String) 38 | 39 | @serializer.dump(value) 40 | end 41 | 42 | private def write_snapshot(value) 43 | @file_operator.write(value) 44 | end 45 | 46 | private def read_snapshot 47 | @file_operator.read 48 | end 49 | 50 | def description 51 | "to match a snapshot containing: \"#{@expected}\"" 52 | end 53 | 54 | def diffable? 55 | true 56 | end 57 | 58 | def failure_message 59 | "\nexpected: #{@expected}\n got: #{@actual}\n" 60 | end 61 | 62 | def failure_message_when_negated 63 | "\nexpected: #{@expected} not to match #{@actual}\n" 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/serializer_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/snapshot/default_serializer' 4 | 5 | module RSpec 6 | module Snapshot 7 | # Uses the factory pattern to initialize a snapshot serializer. 8 | class SerializerFactory 9 | def initialize(config = {}) 10 | @config = config 11 | end 12 | 13 | # @returns [#dump] A serializer object which implements #dump to convert 14 | # any value to string. 15 | def create 16 | serializer_class.new 17 | end 18 | 19 | private def serializer_class 20 | if @config[:snapshot_serializer] 21 | @config[:snapshot_serializer] 22 | elsif RSpec.configuration.snapshot_serializer 23 | RSpec.configuration.snapshot_serializer 24 | else 25 | DefaultSerializer 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rspec/snapshot/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec 4 | module Snapshot 5 | VERSION = '2.0.3' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /rspec-snapshot.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/snapshot/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'rspec-snapshot' 9 | spec.version = RSpec::Snapshot::VERSION 10 | spec.authors = ['Mike Levin'] 11 | spec.email = ['michael_r_levin@yahoo.com'] 12 | spec.license = 'MIT' 13 | 14 | spec.summary = 'RSpec Snapshot Matcher' 15 | spec.description = 'Adding snapshot testing to RSpec' 16 | spec.homepage = 'https://github.com/levinmr/rspec-snapshot' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | spec.bindir = 'exe' 22 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 23 | spec.require_paths = ['lib'] 24 | 25 | spec.required_ruby_version = '>= 2.5.0' 26 | 27 | spec.add_dependency 'awesome_print', '> 1.0.0' 28 | spec.add_dependency 'rspec', '> 3.0.0' 29 | spec.metadata['rubygems_mfa_required'] = 'true' 30 | end 31 | -------------------------------------------------------------------------------- /spec/fixtures/non_existing_snapshots_dir/custom_directory.snap: -------------------------------------------------------------------------------- 1 | custom_directory_test_string -------------------------------------------------------------------------------- /spec/fixtures/snapshots/custom_directory.snap: -------------------------------------------------------------------------------- 1 | custom_directory_test_string -------------------------------------------------------------------------------- /spec/fixtures/snapshots/do_not_update_existing_snapshot.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/fixtures/snapshots/update_existing_snapshot.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/array.snap: -------------------------------------------------------------------------------- 1 | [ 2 | [0] 1, 3 | [1] 2 4 | ] -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/captured_value.snap: -------------------------------------------------------------------------------- 1 | bar -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/custom_global_serializer.snap: -------------------------------------------------------------------------------- 1 | {"foo":"bar","baz":[1,2,3]} -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/custom_instance_serializer.snap: -------------------------------------------------------------------------------- 1 | { 2 | :foo => "bar", 3 | :baz => [ 4 | [0] 1, 5 | [1] 2, 6 | [2] 3 7 | ] 8 | } -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/diff_snapshot.snap: -------------------------------------------------------------------------------- 1 | 2 | expected: { 3 | :foo => { 4 | :bar => [ 5 | [0] 1, 6 | [1] 2, 7 | [2] 3 8 | ] 9 | }, 10 | :baz => true 11 | } 12 | got: { 13 | :foo => { 14 | :bar => [ 15 | [0] 1, 16 | [1] 4, 17 | [2] 3 18 | ] 19 | }, 20 | :baz => false 21 | } 22 | 23 | Diff:[0m 24 | [0m[0m 25 | [0m[34m@@ -2,10 +2,10 @@ 26 | [0m[0m :foo => { 27 | [0m[0m :bar => [ 28 | [0m[0m [0] 1, 29 | [0m[31m- [1] 2, 30 | [0m[32m+ [1] 4, 31 | [0m[0m [2] 3 32 | [0m[0m ] 33 | [0m[0m }, 34 | [0m[31m- :baz => true 35 | [0m[32m+ :baz => false 36 | [0m[0m } 37 | [0m -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/do_not_update_existing_snapshot.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/do_not_update_non_existing_snapshot.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/example_diffable_object.snap: -------------------------------------------------------------------------------- 1 | { 2 | :foo => { 3 | :bar => [ 4 | [0] 1, 5 | [1] 2, 6 | [2] 3 7 | ] 8 | }, 9 | :baz => true 10 | } -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/example_failure_message.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/example_negated_failure_message.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/failure_message_snapshot.snap: -------------------------------------------------------------------------------- 1 | 2 | expected: foo 3 | got: bar 4 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/hash.snap: -------------------------------------------------------------------------------- 1 | { 2 | :a => 1, 3 | :b => 2 4 | } -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/html.snap: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |10 | Snapshot is awesome! 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/negated_failure_message_snapshot.snap: -------------------------------------------------------------------------------- 1 | 2 | expected: foo not to match foo 3 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/nested_data_structure.snap: -------------------------------------------------------------------------------- 1 | { 2 | :a_key => [ 3 | [0] "some", 4 | [1] "values" 5 | ] 6 | } -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/receive_with_match_snapshot.snap: -------------------------------------------------------------------------------- 1 | log message for match_snapshot -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/receive_with_snapshot.snap: -------------------------------------------------------------------------------- 1 | log message for snapshot -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/relative_directory.snap: -------------------------------------------------------------------------------- 1 | relative_directory_test_string -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/update_existing_snapshot.snap: -------------------------------------------------------------------------------- 1 | bar -------------------------------------------------------------------------------- /spec/rspec/snapshot/__snapshots__/update_non_existing_snapshot.snap: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /spec/rspec/snapshot/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rspec/snapshot/configuration' 5 | 6 | describe RSpec::Snapshot::Configuration do 7 | describe '.initialize_configuration' do 8 | let(:rspec_configuration) { object_double(RSpec.configuration) } 9 | 10 | before do 11 | allow(rspec_configuration).to receive(:add_setting) 12 | described_class.initialize_configuration(rspec_configuration) 13 | end 14 | 15 | it 'adds the rspec configuration setting for snapshot_dir' do 16 | expect(rspec_configuration).to( 17 | have_received(:add_setting).with(:snapshot_dir, default: :relative) 18 | ) 19 | end 20 | 21 | it 'adds the rspec configuration setting for snapshot_serializer' do 22 | expect(rspec_configuration).to( 23 | have_received(:add_setting).with(:snapshot_serializer, default: nil) 24 | ) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/default_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rspec/snapshot/default_serializer' 5 | 6 | describe RSpec::Snapshot::DefaultSerializer do 7 | subject { described_class.new } 8 | 9 | describe '#dump' do 10 | let(:object_param) { Object.new } 11 | let(:expected) { 'foobar' } 12 | 13 | let!(:actual) do 14 | allow(object_param).to receive(:ai).and_return(expected) 15 | subject.dump(object_param) 16 | end 17 | 18 | it 'calls .ai on the object to serialize with awesome_print' do 19 | expect(object_param).to have_received(:ai).with(plain: true, indent: 2) 20 | end 21 | 22 | it 'returns the result from awesome_print' do 23 | expect(actual).to be(expected) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/file_operator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rspec/snapshot/file_operator' 5 | 6 | describe RSpec::Snapshot::FileOperator do 7 | subject { described_class.new(snapshot_name, metadata) } 8 | 9 | let(:snapshot_name) { 'descriptive_snapshot_name' } 10 | let(:metadata) { { file_path: 'spec/example_spec.rb' } } 11 | let(:relative_snapshot_path) { "spec/__snapshots__/#{snapshot_name}.snap" } 12 | 13 | before do 14 | allow(FileUtils).to receive(:mkdir_p).and_return(nil) 15 | end 16 | 17 | describe '#initialize' do 18 | context 'when RSpec is configured with :relative snapshot directory' do 19 | let(:relative_snapshot_dir) { 'spec/__snapshots__' } 20 | 21 | before do 22 | allow(RSpec.configuration).to( 23 | receive(:snapshot_dir).and_return(:relative) 24 | ) 25 | subject 26 | end 27 | 28 | it 'creates the snapshot directory if needed' do 29 | expect(FileUtils).to have_received(:mkdir_p).with(relative_snapshot_dir) 30 | end 31 | 32 | it 'sets the snapshot_path instance variable to the relative path' do 33 | expect(subject.instance_variable_get('@snapshot_path')).to( 34 | eq(relative_snapshot_path) 35 | ) 36 | end 37 | end 38 | 39 | context 'when RSpec is configured with a fixed snapshot directory' do 40 | let(:fixed_snapshot_dir) { 'spec/snapshots' } 41 | let(:snapshot_name) do 42 | 'any_sub_folder/any_sub_sub_folder/descriptive_snapshot_name' 43 | end 44 | let(:fixed_snapshot_path) do 45 | "#{fixed_snapshot_dir}/#{snapshot_name}.snap" 46 | end 47 | 48 | before do 49 | allow(RSpec.configuration).to( 50 | receive(:snapshot_dir).and_return(fixed_snapshot_dir) 51 | ) 52 | subject 53 | end 54 | 55 | it 'creates the snapshot directory if needed' do 56 | expect(FileUtils).to( 57 | have_received(:mkdir_p).with( 58 | "#{fixed_snapshot_dir}/any_sub_folder/any_sub_sub_folder" 59 | ) 60 | ) 61 | end 62 | 63 | it 'sets the snapshot_path instance variable to the relative path' do 64 | expect(subject.instance_variable_get('@snapshot_path')).to( 65 | eq(fixed_snapshot_path) 66 | ) 67 | end 68 | end 69 | end 70 | 71 | describe '#read' do 72 | let(:expected) { 'snapshot contents' } 73 | let(:file) { instance_double(File) } 74 | let!(:actual) do 75 | allow(RSpec.configuration).to( 76 | receive(:snapshot_dir).and_return(:relative) 77 | ) 78 | allow(File).to receive(:new).and_return(file) 79 | allow(file).to receive(:read).and_return(expected) 80 | allow(file).to receive(:close) 81 | subject.read 82 | end 83 | 84 | it 'creates a new File class instance' do 85 | expect(File).to have_received(:new).with(relative_snapshot_path) 86 | end 87 | 88 | it 'calls read on the file' do 89 | expect(file).to have_received(:read) 90 | end 91 | 92 | it 'calls close on the file' do 93 | expect(file).to have_received(:close) 94 | end 95 | 96 | it 'returns the file contents' do 97 | expect(actual).to be(expected) 98 | end 99 | end 100 | 101 | describe '#write' do 102 | let(:value) { 'value to write to snapshot' } 103 | let(:file) { instance_double(File) } 104 | 105 | before do 106 | allow(RSpec.configuration).to( 107 | receive(:snapshot_dir).and_return(:relative) 108 | ) 109 | allow(File).to receive(:new).and_return(file) 110 | allow(file).to receive(:write) 111 | allow(file).to receive(:close) 112 | end 113 | 114 | context 'when the snapshot does not exist' do 115 | before do 116 | allow(File).to receive(:exist?).and_return(false) 117 | subject.write(value) 118 | end 119 | 120 | it 'checks for file existence' do 121 | expect(File).to have_received(:exist?).with(relative_snapshot_path) 122 | end 123 | 124 | it 'creates a new file instance' do 125 | expect(File).to have_received(:new).with(relative_snapshot_path, 'w+') 126 | end 127 | 128 | it 'writes the value to the file' do 129 | expect(file).to have_received(:write).with(value) 130 | end 131 | 132 | it 'closes the file' do 133 | expect(file).to have_received(:close) 134 | end 135 | end 136 | 137 | context 'when the snapshot file exists' do 138 | before do 139 | allow(File).to receive(:exist?).and_return(true) 140 | end 141 | 142 | context 'and the UPDATE_SNAPSHOTS env var is set' do 143 | before do 144 | allow(ENV).to( 145 | receive(:fetch).with('UPDATE_SNAPSHOTS', nil).and_return('true') 146 | ) 147 | subject.write(value) 148 | end 149 | 150 | it 'checks for file existence' do 151 | expect(File).to have_received(:exist?).with(relative_snapshot_path) 152 | end 153 | 154 | it 'creates a new file instance' do 155 | expect(File).to have_received(:new).with(relative_snapshot_path, 'w+') 156 | end 157 | 158 | it 'writes the value to the file' do 159 | expect(file).to have_received(:write).with(value) 160 | end 161 | 162 | it 'closes the file' do 163 | expect(file).to have_received(:close) 164 | end 165 | end 166 | 167 | context 'and the UPDATE_SNAPSHOTS env var is not set' do 168 | before do 169 | allow(ENV).to( 170 | receive(:fetch).with('UPDATE_SNAPSHOTS', nil).and_return(nil) 171 | ) 172 | subject.write(value) 173 | end 174 | 175 | it 'checks for file existence' do 176 | expect(File).to have_received(:exist?).with(relative_snapshot_path) 177 | end 178 | 179 | it 'does not create a new file instance' do 180 | expect(File).not_to have_received(:new) 181 | end 182 | end 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/matchers/match_snapshot_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe RSpec::Snapshot::Matchers::MatchSnapshot do 6 | subject { described_class.new(serializer, file_operator) } 7 | 8 | let(:file_operator) { instance_double(RSpec::Snapshot::FileOperator) } 9 | let(:serializer) { instance_double(RSpec::Snapshot::DefaultSerializer) } 10 | 11 | describe '.initialize' do 12 | it 'sets the serializer instance variable' do 13 | expect(subject.instance_variable_get('@serializer')).to be(serializer) 14 | end 15 | 16 | it 'sets the file_operator instance variable' do 17 | expect(subject.instance_variable_get('@file_operator')).to( 18 | be(file_operator) 19 | ) 20 | end 21 | end 22 | 23 | describe '.matches?' do 24 | let(:value_to_match) { { foo: 'bar' } } 25 | let(:serialized_value) { '{ foo: "bar" }' } 26 | 27 | before do 28 | allow(serializer).to receive(:dump).and_return(serialized_value) 29 | allow(file_operator).to receive(:write) 30 | end 31 | 32 | context 'when the serialized value matches the snapshot' do 33 | let(:snapshot_value) { serialized_value } 34 | let!(:actual) do 35 | allow(file_operator).to receive(:read).and_return(snapshot_value) 36 | subject.matches?(value_to_match) 37 | end 38 | 39 | it 'serializes the value' do 40 | expect(serializer).to have_received(:dump).with(value_to_match) 41 | end 42 | 43 | it 'writes the serialized value if needed' do 44 | expect(file_operator).to have_received(:write).with(serialized_value) 45 | end 46 | 47 | it 'reads the snapshot' do 48 | expect(file_operator).to have_received(:read) 49 | end 50 | 51 | it 'returns true' do 52 | expect(actual).to be(true) 53 | end 54 | end 55 | 56 | context 'when the serialized value does not match the snapshot' do 57 | let(:snapshot_value) { 'something unexpected' } 58 | let!(:actual) do 59 | allow(file_operator).to receive(:read).and_return(snapshot_value) 60 | subject.matches?(value_to_match) 61 | end 62 | 63 | it 'serializes the value' do 64 | expect(serializer).to have_received(:dump).with(value_to_match) 65 | end 66 | 67 | it 'writes the serialized value if needed' do 68 | expect(file_operator).to have_received(:write).with(serialized_value) 69 | end 70 | 71 | it 'reads the snapshot' do 72 | expect(file_operator).to have_received(:read) 73 | end 74 | 75 | it 'returns false' do 76 | expect(actual).to be(false) 77 | end 78 | end 79 | end 80 | 81 | describe '.description' do 82 | subject { described_class.new(nil, nil) } 83 | 84 | let(:expected) { 'snapshot value' } 85 | 86 | before do 87 | subject.instance_variable_set(:@expected, expected) 88 | end 89 | 90 | it 'returns a description of the expected value' do 91 | expect(subject.description).to( 92 | eq("to match a snapshot containing: \"#{expected}\"") 93 | ) 94 | end 95 | end 96 | 97 | describe '.diffable?' do 98 | subject { described_class.new(nil, nil) } 99 | 100 | it 'returns true' do 101 | expect(subject.diffable?).to be(true) 102 | end 103 | end 104 | 105 | describe '.failure_message' do 106 | subject { described_class.new(nil, nil) } 107 | 108 | let(:expected) { 'snapshot value' } 109 | let(:actual) { 'some other value' } 110 | 111 | before do 112 | subject.instance_variable_set(:@expected, expected) 113 | subject.instance_variable_set(:@actual, actual) 114 | end 115 | 116 | it 'returns a failure message including the actual and expected' do 117 | expect(subject.failure_message).to( 118 | eq("\nexpected: #{expected}\n got: #{actual}\n") 119 | ) 120 | end 121 | end 122 | 123 | describe '.failure_message_when_negated' do 124 | subject { described_class.new(nil, nil) } 125 | 126 | let(:expected) { 'snapshot value' } 127 | 128 | before do 129 | subject.instance_variable_set(:@expected, expected) 130 | subject.instance_variable_set(:@actual, expected) 131 | end 132 | 133 | it 'returns a failure message including the actual and expected' do 134 | expect(subject.failure_message_when_negated).to( 135 | eq("\nexpected: #{expected} not to match #{expected}\n") 136 | ) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/matchers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'json' 5 | require 'logger' 6 | 7 | describe RSpec::Snapshot::Matchers do 8 | describe 'unit tests' do 9 | # rubocop:disable Lint/ConstantDefinitionInBlock 10 | # rubocop:disable RSpec/LeakyConstantDeclaration 11 | class TestClass 12 | include RSpec::Snapshot::Matchers 13 | end 14 | # rubocop:enable Lint/ConstantDefinitionInBlock 15 | # rubocop:enable RSpec/LeakyConstantDeclaration 16 | subject { TestClass.new } 17 | 18 | let(:serializer) { instance_double(RSpec::Snapshot::DefaultSerializer) } 19 | let(:serializer_factory) do 20 | instance_double(RSpec::Snapshot::SerializerFactory) 21 | end 22 | let(:file_operator) { instance_double(RSpec::Snapshot::FileOperator) } 23 | 24 | before do 25 | allow(RSpec::Snapshot::SerializerFactory).to( 26 | receive(:new).and_return(serializer_factory) 27 | ) 28 | allow(serializer_factory).to receive(:create).and_return(serializer) 29 | allow(RSpec::Snapshot::FileOperator).to( 30 | receive(:new).and_return(file_operator) 31 | ) 32 | end 33 | 34 | describe '.match_snapshot' do 35 | let(:current_example) { object_double(RSpec.current_example) } 36 | let(:rspec_metadata) { { foo: :bar } } 37 | let(:snapshot_name) { 'excellent_test_snapshot_name' } 38 | 39 | before do 40 | allow(RSpec).to receive(:current_example).and_return(current_example) 41 | allow(current_example).to receive(:metadata).and_return(rspec_metadata) 42 | allow(RSpec::Snapshot::Matchers::MatchSnapshot).to receive(:new) 43 | end 44 | 45 | context 'when config is passed' do 46 | let(:config) { { foo: :bar } } 47 | 48 | before do 49 | subject.match_snapshot(snapshot_name, config) 50 | end 51 | 52 | it 'creates a serializer factory instance with config' do 53 | expect(RSpec::Snapshot::SerializerFactory).to( 54 | have_received(:new).with(config) 55 | ) 56 | end 57 | 58 | it 'creates a serializer instance' do 59 | expect(serializer_factory).to have_received(:create) 60 | end 61 | 62 | it 'creates a file operator instance with snapshot name and metadata' do 63 | expect(RSpec::Snapshot::FileOperator).to( 64 | have_received(:new).with(snapshot_name, rspec_metadata) 65 | ) 66 | end 67 | 68 | it 'creates a matcher instance with the serializer and file operator' do 69 | expect(RSpec::Snapshot::Matchers::MatchSnapshot).to( 70 | have_received(:new).with(serializer, file_operator) 71 | ) 72 | end 73 | end 74 | 75 | context 'when config is omitted' do 76 | before do 77 | subject.match_snapshot(snapshot_name) 78 | end 79 | 80 | it 'creates a serializer factory instance' do 81 | expect(RSpec::Snapshot::SerializerFactory).to( 82 | have_received(:new).with({}) 83 | ) 84 | end 85 | 86 | it 'creates a serializer instance' do 87 | expect(serializer_factory).to have_received(:create) 88 | end 89 | 90 | it 'creates a file operator instance with snapshot name and metadata' do 91 | expect(RSpec::Snapshot::FileOperator).to( 92 | have_received(:new).with(snapshot_name, rspec_metadata) 93 | ) 94 | end 95 | 96 | it 'creates a matcher instance with the serializer and file operator' do 97 | expect(RSpec::Snapshot::Matchers::MatchSnapshot).to( 98 | have_received(:new).with(serializer, file_operator) 99 | ) 100 | end 101 | end 102 | end 103 | 104 | describe '.snapshot' do 105 | let(:current_example) { object_double(RSpec.current_example) } 106 | let(:rspec_metadata) { { foo: :bar } } 107 | let(:snapshot_name) { 'excellent_test_snapshot_name' } 108 | 109 | before do 110 | allow(RSpec).to receive(:current_example).and_return(current_example) 111 | allow(current_example).to receive(:metadata).and_return(rspec_metadata) 112 | allow(RSpec::Snapshot::Matchers::MatchSnapshot).to receive(:new) 113 | end 114 | 115 | context 'when config is passed' do 116 | let(:config) { { foo: :bar } } 117 | 118 | before do 119 | subject.snapshot(snapshot_name, config) 120 | end 121 | 122 | it 'creates a serializer factory instance with config' do 123 | expect(RSpec::Snapshot::SerializerFactory).to( 124 | have_received(:new).with(config) 125 | ) 126 | end 127 | 128 | it 'creates a serializer instance' do 129 | expect(serializer_factory).to have_received(:create) 130 | end 131 | 132 | it 'creates a file operator instance with snapshot name and metadata' do 133 | expect(RSpec::Snapshot::FileOperator).to( 134 | have_received(:new).with(snapshot_name, rspec_metadata) 135 | ) 136 | end 137 | 138 | it 'creates a matcher instance with the serializer and file operator' do 139 | expect(RSpec::Snapshot::Matchers::MatchSnapshot).to( 140 | have_received(:new).with(serializer, file_operator) 141 | ) 142 | end 143 | end 144 | 145 | context 'when config is omitted' do 146 | before do 147 | subject.snapshot(snapshot_name) 148 | end 149 | 150 | it 'creates a serializer factory instance' do 151 | expect(RSpec::Snapshot::SerializerFactory).to( 152 | have_received(:new).with({}) 153 | ) 154 | end 155 | 156 | it 'creates a serializer instance' do 157 | expect(serializer_factory).to have_received(:create) 158 | end 159 | 160 | it 'creates a file operator instance with snapshot name and metadata' do 161 | expect(RSpec::Snapshot::FileOperator).to( 162 | have_received(:new).with(snapshot_name, rspec_metadata) 163 | ) 164 | end 165 | 166 | it 'creates a matcher instance with the serializer and file operator' do 167 | expect(RSpec::Snapshot::Matchers::MatchSnapshot).to( 168 | have_received(:new).with(serializer, file_operator) 169 | ) 170 | end 171 | end 172 | end 173 | end 174 | 175 | describe 'integration tests' do 176 | let(:current_directory_path) do 177 | Pathname.new(__dir__) 178 | end 179 | 180 | before do 181 | # Set the default configs so that they are reset per test 182 | RSpec.configure do |config| 183 | config.snapshot_dir = :relative 184 | config.snapshot_serializer = nil 185 | end 186 | end 187 | 188 | context 'when snapshot directory config is set' do 189 | context 'and the value is a directory name' do 190 | context 'and the directory exists' do 191 | let(:expected) { 'custom_directory_test_string' } 192 | let(:snapshot_name) { 'custom_directory' } 193 | let(:snapshot_path) do 194 | current_directory_path.join('..', 195 | '..', 196 | 'fixtures', 197 | 'snapshots', 198 | "#{snapshot_name}.snap") 199 | end 200 | 201 | let!(:actual) do 202 | RSpec.configure do |config| 203 | config.snapshot_dir = 'spec/fixtures/snapshots' 204 | end 205 | 206 | expect(expected).to match_snapshot(snapshot_name) 207 | 208 | file = File.new(snapshot_path) 209 | actual = file.read 210 | file.close 211 | actual 212 | end 213 | 214 | it 'creates a file in the configured directory' do 215 | expect(File.exist?(snapshot_path)).to be(true) 216 | end 217 | 218 | it 'the file contents are the expected value' do 219 | expect(actual).to eq(expected) 220 | end 221 | end 222 | 223 | context 'and the directory does not exist' do 224 | let(:expected) { 'custom_directory_test_string' } 225 | let(:snapshot_name) { 'custom_directory' } 226 | let(:snapshot_dir) do 227 | current_directory_path.join('..', 228 | '..', 229 | 'fixtures', 230 | 'non_existing_snapshots_dir') 231 | end 232 | let(:snapshot_path) do 233 | current_directory_path.join('..', 234 | '..', 235 | 'fixtures', 236 | 'non_existing_snapshots_dir', 237 | "#{snapshot_name}.snap") 238 | end 239 | 240 | let!(:actual) do 241 | RSpec.configure do |config| 242 | config.snapshot_dir = 'spec/fixtures/non_existing_snapshots_dir' 243 | end 244 | 245 | FileUtils.rm_f(snapshot_path) 246 | FileUtils.rm_rf(snapshot_dir) 247 | 248 | expect(expected).to match_snapshot(snapshot_name) 249 | 250 | file = File.new(snapshot_path) 251 | actual = file.read 252 | file.close 253 | actual 254 | end 255 | 256 | it 'creates the file and directory for the configured path' do 257 | expect(File.exist?(snapshot_path)).to be(true) 258 | end 259 | 260 | it 'the file contents are the expected value' do 261 | expect(actual).to eq(expected) 262 | end 263 | end 264 | end 265 | 266 | context 'and the value is :relative' do 267 | let(:expected) { 'relative_directory_test_string' } 268 | let(:snapshot_name) { 'relative_directory' } 269 | let(:snapshot_path) do 270 | current_directory_path.join('__snapshots__', 271 | "#{snapshot_name}.snap") 272 | end 273 | 274 | let!(:actual) do 275 | RSpec.configure do |config| 276 | config.snapshot_dir = :relative 277 | end 278 | 279 | expect(expected).to match_snapshot(snapshot_name) 280 | 281 | file = File.new(snapshot_path) 282 | actual = file.read 283 | file.close 284 | actual 285 | end 286 | 287 | it 'creates a file in the adjecent directory with the snapshot name' do 288 | expect(File.exist?(snapshot_path)).to be(true) 289 | end 290 | 291 | it 'the file contents are the expected value' do 292 | expect(actual).to eq(expected) 293 | end 294 | end 295 | end 296 | 297 | context 'when custom serializer config is set' do 298 | let(:expected) do 299 | { 300 | foo: 'bar', 301 | baz: [1, 2, 3] 302 | } 303 | end 304 | 305 | # rubocop:disable Lint/ConstantDefinitionInBlock 306 | # rubocop:disable RSpec/LeakyConstantDeclaration 307 | class TestJSONSerializer 308 | def dump(object) 309 | JSON.dump(object) 310 | end 311 | end 312 | # rubocop:enable Lint/ConstantDefinitionInBlock 313 | # rubocop:enable RSpec/LeakyConstantDeclaration 314 | 315 | before do 316 | RSpec.configure do |config| 317 | config.snapshot_serializer = TestJSONSerializer 318 | end 319 | end 320 | 321 | context 'when the global config is set' do 322 | it 'matches the serialized snapshot' do 323 | expect(expected).to match_snapshot('custom_global_serializer') 324 | end 325 | end 326 | 327 | context 'when a matcher instance config is set' do 328 | it 'matches the serialized snapshot' do 329 | expect(expected).to match_snapshot( 330 | 'custom_instance_serializer', 331 | { snapshot_serializer: RSpec::Snapshot::DefaultSerializer } 332 | ) 333 | end 334 | end 335 | end 336 | 337 | context 'when UPDATE_SNAPSHOTS environment variable is set' do 338 | before do 339 | allow(ENV).to receive(:fetch).and_call_original 340 | allow(ENV).to receive(:fetch).with('UPDATE_SNAPSHOTS', 341 | nil).and_return(true) 342 | end 343 | 344 | context 'and a snapshot file exists' do 345 | let(:original_snapshot_value) { 'foo' } 346 | let(:updated_snapshot_value) { 'bar' } 347 | let(:snapshot_name) { 'update_existing_snapshot' } 348 | let(:snapshot_path) do 349 | current_directory_path.join('__snapshots__', 350 | "#{snapshot_name}.snap") 351 | end 352 | 353 | let!(:actual) do 354 | file = File.new(snapshot_path, 'w+') 355 | file.write(original_snapshot_value) 356 | file.close 357 | 358 | expect(updated_snapshot_value).to match_snapshot(snapshot_name) 359 | 360 | file = File.new(snapshot_path) 361 | actual = file.read 362 | file.close 363 | actual 364 | end 365 | 366 | it 'ignores the snapshot and updates it to the current value' do 367 | expect(actual).to eq(updated_snapshot_value) 368 | end 369 | end 370 | 371 | context 'and a snapshot file does not exist' do 372 | let(:snapshot_value) { 'foo' } 373 | let(:snapshot_name) { 'update_non_existing_snapshot' } 374 | let(:snapshot_path) do 375 | current_directory_path.join('__snapshots__', 376 | "#{snapshot_name}.snap") 377 | end 378 | 379 | let!(:actual) do 380 | FileUtils.rm_f(snapshot_path) 381 | 382 | expect(snapshot_value).to match_snapshot(snapshot_name) 383 | 384 | file = File.new(snapshot_path) 385 | actual = file.read 386 | file.close 387 | actual 388 | end 389 | 390 | it 'writes the snapshot with the current value' do 391 | expect(actual).to eq(snapshot_value) 392 | end 393 | end 394 | end 395 | 396 | context 'when UPDATE_SNAPSHOTS environment variable is not set' do 397 | before do 398 | allow(ENV).to receive(:fetch).and_call_original 399 | allow(ENV).to receive(:fetch).with('UPDATE_SNAPSHOTS', 400 | nil).and_return(nil) 401 | end 402 | 403 | context 'and a snapshot file exists' do 404 | let(:original_snapshot_value) { 'foo' } 405 | let(:updated_snapshot_value) { 'bar' } 406 | let(:snapshot_name) { 'do_not_update_existing_snapshot' } 407 | let(:snapshot_path) do 408 | current_directory_path.join('__snapshots__', 409 | "#{snapshot_name}.snap") 410 | end 411 | 412 | let!(:actual) do 413 | file = File.new(snapshot_path, 'w+') 414 | file.write(original_snapshot_value) 415 | file.close 416 | 417 | expect(updated_snapshot_value).not_to match_snapshot(snapshot_name) 418 | 419 | file = File.new(snapshot_path) 420 | actual = file.read 421 | file.close 422 | actual 423 | end 424 | 425 | it 'does not update the snapshot to the current value' do 426 | expect(actual).to eq(original_snapshot_value) 427 | end 428 | end 429 | 430 | context 'and a snapshot file does not exist' do 431 | let(:snapshot_value) { 'foo' } 432 | let(:snapshot_name) { 'do_not_update_non_existing_snapshot' } 433 | let(:snapshot_path) do 434 | current_directory_path.join('__snapshots__', 435 | "#{snapshot_name}.snap") 436 | end 437 | 438 | let!(:actual) do 439 | FileUtils.rm_f(snapshot_path) 440 | 441 | expect(snapshot_value).to match_snapshot(snapshot_name) 442 | 443 | file = File.new(snapshot_path) 444 | actual = file.read 445 | file.close 446 | actual 447 | end 448 | 449 | it 'writes the snapshot with the current value' do 450 | expect(actual).to eq(snapshot_value) 451 | end 452 | end 453 | end 454 | 455 | context 'when matching an argument' do 456 | context 'with match_snapshot method' do 457 | let(:logger) { Logger.new($stdout) } 458 | let(:actual) { 'log message for match_snapshot' } 459 | 460 | before do 461 | allow(logger).to receive(:info) 462 | logger.info(actual) 463 | end 464 | 465 | it 'matches the argument with snapshot' do 466 | expect(logger).to have_received(:info).with( 467 | match_snapshot('receive_with_match_snapshot') 468 | ) 469 | end 470 | end 471 | 472 | context 'with snapshot method' do 473 | let(:logger) { Logger.new($stdout) } 474 | let(:actual) { 'log message for snapshot' } 475 | 476 | before do 477 | allow(logger).to receive(:info) 478 | logger.info(actual) 479 | end 480 | 481 | it 'matches the argument with snapshot' do 482 | expect(logger).to have_received(:info).with( 483 | snapshot('receive_with_snapshot') 484 | ) 485 | end 486 | end 487 | end 488 | 489 | context 'when matching an expect.to' do 490 | context 'and the value is a hash' do 491 | let(:actual) { { a: 1, b: 2 } } 492 | 493 | it 'matches the snapshot' do 494 | expect(actual).to match_snapshot('hash') 495 | end 496 | end 497 | 498 | context 'and the value is an array' do 499 | let(:actual) { [1, 2] } 500 | 501 | it 'matches the snapshot' do 502 | expect(actual).to match_snapshot('array') 503 | end 504 | end 505 | 506 | context 'and the value is an HTML value' do 507 | let(:actual) do 508 | <<~HTML 509 | 510 | 511 | 512 | 513 |518 | Snapshot is awesome! 519 |
520 | 521 | 522 | HTML 523 | end 524 | 525 | it 'matches the snapshot' do 526 | expect(actual).to match_snapshot('html') 527 | end 528 | end 529 | 530 | context 'and the value is an nested data structure' do 531 | let(:actual) { { a_key: %w[some values] } } 532 | 533 | it 'matches the snapshot' do 534 | expect(actual).to match_snapshot('nested_data_structure') 535 | end 536 | end 537 | end 538 | 539 | context 'when the snapshot fails to match' do 540 | context 'and a diff should be shown' do 541 | let(:snapshot_value) do 542 | { 543 | foo: { 544 | bar: [1, 2, 3] 545 | }, 546 | baz: true 547 | } 548 | end 549 | let(:serialized_value) do 550 | RSpec::Snapshot::DefaultSerializer.new.dump(snapshot_value) 551 | end 552 | let(:param) do 553 | { 554 | foo: { 555 | bar: [1, 4, 3] 556 | }, 557 | baz: false 558 | } 559 | end 560 | let(:snapshot_name) { 'example_diffable_object' } 561 | let(:snapshot_path) do 562 | current_directory_path.join('__snapshots__', 563 | "#{snapshot_name}.snap") 564 | end 565 | 566 | let!(:actual) do 567 | file = File.new(snapshot_path, 'w+') 568 | file.write(serialized_value) 569 | file.close 570 | 571 | allow(ENV).to receive(:fetch).and_call_original 572 | allow(ENV).to receive(:fetch).with('UPDATE_SNAPSHOTS', 573 | nil).and_return(nil) 574 | message = nil 575 | 576 | begin 577 | expect(param).to match_snapshot(snapshot_name) 578 | # rubocop:disable Lint/RescueException 579 | rescue Exception => e 580 | message = e.message 581 | end 582 | # rubocop:enable Lint/RescueException 583 | message 584 | end 585 | 586 | it 'displays an error with the diff' do 587 | expect(actual).to match_snapshot('diff_snapshot') 588 | end 589 | end 590 | 591 | context 'and the default failure message should be shown' do 592 | let(:snapshot_value) { 'foo' } 593 | let(:param) { 'bar' } 594 | let(:snapshot_name) { 'example_failure_message' } 595 | let(:snapshot_path) do 596 | current_directory_path.join('__snapshots__', 597 | "#{snapshot_name}.snap") 598 | end 599 | 600 | let!(:actual) do 601 | file = File.new(snapshot_path, 'w+') 602 | file.write(snapshot_value) 603 | file.close 604 | 605 | allow(ENV).to receive(:fetch).and_call_original 606 | allow(ENV).to receive(:fetch).with('UPDATE_SNAPSHOTS', 607 | nil).and_return(nil) 608 | message = nil 609 | 610 | begin 611 | expect(param).to match_snapshot(snapshot_name) 612 | # rubocop:disable Lint/RescueException 613 | rescue Exception => e 614 | message = e.message 615 | end 616 | # rubocop:enable Lint/RescueException 617 | message 618 | end 619 | 620 | it 'displays the failure message' do 621 | expect(actual).to match_snapshot('failure_message_snapshot') 622 | end 623 | end 624 | 625 | context 'and the negative failure message should be shown' do 626 | let(:snapshot_value) { 'foo' } 627 | let(:param) { 'foo' } 628 | let(:snapshot_name) { 'example_negated_failure_message' } 629 | let(:snapshot_path) do 630 | current_directory_path.join('__snapshots__', 631 | "#{snapshot_name}.snap") 632 | end 633 | 634 | let!(:actual) do 635 | file = File.new(snapshot_path, 'w+') 636 | file.write(snapshot_value) 637 | file.close 638 | 639 | allow(ENV).to receive(:fetch).and_call_original 640 | allow(ENV).to receive(:fetch).with('UPDATE_SNAPSHOTS', 641 | nil).and_return(nil) 642 | message = nil 643 | 644 | begin 645 | expect(param).not_to match_snapshot(snapshot_name) 646 | # rubocop:disable Lint/RescueException 647 | rescue Exception => e 648 | message = e.message 649 | end 650 | # rubocop:enable Lint/RescueException 651 | message 652 | end 653 | 654 | it 'displays the negated failure message' do 655 | expect(actual).to match_snapshot('negated_failure_message_snapshot') 656 | end 657 | end 658 | end 659 | end 660 | end 661 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/serializer_factory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'rspec/snapshot/serializer_factory' 5 | 6 | describe RSpec::Snapshot::SerializerFactory do 7 | describe '#initialize' do 8 | context 'when config is not provided' do 9 | subject { described_class.new } 10 | 11 | it 'sets config instance var to an empty hash' do 12 | expect(subject.instance_variable_get('@config')).to eq({}) 13 | end 14 | end 15 | 16 | context 'when config is provided' do 17 | subject { described_class.new(config) } 18 | 19 | let(:config) { { foo: 'bar' } } 20 | 21 | it 'sets config instance var to the provided config' do 22 | expect(subject.instance_variable_get('@config')).to be(config) 23 | end 24 | end 25 | end 26 | 27 | describe '#create' do 28 | # rubocop:disable Lint/ConstantDefinitionInBlock 29 | # rubocop:disable RSpec/LeakyConstantDeclaration 30 | class TestSerializer 31 | def dump(object) 32 | object.to_s 33 | end 34 | end 35 | # rubocop:enable Lint/ConstantDefinitionInBlock 36 | # rubocop:enable RSpec/LeakyConstantDeclaration 37 | 38 | context 'when a serializer is provided in the instance config' do 39 | subject { described_class.new(config) } 40 | 41 | let(:config) { { snapshot_serializer: TestSerializer } } 42 | 43 | it 'returns an instance of the configured class' do 44 | expect(subject.create).to be_a(TestSerializer) 45 | end 46 | end 47 | 48 | context 'when a serializer is not provided in the instance config' do 49 | subject { described_class.new } 50 | 51 | context 'and a serializer is provided in RSpec config' do 52 | before do 53 | allow(RSpec.configuration).to( 54 | receive(:snapshot_serializer).and_return(TestSerializer) 55 | ) 56 | end 57 | 58 | it 'returns an instance of the configured class' do 59 | expect(subject.create).to be_a(TestSerializer) 60 | end 61 | end 62 | 63 | context 'and a serializer is not provided in RSpec config' do 64 | before do 65 | allow(RSpec.configuration).to( 66 | receive(:snapshot_serializer).and_return(nil) 67 | ) 68 | end 69 | 70 | it 'returns an instance of the default serializer' do 71 | expect(subject.create).to be_a(RSpec::Snapshot::DefaultSerializer) 72 | end 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/rspec/snapshot/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe RSpec::Snapshot::VERSION do 6 | it 'is set to 2.0.3' do 7 | expect(subject).to eq('2.0.3') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pry' 4 | require 'simplecov' 5 | 6 | SimpleCov.start do 7 | enable_coverage :branch 8 | 9 | add_filter [ 10 | %r{^/spec} 11 | ] 12 | end 13 | 14 | SimpleCov.minimum_coverage line: 100, branch: 94 15 | SimpleCov.refuse_coverage_drop 16 | 17 | require 'rspec/snapshot' 18 | 19 | # This file was generated by the `rspec --init` command. Conventionally, all 20 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 21 | # The generated `.rspec` file contains `--require spec_helper` which will cause 22 | # this file to always be loaded, without a need to explicitly require it in any 23 | # files. 24 | # 25 | # Given that it is always loaded, you are encouraged to keep this file as 26 | # light-weight as possible. Requiring heavyweight dependencies from this file 27 | # will add to the boot time of your test suite on EVERY test run, even for an 28 | # individual file that may not need all of that loaded. Instead, consider making 29 | # a separate helper file that requires the additional dependencies and performs 30 | # the additional setup, and require it from the spec files that actually need 31 | # it. 32 | # 33 | # The `.rspec` file also contains a few flags that are not defaults but that 34 | # users commonly want. 35 | # 36 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 37 | RSpec.configure do |config| 38 | # rspec-expectations config goes here. You can use an alternate 39 | # assertion/expectation library such as wrong or the stdlib/minitest 40 | # assertions if you prefer. 41 | config.expect_with :rspec do |expectations| 42 | # This option will default to `true` in RSpec 4. It makes the `description` 43 | # and `failure_message` of custom matchers include text for helper methods 44 | # defined using `chain`, e.g.: 45 | # be_bigger_than(2).and_smaller_than(4).description 46 | # # => "be bigger than 2 and smaller than 4" 47 | # ...rather than: 48 | # # => "be bigger than 2" 49 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 50 | 51 | # Don't truncate large diffs 52 | expectations.max_formatted_output_length = nil 53 | end 54 | 55 | # rspec-mocks config goes here. You can use an alternate test double 56 | # library (such as bogus or mocha) by changing the `mock_with` option here. 57 | config.mock_with :rspec do |mocks| 58 | # Prevents you from mocking or stubbing a method that does not exist on 59 | # a real object. This is generally recommended, and will default to 60 | # `true` in RSpec 4. 61 | mocks.verify_partial_doubles = true 62 | end 63 | 64 | # Enable colored output always because some tests rely on the rspec output 65 | config.color = true 66 | config.tty = true 67 | 68 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 69 | # have no way to turn it off -- the option exists only for backwards 70 | # compatibility in RSpec 3). It causes shared context metadata to be 71 | # inherited by the metadata hash of host groups and examples, rather than 72 | # triggering implicit auto-inclusion in groups with matching metadata. 73 | # config.shared_context_metadata_behavior = :apply_to_host_groups 74 | 75 | # The settings below are suggested to provide a good initial experience 76 | # with RSpec, but feel free to customize to your heart's content. 77 | # This allows you to limit a spec run to individual examples or groups 78 | # you care about by tagging them with `:focus` metadata. When nothing 79 | # is tagged with `:focus`, all examples get run. RSpec also provides 80 | # aliases for `it`, `describe`, and `context` that include `:focus` 81 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 82 | config.filter_run_when_matching :focus 83 | 84 | # Allows RSpec to persist some state between runs in order to support 85 | # the `--only-failures` and `--next-failure` CLI options. We recommend 86 | # you configure your source control system to ignore this file. 87 | # config.example_status_persistence_file_path = "spec/examples.txt" 88 | 89 | # Limits the available syntax to the non-monkey patched syntax that is 90 | # recommended. For more details, see: 91 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 92 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 93 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 94 | # config.disable_monkey_patching! 95 | 96 | # This setting enables warnings. It's recommended, but in some cases may 97 | # be too noisy due to issues in dependencies. 98 | config.warnings = true 99 | 100 | # Many RSpec users commonly either run the entire suite or an individual 101 | # file, and it's useful to allow more verbose output when running an 102 | # individual spec file. 103 | if config.files_to_run.one? 104 | # Use the documentation formatter for detailed output, 105 | # unless a formatter has already been configured 106 | # (e.g. via a command-line flag). 107 | config.default_formatter = 'doc' 108 | end 109 | 110 | # Print the 10 slowest examples and example groups at the 111 | # end of the spec run, to help surface which specs are running 112 | # particularly slow. 113 | config.profile_examples = 10 114 | 115 | # Run specs in random order to surface order dependencies. If you find an 116 | # order dependency and want to debug it, you can fix the order by providing 117 | # the seed, which is printed after each run. 118 | # --seed 1234 119 | config.order = :random 120 | 121 | # Seed global randomization in this process using the `--seed` CLI option. 122 | # Setting this allows you to use `--seed` to deterministically reproduce 123 | # test failures related to randomization by passing the same `--seed` value 124 | # as the one that triggered the failure. 125 | Kernel.srand config.seed 126 | end 127 | --------------------------------------------------------------------------------