Run tests in parallel via parallel_tests

Uses the parallel_tests gem to execute tests in multiple processes
simultaneously on the same machine.

Adds the `--parallel` CLI option that instructs the QA framework
to use the parallel_tests executable.

Tests need access to global state contained in `Runtime::Scenario`
so when `--parallel` is invoked `Runtime::Scenario` is serialized
to an environment variable, which is passed to parallel_tests,
and then deserialized in `spec_helper`.
This commit is contained in:
Mark Lapierre 2019-07-09 15:40:46 +00:00 committed by Lin Jen-Shin
parent ebcf92c585
commit 7d97102f72
14 changed files with 178 additions and 38 deletions

5
qa/.rspec_parallel Normal file
View file

@ -0,0 +1,5 @@
--color
--format documentation
--format ParallelTests::RSpec::SummaryLogger --out tmp/spec_summary.log
--format ParallelTests::RSpec::RuntimeLogger --out tmp/parallel_runtime_rspec.log
--require spec_helper

View file

@ -1,5 +1,6 @@
source 'https://rubygems.org' source 'https://rubygems.org'
gem 'gitlab-qa'
gem 'pry-byebug', '~> 3.5.1', platform: :mri gem 'pry-byebug', '~> 3.5.1', platform: :mri
gem 'capybara', '~> 2.16.1' gem 'capybara', '~> 2.16.1'
gem 'capybara-screenshot', '~> 1.0.18' gem 'capybara-screenshot', '~> 1.0.18'
@ -11,3 +12,4 @@ gem 'nokogiri', '~> 1.10.3'
gem 'rspec-retry', '~> 0.6.1' gem 'rspec-retry', '~> 0.6.1'
gem 'faker', '~> 1.6', '>= 1.6.6' gem 'faker', '~> 1.6', '>= 1.6.6'
gem 'knapsack', '~> 1.17' gem 'knapsack', '~> 1.17'
gem 'parallel_tests', '~> 2.29'

View file

@ -35,6 +35,7 @@ GEM
faker (1.9.3) faker (1.9.3)
i18n (>= 0.7) i18n (>= 0.7)
ffi (1.9.25) ffi (1.9.25)
gitlab-qa (4.0.0)
http-cookie (1.0.3) http-cookie (1.0.3)
domain_name (~> 0.5) domain_name (~> 0.5)
i18n (0.9.1) i18n (0.9.1)
@ -53,6 +54,9 @@ GEM
netrc (0.11.0) netrc (0.11.0)
nokogiri (1.10.3) nokogiri (1.10.3)
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
parallel (1.17.0)
parallel_tests (2.29.0)
parallel
pry (0.11.3) pry (0.11.3)
coderay (~> 1.1.0) coderay (~> 1.1.0)
method_source (~> 0.9.0) method_source (~> 0.9.0)
@ -104,8 +108,10 @@ DEPENDENCIES
capybara (~> 2.16.1) capybara (~> 2.16.1)
capybara-screenshot (~> 1.0.18) capybara-screenshot (~> 1.0.18)
faker (~> 1.6, >= 1.6.6) faker (~> 1.6, >= 1.6.6)
gitlab-qa
knapsack (~> 1.17) knapsack (~> 1.17)
nokogiri (~> 1.10.3) nokogiri (~> 1.10.3)
parallel_tests (~> 2.29)
pry-byebug (~> 3.5.1) pry-byebug (~> 3.5.1)
rake (~> 12.3.0) rake (~> 12.3.0)
rspec (~> 3.7) rspec (~> 3.7)

View file

@ -360,6 +360,7 @@ module QA
module Specs module Specs
autoload :Config, 'qa/specs/config' autoload :Config, 'qa/specs/config'
autoload :Runner, 'qa/specs/runner' autoload :Runner, 'qa/specs/runner'
autoload :ParallelRunner, 'qa/specs/parallel_runner'
module Helpers module Helpers
autoload :Quarantine, 'qa/specs/helpers/quarantine' autoload :Quarantine, 'qa/specs/helpers/quarantine'

View file

