├── .ci.gemfile ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG ├── MIT-LICENSE ├── README.rdoc ├── Rakefile ├── lib └── minitest │ ├── parallel_fork.rb │ └── parallel_fork │ ├── fail_fast.rb │ ├── halt.rb │ └── interrupt.rb ├── minitest-parallel_fork.gemspec └── spec ├── minitest_parallel_fork_example.rb ├── minitest_parallel_fork_fail_fast_example.rb ├── minitest_parallel_fork_interrupt_example.rb ├── minitest_parallel_fork_pass_example.rb ├── minitest_parallel_fork_spec.rb └── simplecov_helper.rb /.ci.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rake' 4 | gem "minitest", '>=5.15.0' 5 | gem 'minitest-hooks' 6 | gem 'minitest-global_expectations' 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | tests: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: [ 2.3, 2.4, 2.5, 2.6, 2.7, "3.0", 3.1, 3.2, 3.3, 3.4 ] 19 | name: ${{ matrix.ruby }} 20 | env: 21 | BUNDLE_GEMFILE: .ci.gemfile 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - run: bundle exec rake 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /rdoc 3 | /minitest-parallel_fork-*.gem 4 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | === master 2 | 3 | * Add minitest/parallel_fork/interrupt with support handling interrupts (stackmystack, jeremyevans) (#14) 4 | 5 | === 2.0.0 (2023-11-08) 6 | 7 | * Avoid method redefined warning in verbose warning mode (jeremyevans) 8 | 9 | * Add minitest/parallel_fork/fail_fast with support for stopping execution as soon as there is a failure in any child process (jeremyevans, stackmystack) (#12) 10 | 11 | * Refactor internals for easier extension (jeremyevans) 12 | 13 | === 1.3.1 (2023-09-25) 14 | 15 | * Bump required_ruby_version to 2.2, since that is lowest version supported by minitest 5.15.0 (jeremyevans) 16 | 17 | * Update count and assertions for all configured statistics reporters, not just the first (akimd) (#10) 18 | 19 | === 1.3.0 (2022-07-05) 20 | 21 | * Bump minimum minitest version to 5.15.0 (jeremyevans) 22 | 23 | * Detect reporter to use by looking for specific methods, instead of specifically for a Minitest::StatisticsReporter (jeremyevans) (#8) 24 | 25 | === 1.2.0 (2021-08-16) 26 | 27 | * Work correctly with Minitest 5.14+ (jeremyevans) 28 | 29 | * Add on_parallel_fork_marshal_failure hook for handling marshal failures (child process exiting) (jeremyevans) 30 | 31 | === 1.1.2 (2018-07-05) 32 | 33 | * Handle raised exceptions containing unmarshallable data (jeremyevans) 34 | 35 | === 1.1.1 (2018-05-07) 36 | 37 | * Work around marshalling issue when there are failures in *_all hooks when using minitest-hooks (jeremyevans) 38 | 39 | * Don't deadlock when a child process sends a large amount of marshalled data (chanks) (#4, #5) 40 | 41 | === 1.1.0 (2018-04-19) 42 | 43 | * Remove DumpableUnexpectedError, require minitest 5.11.0+ (jeremyevans) 44 | 45 | * Allow overriding the stat reporter used via Minitest.parallel_fork_stat_reporter method (jeremyevans) (#3) 46 | 47 | === 1.0.2 (2017-02-27) 48 | 49 | * Show correct number of errors in output (jeremyevans) 50 | 51 | === 1.0.1 (2017-01-05) 52 | 53 | * Work with test suites that use parallelize_me! (jeremyevans) (#1) 54 | 55 | === 1.0.0 (2015-05-11) 56 | 57 | * Initial Public Release 58 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2023 Jeremy Evans 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to 5 | deal in the Software without restriction, including without limitation the 6 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = minitest-parallel_fork 2 | 3 | minitest-parallel_fork adds fork-based parallelization to Minitest. Each test/spec 4 | suite is run in one of the forks, allowing this to work correctly when using 5 | before_all/after_all/around_all hooks provided by minitest-hooks. Using separate 6 | processes via fork can significantly improve spec performance when using MRI, 7 | and can work in cases where Minitest's default thread-based parallelism do not work, 8 | such as when tests/specs modify the constant namespace. 9 | 10 | = Installation 11 | 12 | gem install minitest-parallel_fork 13 | 14 | = Source Code 15 | 16 | Source code is available on GitHub at https://github.com/jeremyevans/minitest-parallel_fork 17 | 18 | = Usage 19 | 20 | You can enable fork-based parallelism just by requiring +minitest/parallel_fork+. One easy 21 | to do so without modifying the spec code itself is to use the +RUBYOPT+ environment variable. 22 | So if you execute your specs using: 23 | 24 | rake spec 25 | 26 | You can switch to fork-based parallelism using: 27 | 28 | RUBYOPT=-rminitest/parallel_fork rake spec 29 | 30 | To control the number of forks, you can set the +NCPU+ environment variable: 31 | 32 | NCPU=8 RUBYOPT=-rminitest/parallel_fork rake spec 33 | 34 | If you don't set the +NCPU+ environment variable, minitest-parallel_fork will use 35 | 4 forks by default. 36 | 37 | = Hooks 38 | 39 | In some cases, especially when using external databases, you'll need to do some 40 | before fork or after fork setup. minitest/parallel_fork supports +before_parallel_fork+ 41 | and +after_parallel_fork+ hooks. 42 | 43 | +before_parallel_fork+ is called before any child processes are forked: 44 | 45 | Minitest.before_parallel_fork do 46 | DB.disconnect 47 | end 48 | 49 | +after_parallel_fork+ is called after each child process is forked, with the number 50 | of the child process, starting at 0: 51 | 52 | Minitest.after_parallel_fork do |i| 53 | DB.opts[:database] += (i+1).to_s 54 | end 55 | 56 | The above examples show a fairly easy way to use minitest-parallel_fork with an external 57 | database when using Sequel. Before forking, all existing database connections are 58 | disconnected, and after forking, the database name is changed in each child to reference 59 | a child-specific database, so that the child processes do not share a database and are 60 | thus independent. 61 | 62 | There is also a hook for debugging. +on_parallel_fork_marshal_failure+ is called if 63 | there is an error unmarshalling data sent from the child process to the parent process. 64 | This can happen if one of the child processes exits unexpected during the test, before 65 | it reports results. 66 | 67 | Minitest.on_parallel_fork_marshal_failure do 68 | # Gather relevant logs for more debugging 69 | end 70 | 71 | == Fail Fast Support 72 | 73 | If you would like to run tests in parallel, but stop running tests at the first 74 | failure, you can use: 75 | 76 | RUBYOPT=-rminitest/parallel_fork/fail_fast rake spec 77 | 78 | Note that minitest-parallel_fork uses suite-based parallelism, so tests will not 79 | stop until one child has a failing test suite (test class that has a failing test 80 | method), and other children are signaled and also stop processing. 81 | 82 | == Interrupt Support 83 | 84 | If you would like to run tests in parallel, but allow for shutting down children 85 | when SIGINT is sent to the process, you can use: 86 | 87 | RUBYOPT=-rminitest/parallel_fork/interrupt ruby spec_runner.rb 88 | 89 | The first SIGINT to a process will ask the child processes to shut down gracefully 90 | with SIGUSR1. The second SIGINT to a process will kill the child processes with 91 | SIGKILL. 92 | 93 | Note that if you use rake to run the specs, the second SIGINT may not be sent to 94 | to the parent process, as rake does it's own SIGINT handling. 95 | 96 | == ActiveRecord 97 | 98 | To use this with Rails/ActiveRecord, you probably want to use hooks similar to: 99 | 100 | Minitest.before_parallel_fork do 101 | ActiveRecord::Base.connection.disconnect! 102 | end 103 | 104 | Minitest.after_parallel_fork do |i| 105 | db_config = Rails.application.config.database_configuration[Rails.env].clone 106 | db_config['database'] += (i+1).to_s 107 | ActiveRecord::Base.establish_connection(db_config) 108 | end 109 | 110 | = Speedup 111 | 112 | The speedup you get greatly depends on your specs. Here's some examples using Sequel's 113 | specs: 114 | 115 | 2 forks 4 forks 116 | spec_core: 1.25x - 1.36x 1.5x 117 | spec_model: 1.29x - 1.62x 1.72x - 2.02x 118 | spec_plugin: 1.57x - 1.76x 2.29x - 2.37x 119 | spec_sqlite: 1.75x - 1.86x 2.26x - 2.65x 120 | spec_postgres: 1.32x - 1.40x Untested 121 | 122 | = License 123 | 124 | MIT 125 | 126 | = Author 127 | 128 | Jeremy Evans 129 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/clean" 2 | 3 | CLEAN.include ["minitest-parallel_fork-*.gem", "rdoc", "coverage"] 4 | 5 | desc "Build minitest-parallel_fork gem" 6 | task :package=>[:clean] do |p| 7 | sh %{#{FileUtils::RUBY} -S gem build minitest-parallel_fork.gemspec} 8 | end 9 | 10 | ### Specs 11 | 12 | desc "Run specs" 13 | task :spec do 14 | ENV['RUBY'] = FileUtils::RUBY 15 | sh %{#{FileUtils::RUBY} #{"-w" if RUBY_VERSION >= '3'} #{'-W:strict_unused_block' if RUBY_VERSION >= '3.4'} spec/minitest_parallel_fork_spec.rb} 16 | end 17 | 18 | task :default=>:spec 19 | 20 | desc "Run specs with coverage" 21 | task :spec_cov do 22 | ENV['COVERAGE'] = '1' 23 | ENV['RUBY'] = FileUtils::RUBY 24 | sh %{#{FileUtils::RUBY} spec/minitest_parallel_fork_spec.rb} 25 | end 26 | 27 | ### RDoc 28 | 29 | desc "Generate rdoc" 30 | task :rdoc do 31 | rdoc_dir = "rdoc" 32 | rdoc_opts = ["--line-numbers", "--inline-source", '--title', 'minitest-parallel_fork: fork-based parallelization for minitest'] 33 | 34 | begin 35 | gem 'hanna' 36 | rdoc_opts.concat(['-f', 'hanna']) 37 | rescue Gem::LoadError 38 | end 39 | 40 | rdoc_opts.concat(['--main', 'README.rdoc', "-o", rdoc_dir] + 41 | %w"README.rdoc CHANGELOG MIT-LICENSE" + 42 | Dir["lib/**/*.rb"] 43 | ) 44 | 45 | FileUtils.rm_rf(rdoc_dir) 46 | 47 | require "rdoc" 48 | RDoc::RDoc.new.document(rdoc_opts) 49 | end 50 | -------------------------------------------------------------------------------- /lib/minitest/parallel_fork.rb: -------------------------------------------------------------------------------- 1 | require 'minitest' 2 | 3 | module Minitest::Unparallelize 4 | define_method(:run_one_method, &Minitest::Test.method(:run_one_method)) 5 | end 6 | 7 | module Minitest 8 | @before_parallel_fork = nil 9 | @after_parallel_fork = nil 10 | @on_parallel_fork_marshal_failure = nil 11 | end 12 | 13 | class << Minitest 14 | # Set the before_parallel_fork block to the given block 15 | def before_parallel_fork(&block) 16 | @before_parallel_fork = block 17 | end 18 | 19 | # Set the after_parallel_fork block to the given block 20 | def after_parallel_fork(i=nil, &block) 21 | @after_parallel_fork = block 22 | end 23 | 24 | # Set the on_parallel_fork_marshal_failure block to the given block 25 | def on_parallel_fork_marshal_failure(&block) 26 | @on_parallel_fork_marshal_failure = block 27 | end 28 | 29 | attr_reader :parallel_fork_stat_reporter 30 | 31 | def set_parallel_fork_stat_reporter(reporter) 32 | @parallel_fork_stat_reporter = reporter.reporters.detect do |rep| 33 | %w'count assertions results count= assertions='.all?{|meth| rep.respond_to?(meth)} 34 | end 35 | end 36 | 37 | def parallel_fork_suites 38 | Minitest::Runnable.runnables.shuffle 39 | end 40 | 41 | def run_before_parallel_fork_hook 42 | if @before_parallel_fork 43 | @before_parallel_fork.call 44 | end 45 | end 46 | 47 | def run_after_parallel_fork_hook(i) 48 | if @after_parallel_fork 49 | @after_parallel_fork.call(i) 50 | end 51 | end 52 | 53 | def parallel_fork_data_to_marshal 54 | %i'count assertions results'.map{|meth| parallel_fork_stat_reporter.send(meth)} 55 | end 56 | 57 | def parallel_fork_data_from_marshal(data) 58 | Marshal.load(data) 59 | rescue ArgumentError 60 | if @on_parallel_fork_marshal_failure 61 | @on_parallel_fork_marshal_failure.call 62 | end 63 | raise 64 | end 65 | 66 | def parallel_fork_run_test_suites(suites, reporter, options) 67 | suites.each do |suite| 68 | parallel_fork_run_test_suite(suite, reporter, options) 69 | end 70 | end 71 | 72 | def parallel_fork_run_test_suite(suite, reporter, options) 73 | if suite.is_a?(Minitest::Parallel::Test::ClassMethods) 74 | suite.extend(Minitest::Unparallelize) 75 | end 76 | 77 | suite.run(reporter, options) 78 | end 79 | 80 | def parallel_fork_setup_children(suites, reporter, options) 81 | set_parallel_fork_stat_reporter(reporter) 82 | run_before_parallel_fork_hook 83 | 84 | parallel_fork_number.times.map do |i| 85 | parallel_fork_fork_child(i, suites, reporter, options) 86 | end 87 | end 88 | 89 | def parallel_fork_fork_child(i, suites, reporter, options) 90 | read, write = IO.pipe.each{|io| io.binmode} 91 | pid = Process.fork do 92 | read.close 93 | run_after_parallel_fork_hook(i) 94 | 95 | p_suites = [] 96 | n = parallel_fork_number 97 | suites.each_with_index{|s, j| p_suites << s if j % n == i} 98 | parallel_fork_run_test_suites(p_suites, reporter, options) 99 | 100 | write.write(Marshal.dump(parallel_fork_data_to_marshal)) 101 | write.close 102 | end 103 | write.close 104 | [pid, read] 105 | end 106 | 107 | def parallel_fork_child_data(data) 108 | data.map{|_pid, read| Thread.new(read, &:read)}.map(&:value).map{|data| parallel_fork_data_from_marshal(data)} 109 | end 110 | 111 | def parallel_fork_wait_for_children(child_info, reporter) 112 | parallel_fork_child_data(child_info).each do |data| 113 | count, assertions, results = data 114 | reporter.reporters.each do |rep| 115 | next unless %i'count assertions results count= assertions='.all?{|meth| rep.respond_to?(meth)} 116 | rep.count += count 117 | rep.assertions += assertions 118 | rep.results.concat(results) 119 | end 120 | end 121 | end 122 | 123 | def parallel_fork_number 124 | (ENV['NCPU'] || 4).to_i 125 | end 126 | 127 | # Avoid method redefined verbose warning 128 | alias __run __run 129 | 130 | # Override __run to use a child forks to run the speeds, which 131 | # allows for parallel spec execution on MRI. 132 | def __run(reporter, options) 133 | parallel_fork_wait_for_children(parallel_fork_setup_children(parallel_fork_suites, reporter, options), reporter) 134 | nil 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/minitest/parallel_fork/fail_fast.rb: -------------------------------------------------------------------------------- 1 | require_relative 'halt' 2 | require_relative '../parallel_fork' 3 | 4 | module Minitest::ParalleForkFailFast 5 | include Minitest::ParallelForkHalt 6 | 7 | def parallel_fork_run_test_suite(suite, reporter, options) 8 | super 9 | 10 | if parallel_fork_stat_reporter.results.any?{|r| !r.failure.is_a?(Minitest::Skip)} 11 | # At least one failure or error, mark as failing fast 12 | @parallel_fork_stop = true 13 | end 14 | end 15 | end 16 | 17 | Minitest.singleton_class.prepend(Minitest::ParalleForkFailFast) 18 | -------------------------------------------------------------------------------- /lib/minitest/parallel_fork/halt.rb: -------------------------------------------------------------------------------- 1 | module Minitest::ParallelForkHalt 2 | def run_after_parallel_fork_hook(i) 3 | super 4 | Signal.trap(:USR1) do 5 | @parallel_fork_stop = true 6 | end 7 | end 8 | 9 | def parallel_fork_data_to_marshal 10 | super << @parallel_fork_stop 11 | end 12 | 13 | def parallel_fork_data_from_marshal(data) 14 | data = Marshal.load(data) 15 | @parallel_fork_stop = true if data.pop 16 | data 17 | end 18 | 19 | def parallel_fork_run_test_suites(suites, reporter, options) 20 | suites.each do |suite| 21 | parallel_fork_run_test_suite(suite, reporter, options) 22 | 23 | # Halt if this child process requested an exit, 24 | # Or other child processes requested an exit. 25 | break if @parallel_fork_stop 26 | end 27 | end 28 | 29 | def parallel_fork_child_data(data) 30 | threads = {} 31 | data.each{|pid, read| threads[pid] = Thread.new(read, &:read)} 32 | results = [] 33 | 34 | while sleep(0.01) && !threads.empty? 35 | threads.to_a.each do |pid, thread| 36 | unless thread.alive? 37 | threads.delete(pid) 38 | results << parallel_fork_data_from_marshal(thread.value) 39 | 40 | if @parallel_fork_stop 41 | # If halt is requested, signal other children to halt 42 | threads.each_key do |pid| 43 | Process.kill(:USR1, pid) 44 | end 45 | 46 | # Set a flag indicating that all child processes have been signaled 47 | @parallel_fork_stop = :FINISHED 48 | end 49 | end 50 | end 51 | end 52 | 53 | results 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/minitest/parallel_fork/interrupt.rb: -------------------------------------------------------------------------------- 1 | require 'minitest' 2 | 3 | require_relative 'halt' 4 | require_relative '../parallel_fork' 5 | 6 | module Minitest 7 | @parallel_fork_child_pids = [] 8 | end 9 | 10 | module Minitest::ParallelForkInterrupt 11 | include Minitest::ParallelForkHalt 12 | 13 | def run_before_parallel_fork_hook 14 | Signal.trap(:INT) do 15 | Signal.trap(:INT) do 16 | parallel_fork_kill_all :KILL 17 | end 18 | $stderr.puts "\nInterrupted.\nExiting ...\nInterrupt again to exit immediately." 19 | parallel_fork_kill_all :USR1 20 | end 21 | end 22 | 23 | def run_after_parallel_fork_hook(i) 24 | super 25 | Signal.trap(:INT, 'IGNORE') 26 | end 27 | 28 | def parallel_fork_fork_child(i, suites, reporter, options) 29 | res = super 30 | @parallel_fork_child_pids << res[0] 31 | res 32 | end 33 | 34 | def parallel_fork_kill_all(signal) 35 | @parallel_fork_child_pids.each do |pid| 36 | begin 37 | Process.kill(signal, pid) 38 | rescue Errno::ESRCH 39 | # Process already terminated 40 | end 41 | end 42 | end 43 | end 44 | 45 | Minitest.singleton_class.prepend(Minitest::ParallelForkInterrupt) 46 | -------------------------------------------------------------------------------- /minitest-parallel_fork.gemspec: -------------------------------------------------------------------------------- 1 | spec = Gem::Specification.new do |s| 2 | s.name = 'minitest-parallel_fork' 3 | s.version = '2.0.0' 4 | s.platform = Gem::Platform::RUBY 5 | s.extra_rdoc_files = ["README.rdoc", "CHANGELOG", "MIT-LICENSE"] 6 | s.rdoc_options += ["--quiet", "--line-numbers", "--inline-source", '--title', 'minitest-parallel_fork: fork-based parallelization for minitest', '--main', 'README.rdoc'] 7 | s.license = "MIT" 8 | s.summary = "Fork-based parallelization for minitest" 9 | s.author = "Jeremy Evans" 10 | s.email = "code@jeremyevans.net" 11 | s.homepage = "http://github.com/jeremyevans/minitest-parallel_fork" 12 | s.files = %w(MIT-LICENSE CHANGELOG README.rdoc) + Dir["lib/**/*.rb"] 13 | s.description = < 'https://github.com/jeremyevans/minitest-parallel_fork/issues', 24 | 'changelog_uri' => 'https://github.com/jeremyevans/minitest-parallel_fork/blob/master/CHANGELOG', 25 | 'mailing_list_uri' => 'https://github.com/jeremyevans/minitest-parallel_fork/discussions', 26 | "source_code_uri" => 'https://github.com/jeremyevans/minitest-parallel_fork' 27 | } 28 | 29 | s.required_ruby_version = '>=2.2' 30 | s.add_dependency "minitest", '>=5.15.0' 31 | s.add_development_dependency "minitest-hooks" 32 | s.add_development_dependency "minitest-global_expectations" 33 | end 34 | -------------------------------------------------------------------------------- /spec/minitest_parallel_fork_example.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/global_expectations/autorun' 2 | require 'minitest/parallel_fork' 3 | require 'minitest/hooks/default' if ENV['MPF_MINITEST_HOOKS'] 4 | 5 | if ENV['MPF_SEVERAL_STATISTICS_REPORTERS'] 6 | Minitest.singleton_class.send(:prepend, Module.new do 7 | define_method(:init_plugins) do |options| 8 | reporter << Class.new(Minitest::StatisticsReporter) do 9 | def report 10 | super 11 | io.puts "Stats: #{count}R, #{assertions}A, #{failures}F, #{errors}E, #{skips}S" 12 | end 13 | end.new(options[:io], options) 14 | super(options) 15 | end 16 | end) 17 | end 18 | 19 | a = nil 20 | if ENV['MPF_NO_HOOKS'] 21 | a = 'a' 22 | else 23 | Minitest.before_parallel_fork do 24 | a = 'a' 25 | print ":parent" 26 | end 27 | 28 | Minitest.after_parallel_fork do |i| 29 | print ":child#{i}#{a}" 30 | end 31 | 32 | if ENV['MPF_TEST_CHILD_FAILURE'] 33 | Minitest.on_parallel_fork_marshal_failure do |i| 34 | print ":child-failure#{i}#{a}" 35 | end 36 | end 37 | end 38 | 39 | class MyExceptionClass < StandardError 40 | attr_accessor :something 41 | end 42 | 43 | 4.times do |i| 44 | describe 'minitest/parallel_fork' do 45 | parallelize_me! if ENV['MPF_PARALLELIZE_ME'] 46 | 47 | if ENV['MPF_TEST_ORDER_PARALLEL'] 48 | def self.test_order 49 | :parallel 50 | end 51 | end 52 | 53 | it "should work" do 54 | sleep(1) 55 | 1.must_equal 1 56 | end 57 | 58 | it "should fail" do 59 | 1.must_equal 2 60 | end 61 | 62 | it "should raise" do 63 | exit(1) if ENV['MPF_TEST_CHILD_FAILURE'] 64 | raise 65 | end 66 | 67 | it "should raise exception containing undumpable data" do 68 | e = MyExceptionClass.new("error") 69 | e.something = Class.new 70 | raise e 71 | end 72 | 73 | it "should skip" do 74 | skip 75 | end 76 | end 77 | end 78 | 79 | if ENV['MPF_MINITEST_HOOKS'] 80 | describe "failure in before(:all)" do 81 | before(:all) do 82 | e = MyExceptionClass.new("error") 83 | e.something = Class.new 84 | raise e 85 | end 86 | it "should fail" do end 87 | end 88 | 89 | describe "failure in after(:all)" do 90 | after(:all) do 91 | e = MyExceptionClass.new("error") 92 | e.something = Class.new 93 | raise e 94 | end 95 | it "should fail" do end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/minitest_parallel_fork_fail_fast_example.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/global_expectations/autorun' 2 | require 'minitest/parallel_fork/fail_fast' 3 | 4 | lock_file = "spec/spec-wait-#{$$}.lock" 5 | File.write(lock_file, "") 6 | 7 | suites = Minitest::Runnable.runnables 8 | suites.delete(Minitest::Test) 9 | suites.delete(Minitest::Spec) 10 | 11 | # Force in-order execution of test suites 12 | def suites.shuffle; self end 13 | 14 | 4.times do |i| 15 | describe "test suite with fail_fast" do 16 | Object.send(:const_set, :"FailingTest#{i}", self) 17 | if i == 0 18 | it "should fail" do 19 | # Wait until 3 other forks are in first spec 20 | sleep(0.01) while File.size(lock_file) < 3 21 | 22 | 1.must_equal 2 23 | end 24 | else 25 | it "wait until other child failed, then pass" do 26 | # Mark this fork has reached this spec 27 | File.open(lock_file, 'ab'){|f| f << i.to_s} 28 | 29 | # Wait until one fork has a failed and the 30 | # parent process has signaled the child processes 31 | sleep(0.01) while File.file?(lock_file) 32 | 33 | 1.must_equal 1 34 | 2.must_equal 2 35 | end 36 | end 37 | end 38 | end 39 | 40 | 4.times do |i| 41 | describe "will not be executed with fail_fast" do 42 | Object.send(:const_set, :"PassingTest#{i}", self) 43 | it "will not be executed" do 44 | puts "not_executed" 45 | 1.must_equal 1 46 | end 47 | end 48 | end 49 | 50 | Thread.new do 51 | Minitest.module_eval do 52 | sleep 0.01 until @parallel_fork_stop == :FINISHED 53 | 54 | # Delete the lock file after signaling child processes 55 | File.delete(lock_file) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/minitest_parallel_fork_interrupt_example.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/global_expectations/autorun' 2 | require 'minitest/parallel_fork/interrupt' 3 | 4 | 4.times do |i| 5 | describe "test suite with interrupt - #{i}" do 6 | it "should wait" do 7 | sleep(1) while true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/minitest_parallel_fork_pass_example.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/global_expectations/autorun' 2 | require 'minitest/hooks/default' if ENV['MPF_MINITEST_HOOKS'] 3 | 4 | if ENV['MPF_FAIL_FAST'] 5 | require 'minitest/parallel_fork/fail_fast' 6 | else 7 | require 'minitest/parallel_fork' 8 | end 9 | 10 | 8.times do |i| 11 | describe "test suite #{i}" do 12 | parallelize_me! if ENV['MPF_PARALLELIZE_ME'] 13 | 14 | if ENV['MPF_TEST_ORDER_PARALLEL'] 15 | def self.test_order 16 | :parallel 17 | end 18 | end 19 | 20 | 4.times do |j| 21 | it "spec #{i}-#{j}" do 22 | 1.must_equal 1 23 | 2.must_equal 2 24 | end 25 | end 26 | 27 | it "skip-#{i}" do 28 | skip 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/minitest_parallel_fork_spec.rb: -------------------------------------------------------------------------------- 1 | if ENV.delete('COVERAGE') 2 | require_relative 'simplecov_helper' 3 | end 4 | 5 | ENV['MT_NO_PLUGINS'] = '1' 6 | require 'minitest/global_expectations/autorun' 7 | 8 | ENV['NCPU'] = '4' 9 | 10 | require 'open3' 11 | 12 | describe 'minitest/parallel_fork' do 13 | def run_mpf(*env_keys) 14 | env_keys.each{|k| ENV[k] = '1'} 15 | t = Time.now 16 | output = `#{ENV['RUBY']} -I lib spec/minitest_parallel_fork_#{@example_prefix}example.rb 2>&1` 17 | [output, Time.now - t] 18 | ensure 19 | env_keys.each{|k| ENV.delete(k)} 20 | end 21 | 22 | [[nil, ''], 23 | ['MPF_PARALLELIZE_ME', ' when parallelize_me! is used'], 24 | ['MPF_TEST_ORDER_PARALLEL', ' when test_order parallel is used'], 25 | ['MPF_NO_HOOKS', ' when no hooks are used'] 26 | ].each do |env_key, msg| 27 | it "should execute in parallel#{msg}" do 28 | output, time = run_mpf(*env_key) 29 | time.must_be :<, 4 30 | time.must_be :>, 1 31 | output.must_include '20 runs, 8 assertions, 4 failures, 8 errors, 4 skips' 32 | 33 | unless env_key == 'MPF_NO_HOOKS' 34 | output.must_include ':parent' 35 | 4.times do |i| 36 | output.must_include ":child#{i}a" 37 | end 38 | end 39 | end 40 | end 41 | 42 | [[nil, ''], 43 | ['MPF_PARALLELIZE_ME', ' when parallelize_me! is used'], 44 | ['MPF_TEST_ORDER_PARALLEL', ' when test_order parallel is used'], 45 | ['MPF_MINITEST_HOOKS', ' when minitest/hooks is used'], 46 | ['MPF_FAIL_FAST', ' when using fail fast support'], 47 | ].each do |env_key, msg| 48 | it "should execute in parallel#{msg} with passing test suite" do 49 | @example_prefix = 'pass_' 50 | output, = run_mpf(*env_key) 51 | output.must_include '40 runs, 64 assertions, 0 failures, 0 errors, 8 skips' 52 | end 53 | end 54 | 55 | it "should handle marshal failures without on_parallel_fork_marshal_failure" do 56 | output, time = run_mpf('MPF_TEST_CHILD_FAILURE', 'MPF_NO_HOOKS') 57 | time.must_be :<, 4 58 | output.must_include 'marshal data too short' 59 | end 60 | 61 | it "should call on_parallel_fork_marshal_failure on failure" do 62 | output, time = run_mpf('MPF_TEST_CHILD_FAILURE') 63 | time.must_be :<, 4 64 | output.must_include ':child-failurea' 65 | output.must_include 'marshal data too short' 66 | end 67 | 68 | it "should handle failures in *_all methods when using minitest-hooks" do 69 | output, time = run_mpf('MPF_MINITEST_HOOKS') 70 | time.must_be :<, 4 71 | output.must_include '23 runs, 8 assertions, 4 failures, 10 errors, 4 skips' 72 | end 73 | 74 | it "should support several statistics reporters" do 75 | output, time = run_mpf('MPF_SEVERAL_STATISTICS_REPORTERS') 76 | time.must_be :<, 4 77 | output.must_include '20 runs, 8 assertions, 4 failures, 8 errors, 4 skip' 78 | output.must_include 'Stats: 20R, 8A, 4F, 8E, 4S' 79 | end 80 | 81 | it "should stop all serial executions when and Interrupt is raised" do 82 | @example_prefix = 'fail_fast_' 83 | output, time = run_mpf 84 | time.must_be :<, 1 85 | output.must_include '4 runs, 7 assertions, 1 failures, 0 errors, 0 skips' 86 | output.wont_include 'not_executed' 87 | end 88 | 89 | it "should force stop all forks with 2x interrupt" do 90 | command = "#{ENV['RUBY']} -I lib spec/minitest_parallel_fork_interrupt_example.rb" 91 | 92 | stdout = nil 93 | stderr = nil 94 | Open3.popen3(command) do |stdin, out, err, wait_thr| 95 | stdin.close 96 | sleep 1 97 | Process.kill('INT', wait_thr.pid) 98 | sleep 0.2 99 | begin 100 | Process.kill('INT', wait_thr.pid) 101 | rescue Errno::ESRCH 102 | # Already exited 103 | end 104 | wait_thr.value.exitstatus.must_equal 1 105 | stdout = out.read 106 | stderr = err.read 107 | end 108 | 109 | stdout.must_include "Run options: --seed" 110 | stdout.must_include "# Running:" 111 | stderr.must_include "Interrupted.\nExiting ...\nInterrupt again to exit immediately." 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /spec/simplecov_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | SimpleCov.instance_exec do 4 | enable_coverage :branch 5 | add_filter "/spec/" 6 | add_group('Missing'){|src| src.covered_percent < 100} 7 | add_group('Covered'){|src| src.covered_percent == 100} 8 | enable_for_subprocesses true 9 | 10 | at_fork do |pid| 11 | command_name "#{SimpleCov.command_name} (subprocess: #{pid})" 12 | self.print_error_status = false 13 | formatter SimpleCov::Formatter::SimpleFormatter 14 | minimum_coverage 0 15 | start rescue nil 16 | end 17 | 18 | if ENV['COVERAGE'] == 'subprocess' 19 | ENV.delete('COVERAGE') 20 | command_name 'spawn' 21 | at_fork.call(Process.pid) 22 | else 23 | ENV['COVERAGE'] = 'subprocess' 24 | ENV['RUBYOPT'] = "#{ENV['RUBYOPT']} -r ./spec/simplecov_helper" 25 | start 26 | end 27 | end 28 | --------------------------------------------------------------------------------