Skip to content

Commit 8d0e426

Browse files
authored
feat: add diff threshold (#201)
1 parent 4147bce commit 8d0e426

File tree

12 files changed

+396
-33
lines changed

12 files changed

+396
-33
lines changed

.testcoverage.example.yml

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,31 @@ exclude:
4141
- \.pb\.go$ # excludes all protobuf generated files
4242
- ^pkg/bar # exclude package `pkg/bar`
4343

44-
# File name of go-test-coverage breakdown file, which can be used to
45-
# analyze coverage difference.
44+
# If specified, saves the current test coverage breakdown to this file.
45+
#
46+
# Typically, this breakdown is generated only for main (base) branches and
47+
# stored as an artifact. Later, this file can be used in feature branches
48+
# to compare test coverage against the base branch.
4649
breakdown-file-name: ''
4750

4851
diff:
49-
# File name of go-test-coverage breakdown file which will be used to
50-
# report coverage difference.
51-
base-breakdown-file-name: ''
52+
# Path to the test coverage breakdown file from the base branch.
53+
#
54+
# This file is usually generated and stored in the main (base) branch,
55+
# controled via `breakdown-file-name` property.
56+
# When set in a feature branch, it allows the tool to compute and report
57+
# the coverage difference between the current (feature) branch and the base.
58+
base-breakdown-file-name: ''
59+
60+
# Allowed threshold for the test coverage difference (in percentage)
61+
# between the feature branch and the base branch.
62+
#
63+
# By default, this is disabled (set to nil). Valid values range from
64+
# -100.0 to +100.0.
65+
#
66+
# Example:
67+
# If set to 0.5, an error will be reported if the feature branch has
68+
# less than 0.5% more coverage than the base.
69+
#
70+
# If set to -0.5, the check allows up to 0.5% less coverage than the base.
71+
threshold: nil

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,34 @@ exclude:
125125
- \.pb\.go$ # excludes all protobuf generated files
126126
- ^pkg/bar # exclude package `pkg/bar`
127127

128-
# File name of go-test-coverage breakdown file, which can be used to
129-
# analyze coverage difference.
128+
# If specified, saves the current test coverage breakdown to this file.
129+
#
130+
# Typically, this breakdown is generated only for main (base) branches and
131+
# stored as an artifact. Later, this file can be used in feature branches
132+
# to compare test coverage against the base branch.
130133
breakdown-file-name: ''
131134

132135
diff:
133-
# File name of go-test-coverage breakdown file which will be used to
134-
# report coverage difference.
136+
# Path to the test coverage breakdown file from the base branch.
137+
#
138+
# This file is usually generated and stored in the main (base) branch,
139+
# controled via `breakdown-file-name` property.
140+
# When set in a feature branch, it allows the tool to compute and report
141+
# the coverage difference between the current (feature) branch and the base.
135142
base-breakdown-file-name: ''
143+
144+
# Allowed threshold for the test coverage difference (in percentage)
145+
# between the feature branch and the base branch.
146+
#
147+
# By default, this is disabled (set to nil). Valid values range from
148+
# -100.0 to +100.0.
149+
#
150+
# Example:
151+
# If set to 0.5, an error will be reported if the feature branch has
152+
# less than 0.5% more coverage than the base.
153+
#
154+
# If set to -0.5, the check allows up to 0.5% less coverage than the base.
155+
threshold: nil
136156
```
137157

138158
### Exclude Code from Coverage

pkg/testcoverage/check.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func Analyze(cfg Config, current, base []coverage.Stats) AnalyzeResult {
105105

106106
return AnalyzeResult{
107107
Threshold: thr,
108+
DiffThreshold: cfg.Diff.Threshold,
108109
HasFileOverrides: hasFileOverrides,
109110
HasPackageOverrides: hasPackageOverrides,
110111
FilesBelowThreshold: checkCoverageStatsBelowThreshold(current, thr.File, overrideRules),
@@ -115,6 +116,7 @@ func Analyze(cfg Config, current, base []coverage.Stats) AnalyzeResult {
115116
TotalStats: coverage.StatsCalcTotal(current),
116117
HasBaseBreakdown: len(base) > 0,
117118
Diff: calculateStatsDiff(current, base),
119+
DiffPercentage: TotalPercentageDiff(current, base),
118120
}
119121
}
120122

pkg/testcoverage/check_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,109 @@ func TestCheck(t *testing.T) {
267267
})
268268
}
269269

270+
func TestCheckDiff(t *testing.T) {
271+
t.Parallel()
272+
273+
if testing.Short() {
274+
return
275+
}
276+
277+
brakedownFile := t.TempDir() + "/breakdown.testcoverage"
278+
brakedownCurrentFile := t.TempDir() + "/breakdown-current.testcoverage"
279+
brakedownFileEdited := "breakdown-edit.testcoverage"
280+
281+
// run check to generate brakedown file
282+
cfg := Config{
283+
Profile: profileOK,
284+
BreakdownFileName: brakedownFile,
285+
SourceDir: sourceDir,
286+
}
287+
buf := &bytes.Buffer{}
288+
pass, err := Check(buf, cfg)
289+
assert.True(t, pass)
290+
assert.NoError(t, err)
291+
292+
// should pass since brakedown is the same
293+
cfg = Config{
294+
Profile: profileOK,
295+
SourceDir: sourceDir,
296+
Diff: Diff{
297+
BaseBreakdownFileName: brakedownFile,
298+
Threshold: ptr(0.0),
299+
},
300+
}
301+
buf = &bytes.Buffer{}
302+
pass, err = Check(buf, cfg)
303+
assert.True(t, pass)
304+
assert.NoError(t, err)
305+
assertDiffNoChange(t, buf.String())
306+
assertDiffPercentage(t, buf.String(), 0.0)
307+
assertDiffThreshold(t, buf.String(), *cfg.Diff.Threshold, true)
308+
309+
// should pass since diff is negative
310+
cfg = Config{
311+
Profile: profileOK,
312+
SourceDir: sourceDir,
313+
Diff: Diff{
314+
BaseBreakdownFileName: brakedownFile,
315+
Threshold: ptr(-0.001),
316+
},
317+
}
318+
buf = &bytes.Buffer{}
319+
pass, err = Check(buf, cfg)
320+
assert.True(t, pass)
321+
assert.NoError(t, err)
322+
assertDiffNoChange(t, buf.String())
323+
assertDiffPercentage(t, buf.String(), 0.0)
324+
assertDiffThreshold(t, buf.String(), *cfg.Diff.Threshold, true)
325+
326+
// should NOT pass since brakedown is the same, and diff is positive
327+
cfg = Config{
328+
Profile: profileOK,
329+
SourceDir: sourceDir,
330+
Diff: Diff{
331+
BaseBreakdownFileName: brakedownFile,
332+
Threshold: ptr(0.1),
333+
},
334+
}
335+
buf = &bytes.Buffer{}
336+
pass, err = Check(buf, cfg)
337+
assert.False(t, pass)
338+
assert.NoError(t, err)
339+
assertDiffNoChange(t, buf.String())
340+
assertDiffPercentage(t, buf.String(), 0.0)
341+
assertDiffThreshold(t, buf.String(), *cfg.Diff.Threshold, false)
342+
343+
// change brakedown file to have positive difference
344+
base := readStats(t, brakedownFile)
345+
base[0].Covered = 0
346+
base[1].Covered = 0
347+
348+
tmpFile, err := os.CreateTemp(t.TempDir(), brakedownFileEdited)
349+
assert.NoError(t, err)
350+
_, err = tmpFile.Write(coverage.StatsSerialize(base))
351+
assert.NoError(t, err)
352+
353+
// check should now pass since difference has increased
354+
cfg = Config{
355+
Profile: profileOK,
356+
SourceDir: sourceDir,
357+
BreakdownFileName: brakedownCurrentFile,
358+
Diff: Diff{
359+
BaseBreakdownFileName: tmpFile.Name(),
360+
Threshold: ptr(1.0),
361+
},
362+
}
363+
buf = &bytes.Buffer{}
364+
pass, err = Check(buf, cfg)
365+
assert.True(t, pass)
366+
assert.NoError(t, err)
367+
368+
diff := TotalPercentageDiff(readStats(t, brakedownCurrentFile), base)
369+
assertDiffPercentage(t, buf.String(), diff)
370+
assertDiffThreshold(t, buf.String(), *cfg.Diff.Threshold, true)
371+
}
372+
270373
//nolint:paralleltest // must not be parallel because it uses env
271374
func TestCheckNoParallel(t *testing.T) {
272375
if testing.Short() {
@@ -450,6 +553,65 @@ func Test_Analyze(t *testing.T) {
450553
assert.False(t, result.Pass())
451554
assertPrefix(t, result, prefix, true)
452555
})
556+
557+
t.Run("diff stats", func(t *testing.T) {
558+
t.Parallel()
559+
560+
stats := randStats(prefix, 10, 100)
561+
562+
cfg := Config{}
563+
result := Analyze(cfg, stats, stats)
564+
assert.Empty(t, result.Diff)
565+
assert.True(t, result.Pass())
566+
assert.Equal(t, 0.0, result.DiffPercentage) //nolint:testifylint //relax
567+
})
568+
569+
t.Run("diff below threshold", func(t *testing.T) {
570+
t.Parallel()
571+
572+
base := []coverage.Stats{{Name: "foo", Total: 10, Covered: 1}}
573+
stats := []coverage.Stats{{Name: "foo", Total: 10, Covered: 8}}
574+
575+
cfg := Config{
576+
Diff: Diff{Threshold: ptr(999.0)},
577+
}
578+
result := Analyze(cfg, stats, base)
579+
assert.NotEmpty(t, result.Diff)
580+
assert.False(t, result.Pass())
581+
assert.False(t, result.MeetsDiffThreshold())
582+
assert.Equal(t, 70.0, result.DiffPercentage) //nolint:testifylint //relax
583+
})
584+
585+
t.Run("diff above threshold", func(t *testing.T) {
586+
t.Parallel()
587+
588+
base := []coverage.Stats{{Name: "foo", Total: 10, Covered: 1}}
589+
stats := []coverage.Stats{{Name: "foo", Total: 10, Covered: 8}}
590+
591+
cfg := Config{
592+
Diff: Diff{Threshold: ptr(1.0)},
593+
}
594+
result := Analyze(cfg, stats, base)
595+
assert.NotEmpty(t, result.Diff)
596+
assert.True(t, result.Pass())
597+
assert.True(t, result.MeetsDiffThreshold())
598+
assert.Equal(t, 70.0, result.DiffPercentage) //nolint:testifylint //relax
599+
})
600+
601+
t.Run("diff above threshold (small diff)", func(t *testing.T) {
602+
t.Parallel()
603+
604+
base := []coverage.Stats{{Name: "foo", Total: 10000, Covered: 9999}}
605+
stats := []coverage.Stats{{Name: "foo", Total: 10000, Covered: 10000}}
606+
607+
cfg := Config{
608+
Diff: Diff{Threshold: ptr(0.0)},
609+
}
610+
result := Analyze(cfg, stats, base)
611+
assert.True(t, result.Pass())
612+
assert.True(t, result.MeetsDiffThreshold())
613+
assert.Equal(t, 0.01, result.DiffPercentage) //nolint:testifylint //relax
614+
})
453615
}
454616

455617
func TestLoadBaseCoverageBreakdown(t *testing.T) {

pkg/testcoverage/config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ type Exclude struct {
5353
}
5454

5555
type Diff struct {
56-
BaseBreakdownFileName string `yaml:"base-breakdown-file-name"`
56+
BaseBreakdownFileName string `yaml:"base-breakdown-file-name"`
57+
Threshold *float64 `yaml:"threshold,omitempty"`
5758
}
5859

5960
type Badge struct {

pkg/testcoverage/config_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ func nonZeroConfig() Config {
240240
BreakdownFileName: "breakdown.testcoverage",
241241
Diff: Diff{
242242
BaseBreakdownFileName: "breakdown.testcoverage",
243+
Threshold: ptr(-1.01),
243244
},
244245
GithubActionOutput: true,
245246
}
@@ -261,7 +262,8 @@ exclude:
261262
- path2
262263
breakdown-file-name: 'breakdown.testcoverage'
263264
diff:
264-
base-breakdown-file-name: 'breakdown.testcoverage'
265+
base-breakdown-file-name: 'breakdown.testcoverage'
266+
threshold: -1.01
265267
github-action-output: true`
266268
}
267269

