vinted-parallel_tests 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +2 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +6 -0
  5. data/Gemfile +8 -0
  6. data/Gemfile.lock +48 -0
  7. data/Rakefile +6 -0
  8. data/Readme.md +293 -0
  9. data/ReadmeRails2.md +48 -0
  10. data/bin/parallel_cucumber +5 -0
  11. data/bin/parallel_rspec +5 -0
  12. data/bin/parallel_test +5 -0
  13. data/lib/parallel_tests/cli.rb +187 -0
  14. data/lib/parallel_tests/cucumber/failures_logger.rb +25 -0
  15. data/lib/parallel_tests/cucumber/gherkin_listener.rb +82 -0
  16. data/lib/parallel_tests/cucumber/io.rb +41 -0
  17. data/lib/parallel_tests/cucumber/runner.rb +98 -0
  18. data/lib/parallel_tests/cucumber/runtime_logger.rb +28 -0
  19. data/lib/parallel_tests/grouper.rb +56 -0
  20. data/lib/parallel_tests/railtie.rb +8 -0
  21. data/lib/parallel_tests/rspec/failures_logger.rb +44 -0
  22. data/lib/parallel_tests/rspec/logger_base.rb +52 -0
  23. data/lib/parallel_tests/rspec/runner.rb +72 -0
  24. data/lib/parallel_tests/rspec/runtime_logger.rb +54 -0
  25. data/lib/parallel_tests/rspec/summary_logger.rb +19 -0
  26. data/lib/parallel_tests/tasks.rb +139 -0
  27. data/lib/parallel_tests/test/runner.rb +168 -0
  28. data/lib/parallel_tests/test/runtime_logger.rb +97 -0
  29. data/lib/parallel_tests/version.rb +3 -0
  30. data/lib/parallel_tests.rb +61 -0
  31. data/parallel_tests.gemspec +14 -0
  32. data/spec/integration_spec.rb +285 -0
  33. data/spec/parallel_tests/cli_spec.rb +71 -0
  34. data/spec/parallel_tests/cucumber/failure_logger_spec.rb +43 -0
  35. data/spec/parallel_tests/cucumber/gherkin_listener_spec.rb +97 -0
  36. data/spec/parallel_tests/cucumber/runner_spec.rb +179 -0
  37. data/spec/parallel_tests/grouper_spec.rb +52 -0
  38. data/spec/parallel_tests/rspec/failures_logger_spec.rb +82 -0
  39. data/spec/parallel_tests/rspec/runner_spec.rb +187 -0
  40. data/spec/parallel_tests/rspec/runtime_logger_spec.rb +126 -0
  41. data/spec/parallel_tests/rspec/summary_logger_spec.rb +37 -0
  42. data/spec/parallel_tests/tasks_spec.rb +151 -0
  43. data/spec/parallel_tests/test/runner_spec.rb +413 -0
  44. data/spec/parallel_tests/test/runtime_logger_spec.rb +90 -0
  45. data/spec/parallel_tests_spec.rb +137 -0
  46. data/spec/spec_helper.rb +157 -0
  47. metadata +110 -0
