-
-
Notifications
You must be signed in to change notification settings - Fork 752
These changes add handling of an additional parameter "--parallel-test" #1527
Changes from all commits
b4959a9
c2ee7ec
65d4d62
148e081
c898ab0
708e869
28a18e5
3ace928
54b876e
060cb34
9603ae4
5145ed9
38205df
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
Feature: `--parallel-test` option | ||
|
||
Use the `--parallel-test` option to have RSpec run using multiple threads in parallel | ||
|
||
Scenario: Using `--parallel-test 3` | ||
Given a file named "spec/parallel_test_spec.rb" with: | ||
"""ruby | ||
RSpec.configure do |c| | ||
c.before(:suite) { puts "before suite" } | ||
c.after(:suite) { puts "after suite" } | ||
end | ||
|
||
RSpec.describe "parallel run" do | ||
before(:context) { puts "before context" } | ||
before(:example) { puts "before example" } | ||
|
||
it "thread 0 example" do | ||
expect(true).to be(false) | ||
end | ||
|
||
it "thread 1 example" do | ||
expect(true).to be(true) | ||
end | ||
|
||
it "thread 2 example" do | ||
expect(true).to be(true) | ||
end | ||
|
||
after(:example) { puts "after example" } | ||
after(:context) { puts "after context" } | ||
end | ||
""" | ||
When I run `rspec --parallel-test 3` | ||
Then the output should contain "3 examples, 1 failure" | ||
And the output should contain "before suite" | ||
And the output should contain "after suite" | ||
And the output should contain "before context" | ||
And the output should contain "after context" | ||
And the output should contain "before example" | ||
And the output should contain "after example" |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -469,6 +469,34 @@ def self.run(reporter) | |
end | ||
end | ||
|
||
# Runs all the examples in this group | ||
def self.run_parallel(reporter, num_threads, mutex, used_threads) | ||
if RSpec.world.wants_to_quit | ||
RSpec.world.clear_remaining_example_groups if top_level? | ||
return | ||
end | ||
reporter.example_group_started(self) | ||
|
||
begin | ||
run_before_context_hooks(new) | ||
example_threads = RSpec::Core::ExampleThreadRunner.new(num_threads, used_threads) | ||
run_examples_parallel(reporter, example_threads, mutex) | ||
ordering_strategy.order(children).map { |child| child.run_parallel(reporter, num_threads, mutex, used_threads) } | ||
# ensure all examples in the group are done before running 'after :all' | ||
# this does NOT prevent utilization of other available threads | ||
example_threads.wait_for_completion | ||
rescue Pending::SkipDeclaredInExample => ex | ||
for_filtered_examples(reporter) { |example| example.skip_with_exception(reporter, ex) } | ||
rescue Exception => ex | ||
RSpec.world.wants_to_quit = true if fail_fast? | ||
for_filtered_examples(reporter) { |example| example.fail_with_exception(reporter, ex) } | ||
ensure | ||
run_after_context_hooks(new) | ||
before_context_ivars.clear | ||
reporter.example_group_finished(self) | ||
end | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks like it has a lot in common with Actually, I wonder if we can come up with an injectable run strategy abstraction that can have parallel and serial implementations? |
||
|
||
# @private | ||
def self.ordering_strategy | ||
order = metadata.fetch(:order, :global) | ||
|
@@ -497,6 +525,18 @@ def self.run_examples(reporter) | |
end.all? | ||
end | ||
|
||
# @private | ||
def self.run_examples_parallel(reporter, threads, mutex) | ||
ordering_strategy.order(filtered_examples).map do |example| | ||
next if RSpec.world.wants_to_quit | ||
instance = new | ||
set_ivars(instance, before_context_ivars) | ||
mutex.synchronize do | ||
threads.run(example, instance, reporter) | ||
end | ||
end | ||
end | ||
|
||
# @private | ||
def self.for_filtered_examples(reporter, &block) | ||
filtered_examples.each(&block) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
module RSpec | ||
module Core | ||
# ExampleGroupThreadRunner is a class used to execute [ExampleGroup] | ||
# classes in parallel as part of rspec-core. When running in parallel | ||
# the order of example groups will not be honoured. | ||
# This class is used to ensure that we have a way of keeping track of | ||
# the number of threads being created and preventing utilization of | ||
# more than the specified number | ||
# Additionally, this class will contain a mutex used to prevent access | ||
# to shared variables within sub-threads | ||
class ExampleGroupThreadRunner | ||
attr_accessor :thread_array, :max_threads, :mutex, :used_threads | ||
|
||
# Creates a new instance of ExampleGroupThreadRunner. | ||
# @param max_threads [Integer] the maximum limit of threads that can be used | ||
# @param mutex [Mutex] a semaphore used to prevent access to shared variables in | ||
# sub-threads such as those used by [ExampleThreadRunner] | ||
# @param used_threads [Integer] the current number of threads being used | ||
def initialize(max_threads = 1, mutex = Mutex.new, used_threads = 0) | ||
@max_threads = max_threads | ||
@mutex = mutex | ||
@used_threads = used_threads | ||
@thread_array = [] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given that an instance of this is creating in a shared state ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a new ExampleGroupThreadRunner is created for each example_group as long as there are enough available threads to support doing so. Does this answer why it wouldn't be a thread local? |
||
end | ||
|
||
# Method will run an [ExampleGroup] inside a [Thread] to prevent blocking | ||
# execution. The new [Thread] is added to an array for tracking and | ||
# will automatically remove itself when done | ||
# @param example_group [ExampleGroup] the group to be run inside a [Thread] | ||
# @param reporter [Reporter] the passed in reporting class used for | ||
# tracking | ||
def run(example_group, reporter) | ||
@thread_array.push Thread.start { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
example_group.run_parallel(reporter, @max_threads, @mutex, @used_threads) | ||
@thread_array.delete Thread.current | ||
} | ||
end | ||
|
||
# Method will wait for all threads to complete. On completion threads | ||
# remove themselves from the @thread_array so an empty array means they | ||
# completed | ||
def wait_for_completion | ||
@thread_array.each do |t| | ||
t.join | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
module RSpec | ||
module Core | ||
# ExampleThreadRunner is a class used to execute [Example] classes in | ||
# parallel as part of rspec-core. When running in parallel the order | ||
# of examples will not be honoured. | ||
# This class is used to ensure that we have a way of keeping track of | ||
# the number of threads being created and preventing utilization of | ||
# more than the specified number | ||
class ExampleThreadRunner | ||
attr_accessor :num_threads, :thread_array, :used_threads | ||
|
||
# Creates a new instance of ExampleThreadRunner. | ||
# @param num_threads [Integer] the maximum limit of threads that can be used | ||
# @param used_threads [Integer] the current number of threads being used | ||
def initialize(num_threads, used_threads) | ||
@num_threads = num_threads | ||
@thread_array = [] | ||
@used_threads = used_threads | ||
end | ||
|
||
# Method will check global utilization of threads and if that number is | ||
# at or over the allocated maximum it will wait until a thread is available | ||
def wait_for_available_thread | ||
while @used_threads.to_i >= @num_threads.to_i | ||
sleep 0.1 | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a preference for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Personally I like while here. |
||
end | ||
|
||
# Method will run the specified example within an available thread or | ||
# will wait for a thread to become available if none currently are | ||
# @param example [Example] the example to be executed in a [Thread] | ||
# @param instance the instance of an ExampleGroup subclass | ||
# @param reporter [Reporter] the passed in reporting class used for | ||
# tracking | ||
def run(example, instance, reporter) | ||
wait_for_available_thread | ||
@thread_array.push Thread.start { | ||
example.run(instance, reporter) | ||
@thread_array.delete Thread.current # remove from local scope | ||
@used_threads -= 1 | ||
} | ||
@used_threads += 1 | ||
end | ||
|
||
# Method will wait for all threads to complete. On completion threads | ||
# remove themselves from the @thread_array so an empty array means they | ||
# completed | ||
def wait_for_completion | ||
@thread_array.each do |t| | ||
t.join | ||
end | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -137,6 +137,20 @@ def parser(options) | |
$VERBOSE = true | ||
end | ||
|
||
parser.on('--parallel-test NUMBER', 'Run the tests with the specified number of parallel threads (default: 1).') do |n| | ||
options[:thread_maximum] = if !n.nil? | ||
begin | ||
Integer(n) | ||
rescue ArgumentError | ||
RSpec.warning "Non integer specified as number of parallel threads, seperate " + | ||
"your path from options with a space e.g. " + | ||
"`rspec --parallel-test #{n}`", | ||
:call_site => nil | ||
1 | ||
end | ||
end | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lines are too long, break on the = if and indent 2 in from option There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a direct copy of the -p option's line length and spacing. Would you prefer it not match and instead follow your specified indent scheme? |
||
|
||
parser.separator <<-FILTERING | ||
|
||
**** Filtering/tags **** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -83,7 +83,12 @@ def initialize(options, configuration=RSpec.configuration, world=RSpec.world) | |
# @param out [IO] output stream | ||
def run(err, out) | ||
setup(err, out) | ||
run_specs(@world.ordered_example_groups) | ||
if @options.options[:thread_maximum].nil? | ||
run_specs(@world.ordered_example_groups) | ||
else | ||
require 'thread' | ||
run_specs_parallel(@world.ordered_example_groups) | ||
end | ||
end | ||
|
||
# Wires together the various configuration objects and state holders. | ||
|
@@ -116,6 +121,33 @@ def run_specs(example_groups) | |
end | ||
end | ||
|
||
# Runs the provided example groups in parallel. | ||
# | ||
# @param example_groups [Array<RSpec::Core::ExampleGroup>] groups to run | ||
# @return [Fixnum] exit status code. 0 if all specs passed, | ||
# or the configured failure exit code (1 by default) if specs | ||
# failed. | ||
def run_specs_parallel(example_groups) | ||
@configuration.reporter.report(@world.example_count(example_groups)) do |reporter| | ||
begin | ||
hook_context = SuiteHookContext.new | ||
@configuration.hooks.run(:before, :suite, hook_context) | ||
|
||
group_threads = RSpec::Core::ExampleGroupThreadRunner.new(@configuration.thread_maximum) | ||
example_groups.each { |g| group_threads.run(g, reporter) } | ||
group_threads.wait_for_completion | ||
|
||
example_groups.all? do |g| | ||
result_for_this_group = g.filtered_examples.all? { |example| example.metadata[:execution_result].exception.nil? } | ||
results_for_descendants = g.children.all? { |child| child.filtered_examples.all? { |example| example.metadata[:execution_result].exception.nil? } } | ||
result_for_this_group && results_for_descendants | ||
end ? 0 : @configuration.failure_exit_code | ||
ensure | ||
@configuration.hooks.run(:after, :suite, hook_context) | ||
end | ||
end | ||
end | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This also has a lot in common with the non parallel form of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I asked @bicarbon8 to make sure |
||
|
||
# @private | ||
def self.disable_autorun! | ||
@autorun_disabled = true | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
require 'spec_helper' | ||
|
||
RSpec.describe 'command line', :ui, :slow do | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why 3 lines of whitespace? 1 please. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||
before :each do | ||
@out = StringIO.new | ||
write_file 'spec/parallel_spec.rb', """ | ||
RSpec.describe 'parallel run' do | ||
it 'thread 0 example' do | ||
sleep 1 | ||
fail | ||
end | ||
|
||
it 'thread 1 example' do | ||
sleep 1 | ||
pass | ||
end | ||
|
||
it 'thread 2 example' do | ||
sleep 1 | ||
pass | ||
end | ||
|
||
it 'thread 3 example' do | ||
sleep 1 | ||
fail | ||
end | ||
|
||
it 'thread 4 example' do | ||
sleep 1 | ||
pass | ||
end | ||
|
||
it 'thread 5 example' do | ||
sleep 1 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not really a fan of putting sleeps in the test suite like this...why are they needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these tests confirm that the execution time is lessened by adding more parallel threads so there needs to be some time spent in actually running the tests. Fortunately it is only a net result of 10 seconds added to the test suite so I wouldn't say it has much overall impact on the build time (approximately a 2% impact I believe). If you have another recommendation though I'll gladly see what I can do. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Er, 10 seconds to the build suite is more than my entire spec run on my machine, like 300% more (rspec spec runs in about 3 seconds for me). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that it greatly increases the spec test time, but the overall impact to the build process is still small. That said though, I'm open to other recommendations that would net the same result... Thoughts? |
||
pass | ||
end | ||
end | ||
""" | ||
end | ||
|
||
describe '--parallel-test' do | ||
it '1 thread' do | ||
run_command 'spec/parallel_spec.rb --parallel-test 1' | ||
output_str = @out.string | ||
validate_output(output_str) | ||
seconds = get_seconds(output_str).to_i | ||
expect(seconds).to be >= 6 | ||
end | ||
|
||
it '3 threads' do | ||
run_command 'spec/parallel_spec.rb --parallel-test 3' | ||
output_str = @out.string | ||
validate_output(output_str) | ||
seconds = get_seconds(output_str).to_i | ||
expect(seconds).to be >= 2 | ||
expect(seconds).to be < 7 | ||
end | ||
|
||
it '6 threads' do | ||
run_command 'spec/parallel_spec.rb --parallel-test 6' | ||
output_str = @out.string | ||
validate_output(output_str) | ||
seconds = get_seconds(output_str).to_i | ||
expect(seconds).to be >= 0 | ||
expect(seconds).to be < 4 | ||
end | ||
end | ||
|
||
def run_command(cmd) | ||
in_current_dir do | ||
RSpec::Core::Runner.run(cmd.split, @out, @out) | ||
end | ||
end | ||
|
||
def validate_output(output_str) | ||
expect(output_str).to include("6 examples, 2 failures"), "output_str: #{output_str}" | ||
expect(output_str).to include("Finished in "), "output_str: #{output_str}" | ||
end | ||
|
||
def get_seconds(output_str) | ||
return output_str[/Finished in (.*) second/, 1] | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
all?
can take a block. Sincesucceeded?
is simply an attr and has no side-effects, why not leverage that:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