pkg/testcoverage/coverage/types.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ func (s Stats) CoveredPercentage() int {
2727
}
2828

2929
func (s Stats) CoveredPercentageF() float64 {
30-
return coveredPercentageF(s.Total, s.Covered)
30+
return coveredPercentageF(s.Total, s.Covered, true)
31+
}
32+
33+
func (s Stats) CoveredPercentageFNR() float64 {
34+
return coveredPercentageF(s.Total, s.Covered, false)
3135
}
3236

3337
//nolint:mnd // relax
@@ -53,11 +57,11 @@ func StatsSearchMap(stats []Stats) map[string]Stats {
5357
}
5458

5559
func CoveredPercentage(total, covered int64) int {
56-
return int(coveredPercentageF(total, covered))
60+
return int(coveredPercentageF(total, covered, true))
5761
}
5862

5963
//nolint:mnd // relax
60-
func coveredPercentageF(total, covered int64) float64 {
64+
func coveredPercentageF(total, covered int64, round bool) float64 {
6165
if total == 0 {
6266
return 0
6367
}
@@ -68,6 +72,10 @@ func coveredPercentageF(total, covered int64) float64 {
6872

6973
p := float64(covered*100) / float64(total)
7074

75+
if !round {
76+
return p
77+
}
78+
7179
// round to %.1f
7280
return float64(int(math.Round(p*10))) / 10
7381
}

pkg/testcoverage/export_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ var (
1717
LoadBaseCoverageBreakdown = loadBaseCoverageBreakdown
1818
CompressUncoveredLines = compressUncoveredLines
1919
ReportUncoveredLines = reportUncoveredLines
20+
StatusStr = statusStr
2021
)
2122

2223
type (

0 commit comments

Comments
 (0)