@ -13,6 +13,8 @@ module QA
NotRespondingError = Class.new(RuntimeError) NotRespondingError = Class.new(RuntimeError)
CAPYBARA_MAX_WAIT_TIME = 10
def initialize def initialize
self.class.configure! self.class.configure!
end end
@ -43,6 +45,8 @@ module QA
end end
end end
Capybara.server_port = 9887 + ENV['TEST_ENV_NUMBER'].to_i
return if Capybara.drivers.include?(:chrome) return if Capybara.drivers.include?(:chrome)
Capybara.register_driver QA::Runtime::Env.browser do |app| Capybara.register_driver QA::Runtime::Env.browser do |app|
@ -119,7 +123,7 @@ module QA
Capybara.configure do |config| Capybara.configure do |config|
config.default_driver = QA::Runtime::Env.browser config.default_driver = QA::Runtime::Env.browser
config.javascript_driver = QA::Runtime::Env.browser config.javascript_driver = QA::Runtime::Env.browser
config.default_max_wait_time = 10 config.default_max_wait_time = CAPYBARA_MAX_WAIT_TIME
# https://github.com/mattheworiordan/capybara-screenshot/issues/164 # https://github.com/mattheworiordan/capybara-screenshot/issues/164
config.save_path = ::File.expand_path('../../tmp', __dir__) config.save_path = ::File.expand_path('../../tmp', __dir__)
end end

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'gitlab/qa'
module QA module QA
module Runtime module Runtime
module Env module Env
@ -7,6 +9,8 @@ module QA
attr_writer :personal_access_token, :ldap_username, :ldap_password attr_writer :personal_access_token, :ldap_username, :ldap_password
ENV_VARIABLES = Gitlab::QA::Runtime::Env::ENV_VARIABLES
# The environment variables used to indicate if the environment under test # The environment variables used to indicate if the environment under test
# supports the given feature # supports the given feature
SUPPORTED_FEATURES = { SUPPORTED_FEATURES = {
@ -201,6 +205,10 @@ module QA
enabled?(ENV[SUPPORTED_FEATURES[feature]], default: true) enabled?(ENV[SUPPORTED_FEATURES[feature]], default: true)
end end
def runtime_scenario_attributes
ENV['QA_RUNTIME_SCENARIO_ATTRIBUTES']
end
private private
def remote_grid_credentials def remote_grid_credentials

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'json'
module QA module QA
module Runtime module Runtime
## ##
@ -24,6 +26,10 @@ module QA
end end
end end
def from_env(var)
JSON.parse(Runtime::Env.runtime_scenario_attributes).each { |k, v| define(k, v) }
end
def method_missing(name, *) def method_missing(name, *)
raise ArgumentError, "Scenario attribute `#{name}` not defined!" raise ArgumentError, "Scenario attribute `#{name}` not defined!"
end end

View file

@ -7,6 +7,7 @@ module QA
attribute :gitlab_address, '--address URL', 'Address of the instance to test' attribute :gitlab_address, '--address URL', 'Address of the instance to test'
attribute :enable_feature, '--enable-feature FEATURE_FLAG', 'Enable a feature before running tests' attribute :enable_feature, '--enable-feature FEATURE_FLAG', 'Enable a feature before running tests'
attribute :parallel, '--parallel', 'Execute tests in parallel'
end end
end end
end end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
require 'open3'
module QA
module Specs
module ParallelRunner
module_function
def run(args)
unless args.include?('--')
index = args.index { |opt| opt.include?('features') }
args.insert(index, '--') if index
end
env = {}
Runtime::Env::ENV_VARIABLES.each_key do |key|
env[key] = ENV[key] if ENV[key]
end
env['QA_RUNTIME_SCENARIO_ATTRIBUTES'] = Runtime::Scenario.attributes.to_json
env['GITLAB_QA_ACCESS_TOKEN'] = Runtime::API::Client.new(:gitlab).personal_access_token unless env['GITLAB_QA_ACCESS_TOKEN']
cmd = "bundle exec parallel_test -t rspec --combine-stderr --serialize-stdout -- #{args.flatten.join(' ')}"
::Open3.popen2e(env, cmd) do |_, out, wait|
out.each { |line| puts line }
exit wait.value.exitstatus
end
end
end
end
end

View file

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'knapsack'
require 'rspec/core' require 'rspec/core'
require 'rspec/expectations' require 'rspec/expectations'
require 'knapsack'
module QA module QA
module Specs module Specs
@ -17,44 +17,56 @@ module QA
@options = [] @options = []
end end
def paths_from_knapsack
allocator = Knapsack::AllocatorBuilder.new(Knapsack::Adapters::RSpecAdapter).allocator
QA::Runtime::Logger.info ''
QA::Runtime::Logger.info 'Report specs:'
QA::Runtime::Logger.info allocator.report_node_tests.join(', ')
QA::Runtime::Logger.info ''
QA::Runtime::Logger.info 'Leftover specs:'
QA::Runtime::Logger.info allocator.leftover_node_tests.join(', ')
QA::Runtime::Logger.info ''
['--', allocator.node_tests]
end
def rspec_tags
tags_for_rspec = []
if tags.any?
tags.each { |tag| tags_for_rspec.push(['--tag', tag.to_s]) }
else
tags_for_rspec.push(%w[--tag ~orchestrated]) unless (%w[-t --tag] & options).any?
end
tags_for_rspec.push(%w[--tag ~skip_signup_disabled]) if QA::Runtime::Env.signup_disabled?
QA::Runtime::Env.supported_features.each_key do |key|
tags_for_rspec.push(%W[--tag ~requires_#{key}]) unless QA::Runtime::Env.can_test? key
end
tags_for_rspec
end
def perform def perform
args = [] args = []
args.push('--tty') if tty args.push('--tty') if tty
args.push(rspec_tags)
if tags.any?
tags.each { |tag| args.push(['--tag', tag.to_s]) }
else
args.push(%w[--tag ~orchestrated]) unless (%w[-t --tag] & options).any?
end
args.push(%w[--tag ~skip_signup_disabled]) if QA::Runtime::Env.signup_disabled?
QA::Runtime::Env.supported_features.each_key do |key|
args.push(["--tag", "~requires_#{key}"]) unless QA::Runtime::Env.can_test? key
end
args.push(options) args.push(options)
Runtime::Browser.configure!
if Runtime::Env.knapsack? if Runtime::Env.knapsack?
allocator = Knapsack::AllocatorBuilder.new(Knapsack::Adapters::RSpecAdapter).allocator args.push(paths_from_knapsack)
QA::Runtime::Logger.info ''
QA::Runtime::Logger.info 'Report specs:'
QA::Runtime::Logger.info allocator.report_node_tests.join(', ')
QA::Runtime::Logger.info ''
QA::Runtime::Logger.info 'Leftover specs:'
QA::Runtime::Logger.info allocator.leftover_node_tests.join(', ')
QA::Runtime::Logger.info ''
args.push(['--', allocator.node_tests])
else else
args.push(DEFAULT_TEST_PATH_ARGS) unless options.any? { |opt| opt =~ %r{/features/} } args.push(DEFAULT_TEST_PATH_ARGS) unless options.any? { |opt| opt =~ %r{/features/} }
end end
RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status| if Runtime::Scenario.attributes[:parallel]
abort if status.nonzero? ParallelRunner.run(args.flatten)
else
RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status|
abort if status.nonzero?
end
end end
end end
end end

View file

@ -91,26 +91,26 @@ describe QA::Support::Page::Logging do
it 'logs has_element?' do it 'logs has_element?' do
expect { subject.has_element?(:element) } expect { subject.has_element?(:element) }
.to output(/has_element\? :element \(wait: 2\) returned: true/).to_stdout_from_any_process .to output(/has_element\? :element \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
end end
it 'logs has_element? with text' do it 'logs has_element? with text' do
expect { subject.has_element?(:element, text: "some text") } expect { subject.has_element?(:element, text: "some text") }
.to output(/has_element\? :element with text \"some text\" \(wait: 2\) returned: true/).to_stdout_from_any_process .to output(/has_element\? :element with text \"some text\" \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
end end
it 'logs has_no_element?' do it 'logs has_no_element?' do
allow(page).to receive(:has_no_css?).and_return(true) allow(page).to receive(:has_no_css?).and_return(true)
expect { subject.has_no_element?(:element) } expect { subject.has_no_element?(:element) }
.to output(/has_no_element\? :element \(wait: 2\) returned: true/).to_stdout_from_any_process .to output(/has_no_element\? :element \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
end end
it 'logs has_no_element? with text' do it 'logs has_no_element? with text' do
allow(page).to receive(:has_no_css?).and_return(true) allow(page).to receive(:has_no_css?).and_return(true)
expect { subject.has_no_element?(:element, text: "more text") } expect { subject.has_no_element?(:element, text: "more text") }
.to output(/has_no_element\? :element with text \"more text\" \(wait: 2\) returned: true/).to_stdout_from_any_process .to output(/has_no_element\? :element with text \"more text\" \(wait: #{QA::Runtime::Browser::CAPYBARA_MAX_WAIT_TIME}\) returned: true/).to_stdout_from_any_process
end end
it 'logs has_text?' do it 'logs has_text?' do

View file

@ -8,6 +8,10 @@ if ENV['CI'] && QA::Runtime::Env.knapsack? && !ENV['NO_KNAPSACK']
Knapsack::Adapters::RSpecAdapter.bind Knapsack::Adapters::RSpecAdapter.bind
end end
QA::Runtime::Browser.configure!
QA::Runtime::Scenario.from_env(QA::Runtime::Env.runtime_scenario_attributes) if QA::Runtime::Env.runtime_scenario_attributes
%w[helpers shared_examples].each do |d| %w[helpers shared_examples].each do |d|
Dir[::File.join(__dir__, d, '**', '*.rb')].each { |f| require f } Dir[::File.join(__dir__, d, '**', '*.rb')].each { |f| require f }
end end

View file

@ -0,0 +1,58 @@
# frozen_string_literal: true
describe QA::Specs::ParallelRunner do
include Helpers::StubENV
before do
allow(QA::Runtime::Scenario).to receive(:attributes).and_return(parallel: true)
stub_env('GITLAB_QA_ACCESS_TOKEN', 'skip_token_creation')
end
it 'passes args to parallel_tests' do
expect_cli_arguments(['--tag', '~orchestrated', *QA::Specs::Runner::DEFAULT_TEST_PATH_ARGS])
subject.run(['--tag', '~orchestrated', *QA::Specs::Runner::DEFAULT_TEST_PATH_ARGS])
end
it 'passes a given test path to parallel_tests and adds a separator' do
expect_cli_arguments(%w[-- qa/specs/features/foo])
subject.run(%w[qa/specs/features/foo])
end
it 'passes tags and test paths to parallel_tests and adds a separator' do
expect_cli_arguments(%w[--tag smoke -- qa/specs/features/foo qa/specs/features/bar])
subject.run(%w[--tag smoke qa/specs/features/foo qa/specs/features/bar])
end
it 'passes tags and test paths with separators to parallel_tests' do
expect_cli_arguments(%w[-- --tag smoke -- qa/specs/features/foo qa/specs/features/bar])
subject.run(%w[-- --tag smoke -- qa/specs/features/foo qa/specs/features/bar])
end
it 'passes supported environment variables' do
# Test only env vars starting with GITLAB because some of the others
# affect how the runner behaves, and we're not concerned with those
# behaviors in this test
gitlab_env_vars = QA::Runtime::Env::ENV_VARIABLES.reject { |v| !v.start_with?('GITLAB') }
gitlab_env_vars.each do |k, v|
stub_env(k, v)
end
gitlab_env_vars['QA_RUNTIME_SCENARIO_ATTRIBUTES'] = '{"parallel":true}'
expect_cli_arguments([], gitlab_env_vars)
subject.run([])
end
def expect_cli_arguments(arguments, env = { 'QA_RUNTIME_SCENARIO_ATTRIBUTES' => '{"parallel":true}' })
cmd = "bundle exec parallel_test -t rspec --combine-stderr --serialize-stdout -- #{arguments.join(' ')}"
expect(Open3).to receive(:popen2e)
.with(hash_including(env), cmd)
.and_return(0)
end
end

View file

@ -58,11 +58,11 @@ describe QA::Specs::Runner do
end end
end end
context 'when "-- qa/specs/features/foo" is set as options' do context 'when "--tag smoke" and "qa/specs/features/foo" are set as options' do
subject { described_class.new.tap { |runner| runner.options = %w[-- qa/specs/features/foo] } } subject { described_class.new.tap { |runner| runner.options = %w[--tag smoke qa/specs/features/foo] } }
it 'passes the given tests path and excludes the orchestrated tag' do it 'focuses on the given tag and includes the path without excluding the orchestrated tag' do
expect_rspec_runner_arguments(['--tag', '~orchestrated', '--', 'qa/specs/features/foo']) expect_rspec_runner_arguments(['--tag', 'smoke', 'qa/specs/features/foo'])
subject.perform subject.perform
end end