├── test ├── snapshots │ └── minitest_snapshotstest │ │ ├── test_string_matches__1.snap.yaml │ │ ├── test_unmatching_string_raises__1.snap.yaml │ │ ├── test_hash_matches__1.snap.yaml │ │ ├── test_set__1.snap.yaml │ │ └── test_set_3_5__1.snap.yaml ├── test_helper.rb └── minitest │ ├── snapshots_plugin_test.rb │ └── snapshots_test.rb ├── lib └── minitest │ ├── snapshots │ ├── version.rb │ ├── test_extensions.rb │ ├── assertion_extensions.rb │ └── serializer.rb │ ├── snapshots.rb │ └── snapshots_plugin.rb ├── .gitignore ├── bin ├── setup └── console ├── Gemfile ├── .github ├── workflows │ ├── release-drafter.yml │ └── ci.yml ├── dependabot.yml └── release-drafter.yml ├── .kodiak.toml ├── LICENSE.txt ├── minitest-snapshots.gemspec ├── Rakefile ├── README.md ├── CODE_OF_CONDUCT.md └── .rubocop.yml /test/snapshots/minitest_snapshotstest/test_string_matches__1.snap.yaml: -------------------------------------------------------------------------------- 1 | --- foo 2 | -------------------------------------------------------------------------------- /test/snapshots/minitest_snapshotstest/test_unmatching_string_raises__1.snap.yaml: -------------------------------------------------------------------------------- 1 | --- doctored 2 | -------------------------------------------------------------------------------- /lib/minitest/snapshots/version.rb: -------------------------------------------------------------------------------- 1 | module Minitest 2 | module Snapshots 3 | VERSION = "1.1.5".freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /Gemfile.lock 10 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "minitest/snapshots" 3 | 4 | require "minitest/autorun" 5 | -------------------------------------------------------------------------------- /test/snapshots/minitest_snapshotstest/test_hash_matches__1.snap.yaml: -------------------------------------------------------------------------------- 1 | --- !ruby/object:Hash 2 | :baz: 1 3 | :foo: bar 4 | :qux: !ruby/object:Hash 5 | :foo: corge 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | gem "minitest", "~> 5.14" 5 | gem "rake", "~> 13.0" 6 | gem "rubocop", "1.81.7" 7 | gem "rubocop-minitest", "0.38.2" 8 | gem "rubocop-packaging", "0.6.0" 9 | gem "rubocop-performance", "1.26.1" 10 | gem "rubocop-rake", "0.7.1" 11 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | update_release_draft: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: release-drafter/release-drafter@v6 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "minitest/snapshots" 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(__FILE__) 15 | -------------------------------------------------------------------------------- /.kodiak.toml: -------------------------------------------------------------------------------- 1 | # .kodiak.toml 2 | # Minimal config. version is the only required field. 3 | version = 1 4 | 5 | [merge.automerge_dependencies] 6 | # auto merge all PRs opened by "dependabot" that are "minor" or "patch" version upgrades. "major" version upgrades will be ignored. 7 | versions = ["minor", "patch"] 8 | usernames = ["dependabot"] 9 | 10 | # if using `update.always`, add dependabot to `update.ignore_usernames` to allow 11 | # dependabot to update and close stale dependency upgrades. 12 | [update] 13 | ignored_usernames = ["dependabot"] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "16:00" 8 | timezone: America/Los_Angeles 9 | open-pull-requests-limit: 10 10 | labels: 11 | - "🏠 Housekeeping" 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: monthly 16 | time: "16:00" 17 | timezone: America/Los_Angeles 18 | open-pull-requests-limit: 10 19 | labels: 20 | - "🏠 Housekeeping" 21 | -------------------------------------------------------------------------------- /lib/minitest/snapshots.rb: -------------------------------------------------------------------------------- 1 | require "minitest" 2 | 3 | module Minitest::Snapshots 4 | class << self 5 | attr_accessor :force_updates, :lock_snapshots 6 | 7 | def default_snapshots_directory 8 | if defined?(Rails) && Rails.respond_to?(:root) 9 | Rails.root.join("test", "snapshots").to_s 10 | elsif Dir.exist?("test") 11 | File.expand_path("test/snapshots") 12 | elsif Dir.exist?("spec") 13 | File.expand_path("spec/snapshots") 14 | else 15 | File.expand_path("snapshots") 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "⚠️ Breaking Changes" 5 | label: "⚠️ Breaking" 6 | - title: "✨ New Features" 7 | label: "✨ Feature" 8 | - title: "🐛 Bug Fixes" 9 | label: "🐛 Bug Fix" 10 | - title: "📚 Documentation" 11 | label: "📚 Docs" 12 | - title: "🏠 Housekeeping" 13 | label: "🏠 Housekeeping" 14 | version-resolver: 15 | minor: 16 | labels: 17 | - "⚠️ Breaking" 18 | - "✨ Feature" 19 | default: patch 20 | change-template: "- $TITLE (#$NUMBER) @$AUTHOR" 21 | no-changes-template: "- No changes" 22 | template: | 23 | $CHANGES 24 | 25 | **Full Changelog:** https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | rubocop: 9 | name: "Rubocop" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: ruby 16 | bundler-cache: true 17 | - run: bundle exec rubocop 18 | test: 19 | name: "Test / Ruby ${{ matrix.ruby }}" 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "head"] 24 | steps: 25 | - uses: actions/checkout@v6 26 | - uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: ${{ matrix.ruby }} 29 | bundler-cache: true 30 | - run: bundle exec rake test 31 | -------------------------------------------------------------------------------- /lib/minitest/snapshots_plugin.rb: -------------------------------------------------------------------------------- 1 | require_relative "snapshots" 2 | require_relative "snapshots/version" 3 | 4 | module Minitest 5 | def self.plugin_snapshots_options(opts, _options) 6 | Minitest::Snapshots.lock_snapshots = !ENV["CI"].to_s.empty? 7 | 8 | opts.on "-u", "--update-snapshots", "Update (overwrite) stored snapshots" do 9 | Minitest::Snapshots.force_updates = true 10 | end 11 | 12 | opts.on "-l", "--[no-]lock-snapshots", "Prevent any snapshots from being stored" do |bool| 13 | Minitest::Snapshots.lock_snapshots = bool 14 | end 15 | end 16 | 17 | def self.plugin_snapshots_init(_options) 18 | require_relative "snapshots/test_extensions" 19 | require_relative "snapshots/assertion_extensions" 20 | Minitest::Test.include Minitest::Snapshots::TestExtensions 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/minitest/snapshots/test_extensions.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | 3 | module Minitest 4 | module Snapshots 5 | module TestExtensions 6 | def before_setup 7 | super 8 | @snapshot_assertion_counter = 0 9 | @snapshot_dir ||= Minitest::Snapshots.default_snapshots_directory 10 | end 11 | 12 | private 13 | 14 | def sanitize(name) 15 | sanitized = name.to_s.downcase.gsub(/(?:\A[\W_]+)|(?:[\W_]+\z)/, "").gsub(/[\W_]+/, "_") 16 | 17 | if sanitized.empty? 18 | raise NameError, "Can't sanitize name: #{name.inspect}" 19 | end 20 | 21 | sanitized 22 | end 23 | 24 | def snapshot_path(suite_name, snapshot_name) 25 | filename = format("%s__%s.snap.yaml", sanitize(name), sanitize(snapshot_name)) 26 | subdir = sanitize(suite_name) 27 | File.join(@snapshot_dir, subdir, filename) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/minitest/snapshots_plugin_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Minitest::SnapshotsPluginTest < Minitest::Test 4 | def setup 5 | @orig_ci_value = ENV["CI"] 6 | @opt_parse = OptionParser.new 7 | end 8 | 9 | def teardown 10 | ENV["CI"] = @orig_ci_value 11 | end 12 | 13 | def test_does_not_lock_snapshots_by_default 14 | ENV["CI"] = nil 15 | register_plugin 16 | 17 | refute Minitest::Snapshots.lock_snapshots 18 | end 19 | 20 | def test_locks_snapshots_by_defaut_in_ci 21 | ENV["CI"] = "1" 22 | register_plugin 23 | 24 | assert Minitest::Snapshots.lock_snapshots 25 | end 26 | 27 | def test_lock_snapshots_can_be_disabled_in_ci_via_command_line_flag 28 | ENV["CI"] = "1" 29 | register_plugin 30 | @opt_parse.parse!(["--no-lock-snapshots"]) 31 | 32 | refute Minitest::Snapshots.lock_snapshots 33 | end 34 | 35 | private 36 | 37 | def register_plugin 38 | Minitest.plugin_snapshots_options(@opt_parse, {}) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/minitest/snapshots/assertion_extensions.rb: -------------------------------------------------------------------------------- 1 | require_relative "serializer" 2 | require "fileutils" 3 | 4 | module Minitest 5 | module Assertions 6 | def assert_matches_snapshot(value, snapshot_name = nil) 7 | snapshot_file = snapshot_path(self.class.name, snapshot_name || (@snapshot_assertion_counter += 1)) 8 | snapshot = Minitest::Snapshots::Serializer.serialize(value) 9 | 10 | if !Minitest::Snapshots.force_updates && File.exist?(snapshot_file) 11 | assert_equal( 12 | File.read(snapshot_file), 13 | snapshot, 14 | "The value does not match the snapshot (located at #{snapshot_file})" 15 | ) 16 | else 17 | if Minitest::Snapshots.lock_snapshots 18 | assert( 19 | false, 20 | "Attempt to create a snapshot failed because writing is prevented by the --lock-snapshots option" 21 | ) 22 | end 23 | 24 | FileUtils.mkdir_p(File.dirname(snapshot_file)) 25 | File.write(snapshot_file, snapshot) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/snapshots/minitest_snapshotstest/test_set__1.snap.yaml: -------------------------------------------------------------------------------- 1 | --- !ruby/object:Set 2 | - 0 3 | - 1 4 | - 10 5 | - 100 6 | - 11 7 | - 12 8 | - 13 9 | - 14 10 | - 15 11 | - 16 12 | - 17 13 | - 18 14 | - 19 15 | - 2 16 | - 20 17 | - 21 18 | - 22 19 | - 23 20 | - 24 21 | - 25 22 | - 26 23 | - 27 24 | - 28 25 | - 29 26 | - 3 27 | - 30 28 | - 31 29 | - 32 30 | - 33 31 | - 34 32 | - 35 33 | - 36 34 | - 37 35 | - 38 36 | - 39 37 | - 4 38 | - 40 39 | - 41 40 | - 42 41 | - 43 42 | - 44 43 | - 45 44 | - 46 45 | - 47 46 | - 48 47 | - 49 48 | - 5 49 | - 50 50 | - 51 51 | - 52 52 | - 53 53 | - 54 54 | - 55 55 | - 56 56 | - 57 57 | - 58 58 | - 59 59 | - 6 60 | - 60 61 | - 61 62 | - 62 63 | - 63 64 | - 64 65 | - 65 66 | - 66 67 | - 67 68 | - 68 69 | - 69 70 | - 7 71 | - 70 72 | - 71 73 | - 72 74 | - 73 75 | - 74 76 | - 75 77 | - 76 78 | - 77 79 | - 78 80 | - 79 81 | - 8 82 | - 80 83 | - 81 84 | - 82 85 | - 83 86 | - 84 87 | - 85 88 | - 86 89 | - 87 90 | - 88 91 | - 89 92 | - 9 93 | - 90 94 | - 91 95 | - 92 96 | - 93 97 | - 94 98 | - 95 99 | - 96 100 | - 97 101 | - 98 102 | - 99 103 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Harry Brundage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /minitest-snapshots.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/minitest/snapshots/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "minitest-snapshots" 5 | spec.version = Minitest::Snapshots::VERSION 6 | spec.authors = ["Harry Brundage", "Matt Brictson"] 7 | spec.email = ["harry.brundage@gmail.com", "opensource@mattbrictson.com"] 8 | 9 | spec.summary = "Minitest plugin implementing Jest-style snapshot testing" 10 | spec.homepage = "https://github.com/mattbrictson/minitest-snapshots" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 2.7" 13 | 14 | spec.metadata = { 15 | "bug_tracker_uri" => "https://github.com/mattbrictson/minitest-snapshots/issues", 16 | "changelog_uri" => "https://github.com/mattbrictson/minitest-snapshots/releases", 17 | "source_code_uri" => "https://github.com/mattbrictson/minitest-snapshots", 18 | "homepage_uri" => spec.homepage, 19 | "rubygems_mfa_required" => "true" 20 | } 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | spec.files = Dir.glob(%w[LICENSE.txt README.md {exe,lib}/**/*]).reject { |f| File.directory?(f) } 24 | spec.bindir = "exe" 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ["lib"] 27 | end 28 | -------------------------------------------------------------------------------- /test/minitest/snapshots_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Minitest::SnapshotsTest < Minitest::Test 4 | def test_string_matches 5 | assert_matches_snapshot "foo" 6 | end 7 | 8 | def test_unmatching_string_raises 9 | error = assert_raises(Minitest::Assertion) do 10 | assert_matches_snapshot "foo" 11 | end 12 | 13 | assert_match(/does not match the snapshot/, error.message) 14 | end 15 | 16 | def test_hash_matches 17 | assert_matches_snapshot({foo: "bar", baz: 1, qux: {foo: "corge"}}) 18 | end 19 | 20 | if RUBY_VERSION >= "3.5.0" 21 | def test_set_3_5 22 | assert_matches_snapshot(Set.new((0..100).sort_by { rand })) 23 | end 24 | else 25 | def test_set 26 | assert_matches_snapshot(Set.new((0..100).sort_by { rand })) 27 | end 28 | end 29 | 30 | def test_default_snapshots_directory 31 | assert_equal(File.expand_path("../snapshots", __dir__), Minitest::Snapshots.default_snapshots_directory) 32 | end 33 | 34 | def test_default_snapshots_directory_uses_rails_root_if_defined 35 | with_rails_module_stub do 36 | Rails.define_singleton_method(:root) { Pathname.new("/path/to/rails/root") } 37 | assert_equal("/path/to/rails/root/test/snapshots", Minitest::Snapshots.default_snapshots_directory) 38 | end 39 | end 40 | 41 | def test_default_snapshots_directory_ignores_rails_if_root_is_undefined 42 | with_rails_module_stub do 43 | assert_equal(File.expand_path("../snapshots", __dir__), Minitest::Snapshots.default_snapshots_directory) 44 | end 45 | end 46 | 47 | private 48 | 49 | def with_rails_module_stub 50 | Object.const_set :Rails, Module.new 51 | yield 52 | ensure 53 | Object.send(:remove_const, :Rails) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/snapshots/minitest_snapshotstest/test_set_3_5__1.snap.yaml: -------------------------------------------------------------------------------- 1 | --- !ruby/object:Set 2 | hash: !ruby/object:Hash 3 | 0: true 4 | 1: true 5 | 10: true 6 | 100: true 7 | 11: true 8 | 12: true 9 | 13: true 10 | 14: true 11 | 15: true 12 | 16: true 13 | 17: true 14 | 18: true 15 | 19: true 16 | 2: true 17 | 20: true 18 | 21: true 19 | 22: true 20 | 23: true 21 | 24: true 22 | 25: true 23 | 26: true 24 | 27: true 25 | 28: true 26 | 29: true 27 | 3: true 28 | 30: true 29 | 31: true 30 | 32: true 31 | 33: true 32 | 34: true 33 | 35: true 34 | 36: true 35 | 37: true 36 | 38: true 37 | 39: true 38 | 4: true 39 | 40: true 40 | 41: true 41 | 42: true 42 | 43: true 43 | 44: true 44 | 45: true 45 | 46: true 46 | 47: true 47 | 48: true 48 | 49: true 49 | 5: true 50 | 50: true 51 | 51: true 52 | 52: true 53 | 53: true 54 | 54: true 55 | 55: true 56 | 56: true 57 | 57: true 58 | 58: true 59 | 59: true 60 | 6: true 61 | 60: true 62 | 61: true 63 | 62: true 64 | 63: true 65 | 64: true 66 | 65: true 67 | 66: true 68 | 67: true 69 | 68: true 70 | 69: true 71 | 7: true 72 | 70: true 73 | 71: true 74 | 72: true 75 | 73: true 76 | 74: true 77 | 75: true 78 | 76: true 79 | 77: true 80 | 78: true 81 | 79: true 82 | 8: true 83 | 80: true 84 | 81: true 85 | 82: true 86 | 83: true 87 | 84: true 88 | 85: true 89 | 86: true 90 | 87: true 91 | 88: true 92 | 89: true 93 | 9: true 94 | 90: true 95 | 91: true 96 | 92: true 97 | 93: true 98 | 94: true 99 | 95: true 100 | 96: true 101 | 97: true 102 | 98: true 103 | 99: true 104 | -------------------------------------------------------------------------------- /lib/minitest/snapshots/serializer.rb: -------------------------------------------------------------------------------- 1 | require "set" 2 | require "yaml" 3 | 4 | module Minitest 5 | module Snapshots 6 | # The serializer translates values (objects) into strings. By default, 7 | # it uses YAML (Psych). Can be overridden to implement custom 8 | # serialization formats, e.g.: 9 | # 10 | # module Minitest::Snapshots::Serializer 11 | # def self.serialize(value) 12 | # Marshal.dump(value) 13 | # end 14 | # end 15 | module Serializer 16 | # The name of the method used to customize the output of +to_yaml+. 17 | # Used to provide canonical representations for Hash and Set instances. 18 | HOOK = :encode_with 19 | 20 | # Used to ensure the addition/removal of hooks is atomic 21 | @lock = Mutex.new 22 | 23 | # Serialize the supplied value to YAML with hooks to canonicalize 24 | # (i.e. sort) Hash keys and Set elements. 25 | # 26 | # h1 = { foo: "bar", baz: "quux" } 27 | # h2 = { baz: "quux", foo: "bar" } 28 | # 29 | # h1.to_yaml == h2.to_yaml # => false 30 | # Serializer.serialize(h1) == Serializer.serialize(h2) # => true 31 | # 32 | # The hooks are only installed for the duration of the method call 33 | # and are not installed if custom hooks are already defined. 34 | def self.serialize(value) 35 | @lock.synchronize do 36 | if (hook_hash = hook?(Hash)) 37 | Hash.define_method(HOOK) do |coder| 38 | sorted = sort_by { |pair| pair.first.to_yaml }.to_h 39 | coder.map = dup.clear.merge!(sorted) 40 | end 41 | end 42 | 43 | if (hook_set = hook?(Set)) 44 | Set.define_method(HOOK) do |coder| 45 | sorted = sort_by(&:to_yaml) 46 | coder.seq = dup.clear.merge(sorted) 47 | end 48 | end 49 | 50 | value.to_yaml 51 | ensure 52 | Hash.remove_method(HOOK) if hook_hash 53 | Set.remove_method(HOOK) if hook_set 54 | end 55 | end 56 | 57 | # Returns true if a class is hookable (doesn't already have a YAML 58 | # serialization hook defined), false otherwise 59 | def self.hook?(mod) 60 | !(mod.method_defined?(HOOK) || mod.private_method_defined?(HOOK)) 61 | end 62 | 63 | private_class_method :hook? 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | require "rubocop/rake_task" 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << "test" 7 | t.libs << "lib" 8 | t.test_files = FileList["test/**/*_test.rb"] 9 | end 10 | 11 | RuboCop::RakeTask.new 12 | 13 | task default: %i[test rubocop] 14 | 15 | # == "rake release" enhancements ============================================== 16 | 17 | Rake::Task["release"].enhance do 18 | puts "Don't forget to publish the release on GitHub!" 19 | system "open https://github.com/mattbrictson/minitest-snapshots/releases" 20 | end 21 | 22 | task :disable_overcommit do 23 | ENV["OVERCOMMIT_DISABLE"] = "1" 24 | end 25 | 26 | Rake::Task[:build].enhance [:disable_overcommit] 27 | 28 | task :verify_gemspec_files do 29 | git_files = `git ls-files -z`.split("\x0") 30 | gemspec_files = Gem::Specification.load("minitest-snapshots.gemspec").files.sort 31 | ignored_by_git = gemspec_files - git_files 32 | next if ignored_by_git.empty? 33 | 34 | raise <<~ERROR 35 | 36 | The `spec.files` specified in minitest-snapshots.gemspec include the following 37 | files that are being ignored by git. Did you forget to add them to the repo? 38 | If not, you may need to delete these files or modify the gemspec to ensure 39 | that they are not included in the gem by mistake: 40 | 41 | #{ignored_by_git.join("\n").gsub(/^/, " ")} 42 | 43 | ERROR 44 | end 45 | 46 | Rake::Task[:build].enhance [:verify_gemspec_files] 47 | 48 | # == "rake bump" tasks ======================================================== 49 | 50 | task bump: %w[bump:bundler bump:ruby] 51 | 52 | namespace :bump do 53 | task :bundler do 54 | sh "bundle update --bundler" 55 | end 56 | 57 | task :ruby do 58 | replace_in_file "minitest-snapshots.gemspec", /ruby_version = .*">= (.*)"/ => RubyVersions.lowest 59 | replace_in_file ".rubocop.yml", /TargetRubyVersion: (.*)/ => RubyVersions.lowest 60 | replace_in_file ".github/workflows/ci.yml", /ruby: (\[.+\])/ => RubyVersions.all.inspect 61 | replace_in_file "README.md", /Ruby ([\d.]+) or later/ => RubyVersions.lowest 62 | end 63 | end 64 | 65 | require "json" 66 | require "open-uri" 67 | 68 | def replace_in_file(path, replacements) 69 | contents = File.read(path) 70 | orig_contents = contents.dup 71 | replacements.each do |regexp, text| 72 | raise "Can't find #{regexp} in #{path}" unless regexp.match?(contents) 73 | 74 | contents.gsub!(regexp) do |match| 75 | match[regexp, 1] = text 76 | match 77 | end 78 | end 79 | File.write(path, contents) if contents != orig_contents 80 | end 81 | 82 | module RubyVersions 83 | class << self 84 | def lowest 85 | all.first 86 | end 87 | 88 | def all 89 | minor_versions = versions.filter_map { |v| v["cycle"] if v["releaseDate"] >= "2019-12-25" } 90 | [*minor_versions.sort, "head"] 91 | end 92 | 93 | private 94 | 95 | def versions 96 | @_versions ||= begin 97 | json = URI.open("https://endoflife.date/api/ruby.json").read 98 | JSON.parse(json) 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minitest::Snapshots 2 | 3 | [![Gem Version](https://img.shields.io/gem/v/minitest-snapshots)](https://rubygems.org/gems/minitest-snapshots) 4 | [![Gem Downloads](https://img.shields.io/gem/dt/minitest-snapshots)](https://www.ruby-toolbox.com/projects/minitest-snapshots) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/minitest-snapshots/ci.yml)](https://github.com/mattbrictson/minitest-snapshots/actions/workflows/ci.yml) 6 | 7 | Simple minitest plugin gem implementing Jest-style snapshot testing. It's like VCR, but for any value. 8 | 9 | ## Requirements 10 | 11 | - Ruby 2.7 or later 12 | 13 | ## Usage 14 | 15 | Instead of copying and pasting large segments of machine generated text into your test files, and updating them all the time, assert against a snapshot managed by this library. The first time the snapshot assertion is run it will write the value to disk, and then then next time, it will check against that value. 16 | 17 | Example: 18 | 19 | ```ruby 20 | class QueryCompilerText < Minitest::Test 21 | def test_it_can_compile_a_query 22 | assert_matches_snapshot QueryCompiler.new.compile 23 | end 24 | end 25 | ``` 26 | 27 | ## Command line options 28 | 29 | - `-u` or `--update-snapshots`: Update snapshots on disk to the new actual value when re-running the test. Useful when you know the new output of a test case is correct and the snapshot is out of date. 30 | - `-l` or `--lock-snapshots`: Prevents new snapshots from being written. This is enabled by default in CI (i.e. when the `CI` env var is present). 31 | 32 | For example, to update snapshots on a Rails project: 33 | 34 | bin/rails test -u 35 | 36 | In a Rake project: 37 | 38 | rake test TESTOPTS=-u 39 | 40 | ## Installation 41 | 42 | Add this line to your application's Gemfile: 43 | 44 | ```ruby 45 | gem 'minitest-snapshots' 46 | ``` 47 | 48 | And then execute: 49 | 50 | $ bundle 51 | 52 | Or install it yourself as: 53 | 54 | $ gem install minitest-snapshots 55 | 56 | ## Development 57 | 58 | After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 59 | 60 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 61 | 62 | ## Contributing 63 | 64 | Bug reports and pull requests are welcome on GitHub at https://github.com/mattbrictson/minitest-snapshots. 65 | 66 | ## License 67 | 68 | The gem is available as open source under the terms of the [MIT License](LICENSE.txt). 69 | 70 | ## Code of Conduct 71 | 72 | Everyone interacting in the this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md). 73 | 74 | ## History 75 | 76 | `minitest-snapshots` was created by Harry Brundage (@airhorns) and originally published to rubygems in 2019. Significant contributions were [added](https://github.com/mattbrictson/minitest-snapshots/pull/6) by @chocolateboy in 2020. In June 2023, ownership of the project was transferred to Matt Brictson (@mattbrictson). 77 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at harry.brundage@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-minitest 3 | - rubocop-packaging 4 | - rubocop-performance 5 | - rubocop-rake 6 | 7 | AllCops: 8 | DisplayCopNames: true 9 | DisplayStyleGuide: true 10 | NewCops: enable 11 | TargetRubyVersion: 2.7 12 | Exclude: 13 | - "tmp/**/*" 14 | - "vendor/**/*" 15 | 16 | Layout/ArgumentAlignment: 17 | EnforcedStyle: with_fixed_indentation 18 | 19 | Layout/ExtraSpacing: 20 | AllowForAlignment: false 21 | AllowBeforeTrailingComments: true 22 | ForceEqualSignAlignment: false 23 | 24 | Layout/FirstArrayElementIndentation: 25 | EnforcedStyle: consistent 26 | 27 | Layout/FirstArrayElementLineBreak: 28 | Enabled: true 29 | 30 | Layout/FirstHashElementLineBreak: 31 | Enabled: true 32 | 33 | Layout/FirstMethodArgumentLineBreak: 34 | Enabled: true 35 | 36 | Layout/HashAlignment: 37 | EnforcedHashRocketStyle: key 38 | EnforcedColonStyle: key 39 | EnforcedLastArgumentHashStyle: always_inspect 40 | 41 | Layout/LineLength: 42 | Exclude: 43 | - config/initializers/content_security_policy.rb 44 | 45 | Layout/MultilineArrayLineBreaks: 46 | Enabled: true 47 | 48 | Layout/MultilineHashKeyLineBreaks: 49 | Enabled: true 50 | 51 | Layout/MultilineMethodArgumentLineBreaks: 52 | Enabled: true 53 | 54 | Layout/MultilineMethodCallIndentation: 55 | EnforcedStyle: indented 56 | 57 | Layout/MultilineOperationIndentation: 58 | EnforcedStyle: indented 59 | 60 | Layout/SpaceInsideHashLiteralBraces: 61 | EnforcedStyle: no_space 62 | EnforcedStyleForEmptyBraces: no_space 63 | 64 | Lint/AmbiguousBlockAssociation: 65 | Enabled: false 66 | 67 | Lint/DuplicateBranch: 68 | Enabled: false 69 | 70 | Metrics/AbcSize: 71 | Enabled: false 72 | 73 | Metrics/BlockLength: 74 | Max: 25 75 | Exclude: 76 | - config/**/* 77 | - test/**/* 78 | 79 | Metrics/ClassLength: 80 | Max: 200 81 | Exclude: 82 | - test/**/* 83 | 84 | Metrics/CyclomaticComplexity: 85 | Enabled: false 86 | 87 | Metrics/MethodLength: 88 | Max: 25 89 | Exclude: 90 | - db/migrate/* 91 | - test/**/* 92 | 93 | Metrics/ModuleLength: 94 | Max: 200 95 | Exclude: 96 | - config/**/* 97 | 98 | Metrics/ParameterLists: 99 | Max: 6 100 | 101 | Metrics/PerceivedComplexity: 102 | Max: 8 103 | 104 | Minitest/AssertPredicate: 105 | Enabled: false 106 | 107 | Minitest/AssertTruthy: 108 | Enabled: false 109 | 110 | Minitest/EmptyLineBeforeAssertionMethods: 111 | Enabled: false 112 | 113 | Minitest/MultipleAssertions: 114 | Enabled: false 115 | 116 | Minitest/RefuteFalse: 117 | Enabled: false 118 | 119 | Minitest/RefutePredicate: 120 | Enabled: false 121 | 122 | Naming/FileName: 123 | Exclude: 124 | - .tomo/plugins/*.rb 125 | 126 | Naming/MemoizedInstanceVariableName: 127 | Enabled: false 128 | 129 | Naming/VariableNumber: 130 | Enabled: false 131 | 132 | Rake/Desc: 133 | Enabled: false 134 | 135 | Style/Alias: 136 | Enabled: false 137 | 138 | Style/AsciiComments: 139 | Enabled: false 140 | 141 | Style/ClassAndModuleChildren: 142 | Enabled: false 143 | 144 | Style/Documentation: 145 | Enabled: false 146 | 147 | Style/DoubleNegation: 148 | Enabled: false 149 | 150 | Style/EmptyMethod: 151 | EnforcedStyle: expanded 152 | 153 | Style/FetchEnvVar: 154 | Enabled: false 155 | 156 | Style/FormatStringToken: 157 | Enabled: false 158 | 159 | Style/FrozenStringLiteralComment: 160 | Enabled: false 161 | 162 | Style/GuardClause: 163 | Enabled: false 164 | 165 | Style/IfUnlessModifier: 166 | Enabled: false 167 | 168 | Style/ModuleFunction: 169 | Enabled: false 170 | 171 | Style/NumericPredicate: 172 | Enabled: false 173 | 174 | Style/PerlBackrefs: 175 | Enabled: false 176 | 177 | Style/RescueStandardError: 178 | EnforcedStyle: implicit 179 | 180 | Style/SingleLineMethods: 181 | AllowIfMethodIsEmpty: false 182 | 183 | Style/StringConcatenation: 184 | Enabled: false 185 | 186 | Style/StringLiterals: 187 | EnforcedStyle: double_quotes 188 | 189 | Style/StringLiteralsInInterpolation: 190 | EnforcedStyle: double_quotes 191 | 192 | Style/SymbolArray: 193 | Enabled: false 194 | 195 | Style/TrivialAccessors: 196 | AllowPredicates: true 197 | 198 | Style/YodaExpression: 199 | Enabled: false 200 | --------------------------------------------------------------------------------