├── test ├── fixtures │ ├── example_project │ │ ├── Gemfile │ │ ├── test │ │ │ ├── _test.rb │ │ │ ├── test_helper.rb │ │ │ ├── example_test.rb │ │ │ ├── failing_test.rb │ │ │ └── focused_test.rb │ │ └── lib │ │ │ ├── example │ │ │ └── version.rb │ │ │ └── example.rb │ ├── rails_project │ │ ├── app │ │ │ ├── models │ │ │ │ └── user.rb │ │ │ └── lib │ │ │ │ └── not_a_test.rb │ │ └── test │ │ │ ├── test_helper.rb │ │ │ ├── models │ │ │ ├── user_test.rb │ │ │ └── account_test.rb │ │ │ ├── helpers │ │ │ └── users_helper_test.rb │ │ │ └── system │ │ │ └── users_system_test.rb │ └── active_support_test.rb ├── support │ ├── rg.rb │ ├── fixtures_path.rb │ └── cli_result.rb ├── mighty_test_test.rb ├── test_helper.rb ├── mighty_test │ ├── test_parser_test.rb │ ├── option_parser_test.rb │ ├── console_test.rb │ ├── sharder_test.rb │ ├── file_system_test.rb │ ├── cli_test.rb │ └── watcher_test.rb └── integration │ └── mt_test.rb ├── .prettierignore ├── lib ├── mighty_test │ ├── version.rb │ ├── minitest_runner.rb │ ├── test_parser.rb │ ├── file_system.rb │ ├── sharder.rb │ ├── watcher │ │ └── event_queue.rb │ ├── console.rb │ ├── option_parser.rb │ ├── cli.rb │ └── watcher.rb └── mighty_test.rb ├── screenshot.png ├── exe └── mt ├── CHANGELOG.md ├── .gitignore ├── bin ├── setup └── console ├── Gemfile ├── .github ├── workflows │ ├── release-drafter.yml │ └── ci.yml ├── dependabot.yml └── release-drafter.yml ├── .kodiak.toml ├── .overcommit.yml ├── LICENSE.txt ├── mighty_test.gemspec ├── .rubocop.yml ├── Rakefile ├── CODE_OF_CONDUCT.md └── README.md /test/fixtures/example_project/Gemfile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /CODE_OF_CONDUCT.md 2 | -------------------------------------------------------------------------------- /test/fixtures/example_project/test/_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/app/models/user.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/app/lib/not_a_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/test/models/account_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/test/helpers/users_helper_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/rails_project/test/system/users_system_test.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/support/rg.rb: -------------------------------------------------------------------------------- 1 | # Enable color test output 2 | require "minitest/rg" 3 | -------------------------------------------------------------------------------- /lib/mighty_test/version.rb: -------------------------------------------------------------------------------- 1 | module MightyTest 2 | VERSION = "0.5.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattbrictson/mighty_test/main/screenshot.png -------------------------------------------------------------------------------- /exe/mt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "mighty_test" 4 | MightyTest::CLI.new.run 5 | -------------------------------------------------------------------------------- /test/fixtures/example_project/lib/example/version.rb: -------------------------------------------------------------------------------- 1 | module Example 2 | VERSION = "0.1.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Release notes for this project are kept here: https://github.com/mattbrictson/mighty_test/releases 2 | -------------------------------------------------------------------------------- /test/fixtures/example_project/lib/example.rb: -------------------------------------------------------------------------------- 1 | module Example 2 | autoload :VERSION, "example/version" 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /site/ 8 | /spec/reports/ 9 | /tmp/ 10 | /Gemfile.lock 11 | -------------------------------------------------------------------------------- /test/fixtures/example_project/test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "example" 3 | 4 | require "minitest/autorun" 5 | -------------------------------------------------------------------------------- /test/support/fixtures_path.rb: -------------------------------------------------------------------------------- 1 | module FixturesPath 2 | private 3 | 4 | def fixtures_path 5 | Pathname.new(File.expand_path("../fixtures", __dir__)) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/mighty_test_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MightyTestTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::MightyTest::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/example_project/test/example_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ExampleTest < Minitest::Test 4 | def test_that_it_has_a_version_number 5 | refute_nil ::Example::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "mighty_test" 3 | 4 | require "minitest/autorun" 5 | Dir[File.expand_path("support/**/*.rb", __dir__)].each { |rb| require(rb) } 6 | -------------------------------------------------------------------------------- /test/support/cli_result.rb: -------------------------------------------------------------------------------- 1 | CLIResult = Struct.new(:stdout, :stderr, :exitstatus) do 2 | def success? 3 | [0, true].include?(exitstatus) 4 | end 5 | 6 | def failure? 7 | !success? 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | which overcommit > /dev/null 2>&1 && overcommit --install 7 | bundle install 8 | 9 | # Do any other automated setup that you need to do here 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | 4 | gem "irb" 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 | -------------------------------------------------------------------------------- /test/fixtures/example_project/test/failing_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FailingTest < Minitest::Test 4 | def test_assertion_fails 5 | assert_equal "foo", "bar" 6 | end 7 | 8 | def test_assertion_succeeds 9 | assert true # rubocop:disable Minitest/UselessAssertion 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/active_support_test.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | class ActiveSupportTest < ActiveSupport::TestCase 4 | test "it's important" do # Trailing comment OK 5 | assert true # rubocop:disable Minitest/UselessAssertion 6 | end 7 | 8 | test 'it does something "interesting"' do 9 | assert true # rubocop:disable Minitest/UselessAssertion 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/example_project/test/focused_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FocusedTest < Minitest::Test 4 | focus def test_this_test_is_run # rubocop:disable Minitest/Focus 5 | assert true # rubocop:disable Minitest/UselessAssertion 6 | end 7 | 8 | def test_this_test_is_not_run 9 | refute true # rubocop:disable Minitest/UselessAssertion 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: read 11 | 12 | jobs: 13 | update_release_draft: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: release-drafter/release-drafter@v6 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "mighty_test" 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 | -------------------------------------------------------------------------------- /lib/mighty_test.rb: -------------------------------------------------------------------------------- 1 | module MightyTest 2 | autoload :VERSION, "mighty_test/version" 3 | autoload :CLI, "mighty_test/cli" 4 | autoload :Console, "mighty_test/console" 5 | autoload :FileSystem, "mighty_test/file_system" 6 | autoload :MinitestRunner, "mighty_test/minitest_runner" 7 | autoload :OptionParser, "mighty_test/option_parser" 8 | autoload :Sharder, "mighty_test/sharder" 9 | autoload :TestParser, "mighty_test/test_parser" 10 | autoload :Watcher, "mighty_test/watcher" 11 | end 12 | -------------------------------------------------------------------------------- /.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/mighty_test/minitest_runner.rb: -------------------------------------------------------------------------------- 1 | module MightyTest 2 | class MinitestRunner 3 | def print_help_and_exit! 4 | require "minitest" 5 | Minitest.run(["--help"]) 6 | exit 7 | end 8 | 9 | def run_inline_and_exit!(*test_files, args: []) 10 | $LOAD_PATH.unshift "test" 11 | ARGV.replace(Array(args)) 12 | 13 | require "minitest" 14 | require "minitest/focus" 15 | require "minitest/rg" 16 | 17 | test_files.flatten.each { |file| require File.expand_path(file.to_s) } 18 | 19 | require "minitest/autorun" 20 | exit 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | # Overcommit hooks run automatically on certain git operations, like "git commit". 2 | # For a complete list of options that you can use to customize hooks, see: 3 | # https://github.com/sds/overcommit 4 | 5 | gemfile: false 6 | verify_signatures: false 7 | 8 | PreCommit: 9 | BundleCheck: 10 | enabled: true 11 | 12 | FixMe: 13 | enabled: true 14 | keywords: ["FIXME"] 15 | exclude: 16 | - .overcommit.yml 17 | 18 | LocalPathsInGemfile: 19 | enabled: true 20 | 21 | RuboCop: 22 | enabled: true 23 | required_executable: bundle 24 | command: ["bundle", "exec", "rubocop"] 25 | on_warn: fail 26 | 27 | YamlSyntax: 28 | enabled: true 29 | 30 | PostCheckout: 31 | ALL: 32 | quiet: true 33 | -------------------------------------------------------------------------------- /.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: ["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/mighty_test/test_parser.rb: -------------------------------------------------------------------------------- 1 | module MightyTest 2 | class TestParser 3 | def initialize(test_path) 4 | @path = test_path.to_s 5 | end 6 | 7 | def test_name_at_line(number) 8 | method_name = nil 9 | lines = File.read(path).lines 10 | lines[2...number].reverse_each.find do |line| 11 | method_name = 12 | match_minitest_method_name(line) || 13 | match_active_support_test_string(line)&.then { "test_#{_1.gsub(/\s+/, '_')}" } 14 | end 15 | method_name 16 | end 17 | 18 | private 19 | 20 | attr_reader :path 21 | 22 | def match_minitest_method_name(line) 23 | line[/^\s*(?:focus\s+)?def\s+(test_\w+)/, 1] 24 | end 25 | 26 | def match_active_support_test_string(line) 27 | match = line.match(/^\s*test\s+(?:"(.+?)"|'(.+?)')\s*do\s*(?:#.*?)?$/) 28 | return unless match 29 | 30 | match.captures.compact.first 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Matt Brictson 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 | -------------------------------------------------------------------------------- /lib/mighty_test/file_system.rb: -------------------------------------------------------------------------------- 1 | require "open3" 2 | 3 | module MightyTest 4 | class FileSystem 5 | def listen(&) 6 | require "listen" 7 | Listen.to(*%w[app lib test].select { |p| Dir.exist?(p) }, relative: true, &).tap(&:start) 8 | end 9 | 10 | def find_matching_test_path(path) # rubocop:disable Metrics/CyclomaticComplexity 11 | return nil unless path && File.exist?(path) && !Dir.exist?(path) 12 | return path if path.match?(%r{^test/.*_test.rb$}) 13 | 14 | test_path = path[%r{^(?:app|lib)/(.+)\.[^.]+$}, 1]&.then { "test/#{_1}_test.rb" } 15 | test_path if test_path && File.exist?(test_path) 16 | end 17 | 18 | def find_test_paths(directory="test") 19 | glob = File.join(directory, "**/*_test.rb") 20 | Dir[glob] 21 | end 22 | 23 | def slow_test_path?(path) 24 | return false if path.nil? 25 | 26 | path.match?(%r{^test/(e2e|feature|features|integration|system)/}) 27 | end 28 | 29 | def find_new_and_changed_paths 30 | out, _err, status = Open3.capture3(*%w[git status --porcelain=1 -uall -z --no-renames -- test app lib]) 31 | return [] unless status.success? 32 | 33 | out 34 | .split("\x0") 35 | .filter_map { |line| line[/^.. (.+)/, 1] } 36 | .uniq 37 | rescue SystemCallError 38 | [] 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/mighty_test/test_parser_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class TestParserTest < Minitest::Test 5 | include FixturesPath 6 | 7 | def test_returns_nil_if_no_test_found 8 | name = test_name_at_line(fixtures_path.join("example_project/test/example_test.rb"), 3) 9 | assert_nil name 10 | end 11 | 12 | def test_finds_traditional_test_name_by_line_number 13 | name = test_name_at_line(fixtures_path.join("example_project/test/example_test.rb"), 5) 14 | assert_equal "test_that_it_has_a_version_number", name 15 | end 16 | 17 | def test_finds_traditional_test_name_by_line_number_even_with_focus 18 | name = test_name_at_line(fixtures_path.join("example_project/test/focused_test.rb"), 5) 19 | assert_equal "test_this_test_is_run", name 20 | end 21 | 22 | def test_finds_active_support_test_name_by_line_number 23 | name = test_name_at_line(fixtures_path.join("active_support_test.rb"), 5) 24 | assert_equal "test_it's_important", name 25 | end 26 | 27 | def test_finds_single_quoted_active_support_test_name_by_line_number 28 | name = test_name_at_line(fixtures_path.join("active_support_test.rb"), 9) 29 | assert_equal 'test_it_does_something_"interesting"', name 30 | end 31 | 32 | private 33 | 34 | def test_name_at_line(path, line_number) 35 | TestParser.new(path).test_name_at_line(line_number) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /mighty_test.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/mighty_test/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "mighty_test" 5 | spec.version = MightyTest::VERSION 6 | spec.authors = ["Matt Brictson"] 7 | spec.email = ["opensource@mattbrictson.com"] 8 | 9 | spec.summary = "A modern Minitest runner for TDD, with watch mode and more" 10 | spec.homepage = "https://github.com/mattbrictson/mighty_test" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.2" 13 | 14 | spec.metadata = { 15 | "bug_tracker_uri" => "https://github.com/mattbrictson/mighty_test/issues", 16 | "changelog_uri" => "https://github.com/mattbrictson/mighty_test/releases", 17 | "source_code_uri" => "https://github.com/mattbrictson/mighty_test", 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 | 28 | # Runtime dependencies 29 | spec.add_dependency "listen", "~> 3.5" 30 | spec.add_dependency "logger" 31 | spec.add_dependency "minitest", "~> 5.15" 32 | spec.add_dependency "minitest-fail-fast", "~> 0.1.0" 33 | spec.add_dependency "minitest-focus", "~> 1.4" 34 | spec.add_dependency "minitest-rg", "~> 5.3" 35 | end 36 | -------------------------------------------------------------------------------- /lib/mighty_test/sharder.rb: -------------------------------------------------------------------------------- 1 | module MightyTest 2 | class Sharder 3 | DEFAULT_SEED = 123_456_789 4 | 5 | def self.from_argv(value, env: ENV, file_system: FileSystem.new) 6 | index, total = value.to_s.match(%r{\A(\d+)/(\d+)\z})&.captures&.map(&:to_i) 7 | raise ArgumentError, "shard: value must be in the form INDEX/TOTAL (e.g. 2/8)" if total.nil? 8 | 9 | git_sha = env.values_at("GITHUB_SHA", "CIRCLE_SHA1").find { |sha| !sha.to_s.strip.empty? } 10 | seed = git_sha&.unpack1("l_") 11 | 12 | new(index:, total:, seed:, file_system:) 13 | end 14 | 15 | attr_reader :index, :total, :seed 16 | 17 | def initialize(index:, total:, seed: nil, file_system: FileSystem.new) 18 | raise ArgumentError, "shard: total shards must be a number greater than 0" unless total > 0 19 | 20 | valid_group = index > 0 && index <= total 21 | raise ArgumentError, "shard: shard index must be > 0 and <= #{total}" unless valid_group 22 | 23 | @index = index 24 | @total = total 25 | @seed = seed || DEFAULT_SEED 26 | @file_system = file_system 27 | end 28 | 29 | def shard(*test_paths) 30 | random = Random.new(seed) 31 | 32 | # Shuffle slow and normal paths separately so that slow ones get evenly distributed 33 | shuffled_paths = test_paths 34 | .flatten 35 | .partition { |path| !file_system.slow_test_path?(path) } 36 | .flat_map { |paths| paths.shuffle(random:) } 37 | 38 | slices = shuffled_paths.each_slice(total) 39 | slices.filter_map { |slice| slice[index - 1] } 40 | end 41 | 42 | private 43 | 44 | attr_reader :file_system 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/mighty_test/watcher/event_queue.rb: -------------------------------------------------------------------------------- 1 | require "io/console" 2 | 3 | module MightyTest 4 | class Watcher 5 | class EventQueue 6 | def initialize(console: Console.new, file_system: FileSystem.new) 7 | @console = console 8 | @file_system = file_system 9 | @file_system_queue = Thread::Queue.new 10 | end 11 | 12 | def pop 13 | console.with_raw_input do 14 | until stopped? 15 | if (key = console.read_keypress_nonblock) 16 | return [:keypress, key] 17 | end 18 | if (paths = pop_files_changed) 19 | return [:file_system_changed, paths] 20 | end 21 | end 22 | end 23 | end 24 | 25 | def start 26 | raise "Already started" unless stopped? 27 | 28 | @file_system_listener = file_system.listen do |modified, added, _removed| 29 | paths = [*modified, *added].uniq 30 | file_system_queue.push(paths) unless paths.empty? 31 | end 32 | true 33 | end 34 | 35 | def restart 36 | stop 37 | start 38 | end 39 | 40 | def stop 41 | file_system_listener&.stop 42 | @file_system_listener = nil 43 | end 44 | 45 | def stopped? 46 | !file_system_listener 47 | end 48 | 49 | private 50 | 51 | attr_reader :console, :file_system, :file_system_listener, :file_system_queue 52 | 53 | def pop_files_changed 54 | paths = file_system_queue.pop(timeout: 0.2) 55 | return if paths.nil? 56 | 57 | paths += file_system_queue.pop until file_system_queue.empty? 58 | paths.uniq 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/mighty_test/console.rb: -------------------------------------------------------------------------------- 1 | require "io/console" 2 | require "io/wait" 3 | 4 | module MightyTest 5 | class Console 6 | def initialize(stdin: $stdin, sound_player: "/usr/bin/afplay", sound_paths: SOUNDS) 7 | @stdin = stdin 8 | @sound_player = sound_player 9 | @sound_paths = sound_paths 10 | end 11 | 12 | def clear 13 | return false unless tty? 14 | 15 | $stdout.clear_screen 16 | true 17 | end 18 | 19 | def with_raw_input(&) 20 | return yield unless stdin.respond_to?(:raw) && tty? 21 | 22 | stdin.raw(intr: true, &) 23 | end 24 | 25 | def read_keypress_nonblock 26 | stdin.getc if stdin.wait_readable(0) 27 | end 28 | 29 | def play_sound(name, wait: false) 30 | return false unless tty? 31 | 32 | paths = sound_paths.fetch(name) { raise ArgumentError, "Unknown sound name #{name}" } 33 | path = paths.find { |p| File.exist?(p) } 34 | return false unless path && File.executable?(sound_player) 35 | 36 | thread = Thread.new { system(sound_player, path) } 37 | thread.join if wait 38 | true 39 | end 40 | 41 | private 42 | 43 | # rubocop:disable Layout/LineLength 44 | SOUNDS = { 45 | pass: %w[ 46 | /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Milestone-EncoreInfinitum.caf 47 | /System/Library/Sounds/Glass.aiff 48 | ], 49 | fail: %w[ 50 | /System/Library/PrivateFrameworks/ToneLibrary.framework/Versions/A/Resources/AlertTones/EncoreInfinitum/Rebound-EncoreInfinitum.caf 51 | /System/Library/Sounds/Bottle.aiff 52 | ] 53 | }.freeze 54 | private_constant :SOUNDS 55 | # rubocop:enable Layout/LineLength 56 | 57 | attr_reader :sound_player, :sound_paths, :stdin 58 | 59 | def tty? 60 | $stdout.respond_to?(:tty?) && $stdout.tty? 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/mighty_test/option_parser.rb: -------------------------------------------------------------------------------- 1 | require "shellwords" 2 | 3 | module MightyTest 4 | class OptionParser 5 | def parse(argv) 6 | argv, literal_args = split(argv, "--") 7 | options, extra_args = parse_options!(argv) 8 | extra_args += parse_minitest_flags!(argv) unless options[:help] 9 | 10 | [argv + literal_args, extra_args, options] 11 | end 12 | 13 | def to_s 14 | <<~USAGE 15 | Usage: mt [--all] 16 | mt [test file...] [test dir...] 17 | mt --watch 18 | USAGE 19 | end 20 | 21 | private 22 | 23 | def parse_options!(argv) 24 | options = {} 25 | extra_args = [] 26 | options[:all] = true if argv.delete("--all") 27 | options[:watch] = true if argv.delete("--watch") 28 | options[:version] = true if argv.delete("--version") 29 | options[:help] = true if argv.delete("--help") || argv.delete("-h") 30 | parse_shard(argv, options) 31 | extra_args << "-w" if argv.delete("-w") 32 | 33 | [options, extra_args] 34 | end 35 | 36 | def parse_minitest_flags!(argv) 37 | return [] if argv.grep(/\A-/).none? 38 | 39 | require "minitest" 40 | orig_argv = argv.dup 41 | 42 | Minitest.load_plugins unless argv.delete("--no-plugins") || ENV["MT_NO_PLUGINS"] 43 | minitest_options = Minitest.process_args(argv) 44 | 45 | minitest_args = Shellwords.split(minitest_options[:args] || "") 46 | remove_seed_flag(minitest_args) unless orig_argv.include?("--seed") 47 | 48 | minitest_args 49 | end 50 | 51 | def split(array, delim) 52 | delim_at = array.index(delim) 53 | return [array.dup, []] if delim_at.nil? 54 | 55 | [ 56 | array[0...delim_at], 57 | array[(delim_at + 1)..] 58 | ] 59 | end 60 | 61 | def parse_shard(argv, options) 62 | argv.delete_if { |arg| options[:shard] = Regexp.last_match(1) if arg =~ /\A--shard=(.*)/ } 63 | 64 | argv.each_with_index do |flag, i| 65 | value = argv[i + 1] 66 | next unless flag == "--shard" 67 | raise "missing shard value" if value.nil? || value.start_with?("-") 68 | 69 | options[:shard] = value 70 | argv.slice!(i, 2) 71 | break 72 | end 73 | end 74 | 75 | def remove_seed_flag(parsed_argv) 76 | index = parsed_argv.index("--seed") 77 | parsed_argv.slice!(index, 2) if index 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /.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: 3.2 12 | Exclude: 13 | - "tmp/**/*" 14 | - "vendor/**/*" 15 | 16 | Layout/FirstArrayElementIndentation: 17 | EnforcedStyle: consistent 18 | 19 | Layout/FirstArrayElementLineBreak: 20 | Enabled: true 21 | 22 | Layout/FirstHashElementLineBreak: 23 | Enabled: true 24 | 25 | Layout/FirstMethodArgumentLineBreak: 26 | Enabled: true 27 | 28 | Layout/HashAlignment: 29 | EnforcedColonStyle: 30 | - table 31 | - key 32 | EnforcedHashRocketStyle: 33 | - table 34 | - key 35 | 36 | Layout/MultilineArrayLineBreaks: 37 | Enabled: true 38 | 39 | Layout/MultilineHashKeyLineBreaks: 40 | Enabled: true 41 | 42 | Layout/MultilineMethodArgumentLineBreaks: 43 | Enabled: true 44 | 45 | Layout/MultilineMethodCallIndentation: 46 | EnforcedStyle: indented 47 | 48 | Layout/SpaceAroundEqualsInParameterDefault: 49 | EnforcedStyle: no_space 50 | 51 | Lint/EmptyFile: 52 | Exclude: 53 | - "test/fixtures/**/*" 54 | 55 | Metrics/AbcSize: 56 | Max: 20 57 | Exclude: 58 | - "test/**/*" 59 | 60 | Metrics/BlockLength: 61 | Exclude: 62 | - "*.gemspec" 63 | - "Rakefile" 64 | 65 | Metrics/ClassLength: 66 | Exclude: 67 | - "test/**/*" 68 | 69 | Metrics/MethodLength: 70 | Max: 18 71 | Exclude: 72 | - "test/**/*" 73 | 74 | Metrics/ParameterLists: 75 | Max: 6 76 | 77 | Minitest/EmptyLineBeforeAssertionMethods: 78 | Enabled: false 79 | 80 | Naming/MemoizedInstanceVariableName: 81 | Enabled: false 82 | 83 | Naming/PredicateMethod: 84 | Enabled: false 85 | 86 | Naming/VariableNumber: 87 | Enabled: false 88 | 89 | Rake/Desc: 90 | Enabled: false 91 | 92 | Style/BarePercentLiterals: 93 | EnforcedStyle: percent_q 94 | 95 | Style/ClassAndModuleChildren: 96 | Enabled: false 97 | 98 | Style/Documentation: 99 | Enabled: false 100 | 101 | Style/DoubleNegation: 102 | Enabled: false 103 | 104 | Style/EmptyMethod: 105 | Enabled: false 106 | 107 | Style/FrozenStringLiteralComment: 108 | Enabled: false 109 | 110 | Style/NumericPredicate: 111 | Enabled: false 112 | 113 | Style/StringLiterals: 114 | EnforcedStyle: double_quotes 115 | 116 | Style/TrivialAccessors: 117 | AllowPredicates: true 118 | -------------------------------------------------------------------------------- /test/mighty_test/option_parser_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class OptionParserTest < Minitest::Test 5 | def test_parses_known_flags_into_options_and_extra_args 6 | argv = %w[-h -w --version] 7 | _path_args, extra_args, options = OptionParser.new.parse(argv) 8 | 9 | assert_equal( 10 | { 11 | help: true, 12 | version: true 13 | }, 14 | options 15 | ) 16 | assert_equal(["-w"], extra_args) 17 | end 18 | 19 | def test_doesnt_return_seed_args_if_they_werent_specified 20 | argv = %w[-f] 21 | _path_args, extra_args, _options = OptionParser.new.parse(argv) 22 | 23 | assert_equal %w[-f], extra_args 24 | end 25 | 26 | def test_dash_v_is_not_considered_an_abbreviation_of_version 27 | argv = ["-v"] 28 | _path_args, extra_args, options = OptionParser.new.parse(argv) 29 | 30 | assert_empty(options) 31 | assert_includes(extra_args, "-v") 32 | end 33 | 34 | def test_shard_value_can_be_specified_with_equals_sign 35 | argv = %w[--shard=1/2] 36 | path_args, extra_args, options = OptionParser.new.parse(argv) 37 | 38 | assert_empty(path_args) 39 | assert_empty(extra_args) 40 | assert_equal("1/2", options[:shard]) 41 | end 42 | 43 | def test_shard_value_can_be_specified_without_equals_sign 44 | argv = %w[--shard 1/2] 45 | path_args, extra_args, options = OptionParser.new.parse(argv) 46 | 47 | assert_empty(path_args) 48 | assert_empty(extra_args) 49 | assert_equal("1/2", options[:shard]) 50 | end 51 | 52 | def test_places_minitest_seed_option_into_extra_args 53 | argv = %w[--seed 1234] 54 | path_args, extra_args, = OptionParser.new.parse(argv) 55 | 56 | assert_empty(path_args) 57 | assert_equal(%w[--seed 1234], extra_args) 58 | end 59 | 60 | def test_handles_mix_of_mt_and_minitest_flags_and_args 61 | argv = %w[path1 --seed 1234 -w path2 --shard 1/2 path3 -f --all -- path4 --great] 62 | path_args, extra_args, options = OptionParser.new.parse(argv) 63 | 64 | assert_equal(%w[path1 path2 path3 path4 --great], path_args) 65 | assert_equal(%w[-w --seed 1234 -f], extra_args) 66 | assert_equal({ all: true, shard: "1/2" }, options) 67 | end 68 | 69 | def test_exits_with_failing_status_on_unrecognized_flag 70 | argv = %w[--non-existent] 71 | exitstatus = nil 72 | 73 | stdout, = capture_io do 74 | OptionParser.new.parse(argv) 75 | rescue SystemExit => e 76 | exitstatus = e.status 77 | end 78 | 79 | assert_equal 1, exitstatus 80 | assert_includes(stdout, "invalid option: --non-existent") 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/mighty_test/console_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class ConsoleTest < Minitest::Test 5 | def test_clear_returns_false_if_not_tty 6 | result = nil 7 | capture_io { result = Console.new.clear } 8 | refute result 9 | end 10 | 11 | def test_clear_clears_the_screen_and_returns_true_and_if_tty 12 | result = nil 13 | stdout, = capture_io do 14 | $stdout.define_singleton_method(:tty?) { true } 15 | $stdout.define_singleton_method(:clear_screen) { print "clear!" } 16 | result = Console.new.clear 17 | end 18 | 19 | assert result 20 | assert_equal "clear!", stdout 21 | end 22 | 23 | def test_read_keypress_nonblock_returns_the_next_character_on_stdin 24 | stdin = StringIO.new("hi") 25 | stdin.define_singleton_method(:wait_readable) { |_timeout| true } 26 | console = Console.new(stdin:) 27 | 28 | assert_equal "h", console.read_keypress_nonblock 29 | end 30 | 31 | def test_read_keypress_nonblock_returns_nil_if_nothing_is_in_buffer 32 | stdin = StringIO.new 33 | stdin.define_singleton_method(:wait_readable) { |_timeout| false } 34 | console = Console.new(stdin:) 35 | 36 | assert_nil console.read_keypress_nonblock 37 | end 38 | 39 | def test_play_sound_returns_false_if_not_tty 40 | result = nil 41 | capture_io { result = Console.new.play_sound(:pass) } 42 | refute result 43 | end 44 | 45 | def test_play_sound_returns_false_if_player_is_not_executable 46 | result = nil 47 | capture_io do 48 | $stdout.define_singleton_method(:tty?) { true } 49 | console = Console.new(sound_player: "/path/to/nothing") 50 | result = console.play_sound(:pass) 51 | end 52 | refute result 53 | end 54 | 55 | def test_play_sound_returns_false_if_sound_files_are_missing 56 | result = nil 57 | capture_io do 58 | $stdout.define_singleton_method(:tty?) { true } 59 | console = Console.new(sound_player: "/bin/echo", sound_paths: { pass: ["/path/to/nothing"] }) 60 | result = console.play_sound(:pass) 61 | end 62 | refute result 63 | end 64 | 65 | def test_play_sound_raises_argument_error_if_invalid_sound_name_is_specified 66 | capture_io do 67 | $stdout.define_singleton_method(:tty?) { true } 68 | assert_raises(ArgumentError) { Console.new.play_sound(:whatever) } 69 | end 70 | end 71 | 72 | def test_play_sound_calls_sound_player_with_matching_sound_path 73 | result = nil 74 | stdout, = capture_subprocess_io do 75 | $stdout.define_singleton_method(:tty?) { true } 76 | console = Console.new(sound_player: "/bin/echo", sound_paths: { pass: [__FILE__] }) 77 | result = console.play_sound(:pass, wait: true) 78 | end 79 | assert result 80 | assert_equal __FILE__, stdout.chomp 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/mighty_test/cli.rb: -------------------------------------------------------------------------------- 1 | module MightyTest 2 | class CLI 3 | def initialize(file_system: FileSystem.new, env: ENV, option_parser: OptionParser.new, runner: MinitestRunner.new) 4 | @file_system = file_system 5 | @env = env.to_h 6 | @option_parser = option_parser 7 | @runner = runner 8 | end 9 | 10 | def run(argv: ARGV) 11 | @path_args, @extra_args, @options = option_parser.parse(argv) 12 | 13 | if options[:help] 14 | print_help 15 | elsif options[:version] 16 | puts VERSION 17 | elsif options[:watch] 18 | watch 19 | elsif path_args.grep(/.:\d+$/).any? 20 | run_test_by_line_number 21 | else 22 | run_tests_by_path 23 | end 24 | rescue Exception => e # rubocop:disable Lint/RescueException 25 | handle_exception(e) 26 | end 27 | 28 | private 29 | 30 | attr_reader :file_system, :env, :path_args, :extra_args, :options, :option_parser, :runner 31 | 32 | def print_help 33 | # Minitest already prints the `-h, --help` option, so omit mighty_test's 34 | puts option_parser.to_s.sub(/^\s*-h.*?\n/, "") 35 | puts 36 | runner.print_help_and_exit! 37 | end 38 | 39 | def watch 40 | Watcher.new(extra_args:).run 41 | end 42 | 43 | def run_test_by_line_number 44 | path, line = path_args.first.match(/^(.+):(\d+)$/).captures 45 | test_name = TestParser.new(path).test_name_at_line(line.to_i) 46 | 47 | if test_name 48 | run_tests_and_exit!(path, flags: ["-n", "/^#{Regexp.quote(test_name)}$/"]) 49 | else 50 | run_tests_and_exit! 51 | end 52 | end 53 | 54 | def run_tests_by_path 55 | test_paths = find_test_paths 56 | test_paths = excluding_slow_paths(test_paths) unless path_args.any? || ci? || options[:all] 57 | test_paths = Sharder.from_argv(options[:shard], env:, file_system:).shard(test_paths) if options[:shard] 58 | 59 | run_tests_and_exit!(*test_paths) 60 | end 61 | 62 | def excluding_slow_paths(test_paths) 63 | test_paths.reject { |path| file_system.slow_test_path?(path) } 64 | end 65 | 66 | def ci? 67 | !env["CI"].to_s.strip.empty? 68 | end 69 | 70 | def find_test_paths 71 | return file_system.find_test_paths if path_args.empty? 72 | 73 | path_args.flat_map do |path| 74 | if Dir.exist?(path) 75 | file_system.find_test_paths(path) 76 | elsif File.exist?(path) 77 | [path] 78 | else 79 | raise ArgumentError, "#{path} does not exist" 80 | end 81 | end 82 | end 83 | 84 | def run_tests_and_exit!(*test_paths, flags: []) 85 | $VERBOSE = true if extra_args.delete("-w") 86 | runner.run_inline_and_exit!(*test_paths, args: extra_args + flags) 87 | end 88 | 89 | def handle_exception(e) # rubocop:disable Naming/MethodParameterName 90 | case e 91 | when SignalException 92 | exit(128 + e.signo) 93 | when Errno::EPIPE 94 | # pass 95 | else 96 | raise e 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /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"] - FileList["test/fixtures/**/*"] 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/mighty_test/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("mighty_test.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 mighty_test.gemspec include the following files 37 | that are being ignored by git. Did you forget to add them to the repo? If 38 | 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 bump:year] 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 "mighty_test.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 | end 62 | 63 | task :year do 64 | replace_in_file "LICENSE.txt", /\(c\) (\d+)/ => Date.today.year.to_s 65 | end 66 | end 67 | 68 | require "date" 69 | require "open-uri" 70 | require "yaml" 71 | 72 | def replace_in_file(path, replacements) 73 | contents = File.read(path) 74 | orig_contents = contents.dup 75 | replacements.each do |regexp, text| 76 | raise "Can't find #{regexp} in #{path}" unless regexp.match?(contents) 77 | 78 | contents.gsub!(regexp) do |match| 79 | match[regexp, 1] = text 80 | match 81 | end 82 | end 83 | File.write(path, contents) if contents != orig_contents 84 | end 85 | 86 | module RubyVersions 87 | class << self 88 | def lowest 89 | all.first 90 | end 91 | 92 | def all 93 | patches = versions.values_at(:stable, :security_maintenance).compact.flatten 94 | sorted_minor_versions = patches.map { |p| p[/\d+\.\d+/] }.sort_by(&:to_f) 95 | [*sorted_minor_versions, "head"] 96 | end 97 | 98 | private 99 | 100 | def versions 101 | @_versions ||= begin 102 | yaml = URI.open("https://raw.githubusercontent.com/ruby/www.ruby-lang.org/HEAD/_data/downloads.yml") 103 | YAML.safe_load(yaml, symbolize_names: true) 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/mighty_test/watcher.rb: -------------------------------------------------------------------------------- 1 | require_relative "watcher/event_queue" 2 | 3 | module MightyTest 4 | class Watcher 5 | WATCHING_FOR_CHANGES = 'Watching for changes to source and test files. Press "h" for help or "q" to quit.'.freeze 6 | 7 | def initialize(console: Console.new, extra_args: [], event_queue: nil, file_system: nil, system_proc: nil) 8 | @console = console 9 | @extra_args = extra_args 10 | @file_system = file_system || FileSystem.new 11 | @system_proc = system_proc || method(:system) 12 | @event_queue = event_queue || EventQueue.new(console: @console, file_system: @file_system) 13 | end 14 | 15 | def run 16 | event_queue.start 17 | puts WATCHING_FOR_CHANGES 18 | 19 | loop do 20 | case event_queue.pop 21 | in [:file_system_changed, [_, *] => paths] then run_matching_test_files(paths) 22 | in [:keypress, "\r" | "\n"] then run_all_tests 23 | in [:keypress, "a"] then run_all_tests(flags: ["--all"]) 24 | in [:keypress, "d"] then run_matching_test_files_from_git_diff 25 | in [:keypress, "h"] then show_help 26 | in [:keypress, "q"] then break 27 | else 28 | nil 29 | end 30 | end 31 | ensure 32 | event_queue.stop 33 | puts "\nExiting." 34 | end 35 | 36 | private 37 | 38 | attr_reader :console, :extra_args, :file_system, :event_queue, :system_proc 39 | 40 | def show_help 41 | console.clear 42 | puts <<~MENU 43 | `mt --watch` is watching file system activity and will automatically run 44 | test files when they are added or modified. If you modify a source file, 45 | mt will find and run the corresponding tests. 46 | 47 | You can also trigger test runs with the following interactive commands. 48 | 49 | > Press Enter to run all tests. 50 | > Press "a" to run all tests, including slow tests. 51 | > Press "d" to run tests for files diffed or added since the last git commit. 52 | > Press "h" to show this help menu. 53 | > Press "q" to quit. 54 | 55 | MENU 56 | end 57 | 58 | def run_all_tests(flags: []) 59 | console.clear 60 | puts flags.any? ? "Running tests with #{flags.join(' ')}..." : "Running tests..." 61 | puts 62 | mt(flags:) 63 | end 64 | 65 | def run_matching_test_files(paths) 66 | test_paths = paths.flat_map { |path| file_system.find_matching_test_path(path) }.compact.uniq 67 | return false if test_paths.empty? 68 | 69 | console.clear 70 | puts test_paths.join("\n") 71 | puts 72 | mt(*test_paths) 73 | true 74 | end 75 | 76 | def run_matching_test_files_from_git_diff 77 | return if run_matching_test_files(file_system.find_new_and_changed_paths) 78 | 79 | console.clear 80 | puts "No affected test files detected since the last git commit." 81 | puts WATCHING_FOR_CHANGES 82 | end 83 | 84 | def mt(*test_paths, flags: []) 85 | command = ["mt", *extra_args, *flags] 86 | command.append("--", *test_paths.flatten) if test_paths.any? 87 | 88 | success = system_proc.call(*command) 89 | 90 | console.play_sound(success ? :pass : :fail) 91 | puts "\n#{WATCHING_FOR_CHANGES}" 92 | $stdout.flush 93 | rescue Interrupt 94 | # Pressing ctrl-c kills the fs_event background process, so we have to manually restart it. 95 | event_queue.restart 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/mighty_test/sharder_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class SharderTest < Minitest::Test 5 | def test_it_parses_the_shard_value 6 | sharder = Sharder.from_argv("2/7") 7 | 8 | assert_equal(2, sharder.index) 9 | assert_equal(7, sharder.total) 10 | end 11 | 12 | def test_it_raises_an_exception_on_an_invalid_format 13 | error = assert_raises(ArgumentError) do 14 | Sharder.from_argv("a/9") 15 | end 16 | 17 | assert_includes(error.message, "value must be in the form INDEX/TOTAL") 18 | end 19 | 20 | def test_it_raises_an_exception_on_an_invalid_index_value 21 | error = assert_raises(ArgumentError) do 22 | Sharder.from_argv("9/5") 23 | end 24 | 25 | assert_includes(error.message, "index must be > 0 and <= 5") 26 | end 27 | 28 | def test_it_raises_an_exception_on_an_invalid_total_value 29 | error = assert_raises(ArgumentError) do 30 | Sharder.from_argv("1/0") 31 | end 32 | 33 | assert_includes(error.message, "total shards must be a number greater than 0") 34 | end 35 | 36 | def test_it_has_a_default_hardcoded_seed 37 | sharder = Sharder.from_argv("1/2", env: {}) 38 | assert_equal(123_456_789, sharder.seed) 39 | end 40 | 41 | def test_it_derives_a_seed_value_from_the_github_actions_env_var 42 | sharder = Sharder.from_argv("1/2", env: { "GITHUB_SHA" => "b94d6d86a2281d690eafd7bb3282c7032999e85f" }) 43 | assert_equal(3_906_982_861_516_061_026, sharder.seed) 44 | end 45 | 46 | def test_it_derives_a_seed_value_from_the_circle_ci_env_var 47 | sharder = Sharder.from_argv("1/2", env: { "CIRCLE_SHA1" => "189733eff795bd1ea7c586a5234a717f82e58b64" }) 48 | assert_equal(7_378_359_859_579_271_217, sharder.seed) 49 | end 50 | 51 | def test_for_a_given_seed_it_generates_a_stable_shuffled_result 52 | sharder = Sharder.new(index: 1, total: 2, seed: 678) 53 | result = sharder.shard(%w[a b c d e f]) 54 | 55 | assert_equal(%w[f e c], result) 56 | end 57 | 58 | def test_it_divides_items_into_roughly_equally_sized_shards 59 | all = %w[a b c d e f g h i j k l m n o p q r] 60 | shards = (1..4).map do |index| 61 | Sharder.new(index:, total: 4).shard(all) 62 | end 63 | 64 | shards.each do |shard| 65 | assert_includes [4, 5], shard.length 66 | end 67 | 68 | assert_equal all, shards.flatten.sort 69 | end 70 | 71 | def test_it_evenly_distributes_slow_paths_across_shards 72 | all = %w[ 73 | test/system/login_test.rb 74 | test/system/admin_test.rb 75 | test/models/post_test.rb 76 | test/system/editor_test.rb 77 | test/models/user_test.rb 78 | test/system/email_test.rb 79 | test/models/comment_test.rb 80 | test/system/rss_test.rb 81 | test/models/category_test.rb 82 | test/system/moderation_test.rb 83 | ] 84 | shards = (1..3).map do |index| 85 | Sharder.new(index:, total: 3).shard(all) 86 | end 87 | 88 | assert_equal( 89 | [ 90 | %w[ 91 | test/models/user_test.rb 92 | test/models/post_test.rb 93 | test/system/login_test.rb 94 | test/system/admin_test.rb 95 | ], 96 | %w[ 97 | test/models/comment_test.rb 98 | test/system/rss_test.rb 99 | test/system/moderation_test.rb 100 | ], 101 | %w[ 102 | test/models/category_test.rb 103 | test/system/email_test.rb 104 | test/system/editor_test.rb 105 | ] 106 | ], 107 | shards 108 | ) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/integration/mt_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class MtTest < Minitest::Test 5 | include FixturesPath 6 | 7 | def test_mt_can_run_and_print_version 8 | result = bundle_exec_mt(argv: ["--version"]) 9 | 10 | assert_equal(VERSION, result.stdout.chomp) 11 | end 12 | 13 | def test_mt_runs_a_successful_test 14 | project_dir = fixtures_path.join("example_project") 15 | result = bundle_exec_mt(argv: ["test/example_test.rb"], chdir: project_dir) 16 | 17 | assert_match(/Run options:.* --seed \d+/, result.stdout) 18 | assert_match(/Finished/, result.stdout) 19 | assert_match(/\d runs, \d assertions, 0 failures, 0 errors/, result.stdout) 20 | end 21 | 22 | def test_mt_runs_a_failing_test_and_exits_with_non_zero_status 23 | project_dir = fixtures_path.join("example_project") 24 | result = bundle_exec_mt(argv: ["test/failing_test.rb"], chdir: project_dir, raise_on_failure: false) 25 | 26 | assert_predicate(result, :failure?) 27 | assert_match(/\d runs, \d assertions, 1 failures, 0 errors/, result.stdout) 28 | end 29 | 30 | def test_mt_supports_test_focus 31 | project_dir = fixtures_path.join("example_project") 32 | result = bundle_exec_mt(argv: ["test/focused_test.rb"], chdir: project_dir) 33 | 34 | assert_match(/\b1 runs, 1 assertions, 0 failures, 0 errors/, result.stdout) 35 | end 36 | 37 | def test_mt_passes_fail_fast_flag_to_minitest 38 | project_dir = fixtures_path.join("example_project") 39 | result = bundle_exec_mt(argv: ["--fail-fast", "test/example_test.rb"], chdir: project_dir) 40 | 41 | assert_match(/Run options:.* --fail-fast/, result.stdout) 42 | end 43 | 44 | def test_mt_runs_a_single_test_by_line_number_with_verbose_output 45 | project_dir = fixtures_path.join("example_project") 46 | result = bundle_exec_mt(argv: ["--verbose", "test/failing_test.rb:9"], chdir: project_dir) 47 | 48 | assert_includes(result.stdout, "FailingTest#test_assertion_succeeds") 49 | assert_match(/\b1 assertions/, result.stdout) 50 | end 51 | 52 | def test_mt_runs_no_tests_if_line_number_doesnt_match 53 | project_dir = fixtures_path.join("example_project") 54 | result = bundle_exec_mt(argv: ["--verbose", "test/failing_test.rb:2"], chdir: project_dir) 55 | 56 | assert_match(/Run options:.* --verbose/, result.stdout) 57 | refute_match(/FailingTest/, result.stdout) 58 | end 59 | 60 | def test_mt_runs_watch_mode_that_executes_tests_when_files_change # rubocop:disable Minitest/MultipleAssertions 61 | project_dir = fixtures_path.join("example_project") 62 | stdout, stderr = capture_subprocess_io do 63 | # Start mt --watch in the background 64 | pid = spawn(*%w[bundle exec mt --watch --verbose], chdir: project_dir) 65 | 66 | # mt needs time to launch and start its file system listener 67 | sleep 1 68 | 69 | # Touch a file and wait for mt --watch to detect the change and run the corresponding test 70 | FileUtils.touch project_dir.join("lib/example.rb") 71 | sleep 1 72 | 73 | # OK, we're done here. Tell mt --watch to exit. 74 | Process.kill(:TERM, pid) 75 | end 76 | 77 | # Ignore warnings printed to stderr for a known issue with the listen gem 78 | stderr.sub!(/^.*logger was loaded from the standard library.*\n/i, "") 79 | stderr.sub!(/^.*add logger to your Gemfile or gemspec.*\n/i, "") 80 | assert_empty(stderr) 81 | 82 | assert_includes(stdout, "Watching for changes to source and test files.") 83 | assert_match(/ExampleTest/, stdout) 84 | assert_match(/\d runs, \d assertions, 0 failures, 0 errors/, stdout) 85 | end 86 | 87 | private 88 | 89 | def bundle_exec_mt(argv:, env: { "CI" => nil }, chdir: nil, raise_on_failure: true) 90 | stdout, stderr = capture_subprocess_io do 91 | system(env, *%w[bundle exec mt] + argv, { chdir: }.compact) 92 | end 93 | 94 | exitstatus = Process.last_status.exitstatus 95 | result = CLIResult.new(stdout, stderr, exitstatus) 96 | raise "mt exited with status: #{exitstatus} and output: #{stdout + stderr}" if raise_on_failure && result.failure? 97 | 98 | result 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at opensource@mattbrictson.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /test/mighty_test/file_system_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "open3" 3 | 4 | module MightyTest 5 | class FileSystemTest < Minitest::Test 6 | include FixturesPath 7 | 8 | def test_find_matching_test_path_returns_nil_for_nil_path 9 | assert_nil find_matching_test_path(nil) 10 | end 11 | 12 | def test_find_matching_test_path_returns_nil_for_non_existent_path 13 | assert_nil find_matching_test_path("path/to/nowhere.rb") 14 | end 15 | 16 | def test_find_matching_test_path_returns_nil_for_directory_path 17 | assert_nil find_matching_test_path("lib/example", in: fixtures_path.join("example_project")) 18 | end 19 | 20 | def test_find_matching_test_path_returns_nil_for_path_with_no_corresponding_test 21 | assert_nil find_matching_test_path("lib/example/version.rb", in: fixtures_path.join("example_project")) 22 | end 23 | 24 | def test_find_matching_test_path_returns_nil_for_non_lib_path 25 | assert_nil find_matching_test_path("Gemfile", in: fixtures_path.join("example_project")) 26 | end 27 | 28 | def test_find_matching_test_path_returns_nil_for_a_test_support_file 29 | assert_nil find_matching_test_path("test/test_helper.rb", in: fixtures_path.join("example_project")) 30 | end 31 | 32 | def test_find_matching_test_path_returns_argument_if_it_is_already_a_test 33 | test_path = find_matching_test_path("test/example_test.rb", in: fixtures_path.join("example_project")) 34 | assert_equal("test/example_test.rb", test_path) 35 | end 36 | 37 | def test_find_matching_test_path_returns_matching_test_given_an_implementation_path_in_a_gem_project 38 | test_path = find_matching_test_path("lib/example.rb", in: fixtures_path.join("example_project")) 39 | assert_equal("test/example_test.rb", test_path) 40 | end 41 | 42 | def test_find_matching_test_path_returns_matching_test_given_a_model_path_in_a_rails_project 43 | test_path = find_matching_test_path("app/models/user.rb", in: fixtures_path.join("rails_project")) 44 | assert_equal("test/models/user_test.rb", test_path) 45 | end 46 | 47 | def test_find_test_paths_looks_in_test_directory_by_default 48 | test_paths = find_test_paths(in: fixtures_path.join("rails_project")) 49 | 50 | assert_equal( 51 | %w[ 52 | test/helpers/users_helper_test.rb 53 | test/models/account_test.rb 54 | test/models/user_test.rb 55 | test/system/users_system_test.rb 56 | ], 57 | test_paths.sort 58 | ) 59 | end 60 | 61 | def test_find_test_paths_returns_empty_array_if_given_non_existent_path 62 | test_paths = find_test_paths("path/to/nowhere", in: fixtures_path.join("rails_project")) 63 | 64 | assert_empty(test_paths) 65 | end 66 | 67 | def test_find_test_paths_returns_test_files_in_specific_directory 68 | test_paths = find_test_paths("test/models", in: fixtures_path.join("rails_project")) 69 | 70 | assert_equal( 71 | %w[ 72 | test/models/account_test.rb 73 | test/models/user_test.rb 74 | ], 75 | test_paths.sort 76 | ) 77 | end 78 | 79 | def test_paths_in_certain_directories_are_considered_slow 80 | %w[e2e feature features integration system].each do |dir| 81 | assert FileSystem.new.slow_test_path?("test/#{dir}/some_test.rb") 82 | assert FileSystem.new.slow_test_path?("test/#{dir}/nested/path/some_test.rb") 83 | end 84 | end 85 | 86 | def test_paths_in_normal_test_directories_are_not_considered_slow 87 | refute FileSystem.new.slow_test_path?("test/test_helper.rb") 88 | refute FileSystem.new.slow_test_path?("test/models/user_test.rb") 89 | refute FileSystem.new.slow_test_path?("test/fixtures/users.yml") 90 | end 91 | 92 | def test_paths_in_implementation_directories_are_not_considered_slow 93 | refute FileSystem.new.slow_test_path?("Gemfile") 94 | refute FileSystem.new.slow_test_path?("lib/integration/api.rb") 95 | refute FileSystem.new.slow_test_path?("lib/models/user.rb") 96 | end 97 | 98 | def test_find_new_and_changed_paths_returns_empty_array_if_git_exits_with_error 99 | status = Minitest::Mock.new 100 | status.expect(:success?, false) 101 | 102 | paths = Open3.stub(:capture3, ["", "oh no!", status]) do 103 | FileSystem.new.find_new_and_changed_paths 104 | end 105 | 106 | assert_empty paths 107 | end 108 | 109 | def test_find_new_and_changed_paths_returns_empty_array_if_system_call_fails 110 | paths = Open3.stub(:capture3, ->(*) { raise SystemCallError, "oh no!" }) do 111 | FileSystem.new.find_new_and_changed_paths 112 | end 113 | 114 | assert_empty paths 115 | end 116 | 117 | def test_find_new_and_changed_paths_returns_array_based_on_git_output 118 | git_output = [ 119 | "M lib/mighty_test/cli.rb", 120 | " M lib/mighty_test/file_system.rb", 121 | "M lib/mighty_test/sharder.rb", 122 | " M test/mighty_test/file_system_test.rb", 123 | "?? test/mighty_test/sharder_test.rb" 124 | ].join("\x0") 125 | 126 | status = Minitest::Mock.new 127 | status.expect(:success?, true) 128 | 129 | paths = Open3.stub(:capture3, [git_output, "", status]) do 130 | FileSystem.new.find_new_and_changed_paths 131 | end 132 | 133 | assert_equal( 134 | %w[ 135 | lib/mighty_test/cli.rb 136 | lib/mighty_test/file_system.rb 137 | lib/mighty_test/sharder.rb 138 | test/mighty_test/file_system_test.rb 139 | test/mighty_test/sharder_test.rb 140 | ], 141 | paths 142 | ) 143 | end 144 | 145 | private 146 | 147 | def find_matching_test_path(path, in: ".") 148 | Dir.chdir(binding.local_variable_get(:in)) do 149 | FileSystem.new.find_matching_test_path(path) 150 | end 151 | end 152 | 153 | def find_test_paths(*path, in: ".") 154 | Dir.chdir(binding.local_variable_get(:in)) do 155 | FileSystem.new.find_test_paths(*path) 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /test/mighty_test/cli_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class CLITest < Minitest::Test 5 | include FixturesPath 6 | 7 | def test_help_flag_prints_usage_and_minitest_options 8 | result = cli_run(argv: ["--help"]) 9 | 10 | assert_includes(result.stdout, "Usage: mt") 11 | assert_match(/minitest .*options/, result.stdout) 12 | end 13 | 14 | def test_help_flag_takes_precedence_over_other_flags 15 | result = cli_run(argv: %w[--version --help]) 16 | 17 | assert_includes(result.stdout, "Usage: mt") 18 | end 19 | 20 | def test_help_flag_doesnt_describe_help_twice 21 | result = cli_run(argv: ["--help"]) 22 | 23 | assert_equal(1, result.stdout.lines.grep(/^\s*-h\b/).length) 24 | end 25 | 26 | def test_version_flag_prints_version 27 | result = cli_run(argv: ["--version"]) 28 | 29 | assert_equal(VERSION, result.stdout.chomp) 30 | end 31 | 32 | def test_with_no_args_runs_all_tests_in_the_test_directory_excluding_slow_ones 33 | with_fake_minitest_runner do |runner, executed_tests| 34 | cli_run(argv: [], chdir: fixtures_path.join("rails_project"), runner:) 35 | 36 | assert_equal( 37 | %w[ 38 | test/helpers/users_helper_test.rb 39 | test/models/account_test.rb 40 | test/models/user_test.rb 41 | ], 42 | executed_tests.sort 43 | ) 44 | end 45 | end 46 | 47 | def test_with_all_flag_runs_all_tests_in_the_test_directory_including_slow_ones 48 | with_fake_minitest_runner do |runner, executed_tests| 49 | cli_run(argv: ["--all"], chdir: fixtures_path.join("rails_project"), runner:) 50 | 51 | assert_equal( 52 | %w[ 53 | test/helpers/users_helper_test.rb 54 | test/models/account_test.rb 55 | test/models/user_test.rb 56 | test/system/users_system_test.rb 57 | ], 58 | executed_tests.sort 59 | ) 60 | end 61 | end 62 | 63 | def test_with_ci_env_runs_all_tests_in_the_test_directory_including_slow_ones 64 | with_fake_minitest_runner do |runner, executed_tests| 65 | cli_run(argv: [], env: { "CI" => "1" }, chdir: fixtures_path.join("rails_project"), runner:) 66 | 67 | assert_equal( 68 | %w[ 69 | test/helpers/users_helper_test.rb 70 | test/models/account_test.rb 71 | test/models/user_test.rb 72 | test/system/users_system_test.rb 73 | ], 74 | executed_tests.sort 75 | ) 76 | end 77 | end 78 | 79 | def test_with_a_directory_arg_runs_all_test_files_in_that_directory 80 | with_fake_minitest_runner do |runner, executed_tests| 81 | cli_run(argv: ["test/models"], chdir: fixtures_path.join("rails_project"), runner:) 82 | 83 | assert_equal( 84 | %w[ 85 | test/models/account_test.rb 86 | test/models/user_test.rb 87 | ], 88 | executed_tests.sort 89 | ) 90 | end 91 | end 92 | 93 | def test_with_a_mixture_of_file_and_directory_args_runs_all_matching_tests 94 | with_fake_minitest_runner do |runner, executed_tests| 95 | cli_run(argv: %w[test/system test/models/user_test.rb], chdir: fixtures_path.join("rails_project"), runner:) 96 | 97 | assert_equal( 98 | %w[ 99 | test/models/user_test.rb 100 | test/system/users_system_test.rb 101 | ], 102 | executed_tests.sort 103 | ) 104 | end 105 | end 106 | 107 | def test_with_explict_file_args_runs_those_files_regardless_of_whether_they_appear_to_be_tests 108 | with_fake_minitest_runner do |runner, executed_tests| 109 | cli_run(argv: ["app/models/user.rb"], chdir: fixtures_path.join("rails_project"), runner:) 110 | 111 | assert_equal( 112 | %w[ 113 | app/models/user.rb 114 | ], 115 | executed_tests.sort 116 | ) 117 | end 118 | end 119 | 120 | def test_with_directory_args_only_runs_files_that_appear_to_be_tests 121 | with_fake_minitest_runner do |runner, executed_tests| 122 | cli_run(argv: ["app/models"], chdir: fixtures_path.join("rails_project"), runner:) 123 | 124 | assert_empty(executed_tests) 125 | end 126 | end 127 | 128 | def test_with_non_existent_path_raises_an_error 129 | error = assert_raises(ArgumentError) do 130 | cli_run(argv: ["test/models/non_existent_test.rb"], chdir: fixtures_path.join("rails_project")) 131 | end 132 | 133 | assert_includes(error.message, "test/models/non_existent_test.rb does not exist") 134 | end 135 | 136 | def test_divides_tests_into_shards 137 | all = with_fake_minitest_runner do |runner, executed_tests| 138 | cli_run(argv: [], chdir: fixtures_path.join("rails_project"), runner:) 139 | executed_tests 140 | end 141 | 142 | shards = %w[1/2 2/2].map do |shard| 143 | with_fake_minitest_runner do |runner, executed_tests| 144 | cli_run(argv: ["--shard", shard], chdir: fixtures_path.join("rails_project"), runner:) 145 | executed_tests 146 | end 147 | end 148 | 149 | shards.each do |shard| 150 | refute_empty shard 151 | end 152 | 153 | assert_equal all.length, shards.sum(&:length) 154 | end 155 | 156 | def test_w_flag_enables_ruby_warnings 157 | orig_verbose = $VERBOSE 158 | $VERBOSE = false 159 | 160 | with_fake_minitest_runner do |runner, executed_tests| 161 | cli_run(argv: %w[-w app/models/user.rb], chdir: fixtures_path.join("rails_project"), runner:) 162 | 163 | assert_equal(%w[app/models/user.rb], executed_tests) 164 | assert($VERBOSE) 165 | end 166 | ensure 167 | $VERBOSE = orig_verbose 168 | end 169 | 170 | def test_w_flag_is_passed_through_to_watcher 171 | new_mock_watcher = lambda do |extra_args:| 172 | assert_equal(["-w"], extra_args) 173 | 174 | mock = Minitest::Mock.new 175 | mock.expect(:run, nil) 176 | end 177 | 178 | MightyTest::Watcher.stub(:new, new_mock_watcher) do 179 | cli_run(argv: %w[-w --watch]) 180 | end 181 | end 182 | 183 | private 184 | 185 | def with_fake_minitest_runner 186 | executed_tests = [] 187 | runner = MinitestRunner.new 188 | runner.stub(:run_inline_and_exit!, ->(*test_files, **) { executed_tests.append(*test_files.flatten) }) do 189 | yield(runner, executed_tests) 190 | end 191 | end 192 | 193 | def cli_run(argv:, env: {}, chdir: ".", runner: nil, raise_on_failure: true) 194 | exitstatus = true 195 | 196 | stdout, stderr = capture_io do 197 | Dir.chdir(chdir) do 198 | CLI.new(**{ env:, runner: }.compact).run(argv:) 199 | end 200 | rescue SystemExit => e 201 | exitstatus = e.status 202 | end 203 | 204 | result = CLIResult.new(stdout, stderr, exitstatus) 205 | raise "CLI exited with status: #{exitstatus}" if raise_on_failure && result.failure? 206 | 207 | result 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /test/mighty_test/watcher_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module MightyTest 4 | class WatcherTest < Minitest::Test 5 | include FixturesPath 6 | 7 | def setup 8 | @event_queue = FakeEventQueue.new 9 | @system_proc = nil 10 | end 11 | 12 | def test_watcher_passes_unique_set_of_test_files_to_mt_command_based_on_changes_detected 13 | system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } 14 | event_queue.push :file_system_changed, %w[lib/example.rb test/focused_test.rb test/focused_test.rb] 15 | event_queue.push :keypress, "q" 16 | 17 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 18 | 19 | assert_includes(stdout, "[SYSTEM] mt -- test/example_test.rb test/focused_test.rb\n") 20 | end 21 | 22 | def test_watcher_does_nothing_if_a_detected_change_has_no_corresponding_test_file 23 | system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } 24 | event_queue.push :file_system_changed, %w[lib/example/version.rb] 25 | event_queue.push :keypress, "q" 26 | 27 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 28 | 29 | refute_includes(stdout, "[SYSTEM]") 30 | end 31 | 32 | def test_watcher_passes_extra_args_through_to_mt_command 33 | system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } 34 | event_queue.push :file_system_changed, %w[test/example_test.rb] 35 | event_queue.push :keypress, "q" 36 | 37 | stdout, = run_watcher(extra_args: %w[-w --fail-fast], in: fixtures_path.join("example_project")) 38 | 39 | assert_includes(stdout, "[SYSTEM] mt -w --fail-fast -- test/example_test.rb\n") 40 | end 41 | 42 | def test_watcher_clears_the_screen_and_prints_the_test_file_being_run_prior_to_executing_the_mt_command 43 | system_proc { |*args| puts "[SYSTEM] #{args.join(' ')}" } 44 | event_queue.push :file_system_changed, %w[test/example_test.rb] 45 | event_queue.push :keypress, "q" 46 | 47 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 48 | 49 | assert_includes(stdout, <<~EXPECTED) 50 | [CLEAR] 51 | test/example_test.rb 52 | 53 | [SYSTEM] mt -- test/example_test.rb 54 | EXPECTED 55 | end 56 | 57 | def test_watcher_prints_a_status_message_and_plays_a_sound_after_successful_test_run 58 | system_proc do |*args| 59 | puts "[SYSTEM] #{args.join(' ')}" 60 | true 61 | end 62 | event_queue.push :file_system_changed, %w[test/example_test.rb] 63 | event_queue.push :keypress, "q" 64 | 65 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 66 | 67 | assert_includes(stdout, <<~EXPECTED) 68 | [SYSTEM] mt -- test/example_test.rb 69 | [SOUND] :pass 70 | 71 | Watching for changes to source and test files. Press "h" for help or "q" to quit. 72 | EXPECTED 73 | end 74 | 75 | def test_watcher_prints_a_status_message_and_plays_a_sound_after_failed_test_run 76 | system_proc do |*args| 77 | puts "[SYSTEM] #{args.join(' ')}" 78 | false 79 | end 80 | event_queue.push :file_system_changed, %w[test/example_test.rb] 81 | event_queue.push :keypress, "q" 82 | 83 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 84 | 85 | assert_includes(stdout, <<~EXPECTED) 86 | [SYSTEM] mt -- test/example_test.rb 87 | [SOUND] :fail 88 | 89 | Watching for changes to source and test files. Press "h" for help or "q" to quit. 90 | EXPECTED 91 | end 92 | 93 | def test_watcher_restarts_the_listener_when_a_test_run_is_interrupted 94 | restarted = false 95 | system_proc { |*| raise Interrupt } 96 | 97 | event_queue.push :file_system_changed, %w[test/example_test.rb] 98 | event_queue.push :keypress, "q" 99 | event_queue.define_singleton_method(:restart) { restarted = true } 100 | 101 | run_watcher(in: fixtures_path.join("example_project")) 102 | assert restarted 103 | end 104 | 105 | def test_watcher_exits_when_q_key_is_pressed 106 | event_queue.push :keypress, "q" 107 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 108 | 109 | assert_includes(stdout, "Exiting.") 110 | end 111 | 112 | def test_watcher_runs_all_tests_when_enter_key_is_pressed 113 | system_proc do |*args| 114 | puts "[SYSTEM] #{args.join(' ')}" 115 | true 116 | end 117 | event_queue.push :keypress, "\r" 118 | event_queue.push :keypress, "q" 119 | 120 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 121 | 122 | assert_includes(stdout, <<~EXPECTED) 123 | Running tests... 124 | 125 | [SYSTEM] mt 126 | EXPECTED 127 | end 128 | 129 | def test_watcher_runs_all_tests_with_all_flag_when_a_key_is_pressed 130 | system_proc do |*args| 131 | puts "[SYSTEM] #{args.join(' ')}" 132 | true 133 | end 134 | event_queue.push :keypress, "a" 135 | event_queue.push :keypress, "q" 136 | 137 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 138 | 139 | assert_includes(stdout, <<~EXPECTED) 140 | Running tests with --all... 141 | 142 | [SYSTEM] mt --all 143 | EXPECTED 144 | end 145 | 146 | def test_watcher_runs_new_and_changed_files_according_to_git_when_d_key_is_pressed 147 | system_proc do |*args| 148 | puts "[SYSTEM] #{args.join(' ')}" 149 | true 150 | end 151 | event_queue.push :keypress, "d" 152 | event_queue.push :keypress, "q" 153 | 154 | file_system = FileSystem.new 155 | stdout, = file_system.stub(:find_new_and_changed_paths, %w[lib/example.rb]) do 156 | run_watcher(file_system:, in: fixtures_path.join("example_project")) 157 | end 158 | 159 | assert_includes(stdout, <<~EXPECTED) 160 | [CLEAR] 161 | test/example_test.rb 162 | 163 | [SYSTEM] mt -- test/example_test.rb 164 | EXPECTED 165 | end 166 | 167 | def test_watcher_shows_a_message_if_d_key_is_pressed_and_there_are_no_changes 168 | system_proc do |*args| 169 | puts "[SYSTEM] #{args.join(' ')}" 170 | true 171 | end 172 | event_queue.push :keypress, "d" 173 | event_queue.push :keypress, "q" 174 | 175 | file_system = FileSystem.new 176 | stdout, = file_system.stub(:find_new_and_changed_paths, []) do 177 | run_watcher(file_system:, in: fixtures_path.join("example_project")) 178 | end 179 | 180 | assert_includes(stdout, <<~EXPECTED) 181 | [CLEAR] 182 | No affected test files detected since the last git commit. 183 | Watching for changes to source and test files. Press "h" for help or "q" to quit. 184 | EXPECTED 185 | end 186 | 187 | def test_watcher_shows_help_menu_when_h_key_is_pressed 188 | event_queue.push :keypress, "h" 189 | event_queue.push :keypress, "q" 190 | 191 | stdout, = run_watcher(in: fixtures_path.join("example_project")) 192 | 193 | assert_includes(stdout, <<~EXPECTED) 194 | > Press Enter to run all tests. 195 | > Press "a" to run all tests, including slow tests. 196 | > Press "d" to run tests for files diffed or added since the last git commit. 197 | > Press "h" to show this help menu. 198 | > Press "q" to quit. 199 | EXPECTED 200 | end 201 | 202 | private 203 | 204 | attr_reader :event_queue 205 | 206 | class FakeEventQueue 207 | def initialize 208 | @events = [] 209 | end 210 | 211 | def push(type, payload) 212 | @events.unshift([type, payload]) 213 | end 214 | 215 | def pop 216 | @events.pop 217 | end 218 | 219 | def start; end 220 | def stop; end 221 | end 222 | 223 | def run_watcher(in: ".", file_system: FileSystem.new, extra_args: []) 224 | console = Console.new 225 | console.define_singleton_method(:clear) { puts "[CLEAR]" } 226 | console.define_singleton_method(:play_sound) { |sound| puts "[SOUND] #{sound.inspect}" } 227 | 228 | capture_io do 229 | Dir.chdir(binding.local_variable_get(:in)) do 230 | @watcher = Watcher.new(console:, extra_args:, event_queue:, file_system:, system_proc: @system_proc) 231 | @watcher.run 232 | end 233 | end 234 | end 235 | 236 | def system_proc(&proc) 237 | @system_proc = proc 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mighty_test 2 | 3 | [![Gem Version](https://img.shields.io/gem/v/mighty_test)](https://rubygems.org/gems/mighty_test) 4 | [![Gem Downloads](https://img.shields.io/gem/dt/mighty_test)](https://www.ruby-toolbox.com/projects/mighty_test) 5 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/mighty_test/ci.yml)](https://github.com/mattbrictson/mighty_test/actions/workflows/ci.yml) 6 | 7 | mighty_test (`mt`) is a TDD-friendly Minitest runner for Ruby projects. It includes a Jest-inspired interactive watch mode, focus mode, CI sharding, run by directory/file/line number, fail-fast, and color formatting. 8 | 9 | --- 10 | 11 | **Quick Start** 12 | 13 | - [Install](#install) 14 | - [Requirements](#requirements) 15 | - [Usage](#usage) 16 | 17 | **Features** 18 | 19 | - ⚙️ [CI Mode](#%EF%B8%8F-ci-mode) 20 | - 🧑‍🔬 [Watch Mode](#-watch-mode) 21 | - 🔬 [Focus Mode](#-focus-mode) 22 | - 🛑 [Fail Fast](#-fail-fast) 23 | - 🚥 [Color Output](#-color-output) 24 | - 💬 [More Options](#-more-options) 25 | 26 | **Community** 27 | 28 | - [Support](#support) 29 | - [License](#license) 30 | - [Code of conduct](#code-of-conduct) 31 | - [Contribution guide](#contribution-guide) 32 | 33 | ## Install 34 | 35 | The mighty_test gem provides an `mt` binary. To install it into a Ruby project, first add the gem to your Gemfile and run `bundle install`. 36 | 37 | ```ruby 38 | gem "mighty_test" 39 | ``` 40 | 41 | Then generate a binstub: 42 | 43 | ```sh 44 | bundle binstub mighty_test 45 | ``` 46 | 47 | Now you can run mighty_test with `bin/mt`. 48 | 49 | > [!TIP] 50 | > **When installing mighty_test in a Rails project, make sure to put the gem in the `:test` Gemfile group.** Although Rails has a built-in test runner (`bin/rails test`) that already provides a lot of what mighty_test offers, you can still use `bin/mt` with Rails projects for its unique `--watch` mode and CI `--shard` feature. 51 | 52 | ## Requirements 53 | 54 | mighty_test requires modern versions of Minitest and Ruby. 55 | 56 | - Minitest 5.15+ 57 | - Ruby 3.1+ 58 | 59 | Support for older Ruby versions will be dropped when they reach EOL. The EOL schedule can be found here: https://endoflife.date/ruby 60 | 61 | > [!NOTE] 62 | > mighty_test currently assumes that your tests are stored in `test/` and are named `*_test.rb`. Watch mode expects implementation files to be in `app/` and/or `lib/`. 63 | 64 | ## Usage 65 | 66 | `mt` defaults to running all tests, excluding slow tests (see the explanation of slow tests below). You can also run tests by directory, file, or line number. 67 | 68 | ```sh 69 | # Run all tests, excluding slow tests 70 | bin/mt 71 | 72 | # Run all tests, slow tests included 73 | bin/mt --all 74 | 75 | # Run a specific test file 76 | bin/mt test/cli_test.rb 77 | 78 | # Run a test by line number 79 | bin/mt test/importer_test.rb:43 80 | 81 | # Run a directory of tests 82 | bin/mt test/commands 83 | ``` 84 | 85 | > [!TIP] 86 | > mighty_test is optimized for TDD, and excludes slow tests by default. **Slow tests** are defined as those found in `test/{e2e,feature,features,integration,system}` directories. You can run slow tests with `--all` or by specifying a slow test file or directory explicitly, like `bin/mt test/system`. 87 | 88 | ## ⚙️ CI Mode 89 | 90 | If the `CI` environment variable is set, mighty_test defaults to running _all_ tests, including slow tests. This is equivalent to passing `--all`. 91 | 92 | mighty_test can also distribute test files evenly across parallel CI jobs, using the `--shard` option. The _shard_ nomenclature has been borrowed from similar features in [Jest](https://jestjs.io/docs/cli#--shard) and [Playwright](https://playwright.dev/docs/test-sharding). 93 | 94 | ```sh 95 | # Run the 1st group of tests out of 4 total groups 96 | bin/mt --shard 1/4 97 | ``` 98 | 99 | In GitHub Actions, for example, you can use `--shard` with a matrix strategy to easily divide tests across N jobs. 100 | 101 | ```yaml 102 | jobs: 103 | test: 104 | strategy: 105 | matrix: 106 | shard: 107 | - "1/4" 108 | - "2/4" 109 | - "3/4" 110 | - "4/4" 111 | steps: 112 | - uses: actions/checkout@v4 113 | - uses: ruby/setup-ruby@v1 114 | with: 115 | bundler-cache: true 116 | - run: bin/mt --shard ${{ matrix.shard }} 117 | ``` 118 | 119 | In CircleCI, you can use the `parallelism` setting, which automatically injects `$CIRCLE_NODE_INDEX` and `$CIRCLE_NODE_TOTAL` environment variables. Note that `$CIRCLE_NODE_INDEX` is zero-indexed, so it needs to be incremented by 1. 120 | 121 | ```yaml 122 | jobs: 123 | test: 124 | parallelism: 4 125 | steps: 126 | - checkout 127 | - ruby/install-deps 128 | - run: SHARD="$((${CIRCLE_NODE_INDEX}+1))"; bin/mt --shard ${SHARD}/${CIRCLE_NODE_TOTAL} 129 | ``` 130 | 131 | > [!TIP] 132 | > `--shard` will shuffle tests and automatically distribute slow tests evenly across jobs. 133 | 134 | ## 🧑‍🔬 Watch Mode 135 | 136 | mighty_test includes a Jest-style watch mode, which can be started with `--watch`. This is ideal for TDD. 137 | 138 | ```sh 139 | # Start watch mode 140 | bin/mt --watch 141 | ``` 142 | 143 | In watch mode, mighty_test will listen for file system activity and run a test file whenever it is modified. 144 | 145 | When you modify an implementation file, mighty_test will find the corresponding test file and run it automatically. This works as long as your implementation and test files follow a standard path naming convention: e.g. `lib/commands/init.rb` is expected to have a corresponding test file named `test/commands/init_test.rb`. 146 | 147 | Watch mode also offers a menu of interactive commands: 148 | 149 | ``` 150 | > Press Enter to run all tests. 151 | > Press "a" to run all tests, including slow tests. 152 | > Press "d" to run tests for files diffed or added since the last git commit. 153 | > Press "h" to show this help menu. 154 | > Press "q" to quit. 155 | ``` 156 | 157 | ## 🔬 Focus Mode 158 | 159 | You can focus a specific test by annotating the method definition with `focus`. 160 | 161 | ```ruby 162 | class MyTest < Minitest::Test 163 | focus def test_something_important 164 | assert # ... 165 | end 166 | ``` 167 | 168 | Now running `bin/mt` will execute only the focused test: 169 | 170 | ```sh 171 | # Only runs MyTest#test_something_important 172 | bin/mt 173 | ``` 174 | 175 | In Rails projects that use the `test` syntax, `focus` must be placed on the previous line. 176 | 177 | ```ruby 178 | class MyTest < ActiveSupport::TestCase 179 | focus 180 | test "something important" do 181 | assert # ... 182 | end 183 | ``` 184 | 185 | This functionality is provided by the [minitest-focus](https://github.com/minitest/minitest-focus) plugin, which is included with mighty_test. 186 | 187 | ## 🛑 Fail Fast 188 | 189 | By default, mighty_test runs the entire test suite to completion. With the `--fail-fast` option, it will stop on the first failed test. 190 | 191 | ```sh 192 | # Stop immediately on first test failure 193 | bin/mt --fail-fast 194 | 195 | # Use with watch mode for even faster TDD 196 | bin/mt --watch --fail-fast 197 | ``` 198 | 199 | This functionality is provided by the [minitest-fail-fast](https://github.com/teoljungberg/minitest-fail-fast) plugin, which is included with mighty_test. 200 | 201 | ## 🚥 Color Output 202 | 203 | Successes, failures, errors, and skips are colored appropriately by default. 204 | 205 | ```sh 206 | # Run tests with color output (if terminal supports it) 207 | bin/mt 208 | 209 | # Disable color 210 | bin/mt --no-rg 211 | ``` 212 | 213 | Screenshot of bin/mt output 214 | 215 | This functionality is provided by the [minitest-rg](https://github.com/minitest/minitest-rg) plugin, which is included with mighty_test. 216 | 217 | ## 💬 More Options 218 | 219 | Use `-w` to enable Ruby warnings when running tests: 220 | 221 | ```sh 222 | bin/mt -w 223 | ``` 224 | 225 | Minitest options are passed through to Minitest. 226 | 227 | ```sh 228 | # Run tests with Minitest pride color output 229 | bin/mt --pride 230 | 231 | # Run tests with an explicit seed value for test ordering 232 | bin/mt --seed 4519 233 | 234 | # Run tests with detailed progress and explanation of skipped tests 235 | bin/mt --verbose 236 | 237 | # Show the full list of possible options 238 | bin/mt --help 239 | ``` 240 | 241 | If you have Minitest extensions installed, like [minitest-snapshots](https://github.com/mattbrictson/minitest-snapshots), the command line options of those extensions are supported as well. 242 | 243 | ```sh 244 | # Update snapshots 245 | bin/mt -u 246 | ``` 247 | 248 | ## Support 249 | 250 | If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/mattbrictson/mighty_test/issues/new) and I will do my best to provide a helpful answer. Happy hacking! 251 | 252 | ## License 253 | 254 | The gem is available as open source under the terms of the [MIT License](LICENSE.txt). 255 | 256 | ## Code of conduct 257 | 258 | Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md). 259 | 260 | ## Contribution guide 261 | 262 | Pull requests are welcome! 263 | --------------------------------------------------------------------------------