diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8644659198a5c61a97931880ede57e82ba4bcf75..6b98c7c53c2382b5b62409600cd6819510e37fda 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -159,7 +159,7 @@ variables: JUNIT_RETRY_FILE: rspec/junit_rspec-retry.xml KNAPSACK_RSPEC_SUITE_REPORT_PATH: knapsack/report-master.json RSPEC_CHANGED_FILES_PATH: rspec/changed_files.txt - RSPEC_FOSS_IMPACT_PIPELINE_YML: rspec-foss-impact-pipeline.yml + RSPEC_FOSS_IMPACT_PIPELINE_TEMPLATE_YML: .gitlab/ci/rails/rspec-foss-impact.gitlab-ci.yml.erb RSPEC_LAST_RUN_RESULTS_FILE: rspec/rspec_last_run_results.txt RSPEC_MATCHING_JS_FILES_PATH: rspec/js_matching_files.txt RSPEC_MATCHING_TESTS_PATH: rspec/matching_tests.txt diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index 23f38fddb80c90a124dc2ec623214d986d7de1e0..58d034f51f09e0668a153b0a8d3a88eaafb79601 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -821,13 +821,14 @@ rspec-foss-impact:pipeline-generate: extends: - .rails:rules:rspec-foss-impact stage: prepare - needs: ["detect-tests"] + needs: ["detect-tests", "retrieve-tests-metadata"] script: - - scripts/generate-rspec-foss-impact-pipeline "${RSPEC_MATCHING_TESTS_FOSS_PATH}" "${RSPEC_FOSS_IMPACT_PIPELINE_YML}" + - scripts/generate_rspec_pipeline.rb -f "${RSPEC_MATCHING_TESTS_FOSS_PATH}" -t "${RSPEC_FOSS_IMPACT_PIPELINE_TEMPLATE_YML}" -k "${KNAPSACK_RSPEC_SUITE_REPORT_PATH}" + - cat "${RSPEC_FOSS_IMPACT_PIPELINE_TEMPLATE_YML}.yml" artifacts: expire_in: 1 day paths: - - $RSPEC_FOSS_IMPACT_PIPELINE_YML + - "${RSPEC_FOSS_IMPACT_PIPELINE_TEMPLATE_YML}.yml" rspec-foss-impact:trigger: extends: @@ -850,7 +851,7 @@ rspec-foss-impact:trigger: yaml_variables: true pipeline_variables: true include: - - artifact: $RSPEC_FOSS_IMPACT_PIPELINE_YML + - artifact: "${RSPEC_FOSS_IMPACT_PIPELINE_TEMPLATE_YML}.yml" job: rspec-foss-impact:pipeline-generate fail-pipeline-early: diff --git a/.gitlab/ci/rails/rspec-foss-impact.gitlab-ci.yml.erb b/.gitlab/ci/rails/rspec-foss-impact.gitlab-ci.yml.erb index eb54fa258750132316e96acee19ca9c2fb5b236f..02b7d61a4fafbcd73f0e1145963a0a58f6ea54ff 100644 --- a/.gitlab/ci/rails/rspec-foss-impact.gitlab-ci.yml.erb +++ b/.gitlab/ci/rails/rspec-foss-impact.gitlab-ci.yml.erb @@ -21,7 +21,7 @@ dont-interrupt-me: script: - echo "This jobs makes sure this pipeline won't be interrupted! See https://docs.gitlab.com/ee/ci/yaml/#interruptible." -rspec foss-impact: +.base-rspec-foss-impact: extends: .rspec-base-pg12-as-if-foss needs: - pipeline: $PARENT_PIPELINE_ID @@ -37,9 +37,6 @@ rspec foss-impact: variables: RSPEC_TESTS_FILTER_FILE: "${RSPEC_MATCHING_TESTS_FOSS_PATH}" RSPEC_TESTS_MAPPING_ENABLED: "true" -<% if Integer(parallel_value) > 1 %> - parallel: <%= parallel_value %> -<% end %> script: - !reference [.base-script, script] - rspec_paralellized_job "--tag ~quarantine --tag ~level:migration --tag ~zoekt" @@ -48,3 +45,46 @@ rspec foss-impact: paths: - "${RSPEC_MATCHING_TESTS_FOSS_PATH}" - tmp/capybara/ + +<% if rspec_files_per_test_level[:migration][:files].size > 0 %> +rspec migration foss-impact: + extends: .base-rspec-foss-impact +<% if rspec_files_per_test_level[:migration][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:migration][:parallelization] %> +<% end %> + script: + - !reference [.base-script, script] + - rspec_paralellized_job "--tag ~quarantine --tag ~zoekt" +<% end %> + +<% if rspec_files_per_test_level[:background_migration][:files].size > 0 %> +rspec background_migration foss-impact: + extends: .base-rspec-foss-impact +<% if rspec_files_per_test_level[:background_migration][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:background_migration][:parallelization] %> +<% end %> +<% end %> + +<% if rspec_files_per_test_level[:unit][:files].size > 0 %> +rspec unit foss-impact: + extends: .base-rspec-foss-impact +<% if rspec_files_per_test_level[:unit][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:unit][:parallelization] %> +<% end %> +<% end %> + +<% if rspec_files_per_test_level[:integration][:files].size > 0 %> +rspec integration foss-impact: + extends: .base-rspec-foss-impact +<% if rspec_files_per_test_level[:integration][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:integration][:parallelization] %> +<% end %> +<% end %> + +<% if rspec_files_per_test_level[:system][:files].size > 0 %> +rspec system foss-impact: + extends: .base-rspec-foss-impact +<% if rspec_files_per_test_level[:system][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:system][:parallelization] %> +<% end %> +<% end %> diff --git a/scripts/generate-rspec-foss-impact-pipeline b/scripts/generate-rspec-foss-impact-pipeline deleted file mode 100755 index 3277f38ebe147a47ae1ea21c36690848bd097842..0000000000000000000000000000000000000000 --- a/scripts/generate-rspec-foss-impact-pipeline +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Script to generate `rspec foss-impact` test child pipeline with dynamically parallelized jobs. - -source scripts/utils.sh - -rspec_matching_tests_foss_path="${1}" -pipeline_yml="${2}" - -test_file_count=$(wc -w "${rspec_matching_tests_foss_path}" | awk '{ print $1 }') -echoinfo "test_file_count: ${test_file_count}" - -if [[ "${test_file_count}" -eq 0 ]]; then - skip_pipeline=".gitlab/ci/_skip.yml" - - echo "Using ${skip_pipeline} due to no impacted FOSS rspec tests to run" - cp $skip_pipeline "$pipeline_yml" - exit -fi - -# As of 2022-09-01: -# $ find spec -type f | wc -l -# 12825 -# and -# $ find ee/spec -type f | wc -l -# 5610 -# which gives a total of 18435 test files (`number_of_tests_in_total_in_the_test_suite`). -# -# Total time to run all tests (based on https://gitlab-org.gitlab.io/rspec_profiling_stats/) is 170183 seconds (`duration_of_the_test_suite_in_seconds`). -# -# This gives an approximate 170183 / 18435 = 9.2 seconds per test file (`average_test_file_duration_in_seconds`). -# -# If we want each test job to finish in 10 minutes, given we have 3 minutes of setup (`setup_duration_in_seconds`), then we need to give 7 minutes of testing to each test node (`optimal_test_runtime_duration_in_seconds`). -# (7 * 60) / 9.2 = 45.6 -# -# So if we'd want to run the full test suites in 10 minutes (`optimal_test_job_duration_in_seconds`), we'd need to run at max 45 test file per nodes (`optimal_test_file_count_per_node`). -number_of_tests_in_total_in_the_test_suite=18435 -duration_of_the_test_suite_in_seconds=170183 -optimal_test_job_duration_in_seconds=600 # 10 minutes -setup_duration_in_seconds=180 # 3 minutes - -optimal_test_runtime_duration_in_seconds=$(( optimal_test_job_duration_in_seconds - setup_duration_in_seconds )) -echoinfo "optimal_test_runtime_duration_in_seconds: ${optimal_test_runtime_duration_in_seconds}" - -average_test_file_duration_in_seconds=$(( duration_of_the_test_suite_in_seconds / number_of_tests_in_total_in_the_test_suite )) -echoinfo "average_test_file_duration_in_seconds: ${average_test_file_duration_in_seconds}" - -optimal_test_file_count_per_node=$(( optimal_test_runtime_duration_in_seconds / average_test_file_duration_in_seconds )) -echoinfo "optimal_test_file_count_per_node: ${optimal_test_file_count_per_node}" - -node_count=$(( test_file_count / optimal_test_file_count_per_node )) -echoinfo "node_count: ${node_count}" - -echoinfo "Optimal node count for 'rspec foss-impact' jobs is ${node_count}." - -MAX_NODES_COUNT=50 # Maximum parallelization allowed by GitLab -if [[ "${node_count}" -gt "${MAX_NODES_COUNT}" ]]; then - echoinfo "We don't want to parallelize 'rspec foss-impact' to more than ${MAX_NODES_COUNT} jobs for now! Decreasing the parallelization to ${MAX_NODES_COUNT}." - node_count=${MAX_NODES_COUNT} -fi - -ruby -rerb -e "puts ERB.new(File.read('.gitlab/ci/rails/rspec-foss-impact.gitlab-ci.yml.erb')).result_with_hash(parallel_value: ${node_count})" > "${pipeline_yml}" - -echosuccess "Generated ${pipeline_yml} pipeline with following content:" -cat "${pipeline_yml}" diff --git a/scripts/generate_rspec_pipeline.rb b/scripts/generate_rspec_pipeline.rb new file mode 100755 index 0000000000000000000000000000000000000000..e226acc0430f4657362366889ae0e92138219afe --- /dev/null +++ b/scripts/generate_rspec_pipeline.rb @@ -0,0 +1,176 @@ +#!/usr/bin/env ruby + +# frozen_string_literal: true + +require 'optparse' +require 'json' +require 'fileutils' +require 'erb' +require_relative '../tooling/quality/test_level' + +# Class to generate RSpec test child pipeline with dynamically parallelized jobs. +class GenerateRspecPipeline + SKIP_PIPELINE_YML_FILE = ".gitlab/ci/_skip.yml" + TEST_LEVELS = %i[migration background_migration unit integration system].freeze + MAX_NODES_COUNT = 50 # Maximum parallelization allowed by GitLab + + OPTIMAL_TEST_JOB_DURATION_IN_SECONDS = 600 # 10 MINUTES + SETUP_DURATION_IN_SECONDS = 180.0 # 3 MINUTES + OPTIMAL_TEST_RUNTIME_DURATION_IN_SECONDS = OPTIMAL_TEST_JOB_DURATION_IN_SECONDS - SETUP_DURATION_IN_SECONDS + + # As of 2022-09-01: + # $ find spec -type f | wc -l + # 12825 + # and + # $ find ee/spec -type f | wc -l + # 5610 + # which gives a total of 18435 test files (`NUMBER_OF_TESTS_IN_TOTAL_IN_THE_TEST_SUITE`). + # + # Total time to run all tests (based on https://gitlab-org.gitlab.io/rspec_profiling_stats/) + # is 170183 seconds (`DURATION_OF_THE_TEST_SUITE_IN_SECONDS`). + # + # This gives an approximate 170183 / 18435 = 9.2 seconds per test file + # (`DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS`). + # + # If we want each test job to finish in 10 minutes, given we have 3 minutes of setup (`SETUP_DURATION_IN_SECONDS`), + # then we need to give 7 minutes of testing to each test node (`OPTIMAL_TEST_RUNTIME_DURATION_IN_SECONDS`). + # (7 * 60) / 9.2 = 45.6 + # + # So if we'd want to run the full test suites in 10 minutes (`OPTIMAL_TEST_JOB_DURATION_IN_SECONDS`), + # we'd need to run at max 45 test file per nodes (`#optimal_test_file_count_per_node_per_test_level`). + NUMBER_OF_TESTS_IN_TOTAL_IN_THE_TEST_SUITE = 18_435 + DURATION_OF_THE_TEST_SUITE_IN_SECONDS = 170_183 + DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS = + DURATION_OF_THE_TEST_SUITE_IN_SECONDS / NUMBER_OF_TESTS_IN_TOTAL_IN_THE_TEST_SUITE + + # rspec_files_path: A file containing RSpec files to run, separated by a space + # pipeline_template_path: A YAML pipeline configuration template to generate the final pipeline config from + def initialize(pipeline_template_path:, rspec_files_path: nil, knapsack_report_path: nil) + @pipeline_template_path = pipeline_template_path.to_s + @rspec_files_path = rspec_files_path.to_s + @knapsack_report_path = knapsack_report_path.to_s + + raise ArgumentError unless File.exist?(@pipeline_template_path) + end + + def generate! + if all_rspec_files.empty? + info "Using #{SKIP_PIPELINE_YML_FILE} due to no RSpec files to run" + FileUtils.cp(SKIP_PIPELINE_YML_FILE, pipeline_filename) + return + end + + File.open(pipeline_filename, 'w') do |handle| + pipeline_yaml = ERB.new(File.read(pipeline_template_path)).result_with_hash(**erb_binding) + handle.write(pipeline_yaml.squeeze("\n").strip) + end + end + + private + + attr_reader :pipeline_template_path, :rspec_files_path, :knapsack_report_path + + def info(text) + $stdout.puts "[#{self.class.name}] #{text}" + end + + def all_rspec_files + @all_rspec_files ||= File.exist?(rspec_files_path) ? File.read(rspec_files_path).split(' ') : [] + end + + def pipeline_filename + @pipeline_filename ||= "#{pipeline_template_path}.yml" + end + + def erb_binding + { rspec_files_per_test_level: rspec_files_per_test_level } + end + + def rspec_files_per_test_level + @rspec_files_per_test_level ||= begin + all_remaining_rspec_files = all_rspec_files.dup + TEST_LEVELS.each_with_object(Hash.new { |h, k| h[k] = {} }) do |test_level, memo| # rubocop:disable Rails/IndexWith + memo[test_level][:files] = all_remaining_rspec_files + .grep(Quality::TestLevel.new.regexp(test_level)) + .tap { |files| files.each { |file| all_remaining_rspec_files.delete(file) } } + memo[test_level][:parallelization] = optimal_nodes_count(test_level, memo[test_level][:files]) + end + end + end + + def optimal_nodes_count(test_level, rspec_files) + nodes_count = (rspec_files.size / optimal_test_file_count_per_node_per_test_level(test_level)).ceil + info "Optimal node count for #{rspec_files.size} #{test_level} RSpec files is #{nodes_count}." + + if nodes_count > MAX_NODES_COUNT + info "We don't want to parallelize to more than #{MAX_NODES_COUNT} jobs for now! " \ + "Decreasing the parallelization to #{MAX_NODES_COUNT}." + + MAX_NODES_COUNT + else + nodes_count + end + end + + def optimal_test_file_count_per_node_per_test_level(test_level) + [ + (OPTIMAL_TEST_RUNTIME_DURATION_IN_SECONDS / average_test_file_duration_in_seconds_per_test_level[test_level]), + 1 + ].max + end + + def average_test_file_duration_in_seconds_per_test_level + @optimal_test_file_count_per_node_per_test_level ||= + if knapsack_report.any? + remaining_knapsack_report = knapsack_report.dup + TEST_LEVELS.each_with_object({}) do |test_level, memo| + matching_data_per_test_level = remaining_knapsack_report + .select { |test_file, _| test_file.match?(Quality::TestLevel.new.regexp(test_level)) } + .tap { |test_data| test_data.each { |file, _| remaining_knapsack_report.delete(file) } } + memo[test_level] = + matching_data_per_test_level.values.sum / matching_data_per_test_level.keys.size + end + else + TEST_LEVELS.each_with_object({}) do |test_level, memo| # rubocop:disable Rails/IndexWith + memo[test_level] = DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS + end + end + end + + def knapsack_report + @knapsack_report ||= + begin + File.exist?(knapsack_report_path) ? JSON.parse(File.read(knapsack_report_path)) : {} + rescue JSON::ParserError => e + info "[ERROR] Knapsack report at #{knapsack_report_path} couldn't be parsed! Error:\n#{e}" + {} + end + end +end + +if $PROGRAM_NAME == __FILE__ + options = {} + + OptionParser.new do |opts| + opts.on("-f", "--rspec-files-path path", String, "Path to a file containing RSpec files to run, " \ + "separated by a space") do |value| + options[:rspec_files_path] = value + end + + opts.on("-t", "--pipeline-template-path PATH", String, "Path to a YAML pipeline configuration template to " \ + "generate the final pipeline config from") do |value| + options[:pipeline_template_path] = value + end + + opts.on("-k", "--knapsack-report-path path", String, "Path to a Knapsack report") do |value| + options[:knapsack_report_path] = value + end + + opts.on("-h", "--help", "Prints this help") do + puts opts + exit + end + end.parse! + + GenerateRspecPipeline.new(**options).generate! +end diff --git a/spec/scripts/generate_rspec_pipeline_spec.rb b/spec/scripts/generate_rspec_pipeline_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b3eaf9e9127224e903737f039cb5b76f4ea3fa1d --- /dev/null +++ b/spec/scripts/generate_rspec_pipeline_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'tempfile' + +require_relative '../../scripts/generate_rspec_pipeline' + +RSpec.describe GenerateRspecPipeline, :silence_stdout, feature_category: :tooling do + describe '#generate!' do + let!(:rspec_files) { Tempfile.new(['rspec_files_path', '.txt']) } + let(:rspec_files_content) do + "spec/migrations/a_spec.rb spec/migrations/b_spec.rb " \ + "spec/lib/gitlab/background_migration/a_spec.rb spec/lib/gitlab/background_migration/b_spec.rb " \ + "spec/models/a_spec.rb spec/models/b_spec.rb " \ + "spec/controllers/a_spec.rb spec/controllers/b_spec.rb " \ + "spec/features/a_spec.rb spec/features/b_spec.rb" + end + + let(:pipeline_template) { Tempfile.new(['pipeline_template', '.yml.erb']) } + let(:pipeline_template_content) do + <<~YAML + <% if rspec_files_per_test_level[:migration][:files].size > 0 %> + rspec migration: + <% if rspec_files_per_test_level[:migration][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:migration][:parallelization] %> + <% end %> + <% end %> + <% if rspec_files_per_test_level[:background_migration][:files].size > 0 %> + rspec background_migration: + <% if rspec_files_per_test_level[:background_migration][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:background_migration][:parallelization] %> + <% end %> + <% end %> + <% if rspec_files_per_test_level[:unit][:files].size > 0 %> + rspec unit: + <% if rspec_files_per_test_level[:unit][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:unit][:parallelization] %> + <% end %> + <% end %> + <% if rspec_files_per_test_level[:integration][:files].size > 0 %> + rspec integration: + <% if rspec_files_per_test_level[:integration][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:integration][:parallelization] %> + <% end %> + <% end %> + <% if rspec_files_per_test_level[:system][:files].size > 0 %> + rspec system: + <% if rspec_files_per_test_level[:system][:parallelization] > 1 %> + parallel: <%= rspec_files_per_test_level[:system][:parallelization] %> + <% end %> + <% end %> + YAML + end + + let(:knapsack_report) { Tempfile.new(['knapsack_report', '.json']) } + let(:knapsack_report_content) do + <<~JSON + { + "spec/migrations/a_spec.rb": 360.3, + "spec/migrations/b_spec.rb": 180.1, + "spec/lib/gitlab/background_migration/a_spec.rb": 60.5, + "spec/lib/gitlab/background_migration/b_spec.rb": 180.3, + "spec/models/a_spec.rb": 360.2, + "spec/models/b_spec.rb": 180.6, + "spec/controllers/a_spec.rb": 60.2, + "spec/controllers/ab_spec.rb": 180.4, + "spec/features/a_spec.rb": 360.1, + "spec/features/b_spec.rb": 180.5 + } + JSON + end + + around do |example| + rspec_files.write(rspec_files_content) + rspec_files.rewind + pipeline_template.write(pipeline_template_content) + pipeline_template.rewind + knapsack_report.write(knapsack_report_content) + knapsack_report.rewind + example.run + ensure + rspec_files.close + rspec_files.unlink + pipeline_template.close + pipeline_template.unlink + knapsack_report.close + knapsack_report.unlink + end + + context 'when rspec_files and pipeline_template_path exists' do + subject do + described_class.new( + rspec_files_path: rspec_files.path, + pipeline_template_path: pipeline_template.path + ) + end + + it 'generates the pipeline config with default parallelization' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")) + .to eq( + "rspec migration:\nrspec background_migration:\nrspec unit:\n" \ + "rspec integration:\nrspec system:" + ) + end + + context 'when parallelization > 0' do + before do + stub_const("#{described_class}::DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS", 360) + end + + it 'generates the pipeline config' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")) + .to eq( + "rspec migration:\n parallel: 2\nrspec background_migration:\n parallel: 2\n" \ + "rspec unit:\n parallel: 2\nrspec integration:\n parallel: 2\n" \ + "rspec system:\n parallel: 2" + ) + end + end + + context 'when parallelization > MAX_NODES_COUNT' do + let(:rspec_files_content) do + Array.new(51) { |i| "spec/migrations/#{i}_spec.rb" }.join(' ') + end + + before do + stub_const( + "#{described_class}::DEFAULT_AVERAGE_TEST_FILE_DURATION_IN_SECONDS", + described_class::OPTIMAL_TEST_JOB_DURATION_IN_SECONDS + ) + end + + it 'generates the pipeline config with max parallelization of 50' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")).to eq("rspec migration:\n parallel: 50") + end + end + end + + context 'when knapsack_report_path is given' do + subject do + described_class.new( + rspec_files_path: rspec_files.path, + pipeline_template_path: pipeline_template.path, + knapsack_report_path: knapsack_report.path + ) + end + + it 'generates the pipeline config with parallelization based on Knapsack' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")) + .to eq( + "rspec migration:\n parallel: 2\nrspec background_migration:\n" \ + "rspec unit:\n parallel: 2\nrspec integration:\n" \ + "rspec system:\n parallel: 2" + ) + end + + context 'and Knapsack report does not contain valid JSON' do + let(:knapsack_report_content) { "#{super()}," } + + it 'generates the pipeline config with default parallelization' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")) + .to eq( + "rspec migration:\nrspec background_migration:\nrspec unit:\n" \ + "rspec integration:\nrspec system:" + ) + end + end + end + + context 'when rspec_files does not exist' do + subject { described_class.new(rspec_files_path: nil, pipeline_template_path: pipeline_template.path) } + + it 'generates the pipeline config using the no-op template' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")).to include("no-op:") + end + end + + context 'when pipeline_template_path does not exist' do + subject { described_class.new(rspec_files_path: rspec_files.path, pipeline_template_path: nil) } + + it 'generates the pipeline config using the no-op template' do + expect { subject }.to raise_error(ArgumentError) + end + end + end +end