├── .github ├── FUNDING.yml └── workflows │ └── ruby.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── rake └── setup ├── exe ├── minitest └── mt ├── lib └── minitest │ ├── utils.rb │ ├── utils │ ├── autoload.rb │ ├── capybara │ │ └── chrome_headless.rb │ ├── cli.rb │ ├── extension.rb │ ├── rails.rb │ ├── rails │ │ ├── capybara.rb │ │ └── locale.rb │ ├── railtie.rb │ ├── reporter.rb │ ├── setup │ │ ├── database_cleaner.rb │ │ ├── factory_bot.rb │ │ ├── factory_girl.rb │ │ └── webmock.rb │ ├── test_notifier_reporter.rb │ └── version.rb │ └── utils_plugin.rb ├── minitest-utils.gemspec ├── screenshots ├── detect-slow-tests.png ├── replay-command.png ├── screenshots.pxd └── slow-tests.png └── test ├── minitest ├── utils │ ├── cli_test.rb │ ├── let_test.rb │ └── reporter_test.rb └── utils_test.rb └── test_helper.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [fnando] 3 | custom: ["https://www.paypal.me/nandovieira/🍕"] 4 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ruby-tests 3 | 4 | on: 5 | pull_request_target: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | inputs: {} 11 | 12 | jobs: 13 | build: 14 | name: Tests with Ruby ${{ matrix.ruby }} and ${{ matrix.gemfile }} 15 | runs-on: "ubuntu-latest" 16 | if: | 17 | github.actor == 'dependabot[bot]' && github.event_name == 'pull_request_target' || 18 | github.actor != 'dependabot[bot]' 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby: ["3.4", "3.3"] 23 | gemfile: 24 | - Gemfile 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: actions/cache@v4 30 | with: 31 | path: vendor/bundle 32 | key: > 33 | ${{ runner.os }}-${{ matrix.ruby }}-gems-${{ 34 | hashFiles(matrix.gemfile) }}-${{hashFiles('zee.gemspec')}} 35 | 36 | - name: Set up Ruby 37 | uses: ruby/setup-ruby@v1 38 | with: 39 | ruby-version: ${{ matrix.ruby }} 40 | 41 | - name: Install gem dependencies 42 | env: 43 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 44 | run: | 45 | gem install bundler 46 | bundle config set with default:development:test 47 | bundle config path vendor/bundle 48 | bundle update --jobs 4 --retry 3 49 | 50 | - name: Run Tests 51 | env: 52 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 53 | run: | 54 | bundle exec rake 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .minitestfailures 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_gem: 3 | rubocop-fnando: .rubocop.yml 4 | 5 | AllCops: 6 | TargetRubyVersion: 3.3 7 | NewCops: enable 8 | Exclude: 9 | - vendor/**/* 10 | - gemfiles/**/* 11 | 12 | Style/GlobalVars: 13 | Exclude: 14 | - test/minitest/utils/let_test.rb 15 | 16 | Minitest/UselessAssertion: 17 | Enabled: false 18 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in minitest-utils.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nando Vieira 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minitest::Utils 2 | 3 | Some utilities for your Minitest day-to-day usage. 4 | 5 | Includes: 6 | 7 | - A better reporter (see screenshot below) 8 | - A [TestNotifier](http://github.com/fnando/test_notifier) reporter 9 | - Some Rails niceties (set up FactoryBot, WebMock and Capybara) 10 | - Add a `t` and `l` methods (i18n) 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'minitest-utils' 18 | ``` 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install minitest-utils 27 | 28 | ## Defining tests 29 | 30 | This gem adds the `Minitest::Test.test` method, so you can easy define your 31 | methods like the following: 32 | 33 | ```ruby 34 | class SampleTest < Minitest::Test 35 | test "useless test" do 36 | assert true 37 | end 38 | end 39 | ``` 40 | 41 | This is equivalent to defining a method named `test_useless_test`. You can also 42 | skip the block, which will define a 43 | [flunk](https://github.com/seattlerb/minitest/blob/77120c5b2511c4665610cda06c8058c801b28e7f/lib/minitest/assertions.rb#L477-L480) 44 | call. 45 | 46 | You can also define `setup` and `teardown` steps. 47 | 48 | ```ruby 49 | class SampleTest < Minitest::Test 50 | setup do 51 | DB.connect 52 | end 53 | 54 | teardown do 55 | DB.disconnect 56 | end 57 | 58 | test "useless test" do 59 | assert true 60 | end 61 | end 62 | ``` 63 | 64 | If you want to skip slow tests, you can use the `slow_test` method, which only 65 | runs the test when `MT_RUN_SLOW_TESTS` environment variable is set. 66 | 67 | ```ruby 68 | # Only run slow tests in CI. You can bypass it locally by using 69 | # something like `MT_RUN_SLOW_TESTS=1 rake`. 70 | ENV["MT_RUN_SLOW_TESTS"] ||= ENV["CI"] 71 | 72 | class SampleTest < Minitest::Test 73 | test "useless test" do 74 | slow_test 75 | sleep 1 76 | assert true 77 | end 78 | end 79 | ``` 80 | 81 | You can change the default threshold by setting `Minitest::Test.slow_threshold`. 82 | The default value is `0.1` (100ms). 83 | 84 | ```ruby 85 | Minitest::Test.slow_threshold = 0.1 86 | ``` 87 | 88 | This config can also be changed per class: 89 | 90 | ```ruby 91 | class SampleTest < Minitest::Test 92 | self.slow_threshold = 0.1 93 | 94 | test "useless test" do 95 | slow_test 96 | sleep 1 97 | assert true 98 | end 99 | end 100 | ``` 101 | 102 | Finally, you can also use `let`. 103 | 104 | ```ruby 105 | class SampleTest < Minitest::Test 106 | let(:token) { "secret" } 107 | 108 | test "set token" do 109 | assert_equal "secret", token 110 | end 111 | end 112 | ``` 113 | 114 | ## Running tests 115 | 116 | `minitest-utils` comes with a runner: `mt` or `minitest`. 117 | 118 | > [!WARNING] 119 | > 120 | > When using this test runner, you must change your test helper and replace 121 | > `require "minitest/autorun"` with 122 | > `require "minitest/autorun" unless ENV["MT_RUNNER"]`. This way you can use 123 | > both the runner and rake. 124 | 125 | You can run specific files by using `file:number`. 126 | 127 | ```console 128 | $ mt test/models/user_test.rb:42 129 | ``` 130 | 131 | You can also run files by the test name (caveat: you need to underscore the 132 | name): 133 | 134 | ```console 135 | $ mt test/models/user_test.rb --name /validations/ 136 | ``` 137 | 138 | You can also run specific directories: 139 | 140 | ```console 141 | $ mt test/models 142 | ``` 143 | 144 | To exclude tests by name, use --exclude: 145 | 146 | ```console 147 | $ mt test/models --exclude /validations/ 148 | ``` 149 | 150 | It supports `.minitestignore`, which only matches file names partially. Comments 151 | starting with `#` are ignored. 152 | 153 | ``` 154 | # Ignore all tests in test/fixtures 155 | test/fixtures 156 | ``` 157 | 158 | > [!NOTE] 159 | > 160 | > This command is also available as the long form `minitest`, for linux users. 161 | > Linux has a `mt` command for managing magnetic tapes. 162 | 163 | ## Screenshots 164 | 165 | ![](https://raw.githubusercontent.com/fnando/minitest-utils/main/screenshots/detect-slow-tests.png) 166 | ![](https://raw.githubusercontent.com/fnando/minitest-utils/main/screenshots/replay-command.png) 167 | ![](https://raw.githubusercontent.com/fnando/minitest-utils/main/screenshots/slow-tests.png) 168 | 169 | ## Rails extensions 170 | 171 | minitest-utils sets up some things for your Rails application. 172 | 173 | - [Capybara](https://github.com/jnicklas/capybara): includes `Capybara::DSL`, 174 | sets default driver before every test, resets session and creates a helper 175 | method for setting JavaScript driver. If you have 176 | [poltergeist](https://github.com/teampoltergeist/poltergeist) installed, it 177 | will be used as the default JavaScript driver. 178 | - [FactoryBot](https://github.com/thoughtbot/factory_bot): adds methods to 179 | `ActiveSupport::TestCase`. 180 | - [WebMock](https://github.com/bblimke/webmock): disables external requests 181 | (except for codeclimate) and tracks all requests on `WebMock.requests`. 182 | - locale routes: sets `Rails.application.routes.default_url_options[:locale]` 183 | with your current locale. 184 | - [DatabaseCleaner](https://github.com/DatabaseCleaner/database_cleaner): 185 | configure database before running each test. You can configure the strategy by 186 | settings `DatabaseCleaner.strategy = :truncation`, for instance. It defaults 187 | to `:deletion`. 188 | - Other: `t` and `l` alias to I18n. 189 | 190 | ```ruby 191 | class SignupTest < ActionDispatch::IntegrationTtest 192 | use_javascript! #=> enables JavaScript driver 193 | end 194 | ``` 195 | 196 | Also, if you're using routes like `:locale` scope, you can load this file to 197 | automatically set your route's `:locale` param. 198 | 199 | ```ruby 200 | require 'minitest/utils/rails/locale' 201 | ``` 202 | 203 | ## Development 204 | 205 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 206 | `bin/console` for an interactive prompt that will allow you to experiment. 207 | 208 | To install this gem onto your local machine, run `bundle exec rake install`. To 209 | release a new version, update the version number in `version.rb`, and then run 210 | `bundle exec rake release` to create a git tag for the version, push git commits 211 | and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 212 | 213 | ## Contributing 214 | 215 | 1. Fork it ( https://github.com/fnando/minitest-utils/fork ) 216 | 2. Create your feature branch (`git checkout -b my-new-feature`) 217 | 3. Commit your changes (`git commit -am 'Add some feature'`) 218 | 4. Push to the branch (`git push origin my-new-feature`) 219 | 5. Create a new Pull Request 220 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "rubocop/rake_task" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | t.verbose = false 11 | t.warning = false 12 | end 13 | 14 | RuboCop::RakeTask.new 15 | 16 | task default: %i[test rubocop] 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "minitest/utils" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start 16 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | require "rubygems" 16 | require "bundler/setup" 17 | 18 | load Gem.bin_path("rake", "rake") 19 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /exe/minitest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | ENV["MT_RUNNER"] = "true" 5 | require_relative "../lib/minitest/utils/cli" 6 | Minitest::Utils::CLI.loaded_via_bundle_exec = ENV.key?("BUNDLER_VERSION") 7 | Minitest::Utils::CLI.new(ARGV.dup).start 8 | -------------------------------------------------------------------------------- /exe/mt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | load File.join(__dir__, "minitest") 5 | -------------------------------------------------------------------------------- /lib/minitest/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | class << self 5 | attr_accessor :options 6 | end 7 | 8 | self.options = {} 9 | 10 | module Utils 11 | require "minitest" 12 | require "pathname" 13 | require_relative "utils/version" 14 | require_relative "utils/reporter" 15 | require_relative "utils/extension" 16 | require_relative "utils/test_notifier_reporter" 17 | 18 | COLOR = { 19 | red: 31, 20 | green: 32, 21 | yellow: 33, 22 | blue: 34, 23 | gray: 37 24 | }.freeze 25 | 26 | def self.color(string, color = :default) 27 | if color_enabled? 28 | color = COLOR.fetch(color, 0) 29 | "\e[#{color}m#{string}\e[0m" 30 | else 31 | string 32 | end 33 | end 34 | 35 | def self.color_enabled? 36 | !ENV["NO_COLOR"] && !Minitest.options[:no_color] 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/minitest/utils/autoload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "minitest/utils" 4 | 5 | load_lib = lambda do |path, &block| 6 | require path 7 | block&.call 8 | true 9 | rescue LoadError 10 | false 11 | end 12 | 13 | load_lib.call "mocha/mini_test" unless load_lib.call "mocha/minitest" 14 | 15 | load_lib.call "capybara" 16 | 17 | load_lib.call "webmock" do 18 | require_relative "utils/setup/webmock" 19 | end 20 | 21 | load_lib.call "database_cleaner" do 22 | require_relative "utils/setup/database_cleaner" 23 | end 24 | 25 | load_lib.call "factory_girl" do 26 | require_relative "utils/setup/factory_girl" 27 | end 28 | 29 | load_lib.call "factory_bot" do 30 | require_relative "utils/setup/factory_bot" 31 | end 32 | 33 | require_relative "utils/railtie" if defined?(Rails) 34 | -------------------------------------------------------------------------------- /lib/minitest/utils/capybara/chrome_headless.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "selenium-webdriver" 4 | gem "webdrivers" 5 | 6 | Capybara.register_driver :chrome do |app| 7 | options = Selenium::WebDriver::Chrome::Options.new( 8 | args: %w[headless disable-gpu] 9 | ) 10 | 11 | Capybara::Selenium::Driver.new app, browser: :chrome, options: options 12 | end 13 | 14 | Capybara.javascript_driver = :chrome 15 | -------------------------------------------------------------------------------- /lib/minitest/utils/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem "minitest" 4 | require "minitest" 5 | require_relative "../utils" 6 | require "optparse" 7 | require "io/console" 8 | 9 | module Minitest 10 | module Utils 11 | class CLI 12 | MATCHER = 13 | /^(\s+(?:(?-[a-zA-Z]+), )?(?[^ ]+) +)(?.*?)$/ # rubocop:disable Lint/MixedRegexpCaptureTypes 14 | 15 | class << self 16 | attr_accessor :loaded_via_bundle_exec 17 | end 18 | 19 | def initialize(args) 20 | @args = args 21 | end 22 | 23 | def indent(text) 24 | text.gsub(/^/, " ") 25 | end 26 | 27 | def start 28 | OptionParser.new do |parser| 29 | parser.banner = "" 30 | 31 | parser.on("-n", "--name=NAME", 32 | "Run tests that match this name") do |v| 33 | options[:name] = v 34 | end 35 | 36 | parser.on("-s", "--seed=SEED", "Sets fixed seed.") do |v| 37 | options[:seed] = v 38 | end 39 | 40 | parser.on("--slow", "Run slow tests.") do |v| 41 | options[:slow] = v 42 | end 43 | 44 | parser.on("--hide-slow", "Hide list of slow tests.") do |v| 45 | options[:hide_slow] = v 46 | end 47 | 48 | parser.on("--slow-threshold=THRESHOLD", 49 | "Set the slow threshold (in seconds)") do |v| 50 | options[:slow_threshold] = v.to_f 51 | end 52 | 53 | parser.on("--no-color", "Disable colored output.") do 54 | options[:no_color] = true 55 | end 56 | 57 | parser.on("--watch", "Watch for changes, and re-run tests.") do 58 | options[:watch] = true 59 | end 60 | 61 | parser.on( 62 | "-e", 63 | "--exclude=PATTERN", 64 | "Exclude /regexp/ or string from run." 65 | ) do |v| 66 | options[:exclude] = v 67 | end 68 | 69 | parser.on_tail("-h", "--help", "Show this message") do 70 | matches = parser.to_a.map do |line| 71 | line.match(MATCHER).named_captures.transform_keys(&:to_sym) 72 | end 73 | print_help(matches) 74 | exit 75 | end 76 | end.parse!(@args) 77 | 78 | run 79 | end 80 | 81 | def test_dir 82 | File.join(Dir.pwd, "test") 83 | end 84 | 85 | def spec_dir 86 | File.join(Dir.pwd, "spec") 87 | end 88 | 89 | def lib_dir 90 | File.join(Dir.pwd, "lib") 91 | end 92 | 93 | def test_dir? 94 | File.directory?(test_dir) 95 | end 96 | 97 | def spec_dir? 98 | File.directory?(spec_dir) 99 | end 100 | 101 | def lib_dir? 102 | File.directory?(lib_dir) 103 | end 104 | 105 | def run 106 | $LOAD_PATH << lib_dir if lib_dir? 107 | $LOAD_PATH << test_dir if test_dir? 108 | $LOAD_PATH << spec_dir if spec_dir? 109 | 110 | puts "\nNo tests found." if files.empty? 111 | 112 | files.each {|file| require(file) } 113 | 114 | bundler = "bundle exec " if self.class.loaded_via_bundle_exec 115 | 116 | ENV["MT_TEST_COMMAND"] ||= 117 | "#{bundler}mt %{location}:%{line} #{color('# %{description}', :blue)}" 118 | 119 | ARGV.clear 120 | ARGV.push(*to_shell(minitest_options)) 121 | 122 | if options[:watch] 123 | gem "listen" 124 | require "listen" 125 | pid = nil 126 | 127 | listen = 128 | Listen.to(Dir.pwd, only: /(\.rb|Gemfile\.lock)$/) do |*changed, _| 129 | next if pid 130 | 131 | $stdout.clear_screen 132 | 133 | # Make a list of test files that have been changed. 134 | changed = changed.flatten.filter_map do |file| 135 | if file.end_with?("_test.rb") 136 | Pathname(file).relative_path_from(Dir.pwd).to_s 137 | end 138 | end 139 | 140 | options = minitest_options 141 | .slice(:slow, :hide_slow, :no_color, :slow_threshold) 142 | 143 | # Load the list of failures from the last run. 144 | failures = JSON.load_file(".minitestfailures") rescue [] # rubocop:disable Style/RescueModifier 145 | options[:name] = "/^#{failures.join('|')}$/" if failures.any? 146 | 147 | # If there are no failures, run the changed files. 148 | changed = [] if failures.any? 149 | 150 | pid = Process.spawn( 151 | $PROGRAM_NAME, 152 | *to_shell(options), 153 | *changed, 154 | chdir: Dir.pwd 155 | ) 156 | Process.wait(pid) 157 | pid = nil 158 | end 159 | end 160 | 161 | if options[:watch] 162 | pid = Process.spawn( 163 | $PROGRAM_NAME, 164 | *to_shell(minitest_options), 165 | chdir: Dir.pwd 166 | ) 167 | Process.wait(pid) 168 | pid = nil 169 | listen.start 170 | sleep 171 | else 172 | Minitest.autorun 173 | end 174 | rescue Interrupt 175 | Process.kill("INT", pid) if pid 176 | puts "Exiting..." 177 | end 178 | 179 | def minitest_args 180 | args = [] 181 | args += ["--seed", options[:seed]] 182 | args += ["--exclude", options[:exclude]] if options[:exclude] 183 | args += ["--slow", options[:slow]] if options[:slow] 184 | args += ["--name", "/#{only.join('|')}/"] unless only.empty? 185 | args += ["--hide-slow"] if options[:hide_slow] 186 | args += ["--no-color"] if options[:no_color] 187 | 188 | if options[:slow_threshold] 189 | threshold = options[:slow_threshold].to_s 190 | threshold = threshold.gsub(/\.0+$/, "").delete_suffix(".") 191 | args += ["--slow-threshold", threshold] 192 | end 193 | 194 | args.map(&:to_s) 195 | end 196 | 197 | def to_shell(args) 198 | args 199 | .transform_keys {|key| "--#{key.to_s.tr('_', '-')}" } 200 | .to_a 201 | .flatten 202 | .reject { _1&.is_a?(TrueClass) } 203 | .map(&:to_s) 204 | end 205 | 206 | def minitest_options 207 | args = {} 208 | args[:seed] = options[:seed] 209 | args[:exclude] = options[:exclude] if options[:exclude] 210 | args[:slow] = options[:slow] if options[:slow] 211 | args[:name] = "/#{only.join('|')}/" unless only.empty? 212 | args[:hide_slow] = options[:hide_slow] if options[:hide_slow] 213 | args[:no_color] = options[:no_color] if options[:no_color] 214 | 215 | if options[:slow_threshold] 216 | threshold = options[:slow_threshold].to_s 217 | threshold = threshold.gsub(/\.0+$/, "").delete_suffix(".") 218 | args[:slow_threshold] = threshold 219 | end 220 | 221 | args 222 | end 223 | 224 | def files 225 | @files ||= begin 226 | files = @args 227 | files += %w[test spec] if files.empty? 228 | files 229 | .flat_map { expand_entry(_1) } 230 | .reject { ignored_file?(_1) } 231 | end 232 | end 233 | 234 | def ignored_files 235 | @ignored_files ||= if File.file?(".minitestignore") 236 | File.read(".minitestignore") 237 | .lines 238 | .map(&:strip) 239 | .reject { _1.start_with?("#") } 240 | else 241 | [] 242 | end 243 | end 244 | 245 | def ignored_file?(file) 246 | ignored_files.any? { file.include?(_1) } 247 | end 248 | 249 | def only 250 | @only ||= [] 251 | end 252 | 253 | def expand_entry(entry) 254 | entry = extract_entry(entry) 255 | 256 | if File.directory?(entry) 257 | Dir[ 258 | File.join(entry, "**", "*_test.rb"), 259 | File.join(entry, "**", "*_spec.rb") 260 | ] 261 | else 262 | Dir[entry] 263 | end 264 | end 265 | 266 | def extract_entry(entry) 267 | entry = File.expand_path(entry) 268 | return entry unless entry.match?(/:\d+$/) 269 | 270 | entry, line = entry.split(":") 271 | line = line.to_i 272 | return entry unless File.file?(entry) 273 | 274 | content = File.read(entry) 275 | text = content.lines[line - 1].chomp.strip 276 | 277 | method_name = if text =~ /^\s*test\s+(['"])(.*?)\1\s+do\s*$/ 278 | Test.test_method_name(::Regexp.last_match(2)) 279 | elsif text =~ /^def\s+(test_.+)$/ 280 | ::Regexp.last_match(1) 281 | end 282 | 283 | if method_name 284 | class_names = 285 | content.scan(/^\s*class\s+([^<]+)/).flatten.map(&:strip) 286 | 287 | class_name = class_names.find do |name| 288 | name.end_with?("Test") 289 | end 290 | 291 | only << "#{class_name}##{method_name}" if class_name 292 | end 293 | 294 | entry 295 | end 296 | 297 | def options 298 | @options ||= {seed: new_seed} 299 | end 300 | 301 | def new_seed 302 | (ENV["SEED"] || srand).to_i % 0xFFFF 303 | end 304 | 305 | BANNER = <<~TEXT 306 | A better test runner for Minitest. 307 | 308 | You can run specific files by using `file:number`. 309 | 310 | $ mt test/models/user_test.rb:42 311 | 312 | You can also run files by the test name (caveat: you need to underscore the name): 313 | 314 | $ mt test/models/user_test.rb --name /validations/ 315 | 316 | You can also run specific directories: 317 | 318 | $ mt test/models 319 | 320 | To exclude tests by name, use --exclude: 321 | 322 | $ mt test/models --exclude /validations/ 323 | TEXT 324 | 325 | private def color(string, color = :default) 326 | return string if string.empty? 327 | 328 | if $stdout.tty? && !options[:no_color] && !ARGV.include?("--no-color") 329 | Utils.color(string, color) 330 | else 331 | string 332 | end 333 | end 334 | 335 | def print_help(matches) 336 | io = StringIO.new 337 | matches.sort_by! { _1["long"] } 338 | short_size = matches.map { _1[:short].to_s.size }.max 339 | long_size = matches.map { _1[:long].to_s.size }.max 340 | 341 | io << indent(color("Usage:", :green)) 342 | io << indent(color("mt [OPTIONS] [FILES|DIR]...", :blue)) 343 | io << "\n\n" 344 | io << indent("A better test runner for Minitest.") 345 | io << "\n\n" 346 | file_line = color("file:number", :yellow) 347 | io << indent("You can run specific files by using #{file_line}.") 348 | io << "\n\n" 349 | io << indent(color("$ mt test/models/user_test.rb:42", :yellow)) 350 | io << "\n\n" 351 | io << indent("You can run files by the test name.") 352 | io << "\n" 353 | io << indent("Caveat: you need to underscore the name.") 354 | io << "\n\n" 355 | io << indent( 356 | color("$ mt test/models/user_test.rb --name /validations/", :yellow) 357 | ) 358 | io << "\n\n" 359 | io << indent("You can also run specific directories:") 360 | io << "\n\n" 361 | io << indent(color("To exclude tests by name, use --exclude:", :yellow)) 362 | io << "\n\n" 363 | io << indent("To ignore files, you can use a `.minitestignore`.") 364 | io << "\n" 365 | io << indent("Each line can be a partial file/dir name.") 366 | io << "\n" 367 | io << indent("Lines startin with # are ignored.") 368 | io << "\n\n" 369 | io << indent(color("# This is a comment", :yellow)) 370 | io << "\n" 371 | io << indent(color("test/fixtures", :yellow)) 372 | io << "\n\n" 373 | io << indent(color("Options:", :green)) 374 | io << "\n" 375 | 376 | matches.each do |match| 377 | match => { short:, long:, description: } 378 | 379 | io << " " 380 | io << (" " * (short_size - short.to_s.size)) 381 | io << color(short, :blue) if short 382 | io << " " unless short 383 | io << ", " if short 384 | io << color(long.to_s, :blue) 385 | io << (" " * (long_size - long.to_s.size + 4)) 386 | io << description 387 | io << "\n" 388 | end 389 | 390 | puts io.tap(&:rewind).read 391 | end 392 | end 393 | end 394 | end 395 | -------------------------------------------------------------------------------- /lib/minitest/utils/extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Utils 5 | module Assertions 6 | def assert(test, message = nil) 7 | message ||= "expected: truthy value\ngot: #{mu_pp(test)}" 8 | super 9 | end 10 | 11 | def refute(test, message = nil) 12 | message ||= "expected: falsy value\ngot: #{mu_pp(test)}" 13 | super 14 | end 15 | end 16 | end 17 | 18 | class Test 19 | include ::Minitest::Utils::Assertions 20 | 21 | def self.tests 22 | @tests ||= {} 23 | end 24 | 25 | def slow_test 26 | return if ENV["MT_RUN_SLOW_TESTS"] || Minitest.options[:slow] 27 | 28 | skip "slow test" 29 | end 30 | 31 | def self.test_method_name(description) 32 | method_name = description.downcase 33 | .gsub(/[^a-z0-9]+/, "_") 34 | .gsub(/^_+/, "") 35 | .gsub(/_+$/, "") 36 | .squeeze("_") 37 | :"test_#{method_name}" 38 | end 39 | 40 | def self.test(description, &block) 41 | source_location = caller_locations(1..1).first 42 | source_location = [ 43 | Pathname(source_location.path).relative_path_from(Pathname(Dir.pwd)), 44 | source_location.lineno 45 | ] 46 | 47 | klass = name 48 | test_name = test_method_name(description) 49 | defined = method_defined?(test_name) 50 | id = "#{klass}##{test_name}" 51 | 52 | Test.tests[id] = { 53 | id:, 54 | description:, 55 | name: test_name, 56 | source_location:, 57 | time: nil, 58 | slow_threshold: 59 | } 60 | 61 | testable = proc do 62 | err = nil 63 | t0 = Minitest.clock_time 64 | instance_eval(&block) 65 | rescue StandardError => error 66 | err = error 67 | ensure 68 | Test.tests["#{klass}##{test_name}"][:time] = Minitest.clock_time - t0 69 | raise err if err 70 | end 71 | 72 | raise "#{test_name} is already defined in #{self}" if defined 73 | 74 | if block 75 | define_method(test_name, &testable) 76 | else 77 | define_method(test_name) do 78 | flunk "No implementation provided for #{name}" 79 | end 80 | end 81 | end 82 | 83 | def self.setup(&block) 84 | mod = Module.new 85 | mod.module_eval do 86 | define_method :setup do 87 | super() 88 | instance_eval(&block) 89 | end 90 | end 91 | 92 | include mod 93 | end 94 | 95 | def self.teardown(&block) 96 | mod = Module.new 97 | mod.module_eval do 98 | define_method :teardown do 99 | super() 100 | instance_eval(&block) 101 | end 102 | end 103 | 104 | include mod 105 | end 106 | 107 | def self.let(name, &block) 108 | target = begin 109 | instance_method(name) 110 | rescue StandardError 111 | nil 112 | end 113 | 114 | message = "Cannot define let(:#{name});" 115 | 116 | if name.to_s.start_with?("test") 117 | raise ArgumentError, "#{message} method cannot begin with 'test'." 118 | end 119 | 120 | if target 121 | raise ArgumentError, 122 | "#{message} method already defined by #{target.owner}." 123 | end 124 | 125 | define_method(name) do 126 | @_memoized ||= {} 127 | @_memoized.fetch(name) {|k| @_memoized[k] = instance_eval(&block) } 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /lib/minitest/utils/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Utils 5 | module UrlHelpers 6 | include Rails.application.routes.url_helpers 7 | 8 | def default_url_options 9 | config = Rails.configuration 10 | 11 | Rails.application.routes.default_url_options || 12 | config.action_controller.default_url_options || 13 | config.action_mailer.default_url_options || 14 | {} 15 | end 16 | end 17 | end 18 | end 19 | 20 | module ActiveSupport 21 | class TestCase 22 | extend Minitest::Spec::DSL if defined?(Minitest::Spec::DSL) 23 | 24 | require "minitest/utils/rails/capybara" if defined?(Capybara) 25 | 26 | def t(*, **) 27 | I18n.t(*, **) 28 | end 29 | 30 | def l(*, **) 31 | I18n.l(*, **) 32 | end 33 | end 34 | end 35 | 36 | module ActionController 37 | class TestCase 38 | include Minitest::Utils::UrlHelpers 39 | end 40 | end 41 | 42 | module ActionMailer 43 | class TestCase 44 | include Minitest::Utils::UrlHelpers 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/minitest/utils/rails/capybara.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/rails" 4 | 5 | module ActionDispatch 6 | class IntegrationTest 7 | include Capybara::DSL 8 | 9 | setup do 10 | Capybara.reset_sessions! 11 | Capybara.use_default_driver 12 | end 13 | 14 | def self.use_javascript!(raise_on_javascript_errors: true) 15 | setup do 16 | Capybara.current_driver = Capybara.javascript_driver 17 | end 18 | 19 | teardown do 20 | next if failures.any? 21 | next unless raise_on_javascript_errors 22 | 23 | errors = page.driver.browser.manage.logs.get(:browser).select do |log| 24 | log.level == "SEVERE" 25 | end 26 | 27 | next unless errors.any? 28 | 29 | messages = errors 30 | .map(&:message) 31 | .map! {|message| message[/(\d+:\d+ .*?)$/, 1] } 32 | .join("\n") 33 | 34 | raise "JavaScript Errors\n#{messages}" 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/minitest/utils/rails/locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Utils 5 | module Locale 6 | class << self 7 | attr_accessor :setup, :teardown 8 | end 9 | 10 | self.setup = proc do 11 | Rails.application.routes.default_url_options[:locale] = I18n.locale 12 | end 13 | 14 | self.teardown = proc do 15 | Rails.application.routes.default_url_options.delete(:locale) 16 | end 17 | 18 | def self.included(base) 19 | base.setup do 20 | instance_eval(&Minitest::Utils::Locale.setup) 21 | end 22 | 23 | base.teardown do 24 | instance_eval(&Minitest::Utils::Locale.teardown) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | 31 | module ActionDispatch 32 | class IntegrationTest 33 | include Minitest::Utils::UrlHelpers 34 | include Minitest::Utils::Locale 35 | end 36 | end 37 | 38 | module ActionController 39 | class TestCase 40 | include Minitest::Utils::UrlHelpers 41 | include Minitest::Utils::Locale 42 | end 43 | end 44 | 45 | module ActionMailer 46 | class TestCase 47 | include Minitest::Utils::UrlHelpers 48 | include Minitest::Utils::Locale 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/minitest/utils/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/railtie" 4 | 5 | module Minitest 6 | module Utils 7 | class Railtie < ::Rails::Railtie 8 | config.after_initialize do 9 | require "minitest/utils/rails" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/minitest/utils/reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module Minitest 6 | class Test 7 | class << self 8 | attr_accessor :slow_threshold 9 | end 10 | 11 | def self.inherited(child) 12 | child.slow_threshold = slow_threshold 13 | super 14 | end 15 | end 16 | 17 | module Utils 18 | class Reporter < Minitest::StatisticsReporter 19 | def self.filters 20 | @filters ||= [%r{/vendor/}] 21 | end 22 | 23 | COLOR_FOR_RESULT_CODE = { 24 | "." => :green, 25 | "E" => :red, 26 | "F" => :red, 27 | "S" => :yellow 28 | }.freeze 29 | 30 | def initialize(*) 31 | super 32 | @tty = io.respond_to?(:tty?) && io.tty? 33 | end 34 | 35 | def start 36 | super 37 | io.puts "Run options: #{options[:args]}\n" 38 | end 39 | 40 | def record(result) 41 | super 42 | print_result_code(result.result_code) 43 | end 44 | 45 | def color(string, color = :default) 46 | if @tty 47 | Utils.color(string, color) 48 | else 49 | string 50 | end 51 | end 52 | 53 | def report 54 | super 55 | io.sync = true if io.respond_to?(:sync=) 56 | 57 | failing_results = results.reject(&:passed?).reject(&:skipped?) 58 | skipped_results = results.select(&:skipped?) 59 | 60 | write_failures_json(failing_results) 61 | print_failing_results(failing_results) 62 | if failing_results.empty? 63 | print_skipped_results(skipped_results, failing_results.size) 64 | end 65 | 66 | color = :green 67 | color = :yellow if skipped_results.any? 68 | color = :red if failing_results.any? 69 | 70 | io.print "\n\n" 71 | io.puts statistics 72 | io.puts color(summary, color) 73 | 74 | if failing_results.any? 75 | io.puts "\nFailed Tests:\n" 76 | failing_results.each {|result| display_replay_command(result) } 77 | io.puts "\n\n" 78 | else 79 | print_slow_results 80 | end 81 | end 82 | 83 | def write_failures_json(results) 84 | tests = results.each_with_object([]) do |result, buffer| 85 | buffer << find_test_info(result)[:id] 86 | end 87 | 88 | File.write( 89 | File.join(Dir.pwd, ".minitestfailures"), 90 | JSON.dump(tests) 91 | ) 92 | end 93 | 94 | def slow_threshold_for(test_case) 95 | test_case[:slow_threshold] || Minitest.options[:slow_threshold] || 0.1 96 | end 97 | 98 | def slow_tests 99 | Test 100 | .tests 101 | .values 102 | .select { _1[:time] } 103 | .filter { _1[:time] > slow_threshold_for(_1) } 104 | .sort_by { _1[:time] } 105 | .reverse 106 | end 107 | 108 | def print_failing_results(results, initial_index = 1) 109 | results.each.with_index(initial_index) do |result, index| 110 | display_failing(result, index) 111 | end 112 | end 113 | 114 | def print_skipped_results(results, initial_index) 115 | results 116 | .each 117 | .with_index(initial_index + 1) do |result, index| 118 | display_skipped(result, index) 119 | end 120 | end 121 | 122 | def print_slow_results 123 | test_results = slow_tests.take(10) 124 | 125 | return if Minitest.options[:hide_slow] 126 | return unless test_results.any? 127 | 128 | io.puts "\nSlow Tests:\n" 129 | 130 | test_results.each_with_index do |info, index| 131 | location = info[:source_location].join(":") 132 | duration = format_duration(info[:time]) 133 | 134 | prefix = "#{index + 1}) " 135 | padding = " " * prefix.size 136 | 137 | io.puts color("#{prefix}#{info[:description]} (#{duration})", 138 | :red) 139 | io.puts color("#{padding}#{location}", :blue) 140 | io.puts 141 | end 142 | end 143 | 144 | def format_duration(duration_in_seconds) 145 | duration_ns = duration_in_seconds * 1_000_000_000 146 | 147 | number, unit = if duration_ns < 1000 148 | [duration_ns, "ns"] 149 | elsif duration_ns < 1_000_000 150 | [duration_ns / 1000, "μs"] 151 | elsif duration_ns < 1_000_000_000 152 | [duration_ns / 1_000_000, "ms"] 153 | else 154 | [duration_ns / 1_000_000_000, "s"] 155 | end 156 | 157 | number = 158 | format("%.2f", number).gsub(/0+$/, "").delete_suffix(".") 159 | 160 | "#{number}#{unit}" 161 | end 162 | 163 | private def statistics 164 | format( 165 | "Finished in %.6fs, %.4f runs/s, %.4f assertions/s.", 166 | total_time, 167 | count / total_time, 168 | assertions / total_time 169 | ) 170 | end 171 | 172 | private def summary # :nodoc: 173 | [ 174 | pluralize("run", count), 175 | pluralize("assertion", assertions), 176 | pluralize("failure", failures), 177 | pluralize("error", errors), 178 | pluralize("skip", skips) 179 | ].join(", ") 180 | end 181 | 182 | private def indent(text) 183 | text.gsub(/^/, " ") 184 | end 185 | 186 | private def display_failing(result, index) 187 | backtrace = backtrace(result.failure.backtrace) 188 | message = result.failure.message 189 | message = message.lines.tap(&:pop).join.chomp if result.error? 190 | 191 | test = find_test_info(result) 192 | 193 | output = ["\n\n"] 194 | output << color(format("%4d) %s", index, test[:description])) 195 | output << "\n" << color(indent(message), :red) 196 | output << "\n" << color(backtrace, :blue) 197 | io.print output.join 198 | end 199 | 200 | private def display_skipped(result, index) 201 | location = filter_backtrace( 202 | result 203 | .failure 204 | .backtrace_locations 205 | .map {|l| [l.path, l.lineno].join(":") } 206 | ).first 207 | 208 | location, line = location.to_s.split(":") 209 | location = Pathname(location).relative_path_from(Pathname.pwd) 210 | location = "#{location}:#{line}" 211 | 212 | test = find_test_info(result) 213 | output = ["\n\n"] 214 | output << color( 215 | format("%4d) %s [SKIPPED]", index, test[:description]), :yellow 216 | ) 217 | 218 | message = "Reason: #{result.failure.message}" 219 | output << "\n" << indent(color(message, :yellow)) 220 | output << "\n" << indent(color(location, :yellow)) 221 | 222 | io.print output.join 223 | end 224 | 225 | private def display_replay_command(result) 226 | test = find_test_info(result) 227 | return if test[:source_location].empty? 228 | 229 | command = build_test_command(test, result) 230 | 231 | output = ["\n"] 232 | output << color(command, :red) 233 | 234 | io.print output.join 235 | end 236 | 237 | private def find_test_info(result) 238 | Test.tests.fetch("#{result.klass}##{result.name}") 239 | end 240 | 241 | private def backtrace(backtrace) 242 | unless Minitest.options[:backtrace] 243 | backtrace = filter_backtrace(backtrace) 244 | end 245 | 246 | backtrace = backtrace.map {|line| location(line, true) } 247 | 248 | return if backtrace.empty? 249 | 250 | indent(backtrace.join("\n")).gsub(/^(\s+)/, "\\1# ") 251 | end 252 | 253 | private def location(location, include_line_number = false) # rubocop:disable Style/OptionalBooleanParameter 254 | matches = location.match(/^(<.*?>)/) 255 | 256 | return matches[1] if matches 257 | 258 | regex = include_line_number ? /^([^:]+:\d+)/ : /^([^:]+)/ 259 | path = location[regex, 1] 260 | 261 | return location unless path 262 | 263 | location = File.expand_path(path) 264 | 265 | return location unless location.start_with?(Dir.pwd) 266 | 267 | location.delete_prefix("#{Dir.pwd}/") 268 | end 269 | 270 | private def filter_backtrace(backtrace) 271 | Minitest.backtrace_filter 272 | .filter(backtrace) 273 | .reject {|line| Reporter.filters.any? { line.match?(_1) } } 274 | .select {|line| line.start_with?(Dir.pwd) } 275 | end 276 | 277 | private def print_result_code(result_code) 278 | result_code = color(result_code, 279 | COLOR_FOR_RESULT_CODE[result_code]) 280 | io.print result_code 281 | end 282 | 283 | private def pluralize(word, count) 284 | case count 285 | when 0 286 | "no #{word}s" 287 | when 1 288 | "1 #{word}" 289 | else 290 | "#{count} #{word}s" 291 | end 292 | end 293 | 294 | private def running_rails? 295 | defined?(Rails) && 296 | Rails.respond_to?(:version) && 297 | Rails.version >= "5.0.0" 298 | end 299 | 300 | def bundler 301 | "bundle exec " if ENV.key?("BUNDLE_BIN_PATH") 302 | end 303 | 304 | private def build_test_command(test, result) 305 | location, line = test[:source_location] 306 | 307 | if ENV["MT_TEST_COMMAND"] 308 | cmd = ENV["MT_TEST_COMMAND"] 309 | 310 | return format( 311 | cmd, 312 | location: location, 313 | line: line, 314 | description: test[:description], 315 | name: result.name 316 | ) 317 | end 318 | 319 | if running_rails? 320 | %[bin/rails test #{location}:#{line}] 321 | else 322 | %[#{bundler}rake TEST=#{location} TESTOPTS="--name=#{result.name}"] 323 | end 324 | end 325 | end 326 | end 327 | end 328 | -------------------------------------------------------------------------------- /lib/minitest/utils/setup/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | DatabaseCleaner[:active_record].strategy = :truncation 4 | 5 | module Minitest 6 | class Test 7 | setup do 8 | DatabaseCleaner.clean 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/minitest/utils/setup/factory_bot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | class Test 5 | include FactoryBot::Syntax::Methods 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/minitest/utils/setup/factory_girl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | class Test 5 | include FactoryGirl::Syntax::Methods 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/minitest/utils/setup/webmock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webmock/minitest" 4 | 5 | WebMock.disable_net_connect! 6 | 7 | def WebMock.requests 8 | @requests ||= [] 9 | end 10 | 11 | WebMock.after_request do |request, _response| 12 | WebMock.requests << request 13 | end 14 | 15 | module Minitest 16 | class Test 17 | setup do 18 | WebMock.requests.clear 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/minitest/utils/test_notifier_reporter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Utils 5 | class TestNotifierReporter < Minitest::StatisticsReporter 6 | def report 7 | super 8 | 9 | stats = TestNotifier::Stats.new(:minitest, 10 | count: count, 11 | assertions: assertions, 12 | failures: failures, 13 | errors: errors) 14 | 15 | TestNotifier.notify(status: stats.status, message: stats.message) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/minitest/utils/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Minitest 4 | module Utils 5 | VERSION = "0.6.4" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/minitest/utils_plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "utils/reporter" 4 | require_relative "utils/test_notifier_reporter" 5 | 6 | module Minitest 7 | def self.plugin_utils_options(opts, options) 8 | Minitest.options = options 9 | 10 | opts.on("--slow", "Run slow tests") do 11 | options[:slow] = true 12 | end 13 | 14 | opts.on("--backtrace", "Show full backtrace") do 15 | options[:backtrace] = true 16 | end 17 | 18 | opts.on("--no-color", "Disable colored output") do 19 | options[:no_color] = true 20 | end 21 | 22 | opts.on("--hide-slow", "Hide list of slow tests") do 23 | options[:hide_slow] = true 24 | end 25 | 26 | opts.on("--slow-threshold=THRESHOLD", 27 | "Set the slow threshold (in seconds)") do |v| 28 | options[:slow_threshold] = v.to_f 29 | end 30 | end 31 | 32 | def self.plugin_utils_init(options) 33 | reporters = Minitest.reporter.reporters 34 | reporters.clear 35 | reporters << Minitest::Utils::Reporter.new(options[:io], options) 36 | 37 | begin 38 | require "test_notifier" 39 | reporters << Minitest::Utils::TestNotifierReporter.new( 40 | options[:io], 41 | options 42 | ) 43 | rescue LoadError 44 | # noop 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /minitest-utils.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "./lib/minitest/utils/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "minitest-utils" 7 | spec.version = Minitest::Utils::VERSION 8 | spec.authors = ["Nando Vieira"] 9 | spec.email = ["fnando.vieira@gmail.com"] 10 | spec.summary = "Some utilities for your Minitest day-to-day usage." 11 | spec.description = spec.summary 12 | spec.homepage = "http://github.com/fnando/minitest-utils" 13 | spec.required_ruby_version = Gem::Requirement.new(">= 3.3.0") 14 | 15 | spec.files = `git ls-files -z` 16 | .split("\x0") 17 | .reject {|f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) {|f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency "listen" 23 | spec.add_dependency "minitest" 24 | spec.add_development_dependency "bundler" 25 | spec.add_development_dependency "rake" 26 | spec.add_development_dependency "rubocop" 27 | spec.add_development_dependency "rubocop-fnando" 28 | spec.add_development_dependency "simplecov" 29 | spec.add_development_dependency "test_notifier" 30 | spec.metadata["rubygems_mfa_required"] = "true" 31 | end 32 | -------------------------------------------------------------------------------- /screenshots/detect-slow-tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnando/minitest-utils/413e3dcce4a2f86089b6f2f690791361bf96bfde/screenshots/detect-slow-tests.png -------------------------------------------------------------------------------- /screenshots/replay-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnando/minitest-utils/413e3dcce4a2f86089b6f2f690791361bf96bfde/screenshots/replay-command.png -------------------------------------------------------------------------------- /screenshots/screenshots.pxd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnando/minitest-utils/413e3dcce4a2f86089b6f2f690791361bf96bfde/screenshots/screenshots.pxd -------------------------------------------------------------------------------- /screenshots/slow-tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fnando/minitest-utils/413e3dcce4a2f86089b6f2f690791361bf96bfde/screenshots/slow-tests.png -------------------------------------------------------------------------------- /test/minitest/utils/cli_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class CLITest < Test 6 | let(:bin) { "../exe/mt" } 7 | 8 | def create_file(path, content) 9 | FileUtils.mkdir_p(File.dirname(path)) 10 | File.write(path, content) 11 | end 12 | 13 | setup do 14 | create_file "tmp/test/test_helper.rb", <<~RUBY 15 | require_relative "../../lib/minitest/utils" 16 | RUBY 17 | end 18 | 19 | teardown do 20 | FileUtils.rm_rf("tmp") 21 | end 22 | 23 | test "runs all tests" do 24 | create_file "tmp/test/some_test.rb", <<~RUBY 25 | require "test_helper" 26 | 27 | class SomeTest < Minitest::Test 28 | test "it passes" do 29 | assert true 30 | end 31 | end 32 | RUBY 33 | 34 | create_file "tmp/test/another_test.rb", <<~RUBY 35 | require "test_helper" 36 | 37 | class AnotherTest < Minitest::Test 38 | test "it passes" do 39 | assert true 40 | end 41 | end 42 | RUBY 43 | 44 | out, _ = capture_subprocess_io do 45 | Dir.chdir("tmp") { system bin } 46 | end 47 | 48 | assert_match(/^\.\.\n/, out) 49 | assert_includes out, "\n2 runs, 2 assertions, no failures, no errors, " \ 50 | "no skips\n" 51 | end 52 | 53 | test "runs specified file" do 54 | create_file "tmp/test/some_test.rb", <<~RUBY 55 | require "test_helper" 56 | 57 | class SomeTest < Minitest::Test 58 | test "it passes" do 59 | assert true 60 | end 61 | end 62 | RUBY 63 | 64 | create_file "tmp/test/another_test.rb", <<~RUBY 65 | require "test_helper" 66 | 67 | class AnotherTest < Minitest::Test 68 | test "it passes" do 69 | assert true 70 | end 71 | end 72 | RUBY 73 | 74 | out, _ = capture_subprocess_io do 75 | Dir.chdir("tmp") { system bin, "test/some_test.rb" } 76 | end 77 | 78 | assert_match(/^\.\n/, out) 79 | assert_includes out, "\n1 run, 1 assertion, no failures, no errors, " \ 80 | "no skips\n" 81 | end 82 | 83 | test "runs specified test (using block)" do 84 | create_file "tmp/test/some_test.rb", <<~RUBY 85 | require "test_helper" 86 | 87 | class SomeTest < Minitest::Test 88 | test "passes" do 89 | assert true 90 | end 91 | 92 | test "and this one too" do 93 | assert true 94 | end 95 | end 96 | RUBY 97 | 98 | out, _ = capture_subprocess_io do 99 | Dir.chdir("tmp") { system bin, "test/some_test.rb:4" } 100 | end 101 | 102 | assert_match(/^\.\n/, out) 103 | assert_includes out, "\n1 run, 1 assertion, no failures, no errors, " \ 104 | "no skips\n" 105 | end 106 | 107 | test "runs specified test (using def)" do 108 | create_file "tmp/test/some_test.rb", <<~RUBY 109 | require "test_helper" 110 | 111 | class SomeTest < Minitest::Test 112 | test "it passes" do 113 | assert true 114 | end 115 | 116 | def test_and_this_passes_too 117 | assert true 118 | end 119 | end 120 | RUBY 121 | 122 | out, _ = capture_subprocess_io do 123 | Dir.chdir("tmp") { system bin, "test/some_test.rb:8" } 124 | end 125 | 126 | assert_match(/^\.\n/, out) 127 | assert_includes out, "\n1 run, 1 assertion, no failures, no errors, " \ 128 | "no skips\n" 129 | end 130 | 131 | test "sets a seed" do 132 | create_file "tmp/test/some_test.rb", <<~RUBY 133 | require "test_helper" 134 | 135 | class SomeTest < Minitest::Test 136 | test "it passes" do 137 | assert true 138 | end 139 | 140 | def test_and_this_passes_too 141 | assert true 142 | end 143 | end 144 | RUBY 145 | 146 | out, _ = capture_subprocess_io do 147 | Dir.chdir("tmp") { system bin, "--seed", "1234" } 148 | end 149 | 150 | assert_includes out, "Run options: --seed 1234" 151 | end 152 | 153 | test "includes slow tests" do 154 | create_file "tmp/test/some_test.rb", <<~RUBY 155 | require "test_helper" 156 | 157 | class SomeTest < Minitest::Test 158 | test "it passes" do 159 | slow_test 160 | assert true 161 | end 162 | 163 | def test_and_this_passes_too 164 | assert true 165 | end 166 | end 167 | RUBY 168 | 169 | out, _ = capture_subprocess_io do 170 | Dir.chdir("tmp") { system bin, "--seed", "1234", "--slow" } 171 | end 172 | 173 | assert_includes out, "Run options: --seed 1234 --slow\n" 174 | assert_match(/^..\n/, out) 175 | assert_includes out, "\n2 runs, 2 assertions, no failures, no errors, " \ 176 | "no skips\n" 177 | end 178 | 179 | test "shows list of slow tests" do 180 | create_file "tmp/test/some_test.rb", <<~RUBY 181 | require "test_helper" 182 | 183 | class SomeTest < Minitest::Test 184 | self.slow_threshold = 0.1 185 | 186 | test "so slow" do 187 | sleep 0.1 188 | assert true 189 | end 190 | 191 | def test_even_more_slow 192 | assert true 193 | end 194 | end 195 | RUBY 196 | 197 | out, _ = capture_subprocess_io do 198 | Dir.chdir("tmp") { system bin, "--seed", "1234" } 199 | end 200 | 201 | assert_includes out, "\nSlow Tests:\n" 202 | assert_match(/\n1\) so slow \(.+(μs|ms|s)\)\n/, out) 203 | refute_match(/\n2\) even more slow \(.+(μs|ms|s)\)\n/, out) 204 | end 205 | 206 | test "hides list of slow tests" do 207 | create_file "tmp/test/some_test.rb", <<~RUBY 208 | require "test_helper" 209 | 210 | class SomeTest < Minitest::Test 211 | self.slow_threshold = -1 212 | 213 | test "so slow" do 214 | assert true 215 | end 216 | 217 | def test_even_more_slow 218 | assert true 219 | end 220 | end 221 | RUBY 222 | 223 | out, _ = capture_subprocess_io do 224 | Dir.chdir("tmp") { system bin, "--seed", "1234", "--hide-slow" } 225 | end 226 | 227 | assert_includes out, "Run options: --seed 1234 --hide-slow\n" 228 | refute_includes out, "\nSlow Tests:\n" 229 | refute_match(/\n1\) so slow \(.+(μs|ms|s)\)\n/, out) 230 | refute_match(/\n2\) even more slow \(.+(μs|ms|s)\)\n/, out) 231 | end 232 | 233 | test "sets slow threshold" do 234 | create_file "tmp/test/some_test.rb", <<~RUBY 235 | require "test_helper" 236 | 237 | class SomeTest < Minitest::Test 238 | test "so slow" do 239 | assert true 240 | end 241 | 242 | def test_even_more_slow 243 | assert true 244 | end 245 | end 246 | RUBY 247 | 248 | out, _ = capture_subprocess_io do 249 | Dir.chdir("tmp") do 250 | system bin, "--seed", "1234", "--slow-threshold", "-1" 251 | end 252 | end 253 | 254 | assert_includes out, 255 | "Run options: --seed 1234 --slow-threshold -1\n" 256 | assert_includes out, "\nSlow Tests:\n" 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /test/minitest/utils/let_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class LetTest < Test 6 | i_suck_and_my_tests_are_order_dependent! 7 | 8 | $count = 0 9 | let(:count) { $count += 1 } 10 | 11 | test "defines memoized reader (first test)" do 12 | assert_equal 0, $count 13 | 5.times { assert_equal 1, count } 14 | end 15 | 16 | test "defines memoized reader (second test)" do 17 | assert_equal 1, $count 18 | 5.times { assert_equal 2, count } 19 | end 20 | 21 | test "cannot override existing method" do 22 | exception = assert_raises(ArgumentError) do 23 | self.class.let(:count) { true } 24 | end 25 | 26 | expected_message = 27 | "Cannot define let(:count); method already defined by LetTest." 28 | 29 | assert_equal expected_message, exception.message 30 | end 31 | 32 | test "cannot start with test" do 33 | exception = assert_raises(ArgumentError) do 34 | self.class.let(:test_number) { true } 35 | end 36 | 37 | expected_message = 38 | "Cannot define let(:test_number); method cannot begin with 'test'." 39 | 40 | assert_equal expected_message, exception.message 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/minitest/utils/reporter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ReporterTest < Test 6 | def build_test_case(&block) 7 | Class.new(Minitest::Test) do 8 | def self.name 9 | "Sample#{object_id}Test" 10 | end 11 | 12 | instance_eval(&block) 13 | end 14 | end 15 | 16 | def run_test_case(test_case) 17 | reporter = Minitest::Utils::Reporter.new(StringIO.new) 18 | reporter.start 19 | test_case.run(reporter) 20 | reporter.report 21 | 22 | Minitest::Runnable 23 | .runnables 24 | .delete_if {|klass| klass.name.to_s.start_with?("Sample") } 25 | Minitest::Test.tests.delete_if {|key| key.start_with?("Sample") } 26 | 27 | reporter.io.tap(&:rewind).read 28 | end 29 | 30 | test "displays slow test info" do 31 | lineno = __LINE__ + 4 32 | 33 | test_case = build_test_case do 34 | test "slow test" do 35 | slow_test 36 | end 37 | end 38 | 39 | out = run_test_case(test_case) 40 | 41 | exp = " 1) slow test [SKIPPED]\n " \ 42 | "Reason: slow test\n " \ 43 | "test/minitest/utils/reporter_test.rb:#{lineno}\n" 44 | 45 | assert_includes out, exp 46 | end 47 | 48 | test "displays failed test" do 49 | test_case = build_test_case do 50 | test "failed test" do 51 | assert false 52 | end 53 | end 54 | 55 | out = run_test_case(test_case) 56 | 57 | exp = " 1) failed test\n " \ 58 | "expected: truthy value\n " \ 59 | "got: false\n" 60 | 61 | assert_includes out, exp 62 | end 63 | 64 | test "displays replay command" do 65 | test_case = build_test_case do 66 | test "failed test" do 67 | assert false 68 | end 69 | end 70 | 71 | out = run_test_case(test_case) 72 | 73 | exp = "\nFailed Tests:\n\n" \ 74 | "bundle exec rake TEST=test/minitest/utils/reporter_test.rb " \ 75 | "TESTOPTS=\"--name=test_failed_test\"" 76 | 77 | assert_includes out, exp 78 | end 79 | 80 | test "displays replay command with custom command" do 81 | lineno = __LINE__ + 11 82 | 83 | test_case = build_test_case do 84 | setup do 85 | ENV["MT_TEST_COMMAND"] = 86 | "location: %{location}\n" \ 87 | "line: %{line}\n" \ 88 | "description: %{description}\n" \ 89 | "name: %{name}\n" \ 90 | end 91 | 92 | test "failed test" do 93 | assert false 94 | end 95 | end 96 | 97 | out = run_test_case(test_case) 98 | 99 | exp = "location: test/minitest/utils/reporter_test.rb\n" \ 100 | "line: #{lineno}\n" \ 101 | "description: failed test\n" \ 102 | "name: test_failed_test\n" 103 | 104 | assert_includes out, exp 105 | end 106 | 107 | test "skips slow tests when there are failing tests" do 108 | test_case = build_test_case do 109 | self.slow_threshold = -1 110 | 111 | test "it fails" do 112 | assert false 113 | end 114 | end 115 | 116 | out = run_test_case(test_case) 117 | 118 | refute_includes out, "Slow Tests:" 119 | end 120 | 121 | test "formats duration" do 122 | reporter = Minitest::Utils::Reporter.new 123 | 124 | assert_equal "1s", reporter.format_duration(1) 125 | assert_equal "10.5s", reporter.format_duration(10.5) 126 | 127 | assert_equal "100ms", reporter.format_duration(0.1) 128 | assert_equal "150ms", reporter.format_duration(0.15) 129 | 130 | assert_equal "100μs", reporter.format_duration(0.0001) 131 | assert_equal "150μs", reporter.format_duration(0.00015) 132 | 133 | assert_equal "100ns", reporter.format_duration(0.0000001) 134 | assert_equal "150ns", reporter.format_duration(0.00000015) 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/minitest/utils_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class MinitestUtilsTest < Test 6 | def capture_exception 7 | yield 8 | rescue Exception => error # rubocop:disable Lint/RescueException 9 | error 10 | end 11 | 12 | test "defines method name" do 13 | assert_includes( 14 | MinitestUtilsTest.instance_methods, :test_defines_method_name 15 | ) 16 | end 17 | 18 | test "improves assert message" do 19 | exception = capture_exception { assert nil } 20 | 21 | assert_equal "expected: truthy value\ngot: nil", exception.message 22 | end 23 | 24 | test "improves refute message" do 25 | exception = capture_exception { refute 1234 } 26 | 27 | assert_equal "expected: falsy value\ngot: 1234", exception.message 28 | end 29 | 30 | test "raises exception for duplicated method name" do 31 | assert_raises(RuntimeError) do 32 | Class.new(Minitest::Test) do 33 | def self.name 34 | "Sample#{object_id}Test" 35 | end 36 | 37 | test "some test" 38 | test "some test" 39 | end 40 | end 41 | end 42 | 43 | test "defines test with weird names" do 44 | test_case = Class.new(Minitest::Test) do 45 | def self.name 46 | "Sample#{object_id}Test" 47 | end 48 | 49 | test("with parens (nice)") { assert true } 50 | test("with brackets [nice]") { assert true } 51 | test("with multiple spaces") { assert true } 52 | test("with underscores __") { assert true } 53 | test("with UPPERCASE") { assert true } 54 | end 55 | 56 | assert_includes test_case.instance_methods, :test_with_parens_nice 57 | assert_includes test_case.instance_methods, :test_with_brackets_nice 58 | assert_includes test_case.instance_methods, :test_with_multiple_spaces 59 | assert_includes test_case.instance_methods, :test_with_underscores 60 | assert_includes test_case.instance_methods, :test_with_uppercase 61 | end 62 | 63 | test "flunks method without block" do 64 | test_case = Class.new(Minitest::Test) do 65 | def self.name 66 | "Sample#{object_id}Test" 67 | end 68 | 69 | test "flunk test" 70 | end 71 | 72 | assert_raises(Minitest::Assertion) do 73 | test_case.new("test").test_flunk_test 74 | end 75 | end 76 | 77 | test "skips slow test" do 78 | test_case = Class.new(Minitest::Test) do 79 | test "slow test" do 80 | slow_test 81 | end 82 | end 83 | 84 | error = assert_raises(Minitest::Assertion) do 85 | test_case.new("test").test_slow_test 86 | end 87 | 88 | assert_instance_of Minitest::Skip, error 89 | assert_equal "slow test", error.message 90 | end 91 | 92 | test "runs slow test" do 93 | ENV["MT_RUN_SLOW_TESTS"] = "true" 94 | ran = false 95 | 96 | test_case = Class.new(Minitest::Test) do 97 | def self.name 98 | "Sample#{object_id}Test" 99 | end 100 | 101 | test "slow test" do 102 | slow_test 103 | ran = true 104 | end 105 | end 106 | 107 | test_case.new("test").test_slow_test 108 | 109 | assert ran 110 | end 111 | 112 | test "defines setup steps" do 113 | setups = [] 114 | 115 | test_case = Class.new(Minitest::Test) do 116 | def self.name 117 | "Sample#{object_id}Test" 118 | end 119 | 120 | setup { setups << 1 } 121 | setup { setups << 2 } 122 | setup { setups << 3 } 123 | 124 | test("do something") { assert(true) } 125 | end 126 | 127 | test_case.new(Minitest::AbstractReporter).run 128 | 129 | assert_equal [1, 2, 3], setups 130 | end 131 | 132 | test "defines teardown steps" do 133 | teardowns = [] 134 | 135 | test_case = Class.new(Minitest::Test) do 136 | def self.name 137 | "Sample#{object_id}Test" 138 | end 139 | 140 | teardown { teardowns << 1 } 141 | teardown { teardowns << 2 } 142 | teardown { teardowns << 3 } 143 | 144 | test("do something") { assert(true) } 145 | end 146 | 147 | test_case.new(Minitest::AbstractReporter).run 148 | 149 | assert_equal [1, 2, 3], teardowns 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | SimpleCov.start do 5 | add_filter "_test.rb" 6 | end 7 | 8 | require "bundler/setup" 9 | require "minitest/utils" 10 | require "minitest/autorun" unless ENV["MT_RUNNER"] 11 | 12 | class Test < Minitest::Test 13 | setup { ENV.delete("MT_RUN_SLOW_TESTS") } 14 | setup { ENV.delete("MT_TEST_COMMAND") } 15 | end 16 | --------------------------------------------------------------------------------