diff --git a/.testcoverage.example.yml b/.testcoverage.example.yml index 51bfe1b..f807bea 100644 --- a/.testcoverage.example.yml +++ b/.testcoverage.example.yml @@ -43,4 +43,13 @@ exclude: # Exclude files or packages matching their paths paths: - \.pb\.go$ # excludes all protobuf generated files - - ^pkg/bar # exclude package `pkg/bar` \ No newline at end of file + - ^pkg/bar # exclude package `pkg/bar` + +# File name of go-test-coverage breakdown file, which can be used to +# analyze coverage difference. +breakdown-file-name: '' + +diff: + # File name of go-test-coverage breakdown file which will be used to + # report coverage difference. + base-breakdown-file-name: '' \ No newline at end of file diff --git a/Makefile b/Makefile index 831ec35..407cbf2 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ lint: get-golangcilint .PHONY: test test: go test -timeout=3s -race -count=10 -failfast -shuffle=on -short ./... - go test -timeout=10s -race -count=1 -failfast -shuffle=on ./... -coverprofile=./cover.profile -covermode=atomic -coverpkg=./... + go test -timeout=20s -race -count=1 -failfast -shuffle=on ./... -coverprofile=./cover.profile -covermode=atomic -coverpkg=./... # Runs test coverage check .PHONY: check-coverage diff --git a/README.md b/README.md index f54af3e..58450ae 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,15 @@ exclude: paths: - \.pb\.go$ # excludes all protobuf generated files - ^pkg/bar # exclude package `pkg/bar` + +# File name of go-test-coverage breakdown file, which can be used to +# analyze coverage difference. +breakdown-file-name: '' + +diff: + # File name of go-test-coverage breakdown file which will be used to + # report coverage difference. + base-breakdown-file-name: '' ``` ### Exclude Code from Coverage diff --git a/action.yml b/action.yml index e5c9741..ed332a4 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,18 @@ inputs: default: -1 type: number + breakdown-file-name: + description: File name of go-test-coverage breakdown file, which can be used to analyze coverage difference. Overrides value from configuration. + required: false + default: "" + type: string + + diff-base-breakdown-file-name: + description: File name of go-test-coverage breakdown file used to calculate coverage difference from current (head). + required: false + default: "" + type: string + # Badge (as file) badge-file-name: description: If specified, a coverage badge will be generated and saved to the given file path. @@ -123,6 +135,8 @@ runs: - --threshold-file=${{ inputs.threshold-file }} - --threshold-package=${{ inputs.threshold-package }} - --threshold-total=${{ inputs.threshold-total }} + - --breakdown-file-name=${{ inputs.breakdown-file-name || '''''' }} + - --diff-base-breakdown-file-name=${{ inputs.diff-base-breakdown-file-name || '''''' }} - --badge-file-name=${{ inputs.badge-file-name || '''''' }} - --cdn-key=${{ inputs.cdn-key || '''''' }} - --cdn-secret=${{ inputs.cdn-secret || '''''' }} diff --git a/main.go b/main.go index 33fa69c..d340fc2 100644 --- a/main.go +++ b/main.go @@ -31,7 +31,11 @@ type args struct { ThresholdFile int `arg:"-f,--threshold-file"` ThresholdPackage int `arg:"-k,--threshold-package"` ThresholdTotal int `arg:"-t,--threshold-total"` - BadgeFileName string `arg:"-b,--badge-file-name"` + + BreakdownFileName string `arg:"--breakdown-file-name"` + DiffBaseBreakdownFileName string `arg:"--diff-base-breakdown-file-name"` + + BadgeFileName string `arg:"-b,--badge-file-name"` CDNKey string `arg:"--cdn-key"` CDNSecret string `arg:"--cdn-secret"` @@ -57,6 +61,9 @@ func newArgs() args { ThresholdPackage: ciDefaultInt, ThresholdTotal: ciDefaultInt, + BreakdownFileName: ciDefaultString, + DiffBaseBreakdownFileName: ciDefaultString, + // Badge BadgeFileName: ciDefaultString, @@ -81,7 +88,7 @@ func (args) Version() string { return Name + " " + Version } -//nolint:cyclop,maintidx,mnd // relax +//nolint:cyclop,maintidx,mnd,funlen // relax func (a *args) overrideConfig(cfg testcoverage.Config) (testcoverage.Config, error) { if !isCIDefaultString(a.Profile) { cfg.Profile = a.Profile @@ -107,6 +114,14 @@ func (a *args) overrideConfig(cfg testcoverage.Config) (testcoverage.Config, err cfg.Threshold.Total = a.ThresholdTotal } + if !isCIDefaultString(a.BreakdownFileName) { + cfg.BreakdownFileName = a.BreakdownFileName + } + + if !isCIDefaultString(a.DiffBaseBreakdownFileName) { + cfg.Diff.BaseBreakdownFileName = a.DiffBaseBreakdownFileName + } + if !isCIDefaultString(a.BadgeFileName) { cfg.Badge.FileName = a.BadgeFileName } diff --git a/pkg/testcoverage/check.go b/pkg/testcoverage/check.go index d9e357c..d5f337b 100644 --- a/pkg/testcoverage/check.go +++ b/pkg/testcoverage/check.go @@ -5,23 +5,32 @@ import ( "bytes" "fmt" "io" + "os" "strings" "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" ) func Check(w io.Writer, cfg Config) bool { - stats, err := coverage.GenerateCoverageStats(coverage.Config{ - Profiles: strings.Split(cfg.Profile, ","), - LocalPrefix: cfg.LocalPrefix, - ExcludePaths: cfg.Exclude.Paths, - }) + currentStats, err := GenerateCoverageStats(cfg) if err != nil { fmt.Fprintf(w, "failed to generate coverage statistics: %v\n", err) return false } - result := Analyze(cfg, stats) + err = saveCoverageBreakdown(cfg, currentStats) + if err != nil { + fmt.Fprintf(w, "failed to save coverage breakdown: %v\n", err) + return false + } + + baseStats, err := loadBaseCoverageBreakdown(cfg) + if err != nil { + fmt.Fprintf(w, "failed to load base coverage breakdown: %v\n", err) + return false + } + + result := Analyze(cfg, currentStats, baseStats) report := reportForHuman(w, result) @@ -56,16 +65,53 @@ func reportForHuman(w io.Writer, result AnalyzeResult) string { return buffer.String() } -func Analyze(cfg Config, coverageStats []coverage.Stats) AnalyzeResult { +func GenerateCoverageStats(cfg Config) ([]coverage.Stats, error) { + return coverage.GenerateCoverageStats(coverage.Config{ //nolint:wrapcheck // err wrapped above + Profiles: strings.Split(cfg.Profile, ","), + LocalPrefix: cfg.LocalPrefix, + ExcludePaths: cfg.Exclude.Paths, + }) +} + +func Analyze(cfg Config, current, base []coverage.Stats) AnalyzeResult { thr := cfg.Threshold overrideRules := compileOverridePathRules(cfg) return AnalyzeResult{ Threshold: thr, - FilesBelowThreshold: checkCoverageStatsBelowThreshold(coverageStats, thr.File, overrideRules), + FilesBelowThreshold: checkCoverageStatsBelowThreshold(current, thr.File, overrideRules), PackagesBelowThreshold: checkCoverageStatsBelowThreshold( - makePackageStats(coverageStats), thr.Package, overrideRules, + makePackageStats(current), thr.Package, overrideRules, ), - TotalStats: coverage.CalcTotalStats(coverageStats), + TotalStats: coverage.CalcTotalStats(current), + HasBaseBreakdown: len(base) > 0, + Diff: calculateStatsDiff(current, base), + } +} + +func saveCoverageBreakdown(cfg Config, stats []coverage.Stats) error { + if cfg.BreakdownFileName == "" { + return nil + } + + //nolint:mnd,wrapcheck,gosec // relax + return os.WriteFile(cfg.BreakdownFileName, coverage.SerializeStats(stats), 0o644) +} + +func loadBaseCoverageBreakdown(cfg Config) ([]coverage.Stats, error) { + if cfg.Diff.BaseBreakdownFileName == "" { + return nil, nil + } + + data, err := os.ReadFile(cfg.Diff.BaseBreakdownFileName) + if err != nil { + return nil, fmt.Errorf("reading file content failed: %w", err) } + + stats, err := coverage.DeserializeStats(data) + if err != nil { + return nil, fmt.Errorf("parsing file failed: %w", err) + } + + return stats, nil } diff --git a/pkg/testcoverage/check_test.go b/pkg/testcoverage/check_test.go index e2d6071..b421f3b 100644 --- a/pkg/testcoverage/check_test.go +++ b/pkg/testcoverage/check_test.go @@ -2,18 +2,25 @@ package testcoverage_test import ( "bytes" + "os" + "runtime" "strings" "testing" "github.com/stretchr/testify/assert" . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" + "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" + "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/path" "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/testdata" ) const ( - profileOK = "testdata/" + testdata.ProfileOK - profileNOK = "testdata/" + testdata.ProfileNOK + testdataDir = "testdata/" + profileOK = testdataDir + testdata.ProfileOK + profileNOK = testdataDir + testdata.ProfileNOK + breakdownOK = testdataDir + testdata.BreakdownOK + breakdownNOK = testdataDir + testdata.BreakdownNOK ) func TestCheck(t *testing.T) { @@ -136,8 +143,7 @@ func TestCheck(t *testing.T) { buf := &bytes.Buffer{} cfg := Config{ - Profile: profileOK, - Threshold: Threshold{File: 10}, + Profile: profileOK, Badge: Badge{ FileName: t.TempDir(), // should failed because this is dir }, @@ -146,6 +152,54 @@ func TestCheck(t *testing.T) { assert.False(t, pass) assertFailedToSaveBadge(t, buf.String()) }) + + t.Run("valid profile - fail invalid breakdown file", func(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + cfg := Config{ + Profile: profileOK, + BreakdownFileName: t.TempDir(), // should failed because this is dir + } + pass := Check(buf, cfg) + assert.False(t, pass) + assert.Contains(t, buf.String(), "failed to save coverage breakdown") + }) + + t.Run("valid profile - valid breakdown file", func(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + cfg := Config{ + Profile: profileOK, + BreakdownFileName: t.TempDir() + "/breakdown.testcoverage", + } + pass := Check(buf, cfg) + assert.True(t, pass) + + contentBytes, err := os.ReadFile(cfg.BreakdownFileName) + assert.NoError(t, err) + assert.NotEmpty(t, contentBytes) + + stats, err := GenerateCoverageStats(cfg) + assert.NoError(t, err) + assert.Equal(t, coverage.SerializeStats(stats), contentBytes) + }) + + t.Run("valid profile - invalid base breakdown file", func(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + cfg := Config{ + Profile: profileOK, + Diff: Diff{ + BaseBreakdownFileName: t.TempDir(), // should failed because this is dir + }, + } + pass := Check(buf, cfg) + assert.False(t, pass) + assert.Contains(t, buf.String(), "failed to load base coverage breakdown") + }) } // must not be parallel because it uses env @@ -198,7 +252,7 @@ func Test_Analyze(t *testing.T) { t.Run("nil coverage stats", func(t *testing.T) { t.Parallel() - result := Analyze(Config{}, nil) + result := Analyze(Config{}, nil, nil) assert.Empty(t, result.FilesBelowThreshold) assert.Empty(t, result.PackagesBelowThreshold) assert.Equal(t, 0, result.TotalStats.CoveredPercentage()) @@ -210,6 +264,7 @@ func Test_Analyze(t *testing.T) { result := Analyze( Config{LocalPrefix: prefix, Threshold: Threshold{Total: 10}}, randStats(prefix, 10, 100), + nil, ) assert.True(t, result.Pass()) assertPrefix(t, result, prefix, false) @@ -217,6 +272,7 @@ func Test_Analyze(t *testing.T) { result = Analyze( Config{Threshold: Threshold{Total: 10}}, randStats(prefix, 10, 100), + nil, ) assert.True(t, result.Pass()) assertPrefix(t, result, prefix, true) @@ -228,6 +284,7 @@ func Test_Analyze(t *testing.T) { result := Analyze( Config{Threshold: Threshold{Total: 10}}, randStats(prefix, 0, 9), + nil, ) assert.False(t, result.Pass()) }) @@ -238,6 +295,7 @@ func Test_Analyze(t *testing.T) { result := Analyze( Config{LocalPrefix: prefix, Threshold: Threshold{File: 10}}, randStats(prefix, 10, 100), + nil, ) assert.True(t, result.Pass()) assertPrefix(t, result, prefix, false) @@ -252,6 +310,7 @@ func Test_Analyze(t *testing.T) { randStats(prefix, 0, 9), randStats(prefix, 10, 100), ), + nil, ) assert.NotEmpty(t, result.FilesBelowThreshold) assert.Empty(t, result.PackagesBelowThreshold) @@ -265,6 +324,7 @@ func Test_Analyze(t *testing.T) { result := Analyze( Config{LocalPrefix: prefix, Threshold: Threshold{Package: 10}}, randStats(prefix, 10, 100), + nil, ) assert.True(t, result.Pass()) assertPrefix(t, result, prefix, false) @@ -279,6 +339,7 @@ func Test_Analyze(t *testing.T) { randStats(prefix, 0, 9), randStats(prefix, 10, 100), ), + nil, ) assert.Empty(t, result.FilesBelowThreshold) assert.NotEmpty(t, result.PackagesBelowThreshold) @@ -286,3 +347,37 @@ func Test_Analyze(t *testing.T) { assertPrefix(t, result, prefix, true) }) } + +func TestLoadBaseCoverageBreakdown(t *testing.T) { + t.Parallel() + + if testing.Short() { + return + } + + if runtime.GOOS == "windows" { + t.Skip("tests fails windows in ci, but works locally") + } + + stats, err := LoadBaseCoverageBreakdown(Config{Diff: Diff{}}) + assert.NoError(t, err) + assert.Empty(t, stats) + + stats, err = LoadBaseCoverageBreakdown(Config{ + Diff: Diff{BaseBreakdownFileName: path.NormalizeForOS(breakdownOK)}, + }) + assert.NoError(t, err) + assert.Len(t, stats, 14) + + stats, err = LoadBaseCoverageBreakdown(Config{ + Diff: Diff{BaseBreakdownFileName: t.TempDir()}, + }) + assert.Error(t, err) + assert.Empty(t, stats) + + stats, err = LoadBaseCoverageBreakdown(Config{ + Diff: Diff{BaseBreakdownFileName: path.NormalizeForOS(breakdownNOK)}, + }) + assert.Error(t, err) + assert.Empty(t, stats) +} diff --git a/pkg/testcoverage/config.go b/pkg/testcoverage/config.go index 3bec7d6..7334f24 100644 --- a/pkg/testcoverage/config.go +++ b/pkg/testcoverage/config.go @@ -27,7 +27,9 @@ type Config struct { Threshold Threshold `yaml:"threshold"` Override []Override `yaml:"override,omitempty"` Exclude Exclude `yaml:"exclude"` + BreakdownFileName string `yaml:"breakdown-file-name"` GithubActionOutput bool `yaml:"github-action-output"` + Diff Diff `yaml:"diff"` Badge Badge `yaml:"-"` } @@ -46,6 +48,10 @@ type Exclude struct { Paths []string `yaml:"paths,omitempty"` } +type Diff struct { + BaseBreakdownFileName string `yaml:"base-breakdown-file-name"` +} + type Badge struct { FileName string CDN badgestorer.CDN diff --git a/pkg/testcoverage/config_test.go b/pkg/testcoverage/config_test.go index 2f53e2a..af7bb6e 100644 --- a/pkg/testcoverage/config_test.go +++ b/pkg/testcoverage/config_test.go @@ -208,6 +208,10 @@ func nonZeroConfig() Config { Exclude: Exclude{ Paths: []string{"path1", "path2"}, }, + BreakdownFileName: "breakdown.testcoverage", + Diff: Diff{ + BaseBreakdownFileName: "breakdown.testcoverage", + }, GithubActionOutput: true, } } @@ -227,6 +231,9 @@ exclude: paths: - path1 - path2 +breakdown-file-name: 'breakdown.testcoverage' +diff: + base-breakdown-file-name: 'breakdown.testcoverage' github-action-output: true` } diff --git a/pkg/testcoverage/coverage/types.go b/pkg/testcoverage/coverage/types.go index a851a41..3fb067d 100644 --- a/pkg/testcoverage/coverage/types.go +++ b/pkg/testcoverage/coverage/types.go @@ -1,8 +1,11 @@ package coverage import ( + "bytes" + "errors" "fmt" "regexp" + "strconv" "strings" ) @@ -13,10 +16,18 @@ type Stats struct { Threshold int } +func (s Stats) UncoveredLines() int { + return int(s.Total - s.Covered) +} + func (s Stats) CoveredPercentage() int { return CoveredPercentage(s.Total, s.Covered) } +func (s Stats) CoveredPercentageF() float64 { + return coveredPercentageF(s.Total, s.Covered) +} + //nolint:mnd // relax func (s Stats) Str() string { c := s.CoveredPercentage() @@ -24,10 +35,19 @@ func (s Stats) Str() string { if c == 100 { // precision not needed return fmt.Sprintf("%d%% (%d/%d)", c, s.Covered, s.Total) } else if c < 10 { // adds space for singe digit number - return fmt.Sprintf(" %.1f%% (%d/%d)", coveredPercentageF(s.Total, s.Covered), s.Covered, s.Total) + return fmt.Sprintf(" %.1f%% (%d/%d)", s.CoveredPercentageF(), s.Covered, s.Total) + } + + return fmt.Sprintf("%.1f%% (%d/%d)", s.CoveredPercentageF(), s.Covered, s.Total) +} + +func StatsSearchMap(stats []Stats) map[string]Stats { + m := make(map[string]Stats) + for _, s := range stats { + m[s.Name] = s } - return fmt.Sprintf("%.1f%% (%d/%d)", coveredPercentageF(s.Total, s.Covered), s.Covered, s.Total) + return m } func CoveredPercentage(total, covered int64) int { @@ -83,13 +103,71 @@ func compileExcludePathRules(excludePaths []string) []*regexp.Regexp { return compiled } -func CalcTotalStats(coverageStats []Stats) Stats { - totalStats := Stats{} +func CalcTotalStats(stats []Stats) Stats { + total := Stats{} + + for _, s := range stats { + total.Total += s.Total + total.Covered += s.Covered + } + + return total +} + +func SerializeStats(stats []Stats) []byte { + b := bytes.Buffer{} + sep, nl := []byte(";"), []byte("\n") + + //nolint:errcheck // relax + for _, s := range stats { + b.WriteString(s.Name) + b.Write(sep) + b.WriteString(strconv.FormatInt(s.Total, 10)) + b.Write(sep) + b.WriteString(strconv.FormatInt(s.Covered, 10)) + b.Write(nl) + } + + return b.Bytes() +} + +var ErrInvalidFormat = errors.New("invalid format") + +func DeserializeStats(b []byte) ([]Stats, error) { + deserializeLine := func(bl []byte) (Stats, error) { + fields := bytes.Split(bl, []byte(";")) + if len(fields) != 3 { //nolint:mnd // relax + return Stats{}, ErrInvalidFormat + } + + t, err := strconv.ParseInt(string(fields[1]), 10, 64) + if err != nil { + return Stats{}, ErrInvalidFormat + } + + c, err := strconv.ParseInt(string(fields[2]), 10, 64) + if err != nil { + return Stats{}, ErrInvalidFormat + } + + return Stats{Name: string(fields[0]), Total: t, Covered: c}, nil + } + + lines := bytes.Split(b, []byte("\n")) + result := make([]Stats, 0, len(lines)) + + for _, l := range lines { + if len(l) == 0 { + continue + } + + s, err := deserializeLine(l) + if err != nil { + return nil, err + } - for _, stats := range coverageStats { - totalStats.Total += stats.Total - totalStats.Covered += stats.Covered + result = append(result, s) } - return totalStats + return result, nil } diff --git a/pkg/testcoverage/coverage/types_test.go b/pkg/testcoverage/coverage/types_test.go index 99b6077..e6168d7 100644 --- a/pkg/testcoverage/coverage/types_test.go +++ b/pkg/testcoverage/coverage/types_test.go @@ -37,3 +37,34 @@ func TestStatStr(t *testing.T) { assert.Equal(t, "22.2% (2/9)", Stats{Covered: 2, Total: 9}.Str()) assert.Equal(t, "100% (10/10)", Stats{Covered: 10, Total: 10}.Str()) } + +func TestStatsSerialization(t *testing.T) { + t.Parallel() + + stats := []Stats{ + {Name: "foo", Total: 11, Covered: 1}, + {Name: "bar", Total: 9, Covered: 2}, + } + + b := SerializeStats(stats) + assert.Equal(t, "foo;11;1\nbar;9;2\n", string(b)) + + ds, err := DeserializeStats(b) + assert.NoError(t, err) + assert.Equal(t, stats, ds) + + // ignore empty lines + ds, err = DeserializeStats([]byte("\n\n\n\n")) + assert.NoError(t, err) + assert.Empty(t, ds) + + // invalid formats + _, err = DeserializeStats([]byte("foo;11;")) + assert.Error(t, err) + + _, err = DeserializeStats([]byte("foo;;11")) + assert.Error(t, err) + + _, err = DeserializeStats([]byte("foo;")) + assert.Error(t, err) +} diff --git a/pkg/testcoverage/export_test.go b/pkg/testcoverage/export_test.go index c5f08a5..df9b981 100644 --- a/pkg/testcoverage/export_test.go +++ b/pkg/testcoverage/export_test.go @@ -9,11 +9,12 @@ const ( ) var ( - MakePackageStats = makePackageStats - PackageForFile = packageForFile - StoreBadge = storeBadge - GenerateAndSaveBadge = generateAndSaveBadge - SetOutputValue = setOutputValue + MakePackageStats = makePackageStats + PackageForFile = packageForFile + StoreBadge = storeBadge + GenerateAndSaveBadge = generateAndSaveBadge + SetOutputValue = setOutputValue + LoadBaseCoverageBreakdown = loadBaseCoverageBreakdown ) type ( diff --git a/pkg/testcoverage/report.go b/pkg/testcoverage/report.go index 7efd844..e6fac39 100644 --- a/pkg/testcoverage/report.go +++ b/pkg/testcoverage/report.go @@ -18,7 +18,12 @@ func ReportForHuman(w io.Writer, result AnalyzeResult) { out := bufio.NewWriter(w) defer out.Flush() - tabber := tabwriter.NewWriter(out, 1, 8, 2, '\t', 0) //nolint:mnd // relax + reportCoverage(out, result) + reportDiff(out, result) +} + +func reportCoverage(w io.Writer, result AnalyzeResult) { + tabber := tabwriter.NewWriter(w, 1, 8, 2, '\t', 0) //nolint:mnd // relax defer tabber.Flush() statusStr := func(passing bool) string { @@ -68,6 +73,38 @@ func reportIssuesForHuman(w io.Writer, coverageStats []coverage.Stats) { fmt.Fprintf(w, "\n") } +func reportDiff(w io.Writer, result AnalyzeResult) { + if !result.HasBaseBreakdown { + return + } + + tabber := tabwriter.NewWriter(w, 1, 8, 2, '\t', 0) //nolint:mnd // relax + defer tabber.Flush() + + if len(result.Diff) == 0 { + fmt.Fprintf(tabber, "\nCurrent tests coverage has not changed.\n") + return + } + + td := TotalLinesDiff(result.Diff) + fmt.Fprintf(tabber, "\nCurrent tests coverage has changed with %d lines missing coverage.", td) + fmt.Fprintf(tabber, "\n file:\tuncovered:\tcurrent coverage:\tbase coverage:") + + for _, d := range result.Diff { + var baseStr string + if d.Base == nil { + baseStr = " / " + } else { + baseStr = d.Base.Str() + } + + dp := d.Current.UncoveredLines() + fmt.Fprintf(tabber, "\n %s\t%3d\t%s\t%s", d.Current.Name, dp, d.Current.Str(), baseStr) + } + + fmt.Fprintf(tabber, "\n") +} + func ReportForGithubAction(w io.Writer, result AnalyzeResult) { out := bufio.NewWriter(w) defer out.Flush() diff --git a/pkg/testcoverage/report_test.go b/pkg/testcoverage/report_test.go index 9edf956..2a43817 100644 --- a/pkg/testcoverage/report_test.go +++ b/pkg/testcoverage/report_test.go @@ -42,7 +42,7 @@ func Test_ReportForHuman(t *testing.T) { cfg := Config{Threshold: Threshold{File: 10}} statsWithError := randStats(prefix, 0, 9) statsNoError := randStats(prefix, 10, 100) - result := Analyze(cfg, mergeStats(statsWithError, statsNoError)) + result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) ReportForHuman(buf, result) assertHumanReport(t, buf.String(), 0, 1) assertContainStats(t, buf.String(), statsWithError) @@ -56,7 +56,7 @@ func Test_ReportForHuman(t *testing.T) { cfg := Config{Threshold: Threshold{Package: 10}} statsWithError := randStats(prefix, 0, 9) statsNoError := randStats(prefix, 10, 100) - result := Analyze(cfg, mergeStats(statsWithError, statsNoError)) + result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) ReportForHuman(buf, result) assertHumanReport(t, buf.String(), 0, 1) assertContainStats(t, buf.String(), MakePackageStats(statsWithError)) @@ -64,6 +64,40 @@ func Test_ReportForHuman(t *testing.T) { assertNotContainStats(t, buf.String(), statsWithError) assertNotContainStats(t, buf.String(), statsNoError) }) + + t.Run("diff - no change", func(t *testing.T) { + t.Parallel() + + stats := randStats(prefix, 10, 100) + + buf := &bytes.Buffer{} + cfg := Config{} + result := Analyze(cfg, stats, stats) + ReportForHuman(buf, result) + + assert.Contains(t, buf.String(), "Current tests coverage has not changed") + }) + + t.Run("diff - has change", func(t *testing.T) { + t.Parallel() + + stats := randStats(prefix, 10, 100) + base := mergeStats(make([]coverage.Stats, 0), stats) + + stats = append(stats, coverage.Stats{Name: "foo", Total: 9, Covered: 8}) + stats = append(stats, coverage.Stats{Name: "foo-new", Total: 9, Covered: 8}) + + base = append(base, coverage.Stats{Name: "foo", Total: 10, Covered: 10}) + + buf := &bytes.Buffer{} + cfg := Config{} + result := Analyze(cfg, stats, base) + ReportForHuman(buf, result) + + assert.Contains(t, buf.String(), + "Current tests coverage has changed with 2 lines missing coverage", + ) + }) } func Test_ReportForGithubAction(t *testing.T) { @@ -77,7 +111,7 @@ func Test_ReportForGithubAction(t *testing.T) { buf := &bytes.Buffer{} cfg := Config{Threshold: Threshold{Total: 100}} statsNoError := randStats(prefix, 100, 100) - result := Analyze(cfg, statsNoError) + result := Analyze(cfg, statsNoError, nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), 0) assertNotContainStats(t, buf.String(), statsNoError) @@ -90,7 +124,7 @@ func Test_ReportForGithubAction(t *testing.T) { statsWithError := randStats(prefix, 0, 9) statsNoError := randStats(prefix, 10, 100) cfg := Config{Threshold: Threshold{Total: 10}} - result := Analyze(cfg, mergeStats(statsWithError, statsNoError)) + result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), 1) assertNotContainStats(t, buf.String(), statsWithError) @@ -103,7 +137,7 @@ func Test_ReportForGithubAction(t *testing.T) { buf := &bytes.Buffer{} cfg := Config{Threshold: Threshold{File: 10}} statsNoError := randStats(prefix, 10, 100) - result := Analyze(cfg, statsNoError) + result := Analyze(cfg, statsNoError, nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), 0) assertNotContainStats(t, buf.String(), statsNoError) @@ -116,7 +150,7 @@ func Test_ReportForGithubAction(t *testing.T) { cfg := Config{Threshold: Threshold{File: 10}} statsWithError := randStats(prefix, 0, 9) statsNoError := randStats(prefix, 10, 100) - result := Analyze(cfg, mergeStats(statsWithError, statsNoError)) + result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), len(statsWithError)) assertContainStats(t, buf.String(), statsWithError) @@ -129,7 +163,7 @@ func Test_ReportForGithubAction(t *testing.T) { buf := &bytes.Buffer{} cfg := Config{Threshold: Threshold{Package: 10}} statsNoError := randStats(prefix, 10, 100) - result := Analyze(cfg, statsNoError) + result := Analyze(cfg, statsNoError, nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), 0) assertNotContainStats(t, buf.String(), MakePackageStats(statsNoError)) @@ -143,7 +177,7 @@ func Test_ReportForGithubAction(t *testing.T) { cfg := Config{Threshold: Threshold{Package: 10}} statsWithError := randStats(prefix, 0, 9) statsNoError := randStats(prefix, 10, 100) - result := Analyze(cfg, mergeStats(statsWithError, statsNoError)) + result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), len(MakePackageStats(statsWithError))) assertContainStats(t, buf.String(), MakePackageStats(statsWithError)) @@ -160,7 +194,7 @@ func Test_ReportForGithubAction(t *testing.T) { statsWithError := randStats(prefix, 0, 9) statsNoError := randStats(prefix, 10, 100) totalErrorsCount := len(MakePackageStats(statsWithError)) + len(statsWithError) + 1 - result := Analyze(cfg, mergeStats(statsWithError, statsNoError)) + result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) ReportForGithubAction(buf, result) assertGithubActionErrorsCount(t, buf.String(), totalErrorsCount) assertContainStats(t, buf.String(), statsWithError) diff --git a/pkg/testcoverage/testdata/breakdown_nok.testcoverage b/pkg/testcoverage/testdata/breakdown_nok.testcoverage new file mode 100644 index 0000000..4825245 --- /dev/null +++ b/pkg/testcoverage/testdata/breakdown_nok.testcoverage @@ -0,0 +1,2 @@ +pkg/testcoverage/badge.go;33;33 +pkg/testcoverage/badge/generate.go; \ No newline at end of file diff --git a/pkg/testcoverage/testdata/breakdown_ok.testcoverage b/pkg/testcoverage/testdata/breakdown_ok.testcoverage new file mode 100644 index 0000000..d74a6b3 --- /dev/null +++ b/pkg/testcoverage/testdata/breakdown_ok.testcoverage @@ -0,0 +1,14 @@ +pkg/testcoverage/badge.go;33;33 +pkg/testcoverage/badge/generate.go;8;8 +pkg/testcoverage/badgestorer/cdn.go;14;14 +pkg/testcoverage/badgestorer/file.go;5;5 +pkg/testcoverage/badgestorer/github.go;17;11 +pkg/testcoverage/check.go;47;38 +pkg/testcoverage/config.go;51;51 +pkg/testcoverage/coverage/cover.go;81;81 +pkg/testcoverage/coverage/profile.go;34;34 +pkg/testcoverage/coverage/types.go;70;69 +pkg/testcoverage/path/path.go;6;4 +pkg/testcoverage/report.go;81;65 +pkg/testcoverage/types.go;39;33 +pkg/testcoverage/utils.go;10;10 diff --git a/pkg/testcoverage/testdata/consts.go b/pkg/testcoverage/testdata/consts.go index ac84277..76dc141 100644 --- a/pkg/testcoverage/testdata/consts.go +++ b/pkg/testcoverage/testdata/consts.go @@ -25,4 +25,10 @@ const ( // contains profile items for `badge/generate.go` file, but // does not have correct profile items ProfileNOKInvalidData = "invalid_data.profile" + + // holds valid test coverage breakdown + BreakdownOK = "breakdown_ok.testcoverage" + + // holds invalid test coverage breakdown + BreakdownNOK = "breakdown_nok.testcoverage" ) diff --git a/pkg/testcoverage/types.go b/pkg/testcoverage/types.go index 6e7e982..234f3c8 100644 --- a/pkg/testcoverage/types.go +++ b/pkg/testcoverage/types.go @@ -13,6 +13,8 @@ type AnalyzeResult struct { FilesBelowThreshold []coverage.Stats PackagesBelowThreshold []coverage.Stats TotalStats coverage.Stats + HasBaseBreakdown bool + Diff []FileCoverageDiff } func (r *AnalyzeResult) Pass() bool { @@ -76,3 +78,36 @@ func makePackageStats(coverageStats []coverage.Stats) []coverage.Stats { return maps.Values(packageStats) } + +type FileCoverageDiff struct { + Current coverage.Stats + Base *coverage.Stats +} + +func calculateStatsDiff(current, base []coverage.Stats) []FileCoverageDiff { + res := make([]FileCoverageDiff, 0) + baseSearchMap := coverage.StatsSearchMap(base) + + for _, s := range current { + if b, found := baseSearchMap[s.Name]; found { + if s.UncoveredLines() != b.UncoveredLines() { + res = append(res, FileCoverageDiff{Current: s, Base: &b}) + } + } else { + if s.UncoveredLines() > 0 { + res = append(res, FileCoverageDiff{Current: s}) + } + } + } + + return res +} + +func TotalLinesDiff(diff []FileCoverageDiff) int { + r := 0 + for _, d := range diff { + r += d.Current.UncoveredLines() + } + + return r +}