diff --git a/README.md b/README.md index bf9a536a..4b9310dd 100644 --- a/README.md +++ b/README.md @@ -818,6 +818,19 @@ SimpleCov.minimum_coverage_by_file line: 80 SimpleCov.minimum_coverage_by_file line: 90, branch: 80 ``` +### Minimum coverage by group + +You can define the minimum coverage percentage expected for specific groups. SimpleCov will return non-zero if unmet, +ensuring that coverage is consistent across different parts of your codebase. + +```ruby +SimpleCov.minimum_coverage_by_group 'Models' => 80, 'Controllers' => 60 +# same as above (the default is to check line coverage) +SimpleCov.minimum_coverage_by_group 'Models' => { line: 80 }, 'Controllers' => { line: 60 } +# check for a minimum line and branch coverage for 'Models' and 'Controllers' groups +SimpleCov.minimum_coverage_by_group 'Models' => { line: 90, branch: 80 }, 'Controllers' => { line: 60, branch: 50 } +``` + ### Maximum coverage drop You can define the maximum coverage drop percentage at once. SimpleCov will return non-zero if exceeded. diff --git a/lib/simplecov.rb b/lib/simplecov.rb index f49c544a..9fbfe27f 100644 --- a/lib/simplecov.rb +++ b/lib/simplecov.rb @@ -252,11 +252,11 @@ def process_result(result) end # @api private - CoverageLimits = Struct.new(:minimum_coverage, :minimum_coverage_by_file, :maximum_coverage_drop, keyword_init: true) + CoverageLimits = Struct.new(:minimum_coverage, :minimum_coverage_by_file, :minimum_coverage_by_group, :maximum_coverage_drop, keyword_init: true) def result_exit_status(result) coverage_limits = CoverageLimits.new( minimum_coverage: minimum_coverage, minimum_coverage_by_file: minimum_coverage_by_file, - maximum_coverage_drop: maximum_coverage_drop + minimum_coverage_by_group: minimum_coverage_by_group, maximum_coverage_drop: maximum_coverage_drop ) ExitCodes::ExitCodeHandling.call(result, coverage_limits: coverage_limits) diff --git a/lib/simplecov/configuration.rb b/lib/simplecov/configuration.rb index 128014f6..f6fd0413 100644 --- a/lib/simplecov/configuration.rb +++ b/lib/simplecov/configuration.rb @@ -337,6 +337,25 @@ def minimum_coverage_by_file(coverage = nil) @minimum_coverage_by_file = coverage end + # + # Defines the minimum coverage per group required for the testsuite to pass. + # SimpleCov will return non-zero if the current coverage of the least covered group + # is below this threshold. + # + # Default is 0% (disabled) + # + def minimum_coverage_by_group(coverage = nil) + return @minimum_coverage_by_group ||= {} unless coverage + + @minimum_coverage_by_group = coverage.dup.transform_values do |group_coverage| + group_coverage = {primary_coverage => group_coverage} if group_coverage.is_a?(Numeric) + + raise_on_invalid_coverage(group_coverage, "minimum_coverage_by_group") + + group_coverage + end + end + # # Refuses any coverage drop. That is, coverage is only allowed to increase. # SimpleCov will return non-zero if the coverage decreases. diff --git a/lib/simplecov/exit_codes.rb b/lib/simplecov/exit_codes.rb index 3905ba8c..774a3302 100644 --- a/lib/simplecov/exit_codes.rb +++ b/lib/simplecov/exit_codes.rb @@ -12,4 +12,5 @@ module ExitCodes require_relative "exit_codes/exit_code_handling" require_relative "exit_codes/maximum_coverage_drop_check" require_relative "exit_codes/minimum_coverage_by_file_check" +require_relative "exit_codes/minimum_coverage_by_group_check" require_relative "exit_codes/minimum_overall_coverage_check" diff --git a/lib/simplecov/exit_codes/exit_code_handling.rb b/lib/simplecov/exit_codes/exit_code_handling.rb index eb564859..a5006483 100644 --- a/lib/simplecov/exit_codes/exit_code_handling.rb +++ b/lib/simplecov/exit_codes/exit_code_handling.rb @@ -21,6 +21,7 @@ def coverage_checks(result, coverage_limits) [ MinimumOverallCoverageCheck.new(result, coverage_limits.minimum_coverage), MinimumCoverageByFileCheck.new(result, coverage_limits.minimum_coverage_by_file), + MinimumCoverageByGroupCheck.new(result, coverage_limits.minimum_coverage_by_group), MaximumCoverageDropCheck.new(result, coverage_limits.maximum_coverage_drop) ] end diff --git a/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb b/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb new file mode 100644 index 00000000..7c2659dd --- /dev/null +++ b/lib/simplecov/exit_codes/minimum_coverage_by_group_check.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module SimpleCov + module ExitCodes + class MinimumCoverageByGroupCheck + def initialize(result, minimum_coverage_by_group) + @result = result + @minimum_coverage_by_group = minimum_coverage_by_group + end + + def failing? + minimum_violations.any? + end + + def report + minimum_violations.each do |violation| + $stderr.printf( + "%s coverage by group %s (%.2f%%) is below the expected minimum coverage (%.2f%%).\n", + group_name: violation.fetch(:group_name), + covered: SimpleCov.round_coverage(violation.fetch(:actual)), + minimum_coverage: violation.fetch(:minimum_expected), + criterion: violation.fetch(:criterion).capitalize + ) + end + end + + def exit_code + SimpleCov::ExitCodes::MINIMUM_COVERAGE + end + + private + + attr_reader :result, :minimum_coverage_by_group + + def minimum_violations + @minimum_violations ||= + compute_minimum_coverage_data.select do |achieved| + achieved.fetch(:actual) < achieved.fetch(:minimum_expected) + end + end + + def compute_minimum_coverage_data + minimum_coverage_data = [] + + minimum_coverage_by_group.each do |group_name, minimum_group_coverage| + minimum_group_coverage.each do |criterion, expected_percent| + actual_coverage = result.groups.fetch(group_name).coverage_statistics.fetch(criterion) + + minimum_coverage_data << { + group_name: group_name, + criterion: criterion, + minimum_expected: expected_percent, + actual: SimpleCov.round_coverage(actual_coverage.percent) + } + end + end + + minimum_coverage_data + end + end + end +end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index 7cae394e..abefab00 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -124,6 +124,73 @@ it_behaves_like "setting coverage expectations", :minimum_coverage_by_file end + describe "#minimum_coverage_by_group" do + after do + config.clear_coverage_criteria + end + + it "does not warn you about your usage" do + expect(config).not_to receive(:warn) + config.minimum_coverage_by_group({ 'Test Group 1' => 100.00 }) + end + + it "warns you about your usage" do + expect(config).to receive(:warn).with("The coverage you set for minimum_coverage_by_group is greater than 100%") + config.minimum_coverage_by_group({ 'Test Group 1' => 100.01 }) + end + + it "sets the right coverage value when called with a number" do + config.minimum_coverage_by_group({ 'Test Group 1' => 80 }) + + expect(config.minimum_coverage_by_group).to eq({ 'Test Group 1' => { line: 80 } }) + end + + it "sets the right coverage when called with a hash of just line" do + config.minimum_coverage_by_group({ 'Test Group 1' => { line: 85.0 } }) + + expect(config.minimum_coverage_by_group).to eq({ 'Test Group 1' => { line: 85.0 } }) + end + + it "sets the right coverage when called with a hash of just branch" do + config.enable_coverage :branch + config.minimum_coverage_by_group({ 'Test Group 1' => { branch: 85.0 } }) + + expect(config.minimum_coverage_by_group).to eq({ 'Test Group 1' => { branch: 85.0 } }) + end + + it "sets the right coverage when called with both line and branch" do + config.enable_coverage :branch + config.minimum_coverage_by_group({ 'Test Group 1' => { branch: 85.0, line: 95.4 } }) + + expect(config.minimum_coverage_by_group).to eq({ 'Test Group 1' => { branch: 85.0, line: 95.4 } }) + end + + it "raises when trying to set branch coverage but not enabled" do + expect do + config.minimum_coverage_by_group({ 'Test Group 1' => { branch: 42 } }) + end.to raise_error(/branch.*disabled/i) + end + + it "raises when unknown coverage criteria provided" do + expect do + config.minimum_coverage_by_group({ 'Test Group 1' => { unknown: 42 } }) + end.to raise_error(/unsupported.*unknown/i) + end + + context "when primary coverage is set" do + before do + config.enable_coverage :branch + config.primary_coverage :branch + end + + it "sets the right coverage value when called with a number" do + config.minimum_coverage_by_group({ 'Test Group 1' => 80 }) + + expect(config.minimum_coverage_by_group).to eq({ 'Test Group 1' => { branch: 80 } }) + end + end + end + describe "#maximum_coverage_drop" do it_behaves_like "setting coverage expectations", :maximum_coverage_drop end diff --git a/spec/exit_codes/minimum_coverage_by_group_check_spec.rb b/spec/exit_codes/minimum_coverage_by_group_check_spec.rb new file mode 100644 index 00000000..5a83f575 --- /dev/null +++ b/spec/exit_codes/minimum_coverage_by_group_check_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "helper" + +RSpec.describe SimpleCov::ExitCodes::MinimumCoverageByGroupCheck do + subject { described_class.new(result, minimum_coverage_by_group) } + + let(:coverage_statistics) { + { + line: SimpleCov::CoverageStatistics.new(covered: 8, missed: 2), + branch: SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) + } + } + let(:result) { + instance_double( + SimpleCov::Result, + coverage_statistics_by_file: stats, + groups: { + 'Test Group 1' => instance_double(SimpleCov::FileList, coverage_statistics: coverage_statistics) + } + ) + } + let(:stats) { { 'Test Group 1' => coverage_statistics } } + + context "everything exactly ok" do + let(:minimum_coverage_by_group) { { 'Test Group 1' => {line: 80.0} } } + + it { is_expected.not_to be_failing } + end + + context "coverage violated" do + let(:minimum_coverage_by_group) { { 'Test Group 1' => {line: 90.0} } } + + it { is_expected.to be_failing } + end + + context "coverage slightly violated" do + let(:minimum_coverage_by_group) { { 'Test Group 1' => {line: 80.01} } } + + it { is_expected.to be_failing } + end + + context "one criterion violated" do + let(:minimum_coverage_by_group) { { 'Test Group 1' => {line: 80.0, branch: 90.0} } } + + it { is_expected.to be_failing } + end +end