From 8f8fd342313b0cd459d2fedb5b461b0cc063f248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 19 Jul 2017 19:51:59 +0200 Subject: [PATCH] Use a new RspecFlakyListener to detect flaky specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- lib/rspec_flaky/example.rb | 46 +++++ lib/rspec_flaky/flaky_example.rb | 39 ++++ lib/rspec_flaky/listener.rb | 75 ++++++++ .../health_checks/fs_shards_check_spec.rb | 6 - spec/lib/rspec_flaky/example_spec.rb | 89 +++++++++ spec/lib/rspec_flaky/flaky_example_spec.rb | 104 ++++++++++ spec/lib/rspec_flaky/listener_spec.rb | 178 ++++++++++++++++++ spec/spec_helper.rb | 12 +- 8 files changed, 537 insertions(+), 12 deletions(-) create mode 100644 lib/rspec_flaky/example.rb create mode 100644 lib/rspec_flaky/flaky_example.rb create mode 100644 lib/rspec_flaky/listener.rb create mode 100644 spec/lib/rspec_flaky/example_spec.rb create mode 100644 spec/lib/rspec_flaky/flaky_example_spec.rb create mode 100644 spec/lib/rspec_flaky/listener_spec.rb diff --git a/lib/rspec_flaky/example.rb b/lib/rspec_flaky/example.rb new file mode 100644 index 00000000000..b6e790cbbab --- /dev/null +++ b/lib/rspec_flaky/example.rb @@ -0,0 +1,46 @@ +module RspecFlaky + # This is a wrapper class for RSpec::Core::Example + class Example + delegate :status, :exception, to: :execution_result + + def initialize(rspec_example) + @rspec_example = rspec_example.try(:example) || rspec_example + end + + def uid + @uid ||= Digest::MD5.hexdigest("#{description}-#{file}") + end + + def example_id + rspec_example.id + end + + def file + metadata[:file_path] + end + + def line + metadata[:line_number] + end + + def description + metadata[:full_description] + end + + def attempts + rspec_example.try(:attempts) || 1 + end + + private + + attr_reader :rspec_example + + def metadata + rspec_example.metadata + end + + def execution_result + rspec_example.execution_result + end + end +end diff --git a/lib/rspec_flaky/flaky_example.rb b/lib/rspec_flaky/flaky_example.rb new file mode 100644 index 00000000000..f81fb90e870 --- /dev/null +++ b/lib/rspec_flaky/flaky_example.rb @@ -0,0 +1,39 @@ +module RspecFlaky + # This represents a flaky RSpec example and is mainly meant to be saved in a JSON file + class FlakyExample < OpenStruct + def initialize(example) + if example.respond_to?(:example_id) + super( + example_id: example.example_id, + file: example.file, + line: example.line, + description: example.description, + last_attempts_count: example.attempts, + flaky_reports: 1) + else + super + end + end + + def first_flaky_at + self[:first_flaky_at] || Time.now + end + + def last_flaky_at + Time.now + end + + def last_flaky_job + return unless ENV['CI_PROJECT_URL'] && ENV['CI_JOB_ID'] + + "#{ENV['CI_PROJECT_URL']}/-/jobs/#{ENV['CI_JOB_ID']}" + end + + def to_h + super.merge( + first_flaky_at: first_flaky_at, + last_flaky_at: last_flaky_at, + last_flaky_job: last_flaky_job) + end + end +end diff --git a/lib/rspec_flaky/listener.rb b/lib/rspec_flaky/listener.rb new file mode 100644 index 00000000000..ec2fbd9e36c --- /dev/null +++ b/lib/rspec_flaky/listener.rb @@ -0,0 +1,75 @@ +require 'json' + +module RspecFlaky + class Listener + attr_reader :all_flaky_examples, :new_flaky_examples + + def initialize + @new_flaky_examples = {} + @all_flaky_examples = init_all_flaky_examples + end + + def example_passed(notification) + current_example = RspecFlaky::Example.new(notification.example) + + return unless current_example.attempts > 1 + + flaky_example_hash = all_flaky_examples[current_example.uid] + + all_flaky_examples[current_example.uid] = + if flaky_example_hash + FlakyExample.new(flaky_example_hash).tap do |ex| + ex.last_attempts_count = current_example.attempts + ex.flaky_reports += 1 + end + else + FlakyExample.new(current_example).tap do |ex| + new_flaky_examples[current_example.uid] = ex + end + end + end + + def dump_summary(_) + write_report_file(all_flaky_examples, all_flaky_examples_report_path) + + if new_flaky_examples.any? + Rails.logger.warn "\nNew flaky examples detected:\n" + Rails.logger.warn JSON.pretty_generate(to_report(new_flaky_examples)) + + write_report_file(new_flaky_examples, new_flaky_examples_report_path) + end + end + + def to_report(examples) + Hash[examples.map { |k, ex| [k, ex.to_h] }] + end + + private + + def init_all_flaky_examples + return {} unless File.exist?(all_flaky_examples_report_path) + + all_flaky_examples = JSON.parse(File.read(all_flaky_examples_report_path)) + + Hash[(all_flaky_examples || {}).map { |k, ex| [k, FlakyExample.new(ex)] }] + end + + def write_report_file(examples, file_path) + return unless ENV['FLAKY_RSPEC_GENERATE_REPORT'] == 'true' + + report_path_dir = File.dirname(file_path) + FileUtils.mkdir_p(report_path_dir) unless Dir.exist?(report_path_dir) + File.write(file_path, JSON.pretty_generate(to_report(examples))) + end + + def all_flaky_examples_report_path + @all_flaky_examples_report_path ||= ENV['ALL_FLAKY_RSPEC_REPORT_PATH'] || + Rails.root.join("rspec_flaky/all-report.json") + end + + def new_flaky_examples_report_path + @new_flaky_examples_report_path ||= ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] || + Rails.root.join("rspec_flaky/new-report.json") + end + end +end diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index a0e5e401359..f5c9680bf59 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -106,12 +106,6 @@ describe Gitlab::HealthChecks::FsShardsCheck do }.with_indifferent_access end - # Unsolved intermittent failure in CI https://gitlab.com/gitlab-org/gitlab-ce/issues/31128 - around do |example| # rubocop:disable RSpec/AroundBlock - times_to_try = ENV['CI'] ? 4 : 1 - example.run_with_retry retry: times_to_try - end - it 'provides metrics' do metrics = described_class.metrics diff --git a/spec/lib/rspec_flaky/example_spec.rb b/spec/lib/rspec_flaky/example_spec.rb new file mode 100644 index 00000000000..5b4fd5ddf3e --- /dev/null +++ b/spec/lib/rspec_flaky/example_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe RspecFlaky::Example do + let(:example_attrs) do + { + id: 'spec/foo/bar_spec.rb:2', + metadata: { + file_path: 'spec/foo/bar_spec.rb', + line_number: 2, + full_description: 'hello world' + }, + execution_result: double(status: 'passed', exception: 'BOOM!'), + attempts: 1 + } + end + let(:rspec_example) { double(example_attrs) } + + describe '#initialize' do + shared_examples 'a valid Example instance' do + it 'returns valid attributes' do + example = described_class.new(args) + + expect(example.example_id).to eq(example_attrs[:id]) + end + end + + context 'when given an Rspec::Core::Example that responds to #example' do + let(:args) { double(example: rspec_example) } + + it_behaves_like 'a valid Example instance' + end + + context 'when given an Rspec::Core::Example that does not respond to #example' do + let(:args) { rspec_example } + + it_behaves_like 'a valid Example instance' + end + end + + subject { described_class.new(rspec_example) } + + describe '#uid' do + it 'returns a hash of the full description' do + expect(subject.uid).to eq(Digest::MD5.hexdigest("#{subject.description}-#{subject.file}")) + end + end + + describe '#example_id' do + it 'returns the ID of the RSpec::Core::Example' do + expect(subject.example_id).to eq(rspec_example.id) + end + end + + describe '#attempts' do + it 'returns the attempts of the RSpec::Core::Example' do + expect(subject.attempts).to eq(rspec_example.attempts) + end + end + + describe '#file' do + it 'returns the metadata[:file_path] of the RSpec::Core::Example' do + expect(subject.file).to eq(rspec_example.metadata[:file_path]) + end + end + + describe '#line' do + it 'returns the metadata[:line_number] of the RSpec::Core::Example' do + expect(subject.line).to eq(rspec_example.metadata[:line_number]) + end + end + + describe '#description' do + it 'returns the metadata[:full_description] of the RSpec::Core::Example' do + expect(subject.description).to eq(rspec_example.metadata[:full_description]) + end + end + + describe '#status' do + it 'returns the execution_result.status of the RSpec::Core::Example' do + expect(subject.status).to eq(rspec_example.execution_result.status) + end + end + + describe '#exception' do + it 'returns the execution_result.exception of the RSpec::Core::Example' do + expect(subject.exception).to eq(rspec_example.execution_result.exception) + end + end +end diff --git a/spec/lib/rspec_flaky/flaky_example_spec.rb b/spec/lib/rspec_flaky/flaky_example_spec.rb new file mode 100644 index 00000000000..cbfc1e538ab --- /dev/null +++ b/spec/lib/rspec_flaky/flaky_example_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe RspecFlaky::FlakyExample do + let(:flaky_example_attrs) do + { + example_id: 'spec/foo/bar_spec.rb:2', + file: 'spec/foo/bar_spec.rb', + line: 2, + description: 'hello world', + first_flaky_at: 1234, + last_flaky_at: 2345, + last_attempts_count: 2, + flaky_reports: 1 + } + end + let(:example_attrs) do + { + uid: 'abc123', + example_id: flaky_example_attrs[:example_id], + file: flaky_example_attrs[:file], + line: flaky_example_attrs[:line], + description: flaky_example_attrs[:description], + status: 'passed', + exception: 'BOOM!', + attempts: flaky_example_attrs[:last_attempts_count] + } + end + let(:example) { double(example_attrs) } + + describe '#initialize' do + shared_examples 'a valid FlakyExample instance' do + it 'returns valid attributes' do + flaky_example = described_class.new(args) + + expect(flaky_example.uid).to eq(flaky_example_attrs[:uid]) + expect(flaky_example.example_id).to eq(flaky_example_attrs[:example_id]) + end + end + + context 'when given an Rspec::Example' do + let(:args) { example } + + it_behaves_like 'a valid FlakyExample instance' + end + + context 'when given a hash' do + let(:args) { flaky_example_attrs } + + it_behaves_like 'a valid FlakyExample instance' + end + end + + describe '#to_h' do + before do + # Stub these env variables otherwise specs don't behave the same on the CI + stub_env('CI_PROJECT_URL', nil) + stub_env('CI_JOB_ID', nil) + end + + shared_examples 'a valid FlakyExample hash' do + let(:additional_attrs) { {} } + + it 'returns a valid hash' do + flaky_example = described_class.new(args) + final_hash = flaky_example_attrs + .merge(last_flaky_at: instance_of(Time), last_flaky_job: nil) + .merge(additional_attrs) + + expect(flaky_example.to_h).to match(hash_including(final_hash)) + end + end + + context 'when given an Rspec::Example' do + let(:args) { example } + + context 'when run locally' do + it_behaves_like 'a valid FlakyExample hash' do + let(:additional_attrs) do + { first_flaky_at: instance_of(Time) } + end + end + end + + context 'when run on the CI' do + before do + stub_env('CI_PROJECT_URL', 'https://gitlab.com/gitlab-org/gitlab-ce') + stub_env('CI_JOB_ID', 42) + end + + it_behaves_like 'a valid FlakyExample hash' do + let(:additional_attrs) do + { first_flaky_at: instance_of(Time), last_flaky_job: "https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/42" } + end + end + end + end + + context 'when given a hash' do + let(:args) { flaky_example_attrs } + + it_behaves_like 'a valid FlakyExample hash' + end + end +end diff --git a/spec/lib/rspec_flaky/listener_spec.rb b/spec/lib/rspec_flaky/listener_spec.rb new file mode 100644 index 00000000000..0e193bf408b --- /dev/null +++ b/spec/lib/rspec_flaky/listener_spec.rb @@ -0,0 +1,178 @@ +require 'spec_helper' + +describe RspecFlaky::Listener do + let(:flaky_example_report) do + { + 'abc123' => { + example_id: 'spec/foo/bar_spec.rb:2', + file: 'spec/foo/bar_spec.rb', + line: 2, + description: 'hello world', + first_flaky_at: 1234, + last_flaky_at: instance_of(Time), + last_attempts_count: 2, + flaky_reports: 1, + last_flaky_job: nil + } + } + end + let(:example_attrs) do + { + id: 'spec/foo/baz_spec.rb:3', + metadata: { + file_path: 'spec/foo/baz_spec.rb', + line_number: 3, + full_description: 'hello GitLab' + }, + execution_result: double(status: 'passed', exception: nil) + } + end + + before do + # Stub these env variables otherwise specs don't behave the same on the CI + stub_env('CI_PROJECT_URL', nil) + stub_env('CI_JOB_ID', nil) + end + + describe '#initialize' do + shared_examples 'a valid Listener instance' do + let(:expected_all_flaky_examples) { {} } + + it 'returns a valid Listener instance' do + listener = described_class.new + + expect(listener.to_report(listener.all_flaky_examples)) + .to match(hash_including(expected_all_flaky_examples)) + expect(listener.new_flaky_examples).to eq({}) + end + end + + context 'when no report file exists' do + it_behaves_like 'a valid Listener instance' + end + + context 'when a report file exists and set by ALL_FLAKY_RSPEC_REPORT_PATH' do + let(:report_file) do + Tempfile.new(%w[rspec_flaky_report .json]).tap do |f| + f.write(JSON.pretty_generate(flaky_example_report)) + f.rewind + end + end + + before do + stub_env('ALL_FLAKY_RSPEC_REPORT_PATH', report_file.path) + end + + after do + report_file.close + report_file.unlink + end + + it_behaves_like 'a valid Listener instance' do + let(:expected_all_flaky_examples) { flaky_example_report } + end + end + end + + describe '#example_passed' do + let(:rspec_example) { double(example_attrs) } + let(:notification) { double(example: rspec_example) } + + shared_examples 'a non-flaky example' do + it 'does not change the flaky examples hash' do + expect { subject.example_passed(notification) } + .not_to change { subject.all_flaky_examples } + end + end + + describe 'when the RSpec example does not respond to attempts' do + it_behaves_like 'a non-flaky example' + end + + describe 'when the RSpec example has 1 attempt' do + let(:rspec_example) { double(example_attrs.merge(attempts: 1)) } + + it_behaves_like 'a non-flaky example' + end + + describe 'when the RSpec example has 2 attempts' do + let(:rspec_example) { double(example_attrs.merge(attempts: 2)) } + let(:expected_new_flaky_example) do + { + example_id: 'spec/foo/baz_spec.rb:3', + file: 'spec/foo/baz_spec.rb', + line: 3, + description: 'hello GitLab', + first_flaky_at: instance_of(Time), + last_flaky_at: instance_of(Time), + last_attempts_count: 2, + flaky_reports: 1, + last_flaky_job: nil + } + end + + it 'does not change the flaky examples hash' do + expect { subject.example_passed(notification) } + .to change { subject.all_flaky_examples } + + new_example = RspecFlaky::Example.new(rspec_example) + + expect(subject.all_flaky_examples[new_example.uid].to_h) + .to match(hash_including(expected_new_flaky_example)) + end + end + end + + describe '#dump_summary' do + let(:rspec_example) { double(example_attrs) } + let(:notification) { double(example: rspec_example) } + + context 'when a report file path is set by ALL_FLAKY_RSPEC_REPORT_PATH' do + let(:report_file_path) { Rails.root.join('tmp', 'rspec_flaky_report.json') } + + before do + stub_env('ALL_FLAKY_RSPEC_REPORT_PATH', report_file_path) + FileUtils.rm(report_file_path) if File.exist?(report_file_path) + end + + after do + FileUtils.rm(report_file_path) if File.exist?(report_file_path) + end + + context 'when FLAKY_RSPEC_GENERATE_REPORT == "false"' do + before do + stub_env('FLAKY_RSPEC_GENERATE_REPORT', 'false') + end + + it 'does not write the report file' do + subject.example_passed(notification) + + subject.dump_summary(nil) + + expect(File.exist?(report_file_path)).to be(false) + end + end + + context 'when FLAKY_RSPEC_GENERATE_REPORT == "true"' do + before do + stub_env('FLAKY_RSPEC_GENERATE_REPORT', 'true') + end + + it 'writes the report file' do + subject.example_passed(notification) + + subject.dump_summary(nil) + + expect(File.exist?(report_file_path)).to be(true) + end + end + end + end + + describe '#to_report' do + it 'transforms the internal hash to a JSON-ready hash' do + expect(subject.to_report('abc123' => RspecFlaky::FlakyExample.new(flaky_example_report['abc123']))) + .to match(hash_including(flaky_example_report)) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 0ba6ed56314..7b7d4887a08 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -69,6 +69,12 @@ RSpec.configure do |config| config.raise_errors_for_deprecations! + if ENV['CI'] + # This includes the first try, i.e. tests will be run 4 times before failing. + config.default_retry_count = 4 + config.reporter.register_listener(RspecFlaky::Listener.new, :example_passed, :dump_summary) + end + config.before(:suite) do TestEnv.init end @@ -97,12 +103,6 @@ RSpec.configure do |config| reset_delivered_emails! end - if ENV['CI'] - config.around(:each) do |ex| - ex.run_with_retry retry: 2 - end - end - config.around(:each, :use_clean_rails_memory_store_caching) do |example| caching_store = Rails.cache Rails.cache = ActiveSupport::Cache::MemoryStore.new