@@ -0,0 +1,139 @@
1
+ require 'rake'
2
+
3
+ module ParallelTests
4
+ module Tasks
5
+ class << self
6
+ def rails_env
7
+ ENV['RAILS_ENV'] || 'test'
8
+ end
9
+
10
+ def run_in_parallel(cmd, options={})
11
+ count = " -n #{options[:count]}" if options[:count]
12
+ executable = File.expand_path("../../../bin/parallel_test", __FILE__)
13
+ command = "#{executable} --exec '#{cmd}'#{count}#{' --non-parallel' if options[:non_parallel]}"
14
+ command << " --advance-number #{options[:advance_number]}" if options[:advance_number]
15
+ abort unless system(command)
16
+ end
17
+
18
+ # this is a crazy-complex solution for a very simple problem:
19
+ # removing certain lines from the output without chaning the exit-status
20
+ # normally I'd not do this, but it has been lots of fun and a great learning experience :)
21
+ #
22
+ # - sed does not support | without -r
23
+ # - grep changes 0 exitstatus to 1 if nothing matches
24
+ # - sed changes 1 exitstatus to 0
25
+ # - pipefail makes pipe fail with exitstatus of first failed command
26
+ # - pipefail is not supported in (zsh)
27
+ # - defining a new rake task like silence_schema would force users to load parallel_tests in test env
28
+ # - do not use ' since run_in_parallel uses them to quote stuff
29
+ # - simple system "set -o pipefail" returns nil even though set -o pipefail exists with 0
30
+ def suppress_output(command, ignore_regex)
31
+ activate_pipefail = "set -o pipefail"
32
+ remove_ignored_lines = %Q{(grep -v "#{ignore_regex}" || test 1)}
33
+
34
+ if system("#{activate_pipefail} 2>/dev/null && test 1")
35
+ "#{activate_pipefail} && (#{command}) | #{remove_ignored_lines}"
36
+ else
37
+ command
38
+ end
39
+ end
40
+
41
+ def check_for_pending_migrations
42
+ abort_migrations = "db:abort_if_pending_migrations"
43
+ if Rake::Task.task_defined?(abort_migrations)
44
+ Rake::Task[abort_migrations].invoke
45
+ end
46
+ end
47
+
48
+ # parallel:spec[:count, :pattern, :options]
49
+ def parse_args(args)
50
+ # order as given by user
51
+ args = [args[:count], args[:pattern], args[:options]]
52
+
53
+ # count given or empty ?
54
+ # parallel:spec[2,models,options]
55
+ # parallel:spec[,models,options]
56
+ count = args.shift if args.first.to_s =~ /^\d*$/
57
+ num_processes = count.to_i unless count.to_s.empty?
58
+ pattern = args.shift
59
+ options = args.shift
60
+
61
+ [num_processes, pattern.to_s, options.to_s]
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ namespace :parallel do
68
+ desc "create test databases via db:create --> parallel:create[num_cpus]"
69
+ task :create, :count do |t,args|
70
+ ParallelTests::Tasks.run_in_parallel("rake db:create RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
71
+ end
72
+
73
+ desc "drop test databases via db:drop --> parallel:drop[num_cpus]"
74
+ task :drop, :count do |t,args|
75
+ ParallelTests::Tasks.run_in_parallel("rake db:drop RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
76
+ end
77
+
78
+ desc "update test databases by dumping and loading --> parallel:prepare[num_cpus]"
79
+ task(:prepare, [:count]) do |t,args|
80
+ ParallelTests::Tasks.check_for_pending_migrations
81
+ if defined?(ActiveRecord) && ActiveRecord::Base.schema_format == :ruby
82
+ # dump then load in parallel
83
+ Rake::Task['db:schema:dump'].invoke
84
+ Rake::Task['parallel:load_schema'].invoke(args[:count])
85
+ else
86
+ # there is no separate dump / load for schema_format :sql -> do it safe and slow
87
+ args = args.to_hash.merge(:non_parallel => true) # normal merge returns nil
88
+ ParallelTests::Tasks.run_in_parallel('rake db:test:prepare --trace', args)
89
+ end
90
+ end
91
+
92
+ # when dumping/resetting takes too long
93
+ desc "update test databases via db:migrate --> parallel:migrate[num_cpus]"
94
+ task :migrate, :count do |t,args|
95
+ ParallelTests::Tasks.run_in_parallel("rake db:migrate RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
96
+ end
97
+
98
+ # just load the schema (good for integration server <-> no development db)
99
+ desc "load dumped schema for test databases via db:schema:load --> parallel:load_schema[num_cpus]"
100
+ task :load_schema, :count do |t,args|
101
+ command = "rake db:schema:load RAILS_ENV=#{ParallelTests::Tasks.rails_env}"
102
+ ParallelTests::Tasks.run_in_parallel(ParallelTests::Tasks.suppress_output(command, "^ ->\\|^-- "), args)
103
+ end
104
+
105
+ desc "load the seed data from db/seeds.rb via db:seed --> parallel:seed[num_cpus]"
106
+ task :seed, :count do |t,args|
107
+ ParallelTests::Tasks.run_in_parallel("rake db:seed RAILS_ENV=#{ParallelTests::Tasks.rails_env}", args)
108
+ end
109
+
110
+ desc "launch given rake command in parallel"
111
+ task :rake, :command do |t, args|
112
+ ParallelTests::Tasks.run_in_parallel("RAILS_ENV=#{ParallelTests::Tasks.rails_env} rake #{args.command}")
113
+ end
114
+
115
+ ['test', 'spec', 'features'].each do |type|
116
+ desc "run #{type} in parallel with parallel:#{type}[num_cpus]"
117
+ task type, [:count, :pattern, :options] do |t, args|
118
+ ParallelTests::Tasks.check_for_pending_migrations
119
+
120
+ $LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '..'))
121
+ require "parallel_tests"
122
+
123
+ count, pattern, options = ParallelTests::Tasks.parse_args(args)
124
+ test_framework = {
125
+ 'spec' => 'rspec',
126
+ 'test' => 'test',
127
+ 'features' => 'cucumber'
128
+ }[type]
129
+
130
+ executable = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'parallel_test')
131
+ command = "#{executable} #{type} --type #{test_framework} " \
132
+ "-n #{count} " \
133
+ "--pattern '#{pattern}' " \
134
+ "--test-options '#{options}'"
135
+
136
+ abort unless system(command) # allow to chain tasks e.g. rake parallel:spec parallel:features
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,168 @@
1
+ require 'open3'
2
+
3
+ module ParallelTests
4
+ module Test
5
+ class Runner
6
+ NAME = 'Test'
7
+
8
+ class << self
9
+ # --- usually overwritten by other runners
10
+
11
+ def name
12
+ NAME
13
+ end
14
+
15
+ def runtime_log
16
+ 'tmp/parallel_runtime_test.log'
17
+ end
18
+
19
+ def test_suffix
20
+ "_test.rb"
21
+ end
22
+
23
+ def test_file_name
24
+ "test"
25
+ end
26
+
27
+ def run_tests(test_files, process_number, num_processes, options)
28
+ require_list = test_files.map { |filename| %{"#{File.expand_path filename}"} }.join(",")
29
+ cmd = "#{executable} -Itest -e '[#{require_list}].each {|f| require f }' -- #{options[:test_options]}"
30
+ execute_command(cmd, process_number, num_processes, options)
31
+ end
32
+
33
+ def line_is_result?(line)
34
+ line =~ /\d+ failure/
35
+ end
36
+
37
+ # --- usually used by other runners
38
+
39
+ # finds all tests and partitions them into groups
40
+ def tests_in_groups(tests, num_groups, options={})
41
+ tests = find_tests(tests, options)
42
+
43
+ tests = if options[:group_by] == :found
44
+ tests.map { |t| [t, 1] }
45
+ else
46
+ with_runtime_info(tests)
47
+ end
48
+ Grouper.in_even_groups_by_size(tests, num_groups, options)
49
+ end
50
+
51
+ def execute_command(cmd, process_number, num_processes, options)
52
+ env = (options[:env] || {}).merge(
53
+ "TEST_ENV_NUMBER" => test_env_number(process_number, options),
54
+ "PARALLEL_TEST_GROUPS" => num_processes
55
+ )
56
+ cmd = "nice #{cmd}" if options[:nice]
57
+ execute_command_and_capture_output(env, cmd, options[:serialize_stdout])
58
+ end
59
+
60
+ def execute_command_and_capture_output(env, cmd, silence)
61
+ # make processes descriptive / visible in ps -ef
62
+ exports = env.map do |k,v|
63
+ "#{k}=#{v};export #{k}"
64
+ end.join(";")
65
+ cmd = "#{exports};#{cmd}"
66
+
67
+ output = open("|#{cmd}", "r") { |output| capture_output(output, silence) }
68
+ exitstatus = $?.exitstatus
69
+
70
+ {:stdout => output, :exit_status => exitstatus}
71
+ end
72
+
73
+ def find_results(test_output)
74
+ test_output.split("\n").map {|line|
75
+ line = line.gsub(/\.|F|\*/,'').gsub(/\e\[\d+m/,'')
76
+ next unless line_is_result?(line)
77
+ line
78
+ }.compact
79
+ end
80
+
81
+ def test_env_number(process_number, options)
82
+ n = options[:advance_number].to_i + process_number + 1
83
+ n == 0 ? '' : n
84
+ end
85
+
86
+ def summarize_results(results)
87
+ sums = sum_up_results(results)
88
+ sums.sort.map{|word, number| "#{number} #{word}#{'s' if number != 1}" }.join(', ')
89
+ end
90
+
91
+ protected
92
+
93
+ def executable
94
+ ENV['PARALLEL_TESTS_EXECUTABLE'] || determine_executable
95
+ end
96
+
97
+ def determine_executable
98
+ "ruby"
99
+ end
100
+
101
+ def sum_up_results(results)
102
+ results = results.join(' ').gsub(/s\b/,'') # combine and singularize results
103
+ counts = results.scan(/(\d+) (\w+)/)
104
+ counts.inject(Hash.new(0)) do |sum, (number, word)|
105
+ sum[word] += number.to_i
106
+ sum
107
+ end
108
+ end
109
+
110
+ # read output of the process and print it in chunks
111
+ def capture_output(out, silence)
112
+ result = ""
113
+ loop do
114
+ begin
115
+ read = out.readpartial(1000000) # read whatever chunk we can get
116
+ result << read
117
+ unless silence
118
+ $stdout.print read
119
+ $stdout.flush
120
+ end
121
+ end
122
+ end rescue EOFError
123
+ result
124
+ end
125
+
126
+ def with_runtime_info(tests)
127
+ lines = File.read(runtime_log).split("\n") rescue []
128
+
129
+ # use recorded test runtime if we got enough data
130
+ if lines.size * 1.5 > tests.size
131
+ puts "Using recorded test runtime"
132
+ times = Hash.new(1)
133
+ lines.each do |line|
134
+ test, time = line.split(":")
135
+ next unless test and time
136
+ times[File.expand_path(test)] = time.to_f
137
+ end
138
+ tests.sort.map{|test| [test, times[File.expand_path(test)]] }
139
+ else # use file sizes
140
+ tests.sort.map{|test| [test, File.stat(test).size] }
141
+ end
142
+ end
143
+
144
+ def find_tests(tests, options = {})
145
+ (tests || []).map do |file_or_folder|
146
+ if File.directory?(file_or_folder)
147
+ files = files_in_folder(file_or_folder, options)
148
+ files.grep(/#{Regexp.escape test_suffix}$/).grep(options[:pattern]||//)
149
+ else
150
+ file_or_folder
151
+ end
152
+ end.flatten.uniq
153
+ end
154
+
155
+ def files_in_folder(folder, options={})
156
+ pattern = if options[:symlinks] == false # not nil or true
157
+ "**/*"
158
+ else
159
+ # follow one symlink and direct children
160
+ # http://stackoverflow.com/questions/357754/can-i-traverse-symlinked-directories-in-ruby-with-a-glob
161
+ "**{,/*/**}/*"
162
+ end
163
+ Dir[File.join(folder, pattern)].uniq
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,97 @@
1
+ require 'parallel_tests'
2
+ require 'parallel_tests/test/runner'
3
+
4
+ module ParallelTests
5
+ module Test
6
+ class RuntimeLogger
7
+ @@has_started = false
8
+
9
+ class << self
10
+ def log(test, start_time, end_time)
11
+ return if test.is_a? ::Test::Unit::TestSuite # don't log for suites-of-suites
12
+
13
+ if !@@has_started # make empty log file
14
+ File.open(logfile, 'w'){}
15
+ @@has_started = true
16
+ end
17
+
18
+ locked_appending_to(logfile) do |file|
19
+ file.puts(message(test, start_time, end_time))
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def message(test, start_time, end_time)
26
+ delta = "%.2f" % (end_time.to_f-start_time.to_f)
27
+ filename = class_directory(test.class) + class_to_filename(test.class) + ".rb"
28
+ "#{filename}:#{delta}"
29
+ end
30
+
31
+ # Note: this is a best guess at conventional test directory structure, and may need
32
+ # tweaking / post-processing to match correctly for any given project
33
+ def class_directory(suspect)
34
+ result = "test/"
35
+
36
+ if defined?(Rails)
37
+ result += case suspect.superclass.name
38
+ when "ActionDispatch::IntegrationTest"
39
+ "integration/"
40
+ when "ActionDispatch::PerformanceTest"
41
+ "performance/"
42
+ when "ActionController::TestCase"
43
+ "functional/"
44
+ when "ActionView::TestCase"
45
+ "unit/helpers/"
46
+ else
47
+ "unit/"
48
+ end
49
+ end
50
+ result
51
+ end
52
+
53
+ # based on https://github.com/grosser/single_test/blob/master/lib/single_test.rb#L117
54
+ def class_to_filename(suspect)
55
+ word = suspect.to_s.dup
56
+ return word unless word.match /^[A-Z]/ and not word.match %r{/[a-z]}
57
+
58
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
59
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
60
+ word.gsub!(/\:\:/, '/')
61
+ word.tr!("-", "_")
62
+ word.downcase!
63
+ word
64
+ end
65
+
66
+ def locked_appending_to(file)
67
+ File.open(file, 'a') do |f|
68
+ begin
69
+ f.flock File::LOCK_EX
70
+ yield f
71
+ ensure
72
+ f.flock File::LOCK_UN
73
+ end
74
+ end
75
+ end
76
+
77
+ def logfile
78
+ ParallelTests::Test::Runner.runtime_log
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ require 'test/unit/testsuite'
86
+ class ::Test::Unit::TestSuite
87
+ alias :run_without_timing :run unless defined? @@timing_installed
88
+
89
+ def run(result, &progress_block)
90
+ start_time = ParallelTests.now
91
+ run_without_timing(result, &progress_block)
92
+ end_time = ParallelTests.now
93
+ ParallelTests::Test::RuntimeLogger.log(self.tests.first, start_time, end_time)
94
+ end
95
+
96
+ @@timing_installed = true
97
+ end
@@ -0,0 +1,3 @@
1
+ module ParallelTests
2
+ VERSION = Version = '0.13.3'
3
+ end
@@ -0,0 +1,61 @@
1
+ require "parallel"
2
+ require "parallel_tests/railtie" if defined? Rails::Railtie
3
+
4
+ module ParallelTests
5
+ GREP_PROCESSES_COMMAND = "ps -ef | grep [T]EST_ENV_NUMBER= 2>&1"
6
+
7
+ autoload :CLI, "parallel_tests/cli"
8
+ autoload :VERSION, "parallel_tests/version"
9
+ autoload :Grouper, "parallel_tests/grouper"
10
+
11
+ class << self
12
+ def determine_number_of_processes(count)
13
+ [
14
+ count,
15
+ ENV["PARALLEL_TEST_PROCESSORS"],
16
+ Parallel.processor_count
17
+ ].detect{|c| not c.to_s.strip.empty? }.to_i
18
+ end
19
+
20
+ # copied from http://github.com/carlhuda/bundler Bundler::SharedHelpers#find_gemfile
21
+ def bundler_enabled?
22
+ return true if Object.const_defined?(:Bundler)
23
+
24
+ previous = nil
25
+ current = File.expand_path(Dir.pwd)
26
+
27
+ until !File.directory?(current) || current == previous
28
+ filename = File.join(current, "Gemfile")
29
+ return true if File.exists?(filename)
30
+ current, previous = File.expand_path("..", current), current
31
+ end
32
+
33
+ false
34
+ end
35
+
36
+ def first_process?
37
+ !ENV["TEST_ENV_NUMBER"] || ENV["TEST_ENV_NUMBER"].to_i == 0
38
+ end
39
+
40
+ def wait_for_other_processes_to_finish
41
+ return unless ENV["TEST_ENV_NUMBER"]
42
+ sleep 1 until number_of_running_processes <= 1
43
+ end
44
+
45
+ # Fun fact: this includes the current process if it's run via parallel_tests
46
+ def number_of_running_processes
47
+ result = `#{GREP_PROCESSES_COMMAND}`
48
+ raise "Could not grep for processes -> #{result}" if result.strip != "" && !$?.success?
49
+ result.split("\n").size
50
+ end
51
+
52
+ # real time even if someone messed with timecop in tests
53
+ def now
54
+ if Time.respond_to?(:now_without_mock_time) # Timecop
55
+ Time.now_without_mock_time
56
+ else
57
+ Time.now
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,14 @@
1
+ $LOAD_PATH.unshift File.expand_path("../lib", __FILE__)
2
+ name = "vinted-parallel_tests"
3
+ require "parallel_tests/version"
4
+
5
+ Gem::Specification.new name, ParallelTests::VERSION do |s|
6
+ s.summary = "Run Test::Unit / RSpec / Cucumber in parallel"
7
+ s.authors = ["Laurynas Butkus", "Tomas Varaneckas", "Justas Janauskas"]
8
+ s.email = ["laurynas.butkus@gmail.com", "tomas.varaneckas@gmail.com", "jjanauskas@gmail.com"]
9
+ s.homepage = "http://github.com/vinted/parallel_tests"
10
+ s.files = `git ls-files`.split("\n")
11
+ s.license = "MIT"
12
+ s.executables = ["parallel_cucumber", "parallel_rspec", "parallel_test"]
13
+ s.add_runtime_dependency "parallel"
14
+ end