Skip to content

Commit 3ea2cf7

Browse files
sferikshetty-tejas
andcommitted
Add errors to JSON output for files below minimum coverage
Report all four types of coverage threshold violations in the JSON formatter's errors object: minimum_coverage, minimum_coverage_by_file, minimum_coverage_by_group, and maximum_coverage_drop. Each violation includes expected and actual values. This makes the JSON self-contained for downstream consumers (e.g. CI reporters) that don't have access to the Ruby process. Co-Authored-By: Tejas <tejas.shetty@mailbox.org>
1 parent 8931869 commit 3ea2cf7

File tree

6 files changed

+245
-5
lines changed

6 files changed

+245
-5
lines changed

lib/simplecov/formatter/json_formatter/result_hash_formatter.rb

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def format
1414
format_total
1515
format_files
1616
format_groups
17+
format_errors
1718

1819
formatted_result
1920
end
@@ -37,14 +38,92 @@ def format_groups
3738
end
3839
end
3940

41+
def format_errors
42+
format_minimum_coverage_errors
43+
format_minimum_coverage_by_file_errors
44+
format_minimum_coverage_by_group_errors
45+
format_maximum_coverage_drop_errors
46+
end
47+
48+
CRITERION_KEYS = {line: :lines, branch: :branches, method: :methods}.freeze
49+
private_constant :CRITERION_KEYS
50+
51+
def format_minimum_coverage_errors
52+
SimpleCov.minimum_coverage.each do |criterion, expected_percent|
53+
actual = @result.coverage_statistics.fetch(criterion).percent
54+
next unless actual < expected_percent
55+
56+
key = CRITERION_KEYS.fetch(criterion)
57+
minimum_coverage = formatted_result[:errors][:minimum_coverage] ||= {}
58+
minimum_coverage[key] = {expected: expected_percent, actual: actual}
59+
end
60+
end
61+
62+
def format_minimum_coverage_by_file_errors
63+
SimpleCov.minimum_coverage_by_file.each do |criterion, expected_percent|
64+
@result.files.each do |file|
65+
actual = SimpleCov.round_coverage(file.coverage_statistics.fetch(criterion).percent)
66+
next unless actual < expected_percent
67+
68+
key = CRITERION_KEYS.fetch(criterion)
69+
by_file = formatted_result[:errors][:minimum_coverage_by_file] ||= {}
70+
criterion_errors = by_file[key] ||= {}
71+
criterion_errors[file.filename] = {expected: expected_percent, actual: actual}
72+
end
73+
end
74+
end
75+
76+
def format_minimum_coverage_by_group_errors
77+
SimpleCov.minimum_coverage_by_group.each do |group_name, minimum_group_coverage|
78+
group = @result.groups[group_name]
79+
next unless group
80+
81+
minimum_group_coverage.each do |criterion, expected_percent|
82+
actual = SimpleCov.round_coverage(group.coverage_statistics.fetch(criterion).percent)
83+
next unless actual < expected_percent
84+
85+
key = CRITERION_KEYS.fetch(criterion)
86+
by_group = formatted_result[:errors][:minimum_coverage_by_group] ||= {}
87+
group_errors = by_group[group_name] ||= {}
88+
group_errors[key] = {expected: expected_percent, actual: actual}
89+
end
90+
end
91+
end
92+
93+
def format_maximum_coverage_drop_errors
94+
return if SimpleCov.maximum_coverage_drop.empty?
95+
96+
last_run = SimpleCov::LastRun.read
97+
return unless last_run
98+
99+
SimpleCov.maximum_coverage_drop.each do |criterion, max_drop|
100+
drop = coverage_drop_for(criterion, last_run)
101+
next unless drop && drop > max_drop
102+
103+
key = CRITERION_KEYS.fetch(criterion)
104+
coverage_drop = formatted_result[:errors][:maximum_coverage_drop] ||= {}
105+
coverage_drop[key] = {maximum: max_drop, actual: drop}
106+
end
107+
end
108+
109+
def coverage_drop_for(criterion, last_run)
110+
last_coverage_percent = last_run.dig(:result, criterion)
111+
last_coverage_percent ||= last_run.dig(:result, :covered_percent) if criterion == :line
112+
return nil unless last_coverage_percent
113+
114+
current = SimpleCov.round_coverage(@result.coverage_statistics.fetch(criterion).percent)
115+
(last_coverage_percent - current).floor(10)
116+
end
117+
40118
def formatted_result
41119
@formatted_result ||= {
42120
meta: {
43121
simplecov_version: SimpleCov::VERSION
44122
},
45123
total: {},
46124
coverage: {},
47-
groups: {}
125+
groups: {},
126+
errors: {}
48127
}
49128
end
50129

spec/fixtures/json/sample.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,6 @@
4343
"lines_covered_percent": 90.0
4444
}
4545
},
46-
"groups": {}
46+
"groups": {},
47+
"errors": {}
4748
}

spec/fixtures/json/sample_groups.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,6 @@
5353
"strength": 0.0
5454
}
5555
}
56-
}
56+
},
57+
"errors": {}
5758
}

spec/fixtures/json/sample_with_branch.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,6 @@
6565
"branches_covered_percent": 50.0
6666
}
6767
},
68-
"groups": {}
68+
"groups": {},
69+
"errors": {}
6970
}

spec/fixtures/json/sample_with_method.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,6 @@
7777
"methods_covered_percent": 100.0
7878
}
7979
},
80-
"groups": {}
80+
"groups": {},
81+
"errors": {}
8182
}

spec/json_formatter_spec.rb

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,163 @@
107107
end
108108
end
109109

110+
context "with minimum_coverage below threshold" do
111+
before do
112+
allow(SimpleCov).to receive(:minimum_coverage).and_return(line: 95)
113+
end
114+
115+
it "reports the violation in errors" do
116+
subject.format(result)
117+
errors = json_output.fetch("errors")
118+
expect(errors).to eq(
119+
"minimum_coverage" => {"lines" => {"expected" => 95, "actual" => 90.0}}
120+
)
121+
end
122+
end
123+
124+
context "with minimum_coverage above threshold" do
125+
before do
126+
allow(SimpleCov).to receive(:minimum_coverage).and_return(line: 80)
127+
end
128+
129+
it "returns empty errors" do
130+
subject.format(result)
131+
expect(json_output.fetch("errors")).to eq({})
132+
end
133+
end
134+
135+
context "with minimum_coverage_by_file for lines" do
136+
before do
137+
allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(line: 95)
138+
end
139+
140+
it "reports files below the threshold in errors" do
141+
subject.format(result)
142+
errors = json_output.fetch("errors")
143+
expect(errors).to eq(
144+
"minimum_coverage_by_file" => {
145+
"lines" => {source_fixture("json/sample.rb") => {"expected" => 95, "actual" => 90.0}}
146+
}
147+
)
148+
end
149+
end
150+
151+
context "with minimum_coverage_by_file for branches" do
152+
let(:result) do
153+
SimpleCov::Result.new({
154+
source_fixture("json/sample.rb") => {
155+
"lines" => [nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil,
156+
1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil],
157+
"branches" => {
158+
[:if, 0, 13, 4, 17, 7] => {
159+
[:then, 1, 14, 6, 14, 10] => 0,
160+
[:else, 2, 16, 6, 16, 10] => 1
161+
}
162+
}
163+
}
164+
})
165+
end
166+
167+
before do
168+
enable_branch_coverage
169+
allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(branch: 75)
170+
end
171+
172+
it "reports files below the threshold in errors" do
173+
subject.format(result)
174+
errors = json_output.fetch("errors")
175+
expect(errors).to eq(
176+
"minimum_coverage_by_file" => {
177+
"branches" => {source_fixture("json/sample.rb") => {"expected" => 75, "actual" => 50.0}}
178+
}
179+
)
180+
end
181+
end
182+
183+
context "with minimum_coverage_by_file when all files pass" do
184+
before do
185+
allow(SimpleCov).to receive(:minimum_coverage_by_file).and_return(line: 80)
186+
end
187+
188+
it "returns empty errors" do
189+
subject.format(result)
190+
expect(json_output.fetch("errors")).to eq({})
191+
end
192+
end
193+
194+
context "with minimum_coverage_by_group below threshold" do
195+
let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 7, missed: 3) }
196+
197+
let(:result) do
198+
res = SimpleCov::Result.new({
199+
source_fixture("json/sample.rb") => {"lines" => [
200+
nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil,
201+
1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil
202+
]}
203+
})
204+
205+
allow(res).to receive_messages(
206+
groups: {"Models" => double("File List", coverage_statistics: {line: line_stats})}
207+
)
208+
res
209+
end
210+
211+
before do
212+
allow(SimpleCov).to receive(:minimum_coverage_by_group).and_return("Models" => {line: 80})
213+
end
214+
215+
it "reports the group violation in errors" do
216+
subject.format(result)
217+
errors = json_output.fetch("errors")
218+
expect(errors).to eq(
219+
"minimum_coverage_by_group" => {
220+
"Models" => {"lines" => {"expected" => 80, "actual" => 70.0}}
221+
}
222+
)
223+
end
224+
end
225+
226+
context "with maximum_coverage_drop exceeded" do
227+
before do
228+
allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2)
229+
allow(SimpleCov::LastRun).to receive(:read).and_return({result: {line: 95.0}})
230+
end
231+
232+
it "reports the drop in errors" do
233+
subject.format(result)
234+
errors = json_output.fetch("errors")
235+
expect(errors).to eq(
236+
"maximum_coverage_drop" => {
237+
"lines" => {"maximum" => 2, "actual" => 5.0}
238+
}
239+
)
240+
end
241+
end
242+
243+
context "with maximum_coverage_drop not exceeded" do
244+
before do
245+
allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2)
246+
allow(SimpleCov::LastRun).to receive(:read).and_return({result: {line: 91.0}})
247+
end
248+
249+
it "returns empty errors" do
250+
subject.format(result)
251+
expect(json_output.fetch("errors")).to eq({})
252+
end
253+
end
254+
255+
context "with maximum_coverage_drop and no last run" do
256+
before do
257+
allow(SimpleCov).to receive(:maximum_coverage_drop).and_return(line: 2)
258+
allow(SimpleCov::LastRun).to receive(:read).and_return(nil)
259+
end
260+
261+
it "returns empty errors" do
262+
subject.format(result)
263+
expect(json_output.fetch("errors")).to eq({})
264+
end
265+
end
266+
110267
context "with groups" do
111268
let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) }
112269

0 commit comments

Comments
 (0)