├── .github ├── .testcoverage-local.yml ├── .testcoverage.yml ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── action-test.yml │ ├── lint.yml │ ├── pr.yml │ ├── release.yml │ ├── test.yml │ └── testdata │ ├── total100.yml │ └── zero.yml ├── .gitignore ├── .golangci.yml ├── .testcoverage.example.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── action.yml ├── docs ├── badge.md └── github_action.md ├── go.mod ├── go.sum ├── main.go ├── main_config.go └── pkg └── testcoverage ├── badge.go ├── badge ├── generate.go └── generate_test.go ├── badge_test.go ├── badgestorer ├── cdn.go ├── cdn_test.go ├── export_test.go ├── file.go ├── file_test.go ├── github.go ├── github_test.go └── storer.go ├── check.go ├── check_test.go ├── config.go ├── config_test.go ├── coverage ├── cover.go ├── cover_test.go ├── export_test.go ├── module.go ├── module_test.go ├── profile.go ├── profile_test.go ├── types.go └── types_test.go ├── export_test.go ├── helpers_test.go ├── logger └── logger.go ├── path ├── path.go └── path_test.go ├── report.go ├── report_test.go ├── testdata ├── breakdown_nok.testcoverage ├── breakdown_ok.testcoverage ├── consts.go ├── invalid_data.profile ├── invalid_length.profile ├── nok.profile ├── ok.profile ├── ok_full.profile ├── ok_no_badge.profile └── ok_no_statements.profile ├── types.go ├── types_test.go └── utils.go /.github/.testcoverage-local.yml: -------------------------------------------------------------------------------- 1 | # Config file for go-test-coverage running locally. 2 | 3 | profile: cover.profile 4 | threshold: 5 | file: 100 6 | total: 98 7 | override: 8 | - path: badgestorer/github.go$ ## is integration test 9 | threshold: 64 10 | - path: path/path.go$ ## requires windows to be tested 11 | threshold: 66 12 | exclude: 13 | paths: 14 | - main\.go$ 15 | - main_config\.go$ -------------------------------------------------------------------------------- /.github/.testcoverage.yml: -------------------------------------------------------------------------------- 1 | # Config file for go-test-coverage github action. 2 | 3 | profile: '' # set via github action 4 | threshold: 5 | file: 100 6 | total: 100 7 | exclude: 8 | paths: 9 | - main\.go$ 10 | - main_config\.go$ -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | github: [vladopajic] 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "docker" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/action-test.yml: -------------------------------------------------------------------------------- 1 | name: action-test 2 | on: [push] 3 | jobs: 4 | build-dev-image: 5 | name: build dev image 6 | permissions: 7 | packages: write 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout 11 | uses: actions/checkout@v4 12 | 13 | - name: login to GitHub container registry 14 | uses: docker/login-action@v3 15 | with: 16 | registry: ghcr.io 17 | username: ${{ github.repository_owner }} 18 | password: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - name: build and push 21 | uses: docker/build-push-action@v6 22 | with: 23 | push: true 24 | build-args: | 25 | VERSION=dev 26 | tags: | 27 | ghcr.io/vladopajic/go-test-coverage:dev 28 | 29 | - uses: actions/delete-package-versions@v5 30 | with: 31 | owner: vladopajic 32 | package-name: go-test-coverage 33 | package-type: container 34 | min-versions-to-keep: 5 35 | delete-only-untagged-versions: true 36 | test: 37 | name: test 38 | runs-on: ubuntu-latest 39 | needs: build-dev-image 40 | 41 | steps: 42 | - name: checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: setup go 46 | uses: actions/setup-go@v5 47 | with: 48 | go-version-file: go.mod 49 | 50 | - name: generate test coverage 51 | run: go test ./... -coverprofile=./cover.out -covermode=atomic 52 | 53 | - name: set action image version to dev 54 | run: | 55 | yq e -i '.runs.image = "docker://ghcr.io/vladopajic/go-test-coverage:dev"' action.yml 56 | image=$(yq '.runs.image' action.yml) 57 | echo "Image: $image" 58 | 59 | ## Test 1 60 | 61 | - name: "test: total coverage 0% (config)" 62 | uses: ./ 63 | id: test-1 64 | with: 65 | config: ./.github/workflows/testdata/zero.yml 66 | 67 | - name: "check: test output values" 68 | if: ${{ steps.test-1.outputs.total-coverage == '' || steps.test-1.outputs.badge-text == '' || steps.test-1.outputs.badge-color == '' }} 69 | run: echo "Previous step should have output values" && exit 1 70 | 71 | ## Test 2 72 | 73 | - name: "test: total coverage 100% (config)" 74 | uses: ./ 75 | id: test-2 76 | continue-on-error: true 77 | with: 78 | config: ./.github/workflows/testdata/total100.yml 79 | 80 | - name: "check: test should have failed" 81 | if: steps.test-2.outcome != 'failure' 82 | run: echo "Previous step should have failed" && exit 1 83 | 84 | - name: "check: test output values" 85 | if: ${{ steps.test-2.outputs.total-coverage == '' || steps.test-2.outputs.badge-text == '' || steps.test-2.outputs.badge-color == '' }} 86 | run: echo "Previous step should have output values" && exit 1 87 | 88 | ## Test 3 89 | 90 | - name: "test: total coverage 0% (inputs)" 91 | uses: ./ 92 | id: test-3 93 | with: 94 | profile: cover.out 95 | threshold-file: 0 96 | threshold-package: 0 97 | threshold-total: 0 98 | 99 | ## Test 4 100 | 101 | - name: "test: total coverage 100% (inputs)" 102 | uses: ./ 103 | id: test-4 104 | continue-on-error: true 105 | with: 106 | profile: cover.out 107 | threshold-file: 0 108 | threshold-package: 0 109 | threshold-total: 100 110 | 111 | - name: "check: test should have failed" 112 | if: steps.test-4.outcome != 'failure' 113 | run: echo "Previous step should have failed" && exit 1 114 | 115 | ## Test 5 116 | 117 | - name: "test: override config" 118 | uses: ./ 119 | id: test-5 120 | with: 121 | config: ./.github/workflows/testdata/total100.yml 122 | threshold-file: 0 123 | threshold-package: 0 124 | threshold-total: 0 125 | 126 | ## Test 6 127 | 128 | - name: "test: debug output" 129 | uses: ./ 130 | id: test-6 131 | continue-on-error: true 132 | with: 133 | profile: unexistant-profile.out 134 | debug: true 135 | threshold-file: 0 136 | threshold-package: 0 137 | threshold-total: 100 138 | 139 | - name: "check: test should have failed" 140 | if: steps.test-6.outcome != 'failure' 141 | run: echo "Previous step should have failed" && exit 1 -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: [push] 3 | jobs: 4 | lint: 5 | name: lint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: checkout 9 | uses: actions/checkout@v4 10 | - name: setup go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version-file: go.mod 14 | - name: go mod tidy check 15 | uses: katexochen/go-tidy-check@v2 16 | - name: golangci-lint 17 | uses: golangci/golangci-lint-action@v7 18 | with: 19 | version: v2.0.1 # LINT_VERSION: update version in other places 20 | - id: govulncheck 21 | uses: golang/govulncheck-action@v1 22 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: pull request 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | - reopened 10 | 11 | permissions: 12 | pull-requests: read 13 | 14 | jobs: 15 | lint: 16 | name: semantic title 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: amannn/action-semantic-pull-request@v5 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+\\.[0-9]+\\.[0-9]+" 6 | 7 | jobs: 8 | release: 9 | permissions: 10 | contents: write 11 | packages: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version-file: go.mod 19 | 20 | - name: set RELEASE_VERSION ENV var 21 | run: echo "RELEASE_VERSION=${GITHUB_REF:10}" >> $GITHUB_ENV 22 | 23 | - name: ensure image version is set to release version 24 | run: | 25 | image=$(yq '.runs.image' action.yml) 26 | echo "Image: $image" 27 | echo "Release version: ${{ env.RELEASE_VERSION }}" 28 | [[ "$image" == *"${{ env.RELEASE_VERSION }}" ]] 29 | 30 | - name: update the major version tag 31 | id: majorver 32 | uses: actions/publish-action@v0.3.0 33 | with: 34 | source-tag: ${{ env.RELEASE_VERSION }} 35 | 36 | - name: login to GitHub container registry 37 | uses: docker/login-action@v3 38 | with: 39 | registry: ghcr.io 40 | username: ${{ github.repository_owner }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: build and push 44 | uses: docker/build-push-action@v6 45 | with: 46 | push: true 47 | build-args: | 48 | VERSION=${{ env.RELEASE_VERSION }} 49 | tags: | 50 | ghcr.io/vladopajic/go-test-coverage:${{ env.RELEASE_VERSION }} 51 | ghcr.io/vladopajic/go-test-coverage:${{ steps.majorver.outputs.major-tag }} 52 | ghcr.io/vladopajic/go-test-coverage:latest 53 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | permissions: 4 | contents: write 5 | pull-requests: write 6 | jobs: 7 | test: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v4 16 | - name: setup go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version-file: go.mod 20 | 21 | - name: test 22 | env: 23 | GITHUB_TOKEN: ${{ matrix.os == 'ubuntu-latest' && secrets.GITHUB_TOKEN || '' }} # Needed for GitHub badge storer integration test 24 | run: go test -race -count=1 -failfast -shuffle=on -coverprofile=${{ matrix.os }}-profile -covermode=atomic -coverpkg=./... ./... 25 | 26 | - name: upload cover profile artifact 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: ${{ matrix.os }}-profile 30 | path: ${{ matrix.os }}-profile 31 | if-no-files-found: error 32 | 33 | check-coverage: 34 | runs-on: ubuntu-latest 35 | needs: test 36 | 37 | steps: 38 | - name: checkout 39 | uses: actions/checkout@v4 40 | 41 | - name: download ubuntu-latest-profile 42 | uses: actions/download-artifact@v4 43 | with: 44 | name: ubuntu-latest-profile 45 | - name: download macos-latest-profile 46 | uses: actions/download-artifact@v4 47 | with: 48 | name: macos-latest-profile 49 | - name: download windows-latest-profile 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: windows-latest-profile 53 | 54 | - name: download artifact (main.breakdown) 55 | id: download-main-breakdown 56 | uses: dawidd6/action-download-artifact@v9 57 | with: 58 | branch: main 59 | workflow_conclusion: success 60 | name: main.breakdown 61 | if_no_artifact_found: fail 62 | 63 | - name: check test coverage 64 | id: coverage 65 | uses: vladopajic/go-test-coverage@v2 66 | continue-on-error: true # Should fail after coverage comment is posted 67 | with: 68 | config: ./.github/.testcoverage.yml 69 | profile: ubuntu-latest-profile,macos-latest-profile,windows-latest-profile 70 | git-branch: badges 71 | git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }} 72 | breakdown-file-name: ${{ github.ref_name == 'main' && 'main.breakdown' || '' }} 73 | diff-base-breakdown-file-name: ${{ steps.download-main-breakdown.outputs.found_artifact == 'true' && 'main.breakdown' || '' }} 74 | 75 | - name: upload artifact (main.breakdown) 76 | uses: actions/upload-artifact@v4 77 | if: github.ref_name == 'main' 78 | with: 79 | name: main.breakdown 80 | path: main.breakdown 81 | if-no-files-found: error 82 | 83 | - name: find pull request ID 84 | run: | 85 | PR_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 86 | "https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}&state=open") 87 | PR_ID=$(echo "$PR_DATA" | jq -r '.[0].number') 88 | 89 | if [ "$PR_ID" != "null" ]; then 90 | echo "pull_request_id=$PR_ID" >> $GITHUB_ENV 91 | else 92 | echo "No open pull request found for this branch." 93 | fi 94 | - name: post coverage report 95 | if: env.pull_request_id 96 | uses: thollander/actions-comment-pull-request@v3 97 | with: 98 | github-token: ${{ secrets.GITHUB_TOKEN }} 99 | comment-tag: coverage-report 100 | pr-number: ${{ env.pull_request_id }} 101 | message: | 102 | go-test-coverage report: 103 | ``` 104 | ${{ fromJSON(steps.coverage.outputs.report) }}``` 105 | 106 | - name: "finally check coverage" 107 | if: steps.coverage.outcome == 'failure' 108 | shell: bash 109 | run: echo "coverage check failed" && exit 1 110 | -------------------------------------------------------------------------------- /.github/workflows/testdata/total100.yml: -------------------------------------------------------------------------------- 1 | # This file is used for integration tests 2 | 3 | profile: cover.out 4 | threshold: 5 | file: 0 6 | package: 0 7 | total: 100 8 | -------------------------------------------------------------------------------- /.github/workflows/testdata/zero.yml: -------------------------------------------------------------------------------- 1 | # This file is used for integration tests 2 | 3 | profile: cover.out 4 | threshold: 5 | file: 0 6 | package: 0 7 | total: 0 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cover.profile 2 | cover.html -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - asasalint 5 | - asciicheck 6 | - bidichk 7 | - bodyclose 8 | - containedctx 9 | - contextcheck 10 | - copyloopvar 11 | - cyclop 12 | - decorder 13 | - dogsled 14 | - dupl 15 | - dupword 16 | - durationcheck 17 | - err113 18 | - errchkjson 19 | - errname 20 | - errorlint 21 | - exhaustive 22 | - exptostd 23 | - fatcontext 24 | - forbidigo 25 | - forcetypeassert 26 | - funlen 27 | - gocheckcompilerdirectives 28 | - gochecknoglobals 29 | - gochecknoinits 30 | - gochecksumtype 31 | - gocognit 32 | - goconst 33 | - gocritic 34 | - gocyclo 35 | - godox 36 | - goheader 37 | - gomoddirectives 38 | - gomodguard 39 | - goprintffuncname 40 | - gosec 41 | - grouper 42 | - iface 43 | - importas 44 | - inamedparam 45 | - interfacebloat 46 | - intrange 47 | - lll 48 | - loggercheck 49 | - maintidx 50 | - makezero 51 | - mirror 52 | - misspell 53 | - mnd 54 | - nakedret 55 | - nestif 56 | - nilerr 57 | - nilnesserr 58 | - nilnil 59 | - nlreturn 60 | - noctx 61 | - nolintlint 62 | - nonamedreturns 63 | - nosprintfhostport 64 | - paralleltest 65 | - perfsprint 66 | - prealloc 67 | - predeclared 68 | - promlinter 69 | - protogetter 70 | - reassign 71 | - recvcheck 72 | - revive 73 | - sloglint 74 | - sqlclosecheck 75 | - staticcheck 76 | - tagalign 77 | - tagliatelle 78 | - testifylint 79 | - testpackage 80 | - thelper 81 | - tparallel 82 | - unconvert 83 | - unparam 84 | - usestdlibvars 85 | - usetesting 86 | - wastedassign 87 | - whitespace 88 | - wrapcheck 89 | - wsl 90 | - zerologlint 91 | settings: 92 | dupl: 93 | threshold: 120 94 | errcheck: 95 | disable-default-exclusions: true 96 | check-blank: true 97 | errchkjson: 98 | report-no-exported: true 99 | forbidigo: 100 | forbid: 101 | - pattern: time\.Sleep*(# Do not sleep)? 102 | - pattern: panic*(# Do not panic)? 103 | - pattern: os\.Exit*(# Do not exit)? 104 | - pattern: fmt\.Print*(# Do not print)? 105 | funlen: 106 | ignore-comments: true 107 | gocognit: 108 | min-complexity: 20 109 | goconst: 110 | min-occurrences: 3 111 | numbers: true 112 | gocyclo: 113 | min-complexity: 20 114 | govet: 115 | disable: 116 | - fieldalignment 117 | enable-all: true 118 | grouper: 119 | import-require-single-import: true 120 | iface: 121 | enable: 122 | - identical 123 | - unused 124 | lll: 125 | line-length: 100 126 | tab-width: 1 127 | maintidx: 128 | under: 40 129 | misspell: 130 | locale: US 131 | nlreturn: 132 | block-size: 5 133 | nolintlint: 134 | require-explanation: true 135 | tagliatelle: 136 | case: 137 | rules: 138 | yaml: kebab 139 | use-field-name: true 140 | testifylint: 141 | enable-all: true 142 | unparam: 143 | check-exported: true 144 | wsl: 145 | force-err-cuddling: true 146 | exclusions: 147 | generated: lax 148 | presets: 149 | - comments 150 | - common-false-positives 151 | - legacy 152 | - std-error-handling 153 | rules: 154 | - linters: 155 | - funlen ## Function length is okay due to many tests cases 156 | - maintidx ## No need to check wrapping errors in tests 157 | - wrapcheck ## Test are okay to be long 158 | path: _test\.go 159 | - linters: 160 | - gosec 161 | path: _test\.go 162 | text: G404 ## allow weak rand in tests 163 | - linters: 164 | - gochecknoglobals ## Global values are useful when exporting functions 165 | - revive ## Disabling linter because we intentionally want to use unexported types in tests 166 | path: export_test\.go 167 | - linters: 168 | - revive 169 | path: _test\.go 170 | text: dot-imports ## Enable dot-imports in tests 171 | - linters: 172 | - testifylint 173 | text: require-error 174 | - linters: 175 | - err113 176 | text: do not define dynamic errors ## dynamic errors are okay is this is simple tool 177 | paths: 178 | - third_party$ 179 | - builtin$ 180 | - examples$ 181 | formatters: 182 | enable: 183 | - gofmt 184 | - gofumpt 185 | - goimports 186 | settings: 187 | goimports: 188 | local-prefixes: 189 | - github.com/vladopajic/go-test-coverage/v2 190 | exclusions: 191 | generated: lax 192 | paths: 193 | - third_party$ 194 | - builtin$ 195 | - examples$ 196 | -------------------------------------------------------------------------------- /.testcoverage.example.yml: -------------------------------------------------------------------------------- 1 | # (mandatory) 2 | # Path to coverage profile file (output of `go test -coverprofile` command). 3 | # 4 | # For cases where there are many coverage profiles, such as when running 5 | # unit tests and integration tests separately, you can combine all those 6 | # profiles into one. In this case, the profile should have a comma-separated list 7 | # of profile files, e.g., 'cover_unit.out,cover_integration.out'. 8 | profile: cover.out 9 | 10 | # Holds coverage thresholds percentages, values should be in range [0-100]. 11 | threshold: 12 | # (optional; default 0) 13 | # Minimum coverage percentage required for individual files. 14 | file: 70 15 | 16 | # (optional; default 0) 17 | # Minimum coverage percentage required for each package. 18 | package: 80 19 | 20 | # (optional; default 0) 21 | # Minimum overall project coverage percentage required. 22 | total: 95 23 | 24 | # Holds regexp rules which will override thresholds for matched files or packages 25 | # using their paths. 26 | # 27 | # First rule from this list that matches file or package is going to apply 28 | # new threshold to it. If project has multiple rules that match same path, 29 | # override rules should be listed in order from specific to more general rules. 30 | override: 31 | # Increase coverage threshold to 100% for `foo` package 32 | # (default is 80, as configured above in this example). 33 | - path: ^pkg/lib/foo$ 34 | threshold: 100 35 | 36 | # Holds regexp rules which will exclude matched files or packages 37 | # from coverage statistics. 38 | exclude: 39 | # Exclude files or packages matching their paths 40 | paths: 41 | - \.pb\.go$ # excludes all protobuf generated files 42 | - ^pkg/bar # exclude package `pkg/bar` 43 | 44 | # File name of go-test-coverage breakdown file, which can be used to 45 | # analyze coverage difference. 46 | breakdown-file-name: '' 47 | 48 | diff: 49 | # File name of go-test-coverage breakdown file which will be used to 50 | # report coverage difference. 51 | base-breakdown-file-name: '' -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # GO_VERSION: automatically update to most recent via dependabot 2 | FROM golang:1.24.3 as builder 3 | WORKDIR /workspace 4 | 5 | COPY go.mod go.mod 6 | COPY go.sum go.sum 7 | 8 | RUN go mod download all 9 | 10 | COPY ./ ./ 11 | 12 | ARG VERSION 13 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ 14 | go build -o go-test-coverage . 15 | 16 | FROM gcr.io/distroless/base:latest 17 | WORKDIR / 18 | COPY --from=builder /workspace/go-test-coverage . 19 | COPY --from=builder /usr/local/go/bin/go /usr/local/go/bin/go 20 | ENV PATH="${PATH}:/usr/local/go/bin" 21 | ENTRYPOINT ["/go-test-coverage"] 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= go 2 | GOBIN ?= $$($(GO) env GOPATH)/bin 3 | GOLANGCI_LINT ?= $(GOBIN)/golangci-lint 4 | GOLANGCI_LINT_VERSION ?= v2.0.1 # LINT_VERSION: update version in other places 5 | 6 | # Code tidy 7 | .PHONY: tidy 8 | tidy: 9 | go mod tidy 10 | go fmt ./... 11 | 12 | .PHONY: get-golangcilint 13 | get-golangcilint: 14 | test -f $(GOLANGCI_LINT) || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$($(GO) env GOPATH)/bin $(GOLANGCI_LINT_VERSION) 15 | 16 | # Runs lint on entire repo 17 | .PHONY: lint 18 | lint: get-golangcilint 19 | $(GOLANGCI_LINT) run ./... 20 | 21 | # Runs tests on entire repo 22 | .PHONY: test 23 | test: 24 | go test -timeout=3s -race -count=10 -failfast -shuffle=on -short ./... 25 | go test -timeout=20s -race -count=1 -failfast -shuffle=on ./... -coverprofile=./cover.profile -covermode=atomic -coverpkg=./... 26 | 27 | # Runs test coverage check 28 | .PHONY: check-coverage 29 | check-coverage: test 30 | go run ./ --config=./.github/.testcoverage-local.yml 31 | 32 | # View coverage profile 33 | .PHONY: view-coverage 34 | view-coverage: 35 | go tool cover -html=cover.profile -o=cover.html 36 | xdg-open cover.html 37 | 38 | # Recreates badges-integration-test branch 39 | .PHONY: new-branch-badges-integration-test 40 | new-branch-badges-integration-test: 41 | git branch -D badges-integration-test 42 | git push origin --delete badges-integration-test 43 | git checkout --orphan badges-integration-test 44 | git rm -rf . 45 | echo "# Badges from Integration Tests" > README.md 46 | git add README.md 47 | git commit -m "initial commit" 48 | git push origin badges-integration-test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-test-coverage 2 | 3 | [![test](https://github.com/vladopajic/go-test-coverage/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/vladopajic/go-test-coverage/actions/workflows/test.yml) 4 | [![action-test](https://github.com/vladopajic/go-test-coverage/actions/workflows/action-test.yml/badge.svg?branch=main)](https://github.com/vladopajic/go-test-coverage/actions/workflows/action-test.yml) 5 | [![lint](https://github.com/vladopajic/go-test-coverage/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/vladopajic/go-test-coverage/actions/workflows/lint.yml) 6 | [![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/main/coverage.svg)](/.github/.testcoverage.yml) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/vladopajic/go-test-coverage?cache=v1)](https://goreportcard.com/report/github.com/vladopajic/go-test-coverage) 8 | [![Release](https://img.shields.io/github/release/vladopajic/go-test-coverage.svg?color=%23007ec6)](https://github.com/vladopajic/go-test-coverage/releases/latest) 9 | 10 | ![go-test-coverage cover image](https://github.com/user-attachments/assets/2febc74e-7437-4dc6-87a4-0ca47f8e714e) 11 | 12 | `go-test-coverage` is a tool designed to report issues when test coverage falls below a specified threshold, ensuring higher code quality and preventing regressions in test coverage over time. 13 | 14 | ## Why Use go-test-coverage? 15 | 16 | Here are the key features and benefits: 17 | 18 | - **Quick Setup**: Install and configure in just 5 minutes. 19 | - **Serverless Operation**: No need for external servers, registration, or permissions. 20 | - Eliminates connectivity or server-related failures. 21 | - **Data Privacy**: All coverage checks are done locally, so no sensitive information leaks to third parties. 22 | - Learn more about [information leakage risks](https://gist.github.com/vladopajic/0b835b28bcfe4a5a22bb0ae20e365266). 23 | - **Performance**: Lightning-fast execution (e.g., ~1 second on [this repo](https://github.com/vladopajic/go-test-coverage/actions/runs/13832675278/job/38700510962)). 24 | - **Versatility**: Can be used both locally and in CI pipelines. 25 | - **Customizable**: Extensive configuration options to fit any project's needs. 26 | - **Stylish Badges**: Generate beautiful coverage badges for your repository. 27 | - **Coverage Diff**: Detailed comparison of code coverage changes relative to the base branch. 28 | - **Open Source**: Free to use and contribute to! 29 | 30 | ## Usage 31 | 32 | You can use `go-test-coverage` in two ways: 33 | - Locally as part of your development process. 34 | - As a step in your GitHub Workflow. 35 | 36 | It’s recommended to utilize both options for Go projects. 37 | 38 | ### Local Usage 39 | 40 | Here’s an example `Makefile` with a `check-coverage` command that runs `go-test-coverage` locally: 41 | 42 | 43 | ```makefile 44 | GOBIN ?= $$(go env GOPATH)/bin 45 | 46 | .PHONY: install-go-test-coverage 47 | install-go-test-coverage: 48 | go install github.com/vladopajic/go-test-coverage/v2@latest 49 | 50 | .PHONY: check-coverage 51 | check-coverage: install-go-test-coverage 52 | go test ./... -coverprofile=./cover.out -covermode=atomic -coverpkg=./... 53 | ${GOBIN}/go-test-coverage --config=./.testcoverage.yml 54 | ``` 55 | 56 | ### GitHub Workflow 57 | 58 | Here’s an example of how to integrate `go-test-coverage` into a GitHub Actions workflow: 59 | 60 | 61 | ```yml 62 | name: Go test coverage check 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@v3 66 | - uses: actions/setup-go@v3 67 | 68 | - name: generate test coverage 69 | run: go test ./... -coverprofile=./cover.out -covermode=atomic -coverpkg=./... 70 | 71 | - name: check test coverage 72 | uses: vladopajic/go-test-coverage@v2 73 | with: 74 | config: ./.testcoverage.yml 75 | ``` 76 | 77 | For detailed information about the GitHub Action, check out [this page](./docs/github_action.md). 78 | 79 | ### Configuration 80 | 81 | Here’s an example [.testcoverage.yml](./.testcoverage.example.yml) configuration file: 82 | 83 | ```yml 84 | # (mandatory) 85 | # Path to coverage profile file (output of `go test -coverprofile` command). 86 | # 87 | # For cases where there are many coverage profiles, such as when running 88 | # unit tests and integration tests separately, you can combine all those 89 | # profiles into one. In this case, the profile should have a comma-separated list 90 | # of profile files, e.g., 'cover_unit.out,cover_integration.out'. 91 | profile: cover.out 92 | 93 | # Holds coverage thresholds percentages, values should be in range [0-100]. 94 | threshold: 95 | # (optional; default 0) 96 | # Minimum coverage percentage required for individual files. 97 | file: 70 98 | 99 | # (optional; default 0) 100 | # Minimum coverage percentage required for each package. 101 | package: 80 102 | 103 | # (optional; default 0) 104 | # Minimum overall project coverage percentage required. 105 | total: 95 106 | 107 | # Holds regexp rules which will override thresholds for matched files or packages 108 | # using their paths. 109 | # 110 | # First rule from this list that matches file or package is going to apply 111 | # new threshold to it. If project has multiple rules that match same path, 112 | # override rules should be listed in order from specific to more general rules. 113 | override: 114 | # Increase coverage threshold to 100% for `foo` package 115 | # (default is 80, as configured above in this example). 116 | - path: ^pkg/lib/foo$ 117 | threshold: 100 118 | 119 | # Holds regexp rules which will exclude matched files or packages 120 | # from coverage statistics. 121 | exclude: 122 | # Exclude files or packages matching their paths 123 | paths: 124 | - \.pb\.go$ # excludes all protobuf generated files 125 | - ^pkg/bar # exclude package `pkg/bar` 126 | 127 | # File name of go-test-coverage breakdown file, which can be used to 128 | # analyze coverage difference. 129 | breakdown-file-name: '' 130 | 131 | diff: 132 | # File name of go-test-coverage breakdown file which will be used to 133 | # report coverage difference. 134 | base-breakdown-file-name: '' 135 | ``` 136 | 137 | ### Exclude Code from Coverage 138 | 139 | For cases where there is a code block that does not need to be tested, it can be ignored from coverage statistics by adding the comment `// coverage-ignore` at the start line of the statement body (right after `{`). 140 | 141 | ```go 142 | ... 143 | result, err := foo() 144 | if err != nil { // coverage-ignore 145 | return err 146 | } 147 | ... 148 | ``` 149 | 150 | Similarly, the entire function can be excluded from coverage statistics when a comment is found at the start line of the function body (right after `{`). 151 | ```go 152 | func bar() { // coverage-ignore 153 | ... 154 | } 155 | ``` 156 | 157 | ## Generate Coverage Badge 158 | 159 | You can easily generate a stylish coverage badge for your repository and embed it in your markdown files. Here’s an example badge: ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/main/coverage.svg) 160 | 161 | Instructions for badge creation are available [here](./docs/badge.md). 162 | 163 | ## Visualise Coverage 164 | 165 | Go includes a built-in tool for visualizing coverage profiles, allowing you to see which parts of the code are not covered by tests. 166 | Following command will generate `cover.html` page with visualized coverage profile: 167 | ```console 168 | go tool cover -html=cover.out -o=cover.html 169 | ``` 170 | 171 | ## Support the Project 172 | 173 | `go-test-coverage` is freely available for all users. If your organization benefits from this tool, especially if you’ve transitioned from a paid coverage service, consider [sponsoring the project](https://github.com/sponsors/vladopajic). 174 | Your sponsorship will help sustain development, introduce new features, and maintain high-quality support. Every contribution directly impacts the future growth and stability of this project. 175 | 176 | ## Contribution 177 | 178 | We welcome all contributions - whether it's fixing a typo, adding new features, or pointing out an issue. Feel free to open a pull request or issue to contribute! 179 | 180 | 181 | Happy coding 🌞 182 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: go-test-coverage 2 | author: vladopajic 3 | description: go-test-coverage is a tool designed to report issues when test coverage falls below a specified threshold. 4 | inputs: 5 | # Config 6 | config: 7 | description: Path to the configuration file (.testcoverage.yml), which defines test coverage settings and thresholds. 8 | required: false 9 | default: "" 10 | type: string 11 | source-dir: 12 | description: Sets relative path to source files. 13 | required: false 14 | default: "" 15 | type: string 16 | debug: 17 | description: Prints additional debugging output when running action. 18 | required: false 19 | default: false 20 | type: boolean 21 | 22 | # Individual properties 23 | profile: 24 | description: Path to the coverage profile file. Overrides value from configuration. 25 | required: false 26 | default: "" 27 | type: string 28 | # DEPRECATED 29 | local-prefix: 30 | description: DEPRECATED! not used anymore. 31 | required: false 32 | default: "" 33 | type: string 34 | threshold-file: 35 | description: Minimum coverage percentage required for individual files. Overrides value from configuration. 36 | required: false 37 | default: -1 38 | type: number 39 | threshold-package: 40 | description: Minimum coverage percentage required for each package. Overrides value from configuration. 41 | required: false 42 | default: -1 43 | type: number 44 | threshold-total: 45 | description: Minimum overall project coverage percentage required. Overrides value from configuration. 46 | required: false 47 | default: -1 48 | type: number 49 | 50 | breakdown-file-name: 51 | description: File name of go-test-coverage breakdown file, which can be used to analyze coverage difference. Overrides value from configuration. 52 | required: false 53 | default: "" 54 | type: string 55 | 56 | diff-base-breakdown-file-name: 57 | description: File name of go-test-coverage breakdown file used to calculate coverage difference from current (head). 58 | required: false 59 | default: "" 60 | type: string 61 | 62 | # Badge (as file) 63 | badge-file-name: 64 | description: If specified, a coverage badge will be generated and saved to the given file path. 65 | required: false 66 | default: "" 67 | type: string 68 | 69 | # Badge (on CDN) 70 | cdn-secret: 71 | description: API secret key for CDN. If specified, the badge will be uploaded to the CDN. 72 | required: false 73 | default: "" 74 | type: string 75 | cdn-key: 76 | description: API key for CDN access. 77 | required: false 78 | default: "" 79 | type: string 80 | cdn-region: 81 | description: Specifies the CDN region for the badge upload. 82 | required: false 83 | default: "" 84 | type: string 85 | cdn-endpoint: 86 | description: URL endpoint for CDN where the badge will be uploaded. 87 | required: false 88 | default: "" 89 | type: string 90 | cdn-file-name: 91 | description: Filename (including path) for storing the badge on the CDN. 92 | required: false 93 | default: "" 94 | type: string 95 | cdn-bucket-name: 96 | description: Name of the CDN bucket where the badge will be saved. 97 | required: false 98 | default: "" 99 | type: string 100 | cdn-force-path-style: 101 | description: Forces path-style URL access in the CDN. 102 | required: false 103 | default: false 104 | type: boolean 105 | 106 | # Badge (on Git) 107 | git-token: 108 | description: GitHub token for authorization. If provided, the badge will be uploaded to the specified GitHub repository. 109 | required: false 110 | default: "" 111 | type: string 112 | git-repository: 113 | description: Target GitHub repository in {owner}/{repository} format where the badge will be stored. 114 | required: false 115 | default: ${{ github.repository }} 116 | type: string 117 | git-branch: 118 | description: Repository branch where the badge file will be saved. 119 | required: false 120 | default: "" 121 | type: string 122 | git-file-name: 123 | description: File name (including path) for storing the badge in the specified repository. 124 | required: false 125 | default: .badges/${{ github.ref_name }}/coverage.svg 126 | type: string 127 | 128 | outputs: 129 | total-coverage: 130 | description: Integer value in the range [0-100], representing the overall project test coverage percentage. 131 | badge-color: 132 | description: Color hex code for the badge (e.g., `#44cc11`), representing the coverage status. 133 | report: 134 | description: JSON-encoded string containing the detailed test coverage report. 135 | badge-text: 136 | description: Deprecated! Text label for the badge. 137 | 138 | runs: 139 | using: docker 140 | # VERSION: when changing version update version in other places 141 | image: docker://ghcr.io/vladopajic/go-test-coverage:v2.14.3 142 | args: 143 | - --config=${{ inputs.config || '''''' }} 144 | - --profile=${{ inputs.profile || '''''' }} 145 | - --source-dir=${{ inputs.source-dir || '''''' }} 146 | - --debug=${{ inputs.debug }} 147 | - --github-action-output=true 148 | - --threshold-file=${{ inputs.threshold-file }} 149 | - --threshold-package=${{ inputs.threshold-package }} 150 | - --threshold-total=${{ inputs.threshold-total }} 151 | - --breakdown-file-name=${{ inputs.breakdown-file-name || '''''' }} 152 | - --diff-base-breakdown-file-name=${{ inputs.diff-base-breakdown-file-name || '''''' }} 153 | - --badge-file-name=${{ inputs.badge-file-name || '''''' }} 154 | - --cdn-key=${{ inputs.cdn-key || '''''' }} 155 | - --cdn-secret=${{ inputs.cdn-secret || '''''' }} 156 | - --cdn-region=${{ inputs.cdn-region || '''''' }} 157 | - --cdn-endpoint=${{ inputs.cdn-endpoint || '''''' }} 158 | - --cdn-file-name=${{ inputs.cdn-file-name || '''''' }} 159 | - --cdn-bucket-name=${{ inputs.cdn-bucket-name || '''''' }} 160 | - --cdn-force-path-style=${{ inputs.cdn-force-path-style }} 161 | - --git-token=${{ inputs.git-token || '''''' }} 162 | - --git-branch=${{ inputs.git-branch || '''''' }} 163 | - --git-repository=${{ inputs.git-repository || ''''''}} 164 | - --git-file-name=${{ inputs.git-file-name || '''''' }} 165 | 166 | branding: 167 | icon: 'code' 168 | color: 'blue' -------------------------------------------------------------------------------- /docs/badge.md: -------------------------------------------------------------------------------- 1 | # Coverage badge 2 | 3 | Repositories using the go-test-coverage GitHub action can easily generate and embed coverage badges in markdown files, allowing you to visualize and track your test coverage. For example: 4 | 5 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/main/coverage.svg) 6 | 7 | 8 | The go-test-coverage action supports several methods for generating and storing these badges, depending on your repository’s needs: 9 | - **Storing badges within the same GitHub repository** (ideal for public repositories). 10 | - **Storing badges on a Content Delivery Network (CDN)** (suitable for private repositories). 11 | - **Using a custom method for storage**, allowing flexibility. 12 | - **Storing badges in a different public GitHub repository**. 13 | 14 | ## Hosting the Coverage Badge in the Same GitHub Repository 15 | 16 | For public repositories, `go-test-coverage` can automatically create and commit a badge to the same GitHub repository. This is especially useful for keeping all assets together within the project. 17 | 18 | Example: 19 | ```yml 20 | - name: check test coverage 21 | uses: vladopajic/go-test-coverage@v2 22 | with: 23 | profile: cover.out 24 | threshold-total: 95 25 | 26 | ## when token is not specified (value '') this feature is turned off 27 | ## in this example badge is created and committed only for main branch 28 | git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }} 29 | ## name of branch where badges are stored 30 | ## ideally this should be orphan branch (see below how to create this branch) 31 | git-branch: badges 32 | ``` 33 | 34 | Orphan branch (has no history from other branches) needs to be created prior to running this workflow, to create an orphan branch manually: 35 | 36 | ```bash 37 | git checkout --orphan badges 38 | git rm -rf . 39 | rm -f .gitignore 40 | echo '# Badges' > README.md 41 | git add README.md 42 | git commit -m 'init' 43 | git push origin badges 44 | ``` 45 | 46 | Once the workflow completes, check the output of the `Check test coverage` step for a markdown snippet to embed the badge in your documentation. The link should look like: 47 | 48 | ```markdown 49 | ![coverage](https://raw.githubusercontent.com/org/project/badges/.badges/main/coverage.svg) 50 | ``` 51 | 52 | Notes: 53 | - Allow some time for GitHub to make the file available via its link. 54 | - The workflow may fail if the GitHub token doesn’t have write permissions. To fix this: 55 | - Set `permissions: write-all` in your job configuration, or 56 | - Navigate to repository settings → Actions → Workflow permissions, and grant Read and Write permissions. 57 | - This method only works for public repositories because private repository content is not accessible via `raw.githubusercontent.com`. For private repositories, refer to the CDN method described below. 58 | 59 | ## Hosting the Coverage Badge on a CDN 60 | 61 | For private repositories, `go-test-coverage` can generate a badge and upload it to a CDN like Amazon S3 or DigitalOcean Spaces, making it accessible while keeping the repository private. 62 | 63 | Example: 64 | ```yml 65 | - name: check test coverage 66 | uses: vladopajic/go-test-coverage@v2 67 | with: 68 | profile: cover.out 69 | threshold-total: 95 70 | 71 | ## when secret is not specified (value '') this feature is turned off. 72 | ## in this example badge is created and uploaded only for main branch. 73 | cdn-secret: ${{ github.ref_name == 'main' && secrets.CDN_SECRET || '' }} 74 | cdn-key: ${{ secrets.CDN_KEY }} 75 | ## in case of DigitalOcean Spaces use `us-ease-1` always as region, 76 | ## otherwise use region of your CDN. 77 | cdn-region: us-east-1 78 | ## in case of DigitalOcean Spaces endpoint should be with region and without bucket 79 | cdn-endpoint: https://nyc3.digitaloceanspaces.com 80 | cdn-file-name: .badges/${{ github.repository }}/${{ github.ref_name }}/coverage.svg 81 | cdn-bucket-name: my-bucket-name 82 | cdn-force-path-style: false 83 | ``` 84 | 85 | ## Generating a Local Badge 86 | 87 | `go-test-coverage` can also generate a badge and store it locally on the file system, giving you the flexibility to handle badge storage through custom methods. 88 | 89 | Example: 90 | ```yml 91 | - name: check test coverage 92 | uses: vladopajic/go-test-coverage@v2 93 | with: 94 | profile: cover.out 95 | threshold-total: 95 96 | 97 | # badge will be generated and store on file system with `coverage.svg` name 98 | badge-file-name: coverage.svg 99 | 100 | ## ... implement your method for storing badge 101 | ``` 102 | 103 | ## Hosting the Badge in Another Public GitHub Repository 104 | 105 | You can also store the coverage badge in a separate public GitHub repository, which is particularly useful when managing multiple repositories or projects. 106 | 107 | Example: 108 | ```yml 109 | - name: check test coverage 110 | uses: vladopajic/go-test-coverage@v2 111 | with: 112 | profile: cover.out 113 | threshold-total: 95 114 | 115 | ## in this case token should be from other repository that will host badges. 116 | ## this token is provided via secret `BADGES_GITHUB_TOKEN`. 117 | git-token: ${{ github.ref_name == 'main' && secrets.BADGES_GITHUB_TOKEN || '' }} 118 | git-branch: badges 119 | ## repository should match other repository where badges are hosted. 120 | ## format should be `{owner}/{repository}` 121 | git-repository: org/badges-repository 122 | ## use custom file name that will have repository name as prefix. 123 | ## this could be useful if you want to create badges for multiple repositories. 124 | git-file-name: .badges/${{ github.repository }}/${{ github.ref_name }}/coverage.svg 125 | ``` 126 | 127 | Ensure the `badges` branch is created in the target repository using the same steps as described for orphan branches earlier. 128 | 129 | ## Badge Examples 130 | 131 | Here are some example badges generated with this method: 132 | 133 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/badge-examples/coverage-0.svg) 134 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/badge-examples/coverage-50.svg) 135 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/badge-examples/coverage-70.svg) 136 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/badge-examples/coverage-80.svg) 137 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/badge-examples/coverage-90.svg) 138 | ![coverage](https://raw.githubusercontent.com/vladopajic/go-test-coverage/badges/.badges/badge-examples/coverage-100.svg) 139 | -------------------------------------------------------------------------------- /docs/github_action.md: -------------------------------------------------------------------------------- 1 | # go-test-coverage GitHub Action 2 | 3 | The `go-test-coverage` GitHub Action provides the following capabilities: 4 | - Enforce success of GitHub workflows only when a specified coverage threshold is met. 5 | - Generate a coverage badge to display the total test coverage. 6 | - Post a detailed coverage report as a comment on pull requests, including: 7 | - current test coverage 8 | - uncovered lines (reported when any threshold is not satisfied) 9 | - the difference compared to the base branch 10 | 11 | ## Action Inputs and Outputs 12 | 13 | Action inputs and outputs are documented in [action.yml](/action.yml) file. 14 | 15 | 16 | ## Basic Usage 17 | 18 | Here’s an example of how to integrate `go-test-coverage` in a GitHub workflow that uses a config file. This is the preferred way because the same config file can be used for running coverage checks locally. 19 | 20 | ```yml 21 | - name: check test coverage 22 | uses: vladopajic/go-test-coverage@v2 23 | with: 24 | config: ./.testcoverage.yml 25 | ``` 26 | 27 | Alternatively, if you don't need advanced configuration options from a config file, you can specify thresholds directly in the action properties. 28 | 29 | ```yml 30 | - name: check test coverage 31 | uses: vladopajic/go-test-coverage@v2 32 | with: 33 | profile: cover.out 34 | threshold-file: 80 35 | threshold-package: 80 36 | threshold-total: 95 37 | ``` 38 | 39 | Note: When using a config file alongside action properties, specifying these parameters will override the corresponding values in the config file. 40 | 41 | ## Source Directory 42 | 43 | Some projects, such as monorepos with multiple projects under the root directory, may require specifying the path to a project's source. 44 | In such cases, the `source-dir` property can be used to specify the source files location relative to the root directory. 45 | 46 | ```yml 47 | - name: check test coverage 48 | uses: vladopajic/go-test-coverage@v2 49 | with: 50 | config: ./.testcoverage.yml 51 | source-dir: ./some_project 52 | ``` 53 | 54 | ## Liberal Coverage Check 55 | 56 | The `go-test-coverage` GitHub Action can be configured to report the current test coverage without enforcing specific thresholds. To enable this functionality in your GitHub workflow, include the `continue-on-error: true` property in the job step configuration. This ensures that the workflow proceeds even if the coverage check fails. 57 | 58 | Below is an example that reports files with coverage below 80% without causing the workflow to fail: 59 | ```yml 60 | - name: check test coverage 61 | id: coverage 62 | uses: vladopajic/go-test-coverage@v2 63 | continue-on-error: true 64 | with: 65 | profile: cover.out 66 | threshold-file: 80 67 | ``` 68 | 69 | ## Report Coverage Difference 70 | 71 | Using go-test-coverage, you can display a detailed comparison of code coverage changes relative to the base branch. When this feature is enabled, the report highlights files with coverage differences compared to the base branch. 72 | 73 | The same logic is used in workflow in [this repo](/.github/workflows/test.yml). 74 | Example of report that includes coverage difference is [this PR](https://github.com/vladopajic/go-test-coverage/pull/129). 75 | 76 | ```yml 77 | # Download main (aka base) branch breakdown 78 | - name: download artifact (main.breakdown) 79 | id: download-main-breakdown 80 | uses: dawidd6/action-download-artifact@v6 81 | with: 82 | branch: main 83 | workflow_conclusion: success 84 | name: main.breakdown 85 | if_no_artifact_found: warn 86 | 87 | - name: check test coverage 88 | uses: vladopajic/go-test-coverage@v2 89 | with: 90 | config: ./.github/.testcoverage.yml 91 | profile: ubuntu-latest-profile,macos-latest-profile,windows-latest-profile 92 | 93 | # Save current coverage breakdown if current branch is main. It will be 94 | # uploaded as artifact in step below. 95 | breakdown-file-name: ${{ github.ref_name == 'main' && 'main.breakdown' || '' }} 96 | 97 | # If this is not main brach we want to show report including 98 | # file coverage difference from main branch. 99 | diff-base-breakdown-file-name: ${{ steps.download-main-breakdown.outputs.found_artifact == 'true' && 'main.breakdown' || '' }} 100 | 101 | - name: upload artifact (main.breakdown) 102 | uses: actions/upload-artifact@v4 103 | if: github.ref_name == 'main' 104 | with: 105 | name: main.breakdown 106 | path: main.breakdown # as specified via `breakdown-file-name` 107 | if-no-files-found: error 108 | ``` 109 | 110 | ## Post Coverage Report to PR 111 | 112 | Here is an example of how to post comments with the coverage report to your pull request. 113 | 114 | The same logic is used in workflow in [this repo](/.github/workflows/test.yml). 115 | Example of report is in [this PR](https://github.com/vladopajic/go-test-coverage/pull/129). 116 | 117 | ```yml 118 | - name: check test coverage 119 | id: coverage 120 | uses: vladopajic/go-test-coverage@v2 121 | continue-on-error: true # Should fail after coverage comment is posted 122 | with: 123 | config: ./.github/.testcoverage.yml 124 | 125 | # Post coverage report as comment (in 2 steps) 126 | - name: find pull request ID 127 | run: | 128 | PR_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ 129 | "https://api.github.com/repos/${{ github.repository }}/pulls?head=${{ github.repository_owner }}:${{ github.ref_name }}&state=open") 130 | PR_ID=$(echo "$PR_DATA" | jq -r '.[0].number') 131 | 132 | if [ "$PR_ID" != "null" ]; then 133 | echo "pull_request_id=$PR_ID" >> $GITHUB_ENV 134 | else 135 | echo "No open pull request found for this branch." 136 | fi 137 | - name: post coverage report 138 | if: env.pull_request_id 139 | uses: thollander/actions-comment-pull-request@v3 140 | with: 141 | github-token: ${{ secrets.GITHUB_TOKEN }} 142 | comment-tag: coverage-report 143 | pr-number: ${{ env.pull_request_id }} 144 | message: | 145 | go-test-coverage report: 146 | ``` 147 | ${{ fromJSON(steps.coverage.outputs.report) }}``` 148 | 149 | - name: "finally check coverage" 150 | if: steps.coverage.outcome == 'failure' 151 | shell: bash 152 | run: echo "coverage check failed" && exit 1 153 | ``` 154 | 155 | ## Generate Coverage Badge 156 | 157 | Instructions for badge creation are available [here](./badge.md). 158 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vladopajic/go-test-coverage/v2 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.0 6 | 7 | require ( 8 | github.com/alexflint/go-arg v1.4.3 9 | github.com/aws/aws-sdk-go v1.49.4 10 | github.com/google/go-github/v56 v56.0.0 11 | github.com/johannesboyne/gofakes3 v0.0.0-20230914150226-f005f5cc03aa 12 | github.com/narqo/go-badge v0.0.0-20230821190521-c9a75c019a59 13 | github.com/rs/zerolog v1.34.0 14 | github.com/stretchr/testify v1.10.0 15 | golang.org/x/tools v0.26.0 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/alexflint/go-scalar v1.1.0 // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 23 | github.com/google/go-querystring v1.1.0 // indirect 24 | github.com/jmespath/go-jmespath v0.4.0 // indirect 25 | github.com/kr/pretty v0.3.1 // indirect 26 | github.com/mattn/go-colorable v0.1.13 // indirect 27 | github.com/mattn/go-isatty v0.0.19 // indirect 28 | github.com/pmezard/go-difflib v1.0.0 // indirect 29 | github.com/rogpeppe/go-internal v1.11.0 // indirect 30 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect 31 | github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500 // indirect 32 | golang.org/x/image v0.18.0 // indirect 33 | golang.org/x/sys v0.26.0 // indirect 34 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 35 | gopkg.in/yaml.v2 v2.4.0 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= 2 | github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= 3 | github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM= 4 | github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= 5 | github.com/aws/aws-sdk-go v1.44.256/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= 6 | github.com/aws/aws-sdk-go v1.49.4 h1:qiXsqEeLLhdLgUIyfr5ot+N/dGPWALmtM1SetRmbUlY= 7 | github.com/aws/aws-sdk-go v1.49.4/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= 8 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 14 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 15 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 16 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 17 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 18 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4= 20 | github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0= 21 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 22 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 23 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 24 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 25 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 26 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 27 | github.com/johannesboyne/gofakes3 v0.0.0-20230914150226-f005f5cc03aa h1:a6Hc6Hlq6MxPNBW53/S/HnVwVXKc0nbdD/vgnQYuxG0= 28 | github.com/johannesboyne/gofakes3 v0.0.0-20230914150226-f005f5cc03aa/go.mod h1:AxgWC4DDX54O2WDoQO1Ceabtn6IbktjU/7bigor+66g= 29 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 30 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 31 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 32 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 33 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 37 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 38 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 39 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 40 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/narqo/go-badge v0.0.0-20230821190521-c9a75c019a59 h1:kbREB9muGo4sHLoZJD/E/IV8yK3Y15eEA9mYi/ztRsk= 42 | github.com/narqo/go-badge v0.0.0-20230821190521-c9a75c019a59/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM= 43 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 44 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 45 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 46 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 47 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 48 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 49 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 50 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 51 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 52 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 53 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= 54 | github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= 55 | github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500 h1:WnNuhiq+FOY3jNj6JXFT+eLN3CQ/oPIsDPRanvwsmbI= 56 | github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500/go.mod h1:+njLrG5wSeoG4Ds61rFgEzKvenR2UHbjMoDHsczxly0= 57 | github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 58 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 59 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 60 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 61 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 62 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 63 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 64 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 65 | go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= 66 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 67 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 68 | golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= 69 | golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= 70 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 71 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 72 | golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 73 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 74 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 75 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 76 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 77 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 78 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 79 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 80 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 81 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 82 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 83 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 84 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 85 | golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 86 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 87 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 97 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 98 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 99 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 100 | golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 101 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 102 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 103 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 104 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 105 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 106 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 107 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 108 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 109 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 110 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 111 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 112 | golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 113 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 114 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 115 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 116 | golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= 117 | golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 118 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 119 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 120 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 121 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 122 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 123 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 124 | gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= 125 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 126 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 127 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 128 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 129 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 130 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 131 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 132 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 8 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/logger" 9 | ) 10 | 11 | const ( 12 | Version = "v2.14.3" // VERSION: when changing version update version in other places 13 | Name = "go-test-coverage" 14 | ) 15 | 16 | //nolint:forbidigo,wsl // relax 17 | func main() { 18 | cfg, err := readConfig() 19 | if err != nil { 20 | fmt.Println(err.Error()) 21 | os.Exit(1) 22 | } 23 | 24 | logger.Init() 25 | 26 | pass, err := testcoverage.Check(os.Stdout, cfg) 27 | if err != nil { 28 | fmt.Println("Running coverage check failed.") 29 | fmt.Printf("Error: %v\n", err) 30 | if cfg.GithubActionOutput { 31 | fmt.Printf("Please set `debug: true` input to see detailed output.") 32 | } else { 33 | fmt.Println("Please use `--debug=true` flag to see detailed output.") 34 | } 35 | } 36 | if !pass || err != nil { 37 | os.Exit(1) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /main_config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/alexflint/go-arg" 9 | 10 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 11 | ) 12 | 13 | const ( 14 | // default value of string variables passed by CI 15 | ciDefaultString = `''` 16 | // default value of int variables passed by CI 17 | ciDefaultInt = -1 18 | ) 19 | 20 | type args struct { 21 | ConfigPath string `arg:"-c,--config"` 22 | Profile string `arg:"-p,--profile" help:"path to coverage profile"` 23 | Debug bool `arg:"-d,--debug"` 24 | LocalPrefix string `arg:"-l,--local-prefix"` // deprecated 25 | SourceDir string `arg:"-s,--source-dir"` 26 | GithubActionOutput bool `arg:"-o,--github-action-output"` 27 | ThresholdFile int `arg:"-f,--threshold-file"` 28 | ThresholdPackage int `arg:"-k,--threshold-package"` 29 | ThresholdTotal int `arg:"-t,--threshold-total"` 30 | 31 | BreakdownFileName string `arg:"--breakdown-file-name"` 32 | DiffBaseBreakdownFileName string `arg:"--diff-base-breakdown-file-name"` 33 | 34 | BadgeFileName string `arg:"-b,--badge-file-name"` 35 | 36 | CDNKey string `arg:"--cdn-key"` 37 | CDNSecret string `arg:"--cdn-secret"` 38 | CDNRegion string `arg:"--cdn-region"` 39 | CDNEndpoint string `arg:"--cdn-endpoint"` 40 | CDNFileName string `arg:"--cdn-file-name"` 41 | CDNBucketName string `arg:"--cdn-bucket-name"` 42 | CDNForcePathStyle bool `arg:"--cdn-force-path-style"` 43 | 44 | GitToken string `arg:"--git-token"` 45 | GitRepository string `arg:"--git-repository"` 46 | GitBranch string `arg:"--git-branch"` 47 | GitFileName string `arg:"--git-file-name"` 48 | } 49 | 50 | func newArgs() args { 51 | return args{ 52 | ConfigPath: ciDefaultString, 53 | Profile: ciDefaultString, 54 | Debug: false, 55 | LocalPrefix: ciDefaultString, 56 | SourceDir: ciDefaultString, 57 | GithubActionOutput: false, 58 | ThresholdFile: ciDefaultInt, 59 | ThresholdPackage: ciDefaultInt, 60 | ThresholdTotal: ciDefaultInt, 61 | 62 | BreakdownFileName: ciDefaultString, 63 | DiffBaseBreakdownFileName: ciDefaultString, 64 | 65 | // Badge 66 | BadgeFileName: ciDefaultString, 67 | 68 | // CDN 69 | CDNKey: ciDefaultString, 70 | CDNSecret: ciDefaultString, 71 | CDNRegion: ciDefaultString, 72 | CDNEndpoint: ciDefaultString, 73 | CDNFileName: ciDefaultString, 74 | CDNBucketName: ciDefaultString, 75 | CDNForcePathStyle: false, 76 | 77 | // Git 78 | GitToken: ciDefaultString, 79 | GitRepository: ciDefaultString, 80 | GitBranch: ciDefaultString, 81 | GitFileName: ciDefaultString, 82 | } 83 | } 84 | 85 | func (*args) Version() string { 86 | return Name + " " + Version 87 | } 88 | 89 | //nolint:cyclop,maintidx,mnd,funlen // relax 90 | func (a *args) overrideConfig(cfg testcoverage.Config) (testcoverage.Config, error) { 91 | if !isCIDefaultString(a.Profile) { 92 | cfg.Profile = a.Profile 93 | } 94 | 95 | if a.Debug { 96 | cfg.Debug = true 97 | } 98 | 99 | if a.GithubActionOutput { 100 | cfg.GithubActionOutput = true 101 | } 102 | 103 | if !isCIDefaultString(a.LocalPrefix) { 104 | cfg.LocalPrefixDeprecated = a.LocalPrefix 105 | } 106 | 107 | if !isCIDefaultString(a.SourceDir) { 108 | cfg.SourceDir = a.SourceDir 109 | } 110 | 111 | if !isCIDefaultInt(a.ThresholdFile) { 112 | cfg.Threshold.File = a.ThresholdFile 113 | } 114 | 115 | if !isCIDefaultInt(a.ThresholdPackage) { 116 | cfg.Threshold.Package = a.ThresholdPackage 117 | } 118 | 119 | if !isCIDefaultInt(a.ThresholdTotal) { 120 | cfg.Threshold.Total = a.ThresholdTotal 121 | } 122 | 123 | if !isCIDefaultString(a.BreakdownFileName) { 124 | cfg.BreakdownFileName = a.BreakdownFileName 125 | } 126 | 127 | if !isCIDefaultString(a.DiffBaseBreakdownFileName) { 128 | cfg.Diff.BaseBreakdownFileName = a.DiffBaseBreakdownFileName 129 | } 130 | 131 | if !isCIDefaultString(a.BadgeFileName) { 132 | cfg.Badge.FileName = a.BadgeFileName 133 | } 134 | 135 | if !isCIDefaultString(a.CDNSecret) { 136 | cfg.Badge.CDN.Secret = a.CDNSecret 137 | cfg.Badge.CDN.Key = escapeCiDefaultString(a.CDNKey) 138 | cfg.Badge.CDN.Region = escapeCiDefaultString(a.CDNRegion) 139 | cfg.Badge.CDN.FileName = escapeCiDefaultString(a.CDNFileName) 140 | cfg.Badge.CDN.BucketName = escapeCiDefaultString(a.CDNBucketName) 141 | cfg.Badge.CDN.ForcePathStyle = a.CDNForcePathStyle 142 | 143 | if !isCIDefaultString(a.CDNEndpoint) { 144 | cfg.Badge.CDN.Endpoint = a.CDNEndpoint 145 | } 146 | } 147 | 148 | if !isCIDefaultString(a.GitToken) { 149 | cfg.Badge.Git.Token = a.GitToken 150 | cfg.Badge.Git.Branch = escapeCiDefaultString(a.GitBranch) 151 | cfg.Badge.Git.FileName = escapeCiDefaultString(a.GitFileName) 152 | 153 | parts := strings.Split(escapeCiDefaultString(a.GitRepository), "/") 154 | if len(parts) != 2 { 155 | return cfg, errors.New("--git-repository flag should have format {owner}/{repository}") 156 | } 157 | 158 | cfg.Badge.Git.Owner = parts[0] 159 | cfg.Badge.Git.Repository = parts[1] 160 | } 161 | 162 | return cfg, nil 163 | } 164 | 165 | func readConfig() (testcoverage.Config, error) { 166 | cmdArgs := newArgs() 167 | arg.MustParse(&cmdArgs) 168 | 169 | cfg := testcoverage.Config{} 170 | 171 | // Load config from file 172 | if !isCIDefaultString(cmdArgs.ConfigPath) { 173 | err := testcoverage.ConfigFromFile(&cfg, cmdArgs.ConfigPath) 174 | if err != nil { 175 | return testcoverage.Config{}, fmt.Errorf("failed loading config from file: %w", err) 176 | } 177 | } 178 | 179 | // Override config with values from args 180 | cfg, err := cmdArgs.overrideConfig(cfg) 181 | if err != nil { 182 | return testcoverage.Config{}, fmt.Errorf("argument is not valid: %w", err) 183 | } 184 | 185 | // Validate config 186 | if err := cfg.Validate(); err != nil { 187 | return testcoverage.Config{}, fmt.Errorf("config file is not valid: %w", err) 188 | } 189 | 190 | return cfg, nil 191 | } 192 | 193 | func isCIDefaultString(v string) bool { return v == ciDefaultString } 194 | 195 | func isCIDefaultInt(v int) bool { return v == ciDefaultInt } 196 | 197 | func escapeCiDefaultString(v string) string { 198 | if v == ciDefaultString { 199 | return "" 200 | } 201 | 202 | return v 203 | } 204 | -------------------------------------------------------------------------------- /pkg/testcoverage/badge.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge" 10 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badgestorer" 11 | ) 12 | 13 | func generateAndSaveBadge(w io.Writer, cfg Config, totalCoverage int) error { 14 | badge, err := badge.Generate(totalCoverage) 15 | if err != nil { // coverage-ignore // should never happen 16 | return fmt.Errorf("generate badge: %w", err) 17 | } 18 | 19 | buffer := &bytes.Buffer{} 20 | out := bufio.NewWriter(buffer) 21 | 22 | // `out` writer is used as temporary buffer, which will be finally 23 | // written to `w` in this defer call 24 | defer func() { 25 | out.Flush() 26 | 27 | if buffer.Len() != 0 { 28 | // add visual separator before writing result 29 | // of generate and save badge action 30 | fmt.Fprintf(w, "\n-------------------------\n") 31 | w.Write(buffer.Bytes()) //nolint:errcheck // relax 32 | } 33 | }() 34 | 35 | return storeBadge(out, defaultStorerFactories(), cfg, badge) 36 | } 37 | 38 | type storerFactories struct { 39 | File func(string) badgestorer.Storer 40 | Git func(badgestorer.Git) badgestorer.Storer 41 | CDN func(badgestorer.CDN) badgestorer.Storer 42 | } 43 | 44 | func defaultStorerFactories() storerFactories { 45 | return storerFactories{ 46 | File: badgestorer.NewFile, 47 | Git: badgestorer.NewGithub, 48 | CDN: badgestorer.NewCDN, 49 | } 50 | } 51 | 52 | func storeBadge(w io.Writer, sf storerFactories, config Config, badge []byte) error { 53 | if fn := config.Badge.FileName; fn != "" { 54 | _, err := sf.File(fn).Store(badge) 55 | if err != nil { 56 | return fmt.Errorf("save badge to file: %w", err) 57 | } 58 | 59 | fmt.Fprintf(w, "Badge saved to file '%v'\n", fn) 60 | } 61 | 62 | if cfg := config.Badge.CDN; cfg.Secret != "" { 63 | changed, err := sf.CDN(cfg).Store(badge) 64 | if err != nil { 65 | return fmt.Errorf("save badge to cdn: %w", err) 66 | } 67 | 68 | if changed { 69 | fmt.Fprintf(w, "Badge with updated coverage uploaded to CDN. Badge path: %v\n", cfg.FileName) 70 | } else { 71 | fmt.Fprintf(w, "Badge with same coverage already uploaded to CDN.\n") 72 | } 73 | } 74 | 75 | if cfg := config.Badge.Git; cfg.Token != "" { 76 | changed, err := sf.Git(cfg).Store(badge) 77 | if err != nil { 78 | return fmt.Errorf("save badge to git branch: %w", err) 79 | } 80 | 81 | if changed { 82 | fmt.Fprintf(w, "Badge with updated coverage pushed\n") 83 | } else { 84 | fmt.Fprintf(w, "Badge with same coverage already pushed (nothing to commit)\n") 85 | } 86 | 87 | fmt.Fprintf(w, "\nEmbed this badge with markdown:\n") 88 | fmt.Fprintf(w, "![coverage](%s)\n", badgestorer.GitPublicURL(cfg)) 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/testcoverage/badge/generate.go: -------------------------------------------------------------------------------- 1 | package badge 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/narqo/go-badge" 7 | ) 8 | 9 | const ( 10 | ContentType = "image/svg+xml" 11 | 12 | label = "coverage" 13 | ) 14 | 15 | func Generate(coverage int) ([]byte, error) { 16 | return badge.RenderBytes( //nolint:wrapcheck // error should never happen 17 | label, 18 | strconv.Itoa(coverage)+"%", 19 | badge.Color(Color(coverage)), 20 | ) 21 | } 22 | 23 | func Color(coverage int) string { 24 | //nolint:mnd // relax 25 | switch { 26 | case coverage >= 100: 27 | return "#44cc11" // strong green 28 | case coverage >= 90: 29 | return "#97ca00" // light green 30 | case coverage >= 80: 31 | return "#dfb317" // yellow 32 | case coverage >= 70: 33 | return "#fa7739" // orange 34 | case coverage >= 50: 35 | return "#e05d44" // light red 36 | default: 37 | return "#cb2431" // strong red 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/testcoverage/badge/generate_test.go: -------------------------------------------------------------------------------- 1 | package badge_test 2 | 3 | import ( 4 | "strconv" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge" 10 | ) 11 | 12 | func Test_Generate(t *testing.T) { 13 | t.Parallel() 14 | 15 | t.Run("generate for all values [0-100]", func(t *testing.T) { 16 | t.Parallel() 17 | 18 | for i := range 101 { 19 | svg, err := Generate(i) 20 | assert.NoError(t, err) 21 | 22 | svgStr := string(svg) 23 | assert.Contains(t, svgStr, ">"+strconv.Itoa(i)+"%<") 24 | assert.Contains(t, svgStr, Color(i)) 25 | } 26 | }) 27 | 28 | t.Run("exact match", func(t *testing.T) { 29 | t.Parallel() 30 | 31 | //nolint:lll // relax 32 | const expected = `coveragecoverage100%100%` 33 | 34 | svg, err := Generate(100) 35 | assert.NoError(t, err) 36 | assert.Equal(t, expected, string(svg)) 37 | }) 38 | } 39 | 40 | func Test_Color(t *testing.T) { 41 | t.Parallel() 42 | 43 | colors := make(map[string]struct{}) 44 | 45 | { // Assert that there are 5 colors for coverage [0-101] 46 | for i := range 101 { 47 | color := Color(i) 48 | colors[color] = struct{}{} 49 | } 50 | 51 | assert.Len(t, colors, 6) 52 | } 53 | 54 | { // Assert valid color values 55 | isHexColor := func(color string) bool { 56 | return string(color[0]) == "#" && len(color) == 7 57 | } 58 | 59 | for color := range colors { 60 | assert.True(t, isHexColor(color)) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/testcoverage/badge_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 12 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge" 13 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badgestorer" 14 | ) 15 | 16 | func Test_GenerateAndSaveBadge_NoAction(t *testing.T) { 17 | t.Parallel() 18 | 19 | // Empty config - no action 20 | err := GenerateAndSaveBadge(nil, Config{}, 100) 21 | assert.NoError(t, err) 22 | } 23 | 24 | func Test_GenerateAndSaveBadge_SaveToFile(t *testing.T) { 25 | t.Parallel() 26 | 27 | if testing.Short() { 28 | return 29 | } 30 | 31 | const coverage = 100 32 | 33 | testFile := t.TempDir() + "/badge.svg" 34 | buf := &bytes.Buffer{} 35 | err := GenerateAndSaveBadge(buf, Config{ 36 | Badge: Badge{ 37 | FileName: testFile, 38 | }, 39 | }, coverage) 40 | assert.NoError(t, err) 41 | assert.Contains(t, buf.String(), "Badge saved to file") 42 | 43 | contentBytes, err := os.ReadFile(testFile) 44 | assert.NoError(t, err) 45 | assert.NotEmpty(t, contentBytes) 46 | 47 | badge, err := badge.Generate(coverage) 48 | assert.NoError(t, err) 49 | assert.Equal(t, badge, contentBytes) 50 | } 51 | 52 | func Test_StoreBadge(t *testing.T) { 53 | t.Parallel() 54 | 55 | badge, err := badge.Generate(100) 56 | assert.NoError(t, err) 57 | 58 | someError := io.ErrShortBuffer 59 | 60 | // badge saved to file 61 | buf := &bytes.Buffer{} 62 | config := Config{Badge: Badge{ 63 | FileName: t.TempDir() + "/badge.svg", 64 | }} 65 | sf := StorerFactories{File: fileFact(newStorer(true, nil))} 66 | err = StoreBadge(buf, sf, config, badge) 67 | assert.NoError(t, err) 68 | assert.Contains(t, buf.String(), "Badge saved to file") 69 | 70 | // failed to save badge 71 | buf = &bytes.Buffer{} 72 | sf = StorerFactories{File: fileFact(newStorer(false, someError))} 73 | err = StoreBadge(buf, sf, config, badge) 74 | assert.Error(t, err) 75 | assert.Empty(t, buf.String()) 76 | 77 | // badge saved to cdn 78 | buf = &bytes.Buffer{} 79 | config = Config{Badge: Badge{ 80 | CDN: badgestorer.CDN{Secret: `🔑`}, 81 | }} 82 | sf = StorerFactories{CDN: cdnFact(newStorer(true, nil))} 83 | err = StoreBadge(buf, sf, config, badge) 84 | assert.NoError(t, err) 85 | assert.Contains(t, buf.String(), "Badge with updated coverage uploaded to CDN") 86 | 87 | // badge saved to cdn (no change) 88 | buf = &bytes.Buffer{} 89 | sf = StorerFactories{CDN: cdnFact(newStorer(false, nil))} 90 | err = StoreBadge(buf, sf, config, badge) 91 | assert.NoError(t, err) 92 | assert.Contains(t, buf.String(), "Badge with same coverage already uploaded to CDN") 93 | 94 | // failed to save cdn 95 | buf = &bytes.Buffer{} 96 | sf = StorerFactories{CDN: cdnFact(newStorer(false, someError))} 97 | err = StoreBadge(buf, sf, config, badge) 98 | assert.Error(t, err) 99 | assert.Empty(t, buf.String()) 100 | 101 | // badge saved to git 102 | buf = &bytes.Buffer{} 103 | config = Config{Badge: Badge{ 104 | Git: badgestorer.Git{Token: `🔑`}, 105 | }} 106 | sf = StorerFactories{Git: gitFact(newStorer(true, nil))} 107 | err = StoreBadge(buf, sf, config, badge) 108 | assert.NoError(t, err) 109 | assert.Contains(t, buf.String(), "Badge with updated coverage pushed") 110 | 111 | // badge saved to git (no change) 112 | buf = &bytes.Buffer{} 113 | sf = StorerFactories{Git: gitFact(newStorer(false, nil))} 114 | err = StoreBadge(buf, sf, config, badge) 115 | assert.NoError(t, err) 116 | assert.Contains(t, buf.String(), "Badge with same coverage already pushed") 117 | 118 | // failed to save git 119 | buf = &bytes.Buffer{} 120 | sf = StorerFactories{Git: gitFact(newStorer(false, someError))} 121 | err = StoreBadge(buf, sf, config, badge) 122 | assert.Error(t, err) 123 | assert.Empty(t, buf.String()) 124 | 125 | // save badge to all methods 126 | buf = &bytes.Buffer{} 127 | config = Config{Badge: Badge{ 128 | FileName: t.TempDir() + "/badge.svg", 129 | Git: badgestorer.Git{Token: `🔑`}, 130 | CDN: badgestorer.CDN{Secret: `🔑`}, 131 | }} 132 | sf = StorerFactories{ 133 | File: fileFact(newStorer(true, nil)), 134 | Git: gitFact(newStorer(true, nil)), 135 | CDN: cdnFact(newStorer(true, nil)), 136 | } 137 | err = StoreBadge(buf, sf, config, badge) 138 | assert.NoError(t, err) 139 | assert.Contains(t, buf.String(), "Badge saved to file") 140 | assert.Contains(t, buf.String(), "Badge with updated coverage pushed") 141 | assert.Contains(t, buf.String(), "Badge with updated coverage uploaded to CDN") 142 | } 143 | 144 | func fileFact(s badgestorer.Storer) func(string) badgestorer.Storer { 145 | return func(_ string) badgestorer.Storer { 146 | return s 147 | } 148 | } 149 | 150 | func cdnFact(s badgestorer.Storer) func(badgestorer.CDN) badgestorer.Storer { 151 | return func(_ badgestorer.CDN) badgestorer.Storer { 152 | return s 153 | } 154 | } 155 | 156 | func gitFact(s badgestorer.Storer) func(badgestorer.Git) badgestorer.Storer { 157 | return func(_ badgestorer.Git) badgestorer.Storer { 158 | return s 159 | } 160 | } 161 | 162 | func newStorer(updated bool, err error) badgestorer.Storer { 163 | return mockStorer{updated, err} 164 | } 165 | 166 | type mockStorer struct { 167 | updated bool 168 | err error 169 | } 170 | 171 | func (s mockStorer) Store([]byte) (bool, error) { 172 | return s.updated, s.err 173 | } 174 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/cdn.go: -------------------------------------------------------------------------------- 1 | package badgestorer 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/credentials" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/s3" 12 | 13 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge" 14 | ) 15 | 16 | type CDN struct { 17 | Key string 18 | Secret string 19 | Region string 20 | FileName string 21 | BucketName string 22 | Endpoint string 23 | ForcePathStyle bool 24 | } 25 | 26 | type cdnStorer struct { 27 | cfg CDN 28 | } 29 | 30 | func NewCDN(cfg CDN) Storer { 31 | return &cdnStorer{cfg: cfg} 32 | } 33 | 34 | func (s *cdnStorer) Store(data []byte) (bool, error) { 35 | s3Client := createS3Client(s.cfg) 36 | 37 | // First get object and check if data differs that currently uploaded 38 | result, err := s3Client.GetObject(&s3.GetObjectInput{ 39 | Bucket: aws.String(s.cfg.BucketName), 40 | Key: aws.String(s.cfg.FileName), 41 | }) 42 | if err == nil { 43 | //nolint:errcheck // error is intentionally swallowed because if response (badge data) 44 | // is not the same we will upload new badge anyway 45 | resp, _ := io.ReadAll(result.Body) 46 | if bytes.Equal(resp, data) { 47 | return false, nil // has not changed 48 | } 49 | } 50 | 51 | // Currently uploaded badge does not exists or has changed 52 | // so it should be uploaded 53 | _, err = s3Client.PutObject(&s3.PutObjectInput{ 54 | Bucket: aws.String(s.cfg.BucketName), 55 | Key: aws.String(s.cfg.FileName), 56 | Body: bytes.NewReader(data), 57 | ContentType: aws.String(badge.ContentType), 58 | ContentLength: aws.Int64(int64(len(data))), 59 | }) 60 | if err != nil { 61 | return false, fmt.Errorf("put object: %w", err) 62 | } 63 | 64 | return true, nil // has changed 65 | } 66 | 67 | func createS3Client(cfg CDN) *s3.S3 { 68 | s3Config := &aws.Config{ 69 | Credentials: credentials.NewStaticCredentials(cfg.Key, cfg.Secret, ""), 70 | Endpoint: aws.String(cfg.Endpoint), 71 | Region: aws.String(cfg.Region), 72 | S3ForcePathStyle: aws.Bool(cfg.ForcePathStyle), 73 | } 74 | 75 | // calling `session.Must` can potentially panic, which is not practice of this 76 | // codebase to panic outside of main function. however it will never happen as 77 | // this panic only happens when sessions could not be created using env variables. 78 | newSession := session.Must(session.NewSession(s3Config)) 79 | 80 | return s3.New(newSession) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/cdn_test.go: -------------------------------------------------------------------------------- 1 | package badgestorer_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/service/s3" 9 | "github.com/johannesboyne/gofakes3" 10 | "github.com/johannesboyne/gofakes3/backend/s3mem" 11 | "github.com/stretchr/testify/assert" 12 | 13 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badgestorer" 14 | ) 15 | 16 | func Test_CDN_Error(t *testing.T) { 17 | t.Parallel() 18 | 19 | data := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0} 20 | cfg := CDN{ 21 | Secret: `your-secrets-are-safu`, 22 | } 23 | 24 | s := NewCDN(cfg) 25 | updated, err := s.Store(data) 26 | assert.Error(t, err) 27 | assert.False(t, updated) 28 | } 29 | 30 | func Test_CDN(t *testing.T) { 31 | t.Parallel() 32 | 33 | if testing.Short() { 34 | return 35 | } 36 | 37 | data := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0} 38 | 39 | const ( 40 | key = `🔑` 41 | secret = `your-secrets-are-safu` 42 | coverage = 100 43 | ) 44 | 45 | backend := s3mem.New() 46 | faker := gofakes3.New(backend) 47 | ts := httptest.NewServer(faker.Server()) 48 | 49 | defer ts.Close() 50 | 51 | cfg := CDN{ 52 | Key: key, 53 | Secret: secret, 54 | Region: "eu-central-1", 55 | FileName: "coverage.svg", 56 | BucketName: "badges", 57 | Endpoint: ts.URL, 58 | ForcePathStyle: true, 59 | } 60 | 61 | // bucket does not exists 62 | s := NewCDN(cfg) 63 | updated, err := s.Store(data) 64 | assert.Error(t, err) 65 | assert.False(t, updated) 66 | 67 | // create bucket and assert again 68 | s3Client := CreateS3Client(cfg) 69 | 70 | _, err = s3Client.CreateBucket(&s3.CreateBucketInput{ 71 | Bucket: aws.String(cfg.BucketName), 72 | }) 73 | assert.NoError(t, err) 74 | 75 | // put badge 76 | updated, err = s.Store(data) 77 | assert.NoError(t, err) 78 | assert.True(t, updated) 79 | 80 | // put badge again - no change 81 | updated, err = s.Store(data) 82 | assert.NoError(t, err) 83 | assert.False(t, updated) 84 | 85 | // put badge again - expect change 86 | updated, err = s.Store(append(data, byte(1))) 87 | assert.NoError(t, err) 88 | assert.True(t, updated) 89 | } 90 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/export_test.go: -------------------------------------------------------------------------------- 1 | package badgestorer 2 | 3 | var CreateS3Client = createS3Client 4 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/file.go: -------------------------------------------------------------------------------- 1 | package badgestorer 2 | 3 | import "os" 4 | 5 | type fileStorer struct { 6 | filename string 7 | } 8 | 9 | func NewFile(filename string) Storer { 10 | return &fileStorer{filename: filename} 11 | } 12 | 13 | //nolint:gosec,mnd,wrapcheck // relax 14 | func (s *fileStorer) Store(data []byte) (bool, error) { 15 | err := os.WriteFile(s.filename, data, 0o644) 16 | if err != nil { 17 | return false, err 18 | } 19 | 20 | return true, nil 21 | } 22 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/file_test.go: -------------------------------------------------------------------------------- 1 | package badgestorer_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badgestorer" 10 | ) 11 | 12 | func Test_File(t *testing.T) { 13 | t.Parallel() 14 | 15 | if testing.Short() { 16 | return 17 | } 18 | 19 | data := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0} 20 | 21 | t.Run("invalid file", func(t *testing.T) { 22 | t.Parallel() 23 | 24 | s := NewFile(t.TempDir()) 25 | updated, err := s.Store(data) 26 | assert.Error(t, err) // should not be able to write to directory 27 | assert.False(t, updated) 28 | }) 29 | 30 | t.Run("success", func(t *testing.T) { 31 | t.Parallel() 32 | 33 | testFile := t.TempDir() + "/badge.svg" 34 | 35 | s := NewFile(testFile) 36 | updated, err := s.Store(data) 37 | assert.NoError(t, err) 38 | assert.True(t, updated) 39 | 40 | contentBytes, err := os.ReadFile(testFile) 41 | assert.NoError(t, err) 42 | assert.Equal(t, data, contentBytes) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/github.go: -------------------------------------------------------------------------------- 1 | package badgestorer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/google/go-github/v56/github" 9 | ) 10 | 11 | type Git struct { 12 | Token string 13 | Owner string 14 | Repository string 15 | Branch string 16 | FileName string 17 | } 18 | 19 | func GitPublicURL(cfg Git) string { 20 | return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", 21 | cfg.Owner, cfg.Repository, cfg.Branch, cfg.FileName, 22 | ) 23 | } 24 | 25 | type githubStorer struct { 26 | cfg Git 27 | } 28 | 29 | func NewGithub(cfg Git) Storer { 30 | return &githubStorer{cfg: cfg} 31 | } 32 | 33 | func (s *githubStorer) Store(data []byte) (bool, error) { 34 | git := s.cfg 35 | client := github.NewClient(nil).WithAuthToken(git.Token) 36 | 37 | updateBadge := func(sha *string) (bool, error) { 38 | _, _, err := client.Repositories.UpdateFile( 39 | context.Background(), 40 | git.Owner, 41 | git.Repository, 42 | git.FileName, 43 | &github.RepositoryContentFileOptions{ 44 | Message: github.String("update badge " + git.FileName), 45 | Content: data, 46 | Branch: &git.Branch, 47 | SHA: sha, 48 | }, 49 | ) 50 | if err != nil { 51 | return false, fmt.Errorf("update badge contents: %w", err) 52 | } 53 | 54 | return true, nil // has changed 55 | } 56 | 57 | fc, _, httpResp, err := client.Repositories.GetContents( 58 | context.Background(), 59 | git.Owner, 60 | git.Repository, 61 | git.FileName, 62 | &github.RepositoryContentGetOptions{Ref: git.Branch}, 63 | ) 64 | if httpResp.StatusCode == http.StatusNotFound { // when badge is not found create it 65 | return updateBadge(nil) 66 | } 67 | 68 | if err != nil { // coverage-ignore 69 | return false, fmt.Errorf("get badge content: %w", err) 70 | } 71 | 72 | content, err := fc.GetContent() 73 | if err != nil { // coverage-ignore 74 | return false, fmt.Errorf("decode badge content: %w", err) 75 | } 76 | 77 | if content == string(data) { // same badge already exists... do nothing 78 | return false, nil 79 | } 80 | 81 | return updateBadge(fc.SHA) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/github_test.go: -------------------------------------------------------------------------------- 1 | package badgestorer_test 2 | 3 | import ( 4 | crand "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "os" 8 | "testing" 9 | 10 | "github.com/google/go-github/v56/github" 11 | "github.com/stretchr/testify/assert" 12 | 13 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badgestorer" 14 | ) 15 | 16 | const envGitToken = "GITHUB_TOKEN" //nolint:gosec // false-positive 17 | 18 | func Test_Github_Error(t *testing.T) { 19 | t.Parallel() 20 | 21 | if testing.Short() { 22 | return 23 | } 24 | 25 | data := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0} 26 | cfg := Git{ 27 | Token: `🔑`, 28 | Owner: "owner", 29 | Repository: "repo", 30 | } 31 | s := NewGithub(cfg) 32 | 33 | updated, err := s.Store(data) 34 | assert.Error(t, err) 35 | assert.False(t, updated) 36 | } 37 | 38 | func Test_Github(t *testing.T) { 39 | t.Parallel() 40 | 41 | if testing.Short() { 42 | return 43 | } 44 | 45 | if getEnv(envGitToken) == "" { 46 | t.Skipf("%v env variable not set", envGitToken) 47 | return 48 | } 49 | 50 | data := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 0} 51 | cfg := Git{ 52 | Token: getEnv(envGitToken), 53 | Owner: "vladopajic", 54 | Repository: "go-test-coverage", 55 | Branch: "badges-integration-test", 56 | // badge name must be unique because two tests running from different platforms 57 | // in CI can cause race condition if badge has the same name 58 | FileName: fmt.Sprintf("badge_%s.svg", randName()), 59 | } 60 | s := NewGithub(cfg) 61 | 62 | // put badge 63 | updated, err := s.Store(data) 64 | assert.NoError(t, err) 65 | assert.True(t, updated) 66 | 67 | // put badge again - no change 68 | updated, err = s.Store(data) 69 | assert.NoError(t, err) 70 | assert.False(t, updated) 71 | 72 | // put badge again - expect change 73 | updated, err = s.Store(append(data, byte(1))) 74 | assert.NoError(t, err) 75 | assert.True(t, updated) 76 | 77 | deleteFile(t, cfg) 78 | } 79 | 80 | func getEnv(key string) string { 81 | value, _ := os.LookupEnv(key) 82 | return value 83 | } 84 | 85 | func deleteFile(t *testing.T, cfg Git) { 86 | t.Helper() 87 | 88 | client := github.NewClient(nil).WithAuthToken(cfg.Token) 89 | 90 | fc, _, _, err := client.Repositories.GetContents( 91 | t.Context(), 92 | cfg.Owner, 93 | cfg.Repository, 94 | cfg.FileName, 95 | &github.RepositoryContentGetOptions{Ref: cfg.Branch}, 96 | ) 97 | assert.NoError(t, err) 98 | 99 | _, _, err = client.Repositories.DeleteFile( 100 | t.Context(), 101 | cfg.Owner, 102 | cfg.Repository, 103 | cfg.FileName, 104 | &github.RepositoryContentFileOptions{ 105 | Message: github.String("delete testing badge " + cfg.FileName), 106 | Branch: &cfg.Branch, 107 | SHA: fc.SHA, 108 | }, 109 | ) 110 | assert.NoError(t, err) 111 | } 112 | 113 | func randName() string { 114 | buf := make([]byte, 20) 115 | 116 | _, err := crand.Read(buf) 117 | if err != nil { 118 | panic(err) //nolint:forbidigo // okay here because it is only used for tests 119 | } 120 | 121 | return hex.EncodeToString(buf) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/testcoverage/badgestorer/storer.go: -------------------------------------------------------------------------------- 1 | package badgestorer 2 | 3 | type Storer interface { 4 | Store(data []byte) (hasUpdated bool, err error) 5 | } 6 | -------------------------------------------------------------------------------- /pkg/testcoverage/check.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 12 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/logger" 13 | ) 14 | 15 | //nolint:maintidx // relax 16 | func Check(wout io.Writer, cfg Config) (bool, error) { 17 | buffer := &bytes.Buffer{} 18 | w := bufio.NewWriter(buffer) 19 | //nolint:errcheck // relax 20 | defer func() { 21 | if cfg.Debug { 22 | wout.Write(logger.Bytes()) 23 | wout.Write([]byte("-------------------------\n\n")) 24 | } 25 | 26 | w.Flush() 27 | wout.Write(buffer.Bytes()) 28 | }() 29 | 30 | handleErr := func(err error, msg string) (bool, error) { 31 | logger.L.Error().Err(err).Msg(msg) 32 | return false, fmt.Errorf("%s: %w", msg, err) 33 | } 34 | 35 | logger.L.Info().Msg("running check...") 36 | logger.L.Info().Any("config", cfg.Redacted()).Msg("using configuration") 37 | 38 | currentStats, err := GenerateCoverageStats(cfg) 39 | if err != nil { 40 | return handleErr(err, "failed to generate coverage statistics") 41 | } 42 | 43 | err = saveCoverageBreakdown(cfg, currentStats) 44 | if err != nil { 45 | return handleErr(err, "failed to save coverage breakdown") 46 | } 47 | 48 | baseStats, err := loadBaseCoverageBreakdown(cfg) 49 | if err != nil { 50 | return handleErr(err, "failed to load base coverage breakdown") 51 | } 52 | 53 | result := Analyze(cfg, currentStats, baseStats) 54 | 55 | report := reportForHuman(w, result) 56 | 57 | if cfg.GithubActionOutput { 58 | ReportForGithubAction(w, result) 59 | 60 | err = SetGithubActionOutput(result, report) 61 | if err != nil { 62 | return handleErr(err, "failed setting github action output") 63 | } 64 | 65 | if cfg.LocalPrefixDeprecated != "" { // coverage-ignore 66 | //nolint:lll // relax 67 | msg := "`local-prefix` option is deprecated since v2.13.0, you can safely remove setting this option" 68 | logger.L.Warn().Msg(msg) 69 | reportGHWarning(w, "Deprecated option", msg) 70 | } 71 | } 72 | 73 | err = generateAndSaveBadge(w, cfg, result.TotalStats.CoveredPercentage()) 74 | if err != nil { 75 | return handleErr(err, "failed to generate and save badge") 76 | } 77 | 78 | return result.Pass(), nil 79 | } 80 | 81 | func reportForHuman(w io.Writer, result AnalyzeResult) string { 82 | buffer := &bytes.Buffer{} 83 | out := bufio.NewWriter(buffer) 84 | 85 | ReportForHuman(out, result) 86 | out.Flush() 87 | 88 | w.Write(buffer.Bytes()) //nolint:errcheck // relax 89 | 90 | return buffer.String() 91 | } 92 | 93 | func GenerateCoverageStats(cfg Config) ([]coverage.Stats, error) { 94 | return coverage.GenerateCoverageStats(coverage.Config{ //nolint:wrapcheck // err wrapped above 95 | Profiles: strings.Split(cfg.Profile, ","), 96 | ExcludePaths: cfg.Exclude.Paths, 97 | SourceDir: cfg.SourceDir, 98 | }) 99 | } 100 | 101 | func Analyze(cfg Config, current, base []coverage.Stats) AnalyzeResult { 102 | thr := cfg.Threshold 103 | overrideRules := compileOverridePathRules(cfg) 104 | hasFileOverrides, hasPackageOverrides := detectOverrides(cfg.Override) 105 | 106 | return AnalyzeResult{ 107 | Threshold: thr, 108 | HasFileOverrides: hasFileOverrides, 109 | HasPackageOverrides: hasPackageOverrides, 110 | FilesBelowThreshold: checkCoverageStatsBelowThreshold(current, thr.File, overrideRules), 111 | PackagesBelowThreshold: checkCoverageStatsBelowThreshold( 112 | makePackageStats(current), thr.Package, overrideRules, 113 | ), 114 | FilesWithUncoveredLines: coverage.StatsFilterWithUncoveredLines(current), 115 | TotalStats: coverage.StatsCalcTotal(current), 116 | HasBaseBreakdown: len(base) > 0, 117 | Diff: calculateStatsDiff(current, base), 118 | } 119 | } 120 | 121 | func detectOverrides(overrides []Override) (bool, bool) { 122 | hasFileOverrides := false 123 | hasPackageOverrides := false 124 | 125 | for _, override := range overrides { 126 | if strings.HasSuffix(override.Path, ".go") || strings.HasSuffix(override.Path, ".go$") { 127 | hasFileOverrides = true 128 | } else { 129 | hasPackageOverrides = true 130 | } 131 | } 132 | 133 | return hasFileOverrides, hasPackageOverrides 134 | } 135 | 136 | func saveCoverageBreakdown(cfg Config, stats []coverage.Stats) error { 137 | if cfg.BreakdownFileName == "" { 138 | return nil 139 | } 140 | 141 | //nolint:mnd,wrapcheck,gosec // relax 142 | return os.WriteFile(cfg.BreakdownFileName, coverage.StatsSerialize(stats), 0o644) 143 | } 144 | 145 | func loadBaseCoverageBreakdown(cfg Config) ([]coverage.Stats, error) { 146 | if cfg.Diff.BaseBreakdownFileName == "" { 147 | return nil, nil 148 | } 149 | 150 | data, err := os.ReadFile(cfg.Diff.BaseBreakdownFileName) 151 | if err != nil { 152 | return nil, fmt.Errorf("reading file content failed: %w", err) 153 | } 154 | 155 | stats, err := coverage.StatsDeserialize(data) 156 | if err != nil { 157 | return nil, fmt.Errorf("deserializing stats file failed: %w", err) 158 | } 159 | 160 | return stats, nil 161 | } 162 | -------------------------------------------------------------------------------- /pkg/testcoverage/check_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage_test 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 12 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 13 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/logger" 14 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/path" 15 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/testdata" 16 | ) 17 | 18 | const ( 19 | testdataDir = "testdata/" 20 | profileOK = testdataDir + testdata.ProfileOK 21 | profileNOK = testdataDir + testdata.ProfileNOK 22 | breakdownOK = testdataDir + testdata.BreakdownOK 23 | breakdownNOK = testdataDir + testdata.BreakdownNOK 24 | 25 | prefix = "github.com/vladopajic/go-test-coverage/v2" 26 | sourceDir = "../../" 27 | ) 28 | 29 | func TestCheck(t *testing.T) { 30 | t.Parallel() 31 | 32 | if testing.Short() { 33 | return 34 | } 35 | 36 | t.Run("no profile", func(t *testing.T) { 37 | t.Parallel() 38 | 39 | buf := &bytes.Buffer{} 40 | pass, err := Check(buf, Config{}) 41 | assert.False(t, pass) 42 | assert.Error(t, err) 43 | assertGithubActionErrorsCount(t, buf.String(), 0) 44 | assertHumanReport(t, buf.String(), 0, 0) 45 | assertNoUncoveredLinesInfo(t, buf.String()) 46 | }) 47 | 48 | t.Run("invalid profile", func(t *testing.T) { 49 | t.Parallel() 50 | 51 | buf := &bytes.Buffer{} 52 | cfg := Config{Profile: profileNOK, Threshold: Threshold{Total: 65}} 53 | pass, err := Check(buf, cfg) 54 | assert.False(t, pass) 55 | assert.Error(t, err) 56 | assertGithubActionErrorsCount(t, buf.String(), 0) 57 | assertHumanReport(t, buf.String(), 0, 0) 58 | assertNoUncoveredLinesInfo(t, buf.String()) 59 | }) 60 | 61 | t.Run("valid profile - pass", func(t *testing.T) { 62 | t.Parallel() 63 | 64 | buf := &bytes.Buffer{} 65 | cfg := Config{Profile: profileOK, Threshold: Threshold{Total: 65}, SourceDir: sourceDir} 66 | pass, err := Check(buf, cfg) 67 | assert.True(t, pass) 68 | assert.NoError(t, err) 69 | assertGithubActionErrorsCount(t, buf.String(), 0) 70 | assertHumanReport(t, buf.String(), 1, 0) 71 | assertNoFileNames(t, buf.String(), prefix) 72 | assertNoUncoveredLinesInfo(t, buf.String()) 73 | }) 74 | 75 | t.Run("valid profile with exclude - pass", func(t *testing.T) { 76 | t.Parallel() 77 | 78 | buf := &bytes.Buffer{} 79 | cfg := Config{ 80 | Profile: profileOK, 81 | Threshold: Threshold{Total: 100}, 82 | Exclude: Exclude{ 83 | Paths: []string{`cdn\.go$`, `github\.go$`, `cover\.go$`, `check\.go$`, `path\.go$`}, 84 | }, 85 | SourceDir: sourceDir, 86 | } 87 | pass, err := Check(buf, cfg) 88 | assert.True(t, pass) 89 | assert.NoError(t, err) 90 | assertGithubActionErrorsCount(t, buf.String(), 0) 91 | assertHumanReport(t, buf.String(), 1, 0) 92 | assertNoUncoveredLinesInfo(t, buf.String()) 93 | }) 94 | 95 | t.Run("valid profile - fail", func(t *testing.T) { 96 | t.Parallel() 97 | 98 | buf := &bytes.Buffer{} 99 | cfg := Config{Profile: profileOK, Threshold: Threshold{Total: 100}, SourceDir: sourceDir} 100 | pass, err := Check(buf, cfg) 101 | assert.False(t, pass) 102 | assert.NoError(t, err) 103 | assertGithubActionErrorsCount(t, buf.String(), 0) 104 | assertHumanReport(t, buf.String(), 0, 1) 105 | assertHasUncoveredLinesInfo(t, buf.String(), []string{ 106 | "pkg/testcoverage/badgestorer/cdn.go", 107 | "pkg/testcoverage/badgestorer/github.go", 108 | "pkg/testcoverage/check.go", 109 | "pkg/testcoverage/coverage/cover.go", 110 | }) 111 | }) 112 | 113 | t.Run("valid profile - pass after override", func(t *testing.T) { 114 | t.Parallel() 115 | 116 | buf := &bytes.Buffer{} 117 | cfg := Config{ 118 | Profile: profileOK, 119 | Threshold: Threshold{File: 100}, 120 | Override: []Override{{Threshold: 10, Path: "^pkg"}}, 121 | SourceDir: sourceDir, 122 | } 123 | pass, err := Check(buf, cfg) 124 | assert.True(t, pass) 125 | assert.NoError(t, err) 126 | assertGithubActionErrorsCount(t, buf.String(), 0) 127 | assertHumanReport(t, buf.String(), 2, 0) 128 | assertNoFileNames(t, buf.String(), prefix) 129 | assertNoUncoveredLinesInfo(t, buf.String()) 130 | }) 131 | 132 | t.Run("valid profile - fail after override", func(t *testing.T) { 133 | t.Parallel() 134 | 135 | buf := &bytes.Buffer{} 136 | cfg := Config{ 137 | Profile: profileOK, 138 | Threshold: Threshold{File: 10}, 139 | Override: []Override{{Threshold: 100, Path: "^pkg"}}, 140 | SourceDir: sourceDir, 141 | } 142 | pass, err := Check(buf, cfg) 143 | assert.False(t, pass) 144 | assert.NoError(t, err) 145 | assertGithubActionErrorsCount(t, buf.String(), 0) 146 | assertHumanReport(t, buf.String(), 0, 2) 147 | assertHasUncoveredLinesInfo(t, buf.String(), []string{ 148 | "pkg/testcoverage/badgestorer/cdn.go", 149 | "pkg/testcoverage/badgestorer/github.go", 150 | "pkg/testcoverage/check.go", 151 | "pkg/testcoverage/coverage/cover.go", 152 | }) 153 | }) 154 | 155 | t.Run("valid profile - pass after file override", func(t *testing.T) { 156 | t.Parallel() 157 | 158 | buf := &bytes.Buffer{} 159 | cfg := Config{ 160 | Profile: profileOK, 161 | Threshold: Threshold{File: 70}, 162 | Override: []Override{{Threshold: 60, Path: "pkg/testcoverage/badgestorer/github.go"}}, 163 | SourceDir: sourceDir, 164 | } 165 | pass, err := Check(buf, cfg) 166 | assert.True(t, pass) 167 | assert.NoError(t, err) 168 | assertGithubActionErrorsCount(t, buf.String(), 0) 169 | assertHumanReport(t, buf.String(), 1, 0) 170 | assertNoFileNames(t, buf.String(), prefix) 171 | assertNoUncoveredLinesInfo(t, buf.String()) 172 | }) 173 | 174 | t.Run("valid profile - fail after file override", func(t *testing.T) { 175 | t.Parallel() 176 | 177 | buf := &bytes.Buffer{} 178 | cfg := Config{ 179 | Profile: profileOK, 180 | Threshold: Threshold{File: 70}, 181 | Override: []Override{{Threshold: 80, Path: "pkg/testcoverage/badgestorer/github.go"}}, 182 | SourceDir: sourceDir, 183 | } 184 | pass, err := Check(buf, cfg) 185 | assert.False(t, pass) 186 | assert.NoError(t, err) 187 | assertGithubActionErrorsCount(t, buf.String(), 0) 188 | assertHumanReport(t, buf.String(), 0, 1) 189 | assert.GreaterOrEqual(t, strings.Count(buf.String(), prefix), 0) 190 | assertHasUncoveredLinesInfo(t, buf.String(), []string{ 191 | "pkg/testcoverage/badgestorer/cdn.go", 192 | "pkg/testcoverage/badgestorer/github.go", 193 | "pkg/testcoverage/check.go", 194 | "pkg/testcoverage/coverage/cover.go", 195 | }) 196 | }) 197 | 198 | t.Run("valid profile - fail couldn't save badge", func(t *testing.T) { 199 | t.Parallel() 200 | 201 | buf := &bytes.Buffer{} 202 | cfg := Config{ 203 | Profile: profileOK, 204 | Badge: Badge{ 205 | FileName: t.TempDir(), // should failed because this is dir 206 | }, 207 | SourceDir: sourceDir, 208 | } 209 | pass, err := Check(buf, cfg) 210 | assert.False(t, pass) 211 | assert.Error(t, err) 212 | assert.Contains(t, err.Error(), "failed to generate and save badge") 213 | }) 214 | 215 | t.Run("valid profile - fail invalid breakdown file", func(t *testing.T) { 216 | t.Parallel() 217 | 218 | buf := &bytes.Buffer{} 219 | cfg := Config{ 220 | Profile: profileOK, 221 | BreakdownFileName: t.TempDir(), // should failed because this is dir 222 | SourceDir: sourceDir, 223 | } 224 | pass, err := Check(buf, cfg) 225 | assert.False(t, pass) 226 | assert.Error(t, err) 227 | assert.Contains(t, err.Error(), "failed to save coverage breakdown") 228 | }) 229 | 230 | t.Run("valid profile - valid breakdown file", func(t *testing.T) { 231 | t.Parallel() 232 | 233 | buf := &bytes.Buffer{} 234 | cfg := Config{ 235 | Profile: profileOK, 236 | BreakdownFileName: t.TempDir() + "/breakdown.testcoverage", 237 | SourceDir: sourceDir, 238 | } 239 | pass, err := Check(buf, cfg) 240 | assert.True(t, pass) 241 | assert.NoError(t, err) 242 | 243 | contentBytes, err := os.ReadFile(cfg.BreakdownFileName) 244 | assert.NoError(t, err) 245 | assert.NotEmpty(t, contentBytes) 246 | 247 | stats, err := GenerateCoverageStats(cfg) 248 | assert.NoError(t, err) 249 | assert.Equal(t, coverage.StatsSerialize(stats), contentBytes) 250 | }) 251 | 252 | t.Run("valid profile - invalid base breakdown file", func(t *testing.T) { 253 | t.Parallel() 254 | 255 | buf := &bytes.Buffer{} 256 | cfg := Config{ 257 | Profile: profileOK, 258 | Diff: Diff{ 259 | BaseBreakdownFileName: t.TempDir(), // should failed because this is dir 260 | }, 261 | SourceDir: sourceDir, 262 | } 263 | pass, err := Check(buf, cfg) 264 | assert.False(t, pass) 265 | assert.Error(t, err) 266 | assert.Contains(t, err.Error(), "failed to load base coverage breakdown") 267 | }) 268 | } 269 | 270 | //nolint:paralleltest // must not be parallel because it uses env 271 | func TestCheckNoParallel(t *testing.T) { 272 | if testing.Short() { 273 | return 274 | } 275 | 276 | t.Run("ok fail; no github output file", func(t *testing.T) { 277 | t.Setenv(GaOutputFileEnv, "") 278 | 279 | buf := &bytes.Buffer{} 280 | cfg := Config{ 281 | Profile: profileOK, 282 | GithubActionOutput: true, 283 | Threshold: Threshold{Total: 100}, 284 | SourceDir: sourceDir, 285 | } 286 | pass, err := Check(buf, cfg) 287 | assert.False(t, pass) 288 | assert.Error(t, err) 289 | }) 290 | 291 | t.Run("ok pass; with github output file", func(t *testing.T) { 292 | testFile := t.TempDir() + "/ga.output" 293 | t.Setenv(GaOutputFileEnv, testFile) 294 | 295 | buf := &bytes.Buffer{} 296 | cfg := Config{ 297 | Profile: profileOK, 298 | GithubActionOutput: true, 299 | Threshold: Threshold{Total: 10}, 300 | SourceDir: sourceDir, 301 | } 302 | pass, err := Check(buf, cfg) 303 | assert.True(t, pass) 304 | assert.NoError(t, err) 305 | assertGithubActionErrorsCount(t, buf.String(), 0) 306 | assertHumanReport(t, buf.String(), 1, 0) 307 | assertGithubOutputValues(t, testFile) 308 | assertNoUncoveredLinesInfo(t, buf.String()) 309 | }) 310 | 311 | t.Run("ok fail; with github output file", func(t *testing.T) { 312 | testFile := t.TempDir() + "/ga.output" 313 | t.Setenv(GaOutputFileEnv, testFile) 314 | 315 | buf := &bytes.Buffer{} 316 | cfg := Config{ 317 | Profile: profileOK, 318 | GithubActionOutput: true, 319 | Threshold: Threshold{Total: 100}, 320 | SourceDir: sourceDir, 321 | } 322 | pass, err := Check(buf, cfg) 323 | assert.False(t, pass) 324 | assert.NoError(t, err) 325 | assertGithubActionErrorsCount(t, buf.String(), 1) 326 | assertHumanReport(t, buf.String(), 0, 1) 327 | assertGithubOutputValues(t, testFile) 328 | assertHasUncoveredLinesInfo(t, buf.String(), []string{}) 329 | }) 330 | 331 | t.Run("logger has output", func(t *testing.T) { 332 | logger.Init() 333 | defer logger.Destruct() 334 | 335 | buf := &bytes.Buffer{} 336 | cfg := Config{ 337 | Profile: profileOK, 338 | Threshold: Threshold{Total: 65}, 339 | SourceDir: sourceDir, 340 | Debug: true, 341 | } 342 | pass, err := Check(buf, cfg) 343 | assert.True(t, pass) 344 | assert.NoError(t, err) 345 | 346 | assert.NotEmpty(t, logger.Bytes()) 347 | assert.Contains(t, buf.String(), string(logger.Bytes())) 348 | }) 349 | } 350 | 351 | func Test_Analyze(t *testing.T) { 352 | t.Parallel() 353 | 354 | prefix := "organization.org/" + randName() 355 | 356 | t.Run("nil coverage stats", func(t *testing.T) { 357 | t.Parallel() 358 | 359 | result := Analyze(Config{}, nil, nil) 360 | assert.Empty(t, result.FilesBelowThreshold) 361 | assert.Empty(t, result.PackagesBelowThreshold) 362 | assert.Equal(t, 0, result.TotalStats.CoveredPercentage()) 363 | }) 364 | 365 | t.Run("total coverage above threshold", func(t *testing.T) { 366 | t.Parallel() 367 | 368 | result := Analyze( 369 | Config{Threshold: Threshold{Total: 10}}, 370 | randStats(prefix, 10, 100), 371 | nil, 372 | ) 373 | assert.True(t, result.Pass()) 374 | assertPrefix(t, result, prefix, false) 375 | 376 | result = Analyze( 377 | Config{Threshold: Threshold{Total: 10}}, 378 | randStats(prefix, 10, 100), 379 | nil, 380 | ) 381 | assert.True(t, result.Pass()) 382 | assertPrefix(t, result, prefix, true) 383 | }) 384 | 385 | t.Run("total coverage below threshold", func(t *testing.T) { 386 | t.Parallel() 387 | 388 | result := Analyze( 389 | Config{Threshold: Threshold{Total: 10}}, 390 | randStats(prefix, 0, 9), 391 | nil, 392 | ) 393 | assert.False(t, result.Pass()) 394 | }) 395 | 396 | t.Run("files coverage above threshold", func(t *testing.T) { 397 | t.Parallel() 398 | 399 | result := Analyze( 400 | Config{Threshold: Threshold{File: 10}}, 401 | randStats(prefix, 10, 100), 402 | nil, 403 | ) 404 | assert.True(t, result.Pass()) 405 | assertPrefix(t, result, prefix, false) 406 | }) 407 | 408 | t.Run("files coverage below threshold", func(t *testing.T) { 409 | t.Parallel() 410 | 411 | result := Analyze( 412 | Config{Threshold: Threshold{File: 10}}, 413 | mergeStats( 414 | randStats(prefix, 0, 9), 415 | randStats(prefix, 10, 100), 416 | ), 417 | nil, 418 | ) 419 | assert.NotEmpty(t, result.FilesBelowThreshold) 420 | assert.Empty(t, result.PackagesBelowThreshold) 421 | assert.False(t, result.Pass()) 422 | assertPrefix(t, result, prefix, true) 423 | }) 424 | 425 | t.Run("package coverage above threshold", func(t *testing.T) { 426 | t.Parallel() 427 | 428 | result := Analyze( 429 | Config{Threshold: Threshold{Package: 10}}, 430 | randStats(prefix, 10, 100), 431 | nil, 432 | ) 433 | assert.True(t, result.Pass()) 434 | assertPrefix(t, result, prefix, false) 435 | }) 436 | 437 | t.Run("package coverage below threshold", func(t *testing.T) { 438 | t.Parallel() 439 | 440 | result := Analyze( 441 | Config{Threshold: Threshold{Package: 10}}, 442 | mergeStats( 443 | randStats(prefix, 0, 9), 444 | randStats(prefix, 10, 100), 445 | ), 446 | nil, 447 | ) 448 | assert.Empty(t, result.FilesBelowThreshold) 449 | assert.NotEmpty(t, result.PackagesBelowThreshold) 450 | assert.False(t, result.Pass()) 451 | assertPrefix(t, result, prefix, true) 452 | }) 453 | } 454 | 455 | func TestLoadBaseCoverageBreakdown(t *testing.T) { 456 | t.Parallel() 457 | 458 | if testing.Short() { 459 | return 460 | } 461 | 462 | stats, err := LoadBaseCoverageBreakdown(Config{Diff: Diff{}}) 463 | assert.NoError(t, err) 464 | assert.Empty(t, stats) 465 | 466 | stats, err = LoadBaseCoverageBreakdown(Config{ 467 | Diff: Diff{BaseBreakdownFileName: path.NormalizeForOS(breakdownOK)}, 468 | }) 469 | assert.NoError(t, err) 470 | assert.Len(t, stats, 14) 471 | 472 | stats, err = LoadBaseCoverageBreakdown(Config{ 473 | Diff: Diff{BaseBreakdownFileName: t.TempDir()}, 474 | }) 475 | assert.Error(t, err) 476 | assert.Empty(t, stats) 477 | 478 | stats, err = LoadBaseCoverageBreakdown(Config{ 479 | Diff: Diff{BaseBreakdownFileName: path.NormalizeForOS(breakdownNOK)}, 480 | }) 481 | assert.Error(t, err) 482 | assert.Empty(t, stats) 483 | } 484 | -------------------------------------------------------------------------------- /pkg/testcoverage/config.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | "regexp" 9 | "strings" 10 | 11 | yaml "gopkg.in/yaml.v3" 12 | 13 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badgestorer" 14 | ) 15 | 16 | const HiddenValue = "***" 17 | 18 | var ( 19 | ErrThresholdNotInRange = errors.New("threshold must be in range [0 - 100]") 20 | ErrCoverageProfileNotSpecified = errors.New("coverage profile file not specified") 21 | ErrRegExpNotValid = errors.New("regular expression is not valid") 22 | ErrCDNOptionNotSet = errors.New("CDN options are not valid") 23 | ErrGitOptionNotSet = errors.New("git options are not valid") 24 | ) 25 | 26 | type Config struct { 27 | Profile string `yaml:"profile"` 28 | Debug bool `yaml:"-"` 29 | LocalPrefixDeprecated string `yaml:"-"` 30 | SourceDir string `yaml:"-"` 31 | Threshold Threshold `yaml:"threshold"` 32 | Override []Override `yaml:"override,omitempty"` 33 | Exclude Exclude `yaml:"exclude"` 34 | BreakdownFileName string `yaml:"breakdown-file-name"` 35 | GithubActionOutput bool `yaml:"github-action-output"` 36 | Diff Diff `yaml:"diff"` 37 | Badge Badge `yaml:"-"` 38 | } 39 | 40 | type Threshold struct { 41 | File int `yaml:"file"` 42 | Package int `yaml:"package"` 43 | Total int `yaml:"total"` 44 | } 45 | 46 | type Override struct { 47 | Threshold int `yaml:"threshold"` 48 | Path string `yaml:"path"` 49 | } 50 | 51 | type Exclude struct { 52 | Paths []string `yaml:"paths,omitempty"` 53 | } 54 | 55 | type Diff struct { 56 | BaseBreakdownFileName string `yaml:"base-breakdown-file-name"` 57 | } 58 | 59 | type Badge struct { 60 | FileName string 61 | CDN badgestorer.CDN 62 | Git badgestorer.Git 63 | } 64 | 65 | //nolint:wsl,mnd // relax 66 | func (c Config) Redacted() Config { 67 | r := c 68 | 69 | if r.Badge.CDN.Key != "" { 70 | r.Badge.CDN.Key = r.Badge.CDN.Key[0:min(len(r.Badge.CDN.Key), 5)] + HiddenValue 71 | } 72 | if r.Badge.CDN.Secret != "" { 73 | r.Badge.CDN.Secret = HiddenValue 74 | } 75 | if r.Badge.Git.Token != "" { 76 | r.Badge.Git.Token = HiddenValue 77 | } 78 | 79 | return r 80 | } 81 | 82 | func (c Config) Validate() error { 83 | validateRegexp := func(s string) error { 84 | _, err := regexp.Compile("(?i)" + s) 85 | return err //nolint:wrapcheck // error is wrapped at level above 86 | } 87 | 88 | if c.Profile == "" { 89 | return ErrCoverageProfileNotSpecified 90 | } 91 | 92 | if err := c.validateThreshold(); err != nil { 93 | return err 94 | } 95 | 96 | for i, pattern := range c.Exclude.Paths { 97 | if err := validateRegexp(pattern); err != nil { 98 | return fmt.Errorf("%w for excluded paths element[%d]: %w", ErrRegExpNotValid, i, err) 99 | } 100 | } 101 | 102 | for i, o := range c.Override { 103 | if !inRange(o.Threshold) { 104 | return fmt.Errorf("override element[%d] %w", i, ErrThresholdNotInRange) 105 | } 106 | 107 | if err := validateRegexp(o.Path); err != nil { 108 | return fmt.Errorf("%w for override element[%d]: %w", ErrRegExpNotValid, i, err) 109 | } 110 | } 111 | 112 | if err := c.validateCDN(); err != nil { 113 | return fmt.Errorf("%w: %s", ErrCDNOptionNotSet, err.Error()) 114 | } 115 | 116 | if err := c.validateGit(); err != nil { 117 | return fmt.Errorf("%w: %s", ErrGitOptionNotSet, err.Error()) 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func (c Config) validateThreshold() error { 124 | if !inRange(c.Threshold.File) { 125 | return fmt.Errorf("file %w", ErrThresholdNotInRange) 126 | } 127 | 128 | if !inRange(c.Threshold.Package) { 129 | return fmt.Errorf("package %w", ErrThresholdNotInRange) 130 | } 131 | 132 | if !inRange(c.Threshold.Total) { 133 | return fmt.Errorf("total %w", ErrThresholdNotInRange) 134 | } 135 | 136 | return nil 137 | } 138 | 139 | func (c Config) validateCDN() error { 140 | // when cnd config is empty, cnd feature is disabled and it's not need to validate 141 | if reflect.DeepEqual(c.Badge.CDN, badgestorer.CDN{}) { 142 | return nil 143 | } 144 | 145 | return hasNonEmptyFields(c.Badge.CDN) 146 | } 147 | 148 | func (c Config) validateGit() error { 149 | // when git config is empty, git feature is disabled and it's not need to validate 150 | if reflect.DeepEqual(c.Badge.Git, badgestorer.Git{}) { 151 | return nil 152 | } 153 | 154 | return hasNonEmptyFields(c.Badge.Git) 155 | } 156 | 157 | func hasNonEmptyFields(obj any) error { 158 | v := reflect.ValueOf(obj) 159 | for i := range v.NumField() { 160 | f := v.Field(i) 161 | 162 | if !f.IsZero() { // filed is set 163 | continue 164 | } 165 | 166 | if f.Type().Kind() == reflect.Bool { // boolean fields are always set 167 | continue 168 | } 169 | 170 | name := strings.ToLower(v.Type().Field(i).Name) 171 | 172 | return fmt.Errorf("property [%v] should be set", name) 173 | } 174 | 175 | return nil 176 | } 177 | 178 | func ConfigFromFile(cfg *Config, filename string) error { 179 | source, err := os.ReadFile(filename) 180 | if err != nil { 181 | return fmt.Errorf("failed reading file: %w", err) 182 | } 183 | 184 | err = yaml.Unmarshal(source, cfg) 185 | if err != nil { 186 | return fmt.Errorf("failed parsing config file: %w", err) 187 | } 188 | 189 | return nil 190 | } 191 | 192 | func inRange(t int) bool { return t >= 0 && t <= 100 } 193 | -------------------------------------------------------------------------------- /pkg/testcoverage/config_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "gopkg.in/yaml.v3" 9 | 10 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 11 | ) 12 | 13 | const nonEmptyStr = "any" 14 | 15 | func Test_Config_Redacted(t *testing.T) { 16 | t.Parallel() 17 | 18 | cfg := newValidCfg() 19 | cfg.Badge.Git.Token = nonEmptyStr 20 | cfg.Badge.CDN.Secret = nonEmptyStr 21 | cfg.Badge.CDN.Key = nonEmptyStr 22 | 23 | r := cfg.Redacted() 24 | 25 | // redacted should not be equal to original 26 | assert.NotEqual(t, cfg, r) 27 | 28 | // original should not change 29 | assert.Equal(t, nonEmptyStr, cfg.Badge.Git.Token) 30 | assert.Equal(t, nonEmptyStr, cfg.Badge.CDN.Secret) 31 | assert.Equal(t, nonEmptyStr, cfg.Badge.CDN.Key) 32 | 33 | // redacted should have hidden values 34 | assert.Equal(t, HiddenValue, r.Badge.Git.Token) 35 | assert.Equal(t, HiddenValue, r.Badge.CDN.Secret) 36 | assert.Equal(t, nonEmptyStr+HiddenValue, r.Badge.CDN.Key) 37 | 38 | // redacted config of empty field should not do anything 39 | r = Config{}.Redacted() 40 | assert.Empty(t, r.Badge.Git.Token) 41 | assert.Empty(t, r.Badge.CDN.Secret) 42 | assert.Empty(t, r.Badge.CDN.Key) 43 | } 44 | 45 | func Test_Config_Validate(t *testing.T) { 46 | t.Parallel() 47 | 48 | cfg := newValidCfg() 49 | assert.NoError(t, cfg.Validate()) 50 | 51 | cfg = newValidCfg() 52 | cfg.Profile = "" 53 | assert.ErrorIs(t, cfg.Validate(), ErrCoverageProfileNotSpecified) 54 | 55 | cfg = newValidCfg() 56 | cfg.Threshold.File = 101 57 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 58 | 59 | cfg = newValidCfg() 60 | cfg.Threshold.File = -1 61 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 62 | 63 | cfg = newValidCfg() 64 | cfg.Threshold.Package = 101 65 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 66 | 67 | cfg = newValidCfg() 68 | cfg.Threshold.Package = -1 69 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 70 | 71 | cfg = newValidCfg() 72 | cfg.Threshold.Total = 101 73 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 74 | 75 | cfg = newValidCfg() 76 | cfg.Threshold.Total = -1 77 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 78 | 79 | cfg = newValidCfg() 80 | cfg.Override = []Override{{Threshold: 101}} 81 | assert.ErrorIs(t, cfg.Validate(), ErrThresholdNotInRange) 82 | 83 | cfg = newValidCfg() 84 | cfg.Override = []Override{{Threshold: 100, Path: "("}} 85 | assert.ErrorIs(t, cfg.Validate(), ErrRegExpNotValid) 86 | 87 | cfg = newValidCfg() 88 | cfg.Exclude.Paths = []string{"("} 89 | assert.ErrorIs(t, cfg.Validate(), ErrRegExpNotValid) 90 | } 91 | 92 | func Test_Config_ValidateCDN(t *testing.T) { 93 | t.Parallel() 94 | 95 | cfg := newValidCfg() 96 | cfg.Badge.CDN.Secret = nonEmptyStr 97 | assert.ErrorIs(t, cfg.Validate(), ErrCDNOptionNotSet) 98 | 99 | cfg = newValidCfg() 100 | cfg.Badge.CDN.Secret = nonEmptyStr 101 | cfg.Badge.CDN.Key = nonEmptyStr 102 | assert.ErrorIs(t, cfg.Validate(), ErrCDNOptionNotSet) 103 | 104 | cfg = newValidCfg() 105 | cfg.Badge.CDN.Secret = nonEmptyStr 106 | cfg.Badge.CDN.Key = nonEmptyStr 107 | cfg.Badge.CDN.Region = nonEmptyStr 108 | assert.ErrorIs(t, cfg.Validate(), ErrCDNOptionNotSet) 109 | 110 | cfg = newValidCfg() 111 | cfg.Badge.CDN.Secret = nonEmptyStr 112 | cfg.Badge.CDN.Key = nonEmptyStr 113 | cfg.Badge.CDN.Region = nonEmptyStr 114 | cfg.Badge.CDN.BucketName = nonEmptyStr 115 | assert.ErrorIs(t, cfg.Validate(), ErrCDNOptionNotSet) 116 | 117 | cfg = newValidCfg() 118 | cfg.Badge.CDN.Secret = nonEmptyStr 119 | cfg.Badge.CDN.Key = nonEmptyStr 120 | cfg.Badge.CDN.Region = nonEmptyStr 121 | cfg.Badge.CDN.BucketName = nonEmptyStr 122 | cfg.Badge.CDN.FileName = nonEmptyStr 123 | cfg.Badge.CDN.Endpoint = nonEmptyStr 124 | assert.NoError(t, cfg.Validate()) 125 | } 126 | 127 | func Test_Config_ValidateGit(t *testing.T) { 128 | t.Parallel() 129 | 130 | cfg := newValidCfg() 131 | cfg.Badge.Git.Token = nonEmptyStr 132 | assert.ErrorIs(t, cfg.Validate(), ErrGitOptionNotSet) 133 | 134 | cfg = newValidCfg() 135 | cfg.Badge.Git.Token = nonEmptyStr 136 | cfg.Badge.Git.Owner = nonEmptyStr 137 | assert.ErrorIs(t, cfg.Validate(), ErrGitOptionNotSet) 138 | 139 | cfg = newValidCfg() 140 | cfg.Badge.Git.Token = nonEmptyStr 141 | cfg.Badge.Git.Owner = nonEmptyStr 142 | cfg.Badge.Git.Repository = nonEmptyStr 143 | assert.ErrorIs(t, cfg.Validate(), ErrGitOptionNotSet) 144 | 145 | cfg = newValidCfg() 146 | cfg.Badge.Git.Token = nonEmptyStr 147 | cfg.Badge.Git.Owner = nonEmptyStr 148 | cfg.Badge.Git.Repository = nonEmptyStr 149 | cfg.Badge.Git.Branch = nonEmptyStr 150 | assert.ErrorIs(t, cfg.Validate(), ErrGitOptionNotSet) 151 | 152 | cfg = newValidCfg() 153 | cfg.Badge.Git.Token = nonEmptyStr 154 | cfg.Badge.Git.Owner = nonEmptyStr 155 | cfg.Badge.Git.Repository = nonEmptyStr 156 | cfg.Badge.Git.Branch = nonEmptyStr 157 | cfg.Badge.Git.FileName = nonEmptyStr 158 | assert.NoError(t, cfg.Validate()) 159 | } 160 | 161 | func Test_ConfigFromFile(t *testing.T) { 162 | t.Parallel() 163 | 164 | if testing.Short() { 165 | return 166 | } 167 | 168 | setFileWithContent := func(name string, content []byte) { 169 | f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 170 | if err != nil { 171 | t.Errorf("could not open file: %v", err) 172 | } 173 | 174 | _, err = f.Write(content) 175 | assert.NoError(t, err) 176 | 177 | assert.NoError(t, f.Close()) 178 | } 179 | 180 | t.Run("no file", func(t *testing.T) { 181 | t.Parallel() 182 | 183 | cfg := Config{} 184 | err := ConfigFromFile(&cfg, t.TempDir()) 185 | assert.Error(t, err) 186 | assert.Equal(t, Config{}, cfg) 187 | }) 188 | 189 | t.Run("invalid file", func(t *testing.T) { 190 | t.Parallel() 191 | 192 | fileName := t.TempDir() + "file.yml" 193 | setFileWithContent(fileName, []byte("-----")) 194 | 195 | cfg := Config{} 196 | err := ConfigFromFile(&cfg, fileName) 197 | assert.Error(t, err) 198 | assert.Equal(t, Config{}, cfg) 199 | }) 200 | 201 | t.Run("ok file", func(t *testing.T) { 202 | t.Parallel() 203 | 204 | savedCfg := nonZeroConfig() 205 | data, err := yaml.Marshal(savedCfg) 206 | assert.NoError(t, err) 207 | 208 | fileName := t.TempDir() + "file.yml" 209 | setFileWithContent(fileName, data) 210 | 211 | cfg := Config{} 212 | err = ConfigFromFile(&cfg, fileName) 213 | assert.NoError(t, err) 214 | assert.Equal(t, savedCfg, cfg) 215 | }) 216 | } 217 | 218 | func TestConfigYamlParse(t *testing.T) { 219 | t.Parallel() 220 | 221 | zeroCfg := nonZeroConfig() 222 | data, err := yaml.Marshal(zeroCfg) 223 | assert.NoError(t, err) 224 | assert.YAMLEq(t, string(data), nonZeroYaml()) 225 | 226 | cfg := Config{} 227 | err = yaml.Unmarshal([]byte(nonZeroYaml()), &cfg) 228 | assert.NoError(t, err) 229 | assert.Equal(t, nonZeroConfig(), cfg) 230 | } 231 | 232 | func nonZeroConfig() Config { 233 | return Config{ 234 | Profile: "cover.out", 235 | Threshold: Threshold{100, 100, 100}, 236 | Override: []Override{{Path: "pathToFile", Threshold: 99}}, 237 | Exclude: Exclude{ 238 | Paths: []string{"path1", "path2"}, 239 | }, 240 | BreakdownFileName: "breakdown.testcoverage", 241 | Diff: Diff{ 242 | BaseBreakdownFileName: "breakdown.testcoverage", 243 | }, 244 | GithubActionOutput: true, 245 | } 246 | } 247 | 248 | func nonZeroYaml() string { 249 | return ` 250 | profile: cover.out 251 | threshold: 252 | file: 100 253 | package: 100 254 | total: 100 255 | override: 256 | - threshold: 99 257 | path: pathToFile 258 | exclude: 259 | paths: 260 | - path1 261 | - path2 262 | breakdown-file-name: 'breakdown.testcoverage' 263 | diff: 264 | base-breakdown-file-name: 'breakdown.testcoverage' 265 | github-action-output: true` 266 | } 267 | 268 | func newValidCfg() Config { 269 | return Config{Profile: "cover.out"} 270 | } 271 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/cover.go: -------------------------------------------------------------------------------- 1 | package coverage 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/build" 7 | "go/parser" 8 | "go/token" 9 | "math" 10 | "os" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | 15 | "golang.org/x/tools/cover" 16 | 17 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/logger" 18 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/path" 19 | ) 20 | 21 | const IgnoreText = "coverage-ignore" 22 | 23 | type Config struct { 24 | Profiles []string 25 | ExcludePaths []string 26 | SourceDir string 27 | } 28 | 29 | func GenerateCoverageStats(cfg Config) ([]Stats, error) { 30 | profiles, err := parseProfiles(cfg.Profiles) 31 | if err != nil { 32 | return nil, fmt.Errorf("parsing profiles: %w", err) 33 | } 34 | 35 | files, err := findFiles(profiles, cfg.SourceDir) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | fileStats := make([]Stats, 0, len(profiles)) 41 | excludeRules := compileExcludePathRules(cfg.ExcludePaths) 42 | 43 | for _, profile := range profiles { 44 | fi, ok := files[profile.FileName] 45 | if !ok { // coverage-ignore 46 | // should already be handled above, but let's check it again 47 | return nil, fmt.Errorf("could not find file [%s]", profile.FileName) 48 | } 49 | 50 | if ok := matches(excludeRules, fi.name); ok { 51 | logger.L.Debug().Str("file", fi.name).Msg("file excluded") 52 | continue // this file is excluded 53 | } 54 | 55 | s, err := coverageForFile(profile, fi) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if s.Total == 0 { 61 | // do not include files that doesn't have statements. 62 | // this can happen when everything is excluded with comment annotations, or 63 | // simply file doesn't have any statement. 64 | // 65 | // note: we are explicitly adding `continue` statement, instead of having code like this: 66 | // if s.Total != 0 { 67 | // fileStats = append(fileStats, s) 68 | // } 69 | // because with `continue` add additional statements in coverage profile which will require 70 | // to have it covered with tests. since this is interesting case, to have it covered 71 | // with tests, we have code written in this way 72 | continue 73 | } 74 | 75 | fileStats = append(fileStats, s) 76 | } 77 | 78 | return fileStats, nil 79 | } 80 | 81 | func coverageForFile(profile *cover.Profile, fi fileInfo) (Stats, error) { 82 | source, err := os.ReadFile(fi.path) 83 | if err != nil { // coverage-ignore 84 | return Stats{}, fmt.Errorf("failed reading file source [%s]: %w", fi.path, err) 85 | } 86 | 87 | funcs, blocks, err := findFuncsAndBlocks(source) 88 | if err != nil { // coverage-ignore 89 | return Stats{}, err 90 | } 91 | 92 | annotations, err := findAnnotations(source) 93 | if err != nil { // coverage-ignore 94 | return Stats{}, err 95 | } 96 | 97 | s := sumCoverage(profile, funcs, blocks, annotations) 98 | s.Name = fi.name 99 | 100 | return s, nil 101 | } 102 | 103 | type fileInfo struct { 104 | path string 105 | name string 106 | } 107 | 108 | func findFiles(profiles []*cover.Profile, rootDir string) (map[string]fileInfo, error) { 109 | result := make(map[string]fileInfo) 110 | findFile := findFileCreator(rootDir) 111 | 112 | for _, profile := range profiles { 113 | path, noPrefixName, found := findFile(profile.FileName) 114 | if !found { 115 | return nil, fmt.Errorf("could not find file [%s]", profile.FileName) 116 | } 117 | 118 | result[profile.FileName] = fileInfo{ 119 | path: path, 120 | name: noPrefixName, 121 | } 122 | } 123 | 124 | return result, nil 125 | } 126 | 127 | func findFileCreator(rootDir string) func(file string) (string, string, bool) { 128 | cache := make(map[string]*build.Package) 129 | findBuildImport := func(file string) (string, string, bool) { 130 | dir, file := filepath.Split(file) 131 | pkg, exists := cache[dir] 132 | 133 | if !exists { 134 | var err error 135 | 136 | pkg, err = build.Import(dir, ".", build.FindOnly) 137 | if err != nil { 138 | return "", "", false 139 | } 140 | 141 | cache[dir] = pkg 142 | } 143 | 144 | file = filepath.Join(pkg.Dir, file) 145 | _, err := os.Stat(file) 146 | noPrefixName := stripPrefix(path.NormalizeForTool(file), path.NormalizeForTool(pkg.Root)) 147 | 148 | return file, noPrefixName, err == nil 149 | } 150 | 151 | rootDir = defaultRootDir(rootDir) 152 | prefix := findModuleDirective(rootDir) 153 | files := listAllFiles(rootDir) 154 | findFsSearch := func(file string) (string, string, bool) { 155 | noPrefixName := stripPrefix(file, prefix) 156 | fPath := findFilePathMatchingSearch(&files, noPrefixName) 157 | 158 | return path.NormalizeForOS(fPath), noPrefixName, fPath != "" 159 | } 160 | 161 | return func(fileName string) (string, string, bool) { 162 | if fileName == "" { 163 | return "", "", false 164 | } 165 | 166 | if path, name, found := findFsSearch(fileName); found { 167 | return path, name, found 168 | } 169 | 170 | // when file is not find searching file system, search will fallback to 171 | // searching using build.Import, which can be slower on some systems (windows) 172 | return findBuildImport(fileName) 173 | } 174 | } 175 | 176 | func defaultRootDir(rootDir string) string { 177 | if rootDir == "" { 178 | rootDir = "." 179 | } 180 | 181 | return rootDir 182 | } 183 | 184 | func listAllFiles(rootDir string) []fileInfo { 185 | files := make([]fileInfo, 0) 186 | 187 | makeName := func(file string) string { 188 | name, _ := strings.CutPrefix(file, rootDir) 189 | name = path.NormalizeForTool(name) 190 | 191 | return name 192 | } 193 | 194 | err := filepath.Walk(rootDir, func(file string, info os.FileInfo, err error) error { 195 | if err != nil { // coverage-ignore 196 | return err 197 | } 198 | 199 | if !info.IsDir() && 200 | strings.HasSuffix(file, ".go") && 201 | !strings.HasSuffix(file, "_test.go") { 202 | files = append(files, fileInfo{ 203 | path: file, 204 | name: makeName(file), 205 | }) 206 | } 207 | 208 | return nil 209 | }) 210 | if err != nil { // coverage-ignore 211 | logger.L.Error().Err(err).Msg("listing files (.go files search)") 212 | } 213 | 214 | return files 215 | } 216 | 217 | func findFilePathMatchingSearch(files *[]fileInfo, search string) string { 218 | // Finds file that best matches search. For example search file "foo.go" 219 | // matches files "bar/foo.go", "bar/baz/foo.go" and "foo.go", but it's the 220 | // best match with "foo.go". 221 | bestMatch := func() int { 222 | fIndex, searchPos := -1, math.MaxInt64 223 | 224 | for i, f := range *files { 225 | pos := strings.LastIndex(f.name, search) 226 | if pos == -1 { 227 | continue 228 | } 229 | 230 | if searchPos > pos { 231 | searchPos = pos 232 | fIndex = i 233 | 234 | if searchPos == 0 { // 100% match 235 | return fIndex 236 | } 237 | } 238 | } 239 | 240 | return fIndex 241 | } 242 | 243 | i := bestMatch() 244 | if i == -1 { 245 | return "" 246 | } 247 | 248 | path := (*files)[i].path 249 | *files = append((*files)[:i], (*files)[i+1:]...) 250 | 251 | return path 252 | } 253 | 254 | func findAnnotations(source []byte) ([]extent, error) { 255 | fset := token.NewFileSet() 256 | 257 | node, err := parser.ParseFile(fset, "", source, parser.ParseComments) 258 | if err != nil { 259 | return nil, fmt.Errorf("can't parse comments: %w", err) 260 | } 261 | 262 | var res []extent 263 | 264 | for _, c := range node.Comments { 265 | if strings.Contains(c.Text(), IgnoreText) { 266 | res = append(res, newExtent(fset, c)) 267 | } 268 | } 269 | 270 | return res, nil 271 | } 272 | 273 | func findFuncsAndBlocks(source []byte) ([]extent, []extent, error) { 274 | fset := token.NewFileSet() 275 | 276 | parsedFile, err := parser.ParseFile(fset, "", source, 0) 277 | if err != nil { 278 | return nil, nil, fmt.Errorf("can't parse source: %w", err) 279 | } 280 | 281 | v := &visitor{fset: fset} 282 | ast.Walk(v, parsedFile) 283 | 284 | return v.funcs, v.blocks, nil 285 | } 286 | 287 | type visitor struct { 288 | fset *token.FileSet 289 | funcs []extent 290 | blocks []extent 291 | } 292 | 293 | // Visit implements the ast.Visitor interface. 294 | func (v *visitor) Visit(node ast.Node) ast.Visitor { 295 | switch n := node.(type) { 296 | case *ast.FuncDecl: 297 | v.funcs = append(v.funcs, newExtent(v.fset, n.Body)) 298 | 299 | case *ast.IfStmt: 300 | v.addBlock(n.Body) 301 | case *ast.SwitchStmt: 302 | v.addBlock(n.Body) 303 | case *ast.TypeSwitchStmt: 304 | v.addBlock(n.Body) 305 | case *ast.SelectStmt: // coverage-ignore 306 | v.addBlock(n.Body) 307 | case *ast.ForStmt: 308 | v.addBlock(n.Body) 309 | case *ast.RangeStmt: 310 | v.addBlock(n.Body) 311 | } 312 | 313 | return v 314 | } 315 | 316 | func (v *visitor) addBlock(n ast.Node) { 317 | v.blocks = append(v.blocks, newExtent(v.fset, n)) 318 | } 319 | 320 | type extent struct { 321 | StartLine int 322 | StartCol int 323 | EndLine int 324 | EndCol int 325 | } 326 | 327 | func newExtent(fset *token.FileSet, n ast.Node) extent { 328 | start := fset.Position(n.Pos()) 329 | end := fset.Position(n.End()) 330 | 331 | return extent{ 332 | StartLine: start.Line, 333 | StartCol: start.Column, 334 | EndLine: end.Line, 335 | EndCol: end.Column, 336 | } 337 | } 338 | 339 | func findExtentWithStartLine(ee []extent, line int) (extent, bool) { 340 | for _, e := range ee { 341 | if e.StartLine <= line && e.EndLine >= line { 342 | return e, true 343 | } 344 | } 345 | 346 | return extent{}, false 347 | } 348 | 349 | func hasExtentWithStartLine(ee []extent, startLine int) bool { 350 | _, found := findExtentWithStartLine(ee, startLine) 351 | return found 352 | } 353 | 354 | func sumCoverage(profile *cover.Profile, funcs, blocks, annotations []extent) Stats { 355 | s := Stats{} 356 | 357 | for _, f := range funcs { 358 | c, t, ul := coverage(profile, f, blocks, annotations) 359 | s.Total += t 360 | s.Covered += c 361 | s.UncoveredLines = append(s.UncoveredLines, ul...) 362 | } 363 | 364 | s.UncoveredLines = dedup(s.UncoveredLines) 365 | 366 | return s 367 | } 368 | 369 | // coverage returns the fraction of the statements in the 370 | // function that were covered, as a numerator and denominator. 371 | // 372 | //nolint:cyclop,gocognit,maintidx // relax 373 | func coverage( 374 | profile *cover.Profile, 375 | f extent, 376 | blocks, annotations []extent, 377 | ) (int64, int64, []int) { 378 | if hasExtentWithStartLine(annotations, f.StartLine) { 379 | // case when entire function is ignored 380 | return 0, 0, nil 381 | } 382 | 383 | var ( 384 | covered, total int64 385 | skip extent 386 | uncoveredLines []int 387 | ) 388 | 389 | // the blocks are sorted, so we can stop counting as soon as 390 | // we reach the end of the relevant block. 391 | for _, b := range profile.Blocks { 392 | if b.StartLine > f.EndLine || (b.StartLine == f.EndLine && b.StartCol >= f.EndCol) { 393 | // past the end of the function. 394 | break 395 | } 396 | 397 | if b.EndLine < f.StartLine || (b.EndLine == f.StartLine && b.EndCol <= f.StartCol) { 398 | // before the beginning of the function 399 | continue 400 | } 401 | 402 | if b.StartLine < skip.EndLine || (b.EndLine == f.StartLine && b.StartCol <= skip.EndCol) { 403 | // this block has comment annotation 404 | continue 405 | } 406 | 407 | // add block to coverage statistics only if it was not ignored using comment annotations 408 | if hasExtentWithStartLine(annotations, b.StartLine) { 409 | if e, found := findExtentWithStartLine(blocks, b.StartLine); found { 410 | skip = e 411 | } 412 | 413 | continue 414 | } 415 | 416 | total += int64(b.NumStmt) 417 | 418 | if b.Count > 0 { 419 | covered += int64(b.NumStmt) 420 | } else { 421 | for i := range (b.EndLine - b.StartLine) + 1 { 422 | uncoveredLines = append(uncoveredLines, b.StartLine+i) 423 | } 424 | } 425 | } 426 | 427 | return covered, total, uncoveredLines 428 | } 429 | 430 | func dedup(ss []int) []int { 431 | if len(ss) <= 1 { 432 | return ss 433 | } 434 | 435 | sort.Ints(ss) 436 | result := []int{ss[0]} 437 | 438 | for i := 1; i < len(ss); i++ { 439 | if ss[i] != ss[i-1] { 440 | result = append(result, ss[i]) 441 | } 442 | } 443 | 444 | return result 445 | } 446 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/cover_test.go: -------------------------------------------------------------------------------- 1 | package coverage_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "golang.org/x/tools/cover" 9 | 10 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 11 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/path" 12 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/testdata" 13 | ) 14 | 15 | const ( 16 | testdataDir = "../testdata/" 17 | profileOK = testdataDir + testdata.ProfileOK 18 | profileOKFull = testdataDir + testdata.ProfileOKFull 19 | profileOKNoBadge = testdataDir + testdata.ProfileOKNoBadge 20 | profileOKNoStatements = testdataDir + testdata.ProfileOKNoStatements 21 | profileNOK = testdataDir + testdata.ProfileNOK 22 | profileNOKInvalidLength = testdataDir + testdata.ProfileNOKInvalidLength 23 | profileNOKInvalidData = testdataDir + testdata.ProfileNOKInvalidData 24 | 25 | prefix = "github.com/vladopajic/go-test-coverage/v2" 26 | coverFilename = "pkg/testcoverage/coverage/cover.go" 27 | 28 | sourceDir = "../../../" 29 | ) 30 | 31 | func Test_GenerateCoverageStats(t *testing.T) { 32 | t.Parallel() 33 | 34 | if testing.Short() { 35 | return 36 | } 37 | 38 | // should not be able to read directory 39 | stats, err := GenerateCoverageStats(Config{Profiles: []string{t.TempDir()}}) 40 | assert.Error(t, err) 41 | assert.Empty(t, stats) 42 | 43 | // should get error parsing invalid profile file 44 | stats, err = GenerateCoverageStats(Config{ 45 | Profiles: []string{profileNOK}, 46 | SourceDir: sourceDir, 47 | }) 48 | assert.Error(t, err) 49 | assert.Empty(t, stats) 50 | 51 | // should be okay to read valid profile 52 | stats1, err := GenerateCoverageStats(Config{ 53 | Profiles: []string{profileOK}, 54 | SourceDir: sourceDir, 55 | }) 56 | assert.NoError(t, err) 57 | assert.NotEmpty(t, stats1) 58 | 59 | // should be okay to read valid profile 60 | stats2, err := GenerateCoverageStats(Config{ 61 | Profiles: []string{profileOK}, 62 | ExcludePaths: []string{`cover\.go$`}, 63 | SourceDir: sourceDir, 64 | }) 65 | assert.NoError(t, err) 66 | assert.NotEmpty(t, stats2) 67 | // stats2 should have less total statements because cover.go should have been excluded 68 | assert.Greater(t, StatsCalcTotal(stats1).Total, StatsCalcTotal(stats2).Total) 69 | 70 | // should have total coverage because of second profile 71 | stats3, err := GenerateCoverageStats(Config{ 72 | Profiles: []string{profileOK, profileOKFull}, 73 | SourceDir: sourceDir, 74 | }) 75 | assert.NoError(t, err) 76 | assert.NotEmpty(t, stats3) 77 | assert.Equal(t, 100, StatsCalcTotal(stats3).CoveredPercentage()) 78 | 79 | // should not have `badge/generate.go` in statistics because it has no statements 80 | stats4, err := GenerateCoverageStats(Config{ 81 | Profiles: []string{profileOKNoStatements}, 82 | SourceDir: sourceDir, 83 | }) 84 | assert.NoError(t, err) 85 | assert.Len(t, stats4, 1) 86 | assert.NotContains(t, `badge/generate.go`, stats4[0].Name) 87 | } 88 | 89 | func Test_findFile(t *testing.T) { 90 | t.Parallel() 91 | 92 | if testing.Short() { 93 | return 94 | } 95 | 96 | const filename = "pkg/testcoverage/coverage/cover.go" 97 | 98 | findFile := FindFileCreator("../../../") 99 | findFileFallbackToImport := FindFileCreator("") 100 | 101 | file, noPrefixName, found := findFile(prefix + "/" + filename) 102 | assert.True(t, found) 103 | assert.Equal(t, filename, noPrefixName) 104 | assert.True(t, strings.HasSuffix(file, path.NormalizeForOS(filename))) 105 | 106 | file, noPrefixName, found = findFileFallbackToImport(prefix + "/" + filename) 107 | assert.True(t, found) 108 | assert.Equal(t, filename, noPrefixName) 109 | assert.True(t, strings.HasSuffix(file, path.NormalizeForOS(filename))) 110 | 111 | _, _, found = findFile(prefix + "/main1.go") 112 | assert.False(t, found) 113 | 114 | _, _, found = findFile("") 115 | assert.False(t, found) 116 | 117 | _, _, found = findFile(prefix) 118 | assert.False(t, found) 119 | } 120 | 121 | func Test_findAnnotations(t *testing.T) { 122 | t.Parallel() 123 | 124 | _, err := FindAnnotations(nil) 125 | assert.Error(t, err) 126 | 127 | _, err = FindAnnotations([]byte{}) 128 | assert.Error(t, err) 129 | 130 | const source = ` 131 | package foo 132 | func foo() int { // coverage-ignore 133 | a := 0 134 | for i := range 10 { // coverage-ignore 135 | a += i 136 | } 137 | return a 138 | } 139 | ` 140 | 141 | comments, err := FindAnnotations([]byte(source)) 142 | assert.NoError(t, err) 143 | assert.Equal(t, []int{3, 5}, pluckStartLine(comments)) 144 | } 145 | 146 | func Test_findFuncs(t *testing.T) { 147 | t.Parallel() 148 | 149 | _, _, err := FindFuncsAndBlocks(nil) 150 | assert.Error(t, err) 151 | 152 | _, _, err = FindFuncsAndBlocks([]byte{}) 153 | assert.Error(t, err) 154 | 155 | const source = ` 156 | package foo 157 | func foo() int { 158 | return 1 159 | } 160 | func bar() int { 161 | a := 0 162 | for range 10 { 163 | a += 1 164 | } 165 | return a 166 | } 167 | func baraba() int { 168 | a := 0 169 | for i:=0;i<10; i++ { 170 | a += 1 171 | } 172 | return a 173 | } 174 | func zab(a int) int { 175 | if a == 0 { 176 | return a + 1 177 | } else if a == 1 { 178 | return a + 2 179 | } 180 | return a 181 | } 182 | ` 183 | 184 | funcs, blocks, err := FindFuncsAndBlocks([]byte(source)) 185 | assert.NoError(t, err) 186 | assert.Equal(t, []int{3, 6, 13, 20}, pluckStartLine(funcs)) 187 | assert.Equal(t, []Extent{ 188 | {8, 16, 10, 4}, 189 | {15, 22, 17, 4}, 190 | {21, 13, 23, 4}, 191 | {23, 20, 25, 4}, 192 | }, blocks) 193 | } 194 | 195 | func Test_sumCoverage(t *testing.T) { 196 | t.Parallel() 197 | 198 | funcs := []Extent{ 199 | {StartLine: 1, EndLine: 10}, 200 | {StartLine: 12, EndLine: 20}, 201 | } 202 | profile := &cover.Profile{Blocks: []cover.ProfileBlock{ 203 | {StartLine: 1, EndLine: 2, NumStmt: 1}, 204 | {StartLine: 2, EndLine: 3, NumStmt: 1}, 205 | {StartLine: 4, EndLine: 5, NumStmt: 1}, 206 | {StartLine: 5, EndLine: 6, NumStmt: 1}, 207 | {StartLine: 6, EndLine: 10, NumStmt: 1}, 208 | {StartLine: 12, EndLine: 20, NumStmt: 5}, 209 | }} 210 | 211 | s := SumCoverage(profile, funcs, nil, nil) 212 | expected := Stats{Total: 10, Covered: 0, UncoveredLines: []int{ 213 | 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 214 | }} 215 | assert.Equal(t, expected, s) 216 | 217 | // Coverage should be empty when every function is excluded 218 | s = SumCoverage(profile, funcs, nil, funcs) 219 | assert.Equal(t, Stats{Total: 0, Covered: 0}, s) 220 | 221 | // Case when annotations is set on block (it should ignore whole block) 222 | annotations := []Extent{{StartLine: 4, EndLine: 4}} 223 | blocks := []Extent{{StartLine: 4, EndLine: 10}} 224 | s = SumCoverage(profile, funcs, blocks, annotations) 225 | expected = Stats{Total: 7, Covered: 0, UncoveredLines: []int{ 226 | 1, 2, 3, 12, 13, 14, 15, 16, 17, 18, 19, 20, 227 | }} 228 | assert.Equal(t, expected, s) 229 | } 230 | 231 | func pluckStartLine(extents []Extent) []int { 232 | res := make([]int, len(extents)) 233 | for i, e := range extents { 234 | res[i] = e.StartLine 235 | } 236 | 237 | return res 238 | } 239 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/export_test.go: -------------------------------------------------------------------------------- 1 | package coverage 2 | 3 | var ( 4 | FindFileCreator = findFileCreator 5 | FindAnnotations = findAnnotations 6 | FindFuncsAndBlocks = findFuncsAndBlocks 7 | ParseProfiles = parseProfiles 8 | SumCoverage = sumCoverage 9 | FindGoModFile = findGoModFile 10 | ) 11 | 12 | type Extent = extent 13 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/module.go: -------------------------------------------------------------------------------- 1 | package coverage 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/logger" 10 | ) 11 | 12 | func findModuleDirective(rootDir string) string { 13 | goModFile := findGoModFile(rootDir) 14 | if goModFile == "" { 15 | logger.L.Warn().Str("dir", rootDir). 16 | Msg("go.mod file not found in root directory (consider setting up source dir)") 17 | return "" 18 | } 19 | 20 | logger.L.Debug().Str("file", goModFile).Msg("go.mod file found") 21 | 22 | module := readModuleDirective(goModFile) 23 | if module == "" { // coverage-ignore 24 | logger.L.Warn().Msg("`module` directive not found") 25 | } 26 | 27 | logger.L.Debug().Str("module", module).Msg("using module directive") 28 | 29 | return module 30 | } 31 | 32 | func findGoModFile(rootDir string) string { 33 | goModFile := findGoModFromRoot(rootDir) 34 | if goModFile != "" { 35 | return goModFile 36 | } 37 | 38 | // fallback to find first go mod file wherever it may be 39 | // not really sure if we really need this ??? 40 | return findGoModWithWalk(rootDir) 41 | } 42 | 43 | func findGoModWithWalk(rootDir string) string { // coverage-ignore 44 | var goModFiles []string 45 | 46 | err := filepath.Walk(rootDir, func(file string, info os.FileInfo, err error) error { 47 | if err != nil { // coverage-ignore 48 | return err 49 | } 50 | 51 | if info.Name() == "go.mod" { 52 | goModFiles = append(goModFiles, file) 53 | } 54 | 55 | return nil 56 | }) 57 | if err != nil { 58 | logger.L.Error().Err(err).Msg("listing files (go.mod search)") 59 | } 60 | 61 | if len(goModFiles) == 0 { 62 | logger.L.Warn().Msg("go.mod file not found via walk method") 63 | return "" 64 | } 65 | 66 | if len(goModFiles) > 1 { 67 | logger.L.Warn().Msg("found multiple go.mod files via walk method") 68 | return "" 69 | } 70 | 71 | return goModFiles[0] 72 | } 73 | 74 | func findGoModFromRoot(rootDir string) string { 75 | files, err := os.ReadDir(rootDir) 76 | if err != nil { // coverage-ignore 77 | logger.L.Error().Err(err).Msg("reading directory") 78 | return "" 79 | } 80 | 81 | for _, info := range files { 82 | if info.Name() == "go.mod" { 83 | return filepath.Join(rootDir, info.Name()) 84 | } 85 | } 86 | 87 | return "" 88 | } 89 | 90 | func readModuleDirective(filename string) string { 91 | file, err := os.Open(filename) 92 | if err != nil { // coverage-ignore 93 | return "" 94 | } 95 | defer file.Close() 96 | 97 | scanner := bufio.NewScanner(file) 98 | for scanner.Scan() { 99 | line := strings.TrimSpace(scanner.Text()) 100 | if strings.HasPrefix(line, "module ") { 101 | return strings.TrimSpace(strings.TrimPrefix(line, "module ")) 102 | } 103 | } 104 | 105 | return "" // coverage-ignore 106 | } 107 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/module_test.go: -------------------------------------------------------------------------------- 1 | package coverage_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 9 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/path" 10 | ) 11 | 12 | func Test_FindGoModFile(t *testing.T) { 13 | t.Parallel() 14 | 15 | assert.Empty(t, FindGoModFile("")) 16 | assert.Equal(t, "../../../go.mod", path.NormalizeForTool(FindGoModFile("../../../"))) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/profile.go: -------------------------------------------------------------------------------- 1 | package coverage 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "golang.org/x/tools/cover" 9 | ) 10 | 11 | func parseProfiles(paths []string) ([]*cover.Profile, error) { 12 | var result []*cover.Profile 13 | 14 | for _, path := range paths { 15 | profiles, err := cover.ParseProfiles(path) 16 | if err != nil { 17 | return nil, fmt.Errorf("parsing profile file: %w", err) 18 | } 19 | 20 | if result == nil { 21 | result = profiles 22 | continue 23 | } 24 | 25 | result, err = mergeProfiles(result, profiles) 26 | if err != nil { 27 | return nil, fmt.Errorf("merging profiles: %w", err) 28 | } 29 | } 30 | 31 | slices.SortFunc(result, func(a, b *cover.Profile) int { 32 | return strings.Compare(a.FileName, b.FileName) 33 | }) 34 | 35 | return result, nil 36 | } 37 | 38 | func mergeProfiles(a, b []*cover.Profile) ([]*cover.Profile, error) { 39 | for _, pb := range b { 40 | if idx, found := findProfileForFile(a, pb.FileName); found { 41 | m, err := mergeSameFileProfile(a[idx], pb) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | a[idx] = m 47 | } else { 48 | a = append(a, pb) 49 | } 50 | } 51 | 52 | return a, nil 53 | } 54 | 55 | func findProfileForFile(profiles []*cover.Profile, file string) (int, bool) { 56 | for i, p := range profiles { 57 | if p.FileName == file { 58 | return i, true 59 | } 60 | } 61 | 62 | return -1, false 63 | } 64 | 65 | func mergeSameFileProfile(ap, bp *cover.Profile) (*cover.Profile, error) { 66 | if len(ap.Blocks) != len(bp.Blocks) { 67 | return nil, fmt.Errorf("inconsistent profiles length [%q, %q]", ap.FileName, bp.FileName) 68 | } 69 | 70 | for i := range ap.Blocks { 71 | a, b := ap.Blocks[i], bp.Blocks[i] 72 | 73 | if b.StartLine == a.StartLine && 74 | b.StartCol == a.StartCol && 75 | b.EndLine == a.EndLine && 76 | b.EndCol == a.EndCol && 77 | b.NumStmt == a.NumStmt { 78 | ap.Blocks[i].Count = max(a.Count, b.Count) 79 | } else { 80 | return nil, fmt.Errorf("inconsistent profile data [%q, %q]", ap.FileName, bp.FileName) 81 | } 82 | } 83 | 84 | return ap, nil 85 | } 86 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/profile_test.go: -------------------------------------------------------------------------------- 1 | package coverage_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 9 | ) 10 | 11 | func Test_parseProfiles(t *testing.T) { 12 | t.Parallel() 13 | 14 | if testing.Short() { 15 | return 16 | } 17 | 18 | _, err := ParseProfiles([]string{""}) 19 | assert.Error(t, err) 20 | 21 | _, err = ParseProfiles([]string{profileOK, profileNOKInvalidLength}) 22 | assert.Error(t, err) 23 | 24 | _, err = ParseProfiles([]string{profileOK, profileNOKInvalidData}) 25 | assert.Error(t, err) 26 | 27 | p1, err := ParseProfiles([]string{profileOK, profileOKFull}) 28 | assert.NoError(t, err) 29 | assert.NotEmpty(t, p1) 30 | 31 | p2, err := ParseProfiles([]string{profileOKFull}) 32 | assert.NoError(t, err) 33 | assert.Equal(t, p1, p2) 34 | 35 | p3, err := ParseProfiles([]string{profileOK}) 36 | assert.NoError(t, err) 37 | assert.NotEmpty(t, p3) 38 | 39 | p4, err := ParseProfiles([]string{profileOKNoBadge, profileOK}) 40 | assert.NoError(t, err) 41 | assert.Equal(t, p3, p4) 42 | 43 | p5, err := ParseProfiles([]string{profileOK, profileOKNoBadge}) 44 | assert.NoError(t, err) 45 | assert.Equal(t, p4, p5) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/types.go: -------------------------------------------------------------------------------- 1 | package coverage 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "math" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type Stats struct { 14 | Name string 15 | Total int64 16 | Covered int64 17 | Threshold int 18 | UncoveredLines []int 19 | } 20 | 21 | func (s Stats) UncoveredLinesCount() int { 22 | return int(s.Total - s.Covered) 23 | } 24 | 25 | func (s Stats) CoveredPercentage() int { 26 | return CoveredPercentage(s.Total, s.Covered) 27 | } 28 | 29 | func (s Stats) CoveredPercentageF() float64 { 30 | return coveredPercentageF(s.Total, s.Covered) 31 | } 32 | 33 | //nolint:mnd // relax 34 | func (s Stats) Str() string { 35 | c := s.CoveredPercentage() 36 | 37 | if c == 100 { // precision not needed 38 | return fmt.Sprintf("%d%% (%d/%d)", c, s.Covered, s.Total) 39 | } else if c < 10 { // adds space for singe digit number 40 | return fmt.Sprintf(" %.1f%% (%d/%d)", s.CoveredPercentageF(), s.Covered, s.Total) 41 | } 42 | 43 | return fmt.Sprintf("%.1f%% (%d/%d)", s.CoveredPercentageF(), s.Covered, s.Total) 44 | } 45 | 46 | func StatsSearchMap(stats []Stats) map[string]Stats { 47 | m := make(map[string]Stats) 48 | for _, s := range stats { 49 | m[s.Name] = s 50 | } 51 | 52 | return m 53 | } 54 | 55 | func CoveredPercentage(total, covered int64) int { 56 | return int(coveredPercentageF(total, covered)) 57 | } 58 | 59 | //nolint:mnd // relax 60 | func coveredPercentageF(total, covered int64) float64 { 61 | if total == 0 { 62 | return 0 63 | } 64 | 65 | if covered == total { 66 | return 100 67 | } 68 | 69 | p := float64(covered*100) / float64(total) 70 | 71 | // round to %.1f 72 | return float64(int(math.Round(p*10))) / 10 73 | } 74 | 75 | func stripPrefix(name, prefix string) string { 76 | if prefix == "" { 77 | return name 78 | } 79 | 80 | if string(prefix[len(prefix)-1]) != "/" { 81 | prefix += "/" 82 | } 83 | 84 | return strings.Replace(name, prefix, "", 1) 85 | } 86 | 87 | func matches(regexps []*regexp.Regexp, str string) bool { 88 | for _, r := range regexps { 89 | if r.MatchString(str) { 90 | return true 91 | } 92 | } 93 | 94 | return false 95 | } 96 | 97 | func compileExcludePathRules(excludePaths []string) []*regexp.Regexp { 98 | if len(excludePaths) == 0 { 99 | return nil 100 | } 101 | 102 | compiled := make([]*regexp.Regexp, len(excludePaths)) 103 | 104 | for i, pattern := range excludePaths { 105 | compiled[i] = regexp.MustCompile(pattern) 106 | } 107 | 108 | return compiled 109 | } 110 | 111 | func StatsCalcTotal(stats []Stats) Stats { 112 | total := Stats{} 113 | 114 | for _, s := range stats { 115 | total.Total += s.Total 116 | total.Covered += s.Covered 117 | } 118 | 119 | return total 120 | } 121 | 122 | func StatsPluckName(stats []Stats) []string { 123 | result := make([]string, len(stats)) 124 | 125 | for i, s := range stats { 126 | result[i] = s.Name 127 | } 128 | 129 | return result 130 | } 131 | 132 | func StatsFilterWithUncoveredLines(stats []Stats) []Stats { 133 | return filter(stats, func(s Stats) bool { 134 | return len(s.UncoveredLines) > 0 135 | }) 136 | } 137 | 138 | func StatsFilterWithCoveredLines(stats []Stats) []Stats { 139 | return filter(stats, func(s Stats) bool { 140 | return len(s.UncoveredLines) == 0 141 | }) 142 | } 143 | 144 | func filter[T any](slice []T, predicate func(T) bool) []T { 145 | var result []T 146 | 147 | for _, value := range slice { 148 | if predicate(value) { 149 | result = append(result, value) 150 | } 151 | } 152 | 153 | return result 154 | } 155 | 156 | func StatsSerialize(stats []Stats) []byte { 157 | b := bytes.Buffer{} 158 | sep, nl := []byte(";"), []byte("\n") 159 | 160 | //nolint:errcheck // relax 161 | for _, s := range stats { 162 | b.WriteString(s.Name) 163 | b.Write(sep) 164 | b.WriteString(strconv.FormatInt(s.Total, 10)) 165 | b.Write(sep) 166 | b.WriteString(strconv.FormatInt(s.Covered, 10)) 167 | b.Write(nl) 168 | } 169 | 170 | return b.Bytes() 171 | } 172 | 173 | var ErrInvalidFormat = errors.New("invalid format") 174 | 175 | func StatsDeserialize(b []byte) ([]Stats, error) { 176 | deserializeLine := func(bl []byte) (Stats, error) { 177 | fields := bytes.Split(bl, []byte(";")) 178 | if len(fields) != 3 { //nolint:mnd // relax 179 | return Stats{}, ErrInvalidFormat 180 | } 181 | 182 | t, err := strconv.ParseInt(strings.TrimSpace(string(fields[1])), 10, 64) 183 | if err != nil { 184 | return Stats{}, ErrInvalidFormat 185 | } 186 | 187 | c, err := strconv.ParseInt(strings.TrimSpace(string(fields[2])), 10, 64) 188 | if err != nil { 189 | return Stats{}, ErrInvalidFormat 190 | } 191 | 192 | return Stats{ 193 | Name: strings.TrimSpace(string(fields[0])), 194 | Total: t, 195 | Covered: c, 196 | }, nil 197 | } 198 | 199 | lines := bytes.Split(b, []byte("\n")) 200 | result := make([]Stats, 0, len(lines)) 201 | 202 | for _, l := range lines { 203 | if len(l) == 0 { 204 | continue 205 | } 206 | 207 | s, err := deserializeLine(l) 208 | if err != nil { 209 | return nil, err 210 | } 211 | 212 | result = append(result, s) 213 | } 214 | 215 | return result, nil 216 | } 217 | -------------------------------------------------------------------------------- /pkg/testcoverage/coverage/types_test.go: -------------------------------------------------------------------------------- 1 | package coverage_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 9 | ) 10 | 11 | func TestCoveredPercentage(t *testing.T) { 12 | t.Parallel() 13 | 14 | tests := []struct { 15 | percentage int 16 | total int64 17 | covered int64 18 | }{ 19 | {percentage: 0, total: 0, covered: 0}, 20 | {percentage: 0, total: 0, covered: 1}, 21 | {percentage: 100, total: 1, covered: 1}, 22 | {percentage: 10, total: 10, covered: 1}, 23 | {percentage: 22, total: 9, covered: 2}, // 22.222.. should round down to 22 24 | {percentage: 66, total: 9, covered: 6}, // 66.666.. should round down to 66 25 | {percentage: 73, total: 274, covered: 200}, 26 | } 27 | 28 | for _, tc := range tests { 29 | assert.Equal(t, tc.percentage, CoveredPercentage(tc.total, tc.covered)) 30 | } 31 | } 32 | 33 | func TestStatStr(t *testing.T) { 34 | t.Parallel() 35 | 36 | assert.Equal(t, " 0.0% (0/0)", Stats{}.Str()) 37 | assert.Equal(t, " 9.1% (1/11)", Stats{Covered: 1, Total: 11}.Str()) 38 | assert.Equal(t, "22.2% (2/9)", Stats{Covered: 2, Total: 9}.Str()) 39 | assert.Equal(t, "73.0% (200/274)", Stats{Covered: 200, Total: 274}.Str()) 40 | assert.Equal(t, "100% (10/10)", Stats{Covered: 10, Total: 10}.Str()) 41 | } 42 | 43 | func TestStatsSerialization(t *testing.T) { 44 | t.Parallel() 45 | 46 | stats := []Stats{ 47 | {Name: "foo", Total: 11, Covered: 1}, 48 | {Name: "bar", Total: 9, Covered: 2}, 49 | } 50 | 51 | b := StatsSerialize(stats) 52 | assert.Equal(t, "foo;11;1\nbar;9;2\n", string(b)) 53 | 54 | ds, err := StatsDeserialize(b) 55 | assert.NoError(t, err) 56 | assert.Equal(t, stats, ds) 57 | 58 | // ignore empty lines 59 | ds, err = StatsDeserialize([]byte("\n\n\n\n")) 60 | assert.NoError(t, err) 61 | assert.Empty(t, ds) 62 | 63 | // invalid formats 64 | _, err = StatsDeserialize([]byte("foo;11;")) 65 | assert.Error(t, err) 66 | 67 | _, err = StatsDeserialize([]byte("foo;;11")) 68 | assert.Error(t, err) 69 | 70 | _, err = StatsDeserialize([]byte("foo;")) 71 | assert.Error(t, err) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/testcoverage/export_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | const ( 4 | GaOutputFileEnv = gaOutputFileEnv 5 | GaOutputTotalCoverage = gaOutputTotalCoverage 6 | GaOutputBadgeColor = gaOutputBadgeColor 7 | GaOutputBadgeText = gaOutputBadgeText 8 | GaOutputReport = gaOutputReport 9 | ) 10 | 11 | var ( 12 | MakePackageStats = makePackageStats 13 | PackageForFile = packageForFile 14 | StoreBadge = storeBadge 15 | GenerateAndSaveBadge = generateAndSaveBadge 16 | SetOutputValue = setOutputValue 17 | LoadBaseCoverageBreakdown = loadBaseCoverageBreakdown 18 | CompressUncoveredLines = compressUncoveredLines 19 | ReportUncoveredLines = reportUncoveredLines 20 | ) 21 | 22 | type ( 23 | StorerFactories = storerFactories 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/testcoverage/helpers_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage_test 2 | 3 | import ( 4 | crand "crypto/rand" 5 | "encoding/hex" 6 | "math/rand" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 14 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 15 | ) 16 | 17 | func mergeStats(a, b []coverage.Stats) []coverage.Stats { 18 | r := make([]coverage.Stats, 0, len(a)+len(b)) 19 | r = append(r, a...) 20 | r = append(r, b...) 21 | 22 | return r 23 | } 24 | 25 | func randStats(localPrefix string, minc, maxc int) []coverage.Stats { 26 | const count = 100 27 | 28 | coverageGen := makeCoverageGenFn(minc, maxc) 29 | result := make([]coverage.Stats, 0, count) 30 | 31 | for { 32 | pkg := randPackageName(localPrefix) 33 | 34 | for range rand.Int31n(10) { 35 | total, covered := coverageGen() 36 | stat := coverage.Stats{ 37 | Name: randFileName(pkg), 38 | Covered: covered, 39 | Total: total, 40 | // should have at least 1 uncovered line if has file has uncovered lines 41 | UncoveredLines: make([]int, min(1, total-covered)), 42 | } 43 | result = append(result, stat) 44 | 45 | if len(result) == count { 46 | return result 47 | } 48 | } 49 | } 50 | } 51 | 52 | func makeCoverageGenFn(minc, maxc int) func() (total, covered int64) { 53 | return func() (int64, int64) { 54 | tc := rand.Intn(maxc-minc+1) + minc 55 | if tc == 0 { 56 | return 0, 0 57 | } 58 | 59 | for { 60 | covered := int64(rand.Intn(200)) 61 | total := int64(float64(100*covered) / float64(tc)) 62 | 63 | cp := coverage.CoveredPercentage(total, covered) 64 | if cp >= minc && cp <= maxc { 65 | return total, covered 66 | } 67 | } 68 | } 69 | } 70 | 71 | func randPackageName(localPrefix string) string { 72 | if localPrefix != "" { 73 | localPrefix += "/" 74 | } 75 | 76 | return localPrefix + randName() 77 | } 78 | 79 | func randFileName(pkg string) string { 80 | return pkg + "/" + randName() + ".go" 81 | } 82 | 83 | func randName() string { 84 | buf := make([]byte, 10) 85 | 86 | _, err := crand.Read(buf) 87 | if err != nil { 88 | panic(err) //nolint:forbidigo // okay here because it is only used for tests 89 | } 90 | 91 | return hex.EncodeToString(buf) 92 | } 93 | 94 | func assertHumanReport(t *testing.T, content string, passCount, failCount int) { 95 | t.Helper() 96 | 97 | assert.Equal(t, passCount, strings.Count(content, "PASS")) 98 | assert.Equal(t, failCount, strings.Count(content, "FAIL")) 99 | } 100 | 101 | func assertNoFileNames(t *testing.T, content, prefix string) { 102 | t.Helper() 103 | 104 | assert.Equal(t, 0, strings.Count(content, prefix)) 105 | } 106 | 107 | func assertContainStats(t *testing.T, content string, stats []coverage.Stats) { 108 | t.Helper() 109 | 110 | contains := 0 111 | 112 | for _, stat := range stats { 113 | if strings.Count(content, stat.Name) == 1 { 114 | contains++ 115 | } 116 | } 117 | 118 | if contains != len(stats) { 119 | t.Errorf("content doesn't contain exactly one stats: got %d, want %d", contains, len(stats)) 120 | } 121 | } 122 | 123 | func assertNotContainStats(t *testing.T, content string, stats []coverage.Stats) { 124 | t.Helper() 125 | 126 | contains := 0 127 | 128 | for _, stat := range stats { 129 | if strings.Count(content, stat.Name) >= 0 { 130 | contains++ 131 | } 132 | } 133 | 134 | if contains != len(stats) { 135 | t.Errorf("content should not contain stats: got %d", contains) 136 | } 137 | } 138 | 139 | //nolint:nonamedreturns // relax 140 | func splitReport(t *testing.T, content string) (head, uncovered string) { 141 | t.Helper() 142 | 143 | index := strings.Index(content, "Files with uncovered lines") 144 | if index == -1 { 145 | return content, "" 146 | } 147 | 148 | head = content[:index] 149 | 150 | content = content[index:] 151 | 152 | // section ends at the end of output or two \n 153 | index = strings.Index(content, "\n\n") 154 | if index == -1 { 155 | index = len(content) 156 | } 157 | 158 | uncovered = content[:index] 159 | 160 | return 161 | } 162 | 163 | func assertHasUncoveredLinesInfo(t *testing.T, content string, lines []string) { 164 | t.Helper() 165 | 166 | _, uncoveredReport := splitReport(t, content) 167 | assert.NotEmpty(t, uncoveredReport) 168 | 169 | for _, l := range lines { 170 | assert.Contains(t, uncoveredReport, l, "must contain file %v with uncovered lines", l) 171 | } 172 | } 173 | 174 | func assertHasUncoveredLinesInfoWithout(t *testing.T, content string, lines []string) { 175 | t.Helper() 176 | 177 | _, uncoveredReport := splitReport(t, content) 178 | assert.NotEmpty(t, uncoveredReport) 179 | 180 | for _, l := range lines { 181 | assert.NotContains(t, uncoveredReport, l, "must not contain file %v with uncovered lines", l) 182 | } 183 | } 184 | 185 | func assertNoUncoveredLinesInfo(t *testing.T, content string) { 186 | t.Helper() 187 | 188 | _, uncoveredReport := splitReport(t, content) 189 | assert.Empty(t, uncoveredReport) 190 | } 191 | 192 | func assertGithubActionErrorsCount(t *testing.T, content string, count int) { 193 | t.Helper() 194 | 195 | assert.Equal(t, count, strings.Count(content, "::error")) 196 | } 197 | 198 | func assertPrefix(t *testing.T, result AnalyzeResult, prefix string, has bool) { 199 | t.Helper() 200 | 201 | checkPrefix := func(stats []coverage.Stats) { 202 | for _, stat := range stats { 203 | assert.Equal(t, has, strings.Contains(stat.Name, prefix)) 204 | } 205 | } 206 | 207 | checkPrefix(result.FilesBelowThreshold) 208 | checkPrefix(result.PackagesBelowThreshold) 209 | } 210 | 211 | func assertGithubOutputValues(t *testing.T, file string) { 212 | t.Helper() 213 | 214 | assertNonEmptyValue := func(t *testing.T, content, name string) { 215 | t.Helper() 216 | 217 | i := strings.Index(content, name+"") 218 | if i == -1 { 219 | t.Errorf("value [%s] not found", name) 220 | return 221 | } 222 | 223 | content = content[i+len(name)+1:] 224 | 225 | j := strings.Index(content, "\n") 226 | if j == -1 { 227 | t.Errorf("value [%s] should end with new line", name) 228 | return 229 | } 230 | 231 | assert.NotEmpty(t, content[:j]) 232 | } 233 | 234 | contentBytes, err := os.ReadFile(file) 235 | assert.NoError(t, err) 236 | 237 | content := string(contentBytes) 238 | 239 | // There should be exactly 4 variables 240 | assert.Equal(t, 4, strings.Count(content, "=")) 241 | 242 | // Variables should have non empty values 243 | assertNonEmptyValue(t, content, GaOutputTotalCoverage) 244 | assertNonEmptyValue(t, content, GaOutputBadgeColor) 245 | assertNonEmptyValue(t, content, GaOutputBadgeText) 246 | assertNonEmptyValue(t, content, GaOutputReport) 247 | } 248 | -------------------------------------------------------------------------------- /pkg/testcoverage/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/rs/zerolog" 7 | ) 8 | 9 | //nolint:gochecknoglobals // relax 10 | var ( 11 | buffer bytes.Buffer 12 | L zerolog.Logger 13 | ) 14 | 15 | func Init() { // coverage-ignore 16 | L = zerolog.New(&buffer).With().Logger() 17 | } 18 | 19 | func Destruct() { 20 | L = zerolog.Logger{} 21 | buffer = bytes.Buffer{} 22 | } 23 | 24 | func Bytes() []byte { 25 | return buffer.Bytes() 26 | } 27 | -------------------------------------------------------------------------------- /pkg/testcoverage/path/path.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "path/filepath" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | const separatorToReplace = string(filepath.Separator) 10 | 11 | func NormalizeForOS(path string) string { 12 | if runtime.GOOS != "windows" { 13 | return path 14 | } 15 | 16 | return strings.ReplaceAll(path, "/", separatorToReplace) 17 | } 18 | 19 | func NormalizeForTool(path string) string { 20 | if runtime.GOOS != "windows" { 21 | return path 22 | } 23 | 24 | return strings.ReplaceAll(path, separatorToReplace, "/") 25 | } 26 | -------------------------------------------------------------------------------- /pkg/testcoverage/path/path_test.go: -------------------------------------------------------------------------------- 1 | package path_test 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | 7 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/path" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func Test_NormalizeForOS(t *testing.T) { 13 | t.Parallel() 14 | 15 | if runtime.GOOS == "windows" { 16 | assert.Equal(t, "foo\\bar", NormalizeForOS("foo/bar")) 17 | } else { 18 | assert.Equal(t, "foo/bar", NormalizeForOS("foo/bar")) 19 | } 20 | } 21 | 22 | func Test_NormalizeForTool(t *testing.T) { 23 | t.Parallel() 24 | 25 | if runtime.GOOS == "windows" { 26 | assert.Equal(t, "foo/bar", NormalizeForTool("foo\\bar")) 27 | } else { 28 | assert.Equal(t, "foo/bar", NormalizeForTool("foo/bar")) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pkg/testcoverage/report.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strconv" 11 | "text/tabwriter" 12 | 13 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge" 14 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 15 | ) 16 | 17 | func ReportForHuman(w io.Writer, result AnalyzeResult) { 18 | out := bufio.NewWriter(w) 19 | defer out.Flush() 20 | 21 | reportCoverage(out, result) 22 | reportUncoveredLines(out, result) 23 | reportDiff(out, result) 24 | } 25 | 26 | func reportCoverage(w io.Writer, result AnalyzeResult) { 27 | tabber := tabwriter.NewWriter(w, 1, 8, 2, '\t', 0) //nolint:mnd // relax 28 | defer tabber.Flush() 29 | 30 | statusStr := func(passing bool) string { 31 | if passing { 32 | return "PASS" 33 | } 34 | 35 | return "FAIL" 36 | } 37 | 38 | thr := result.Threshold 39 | 40 | if thr.File > 0 || result.HasFileOverrides { // File threshold report 41 | fmt.Fprintf(tabber, "File coverage threshold (%d%%) satisfied:\t", thr.File) 42 | fmt.Fprint(tabber, statusStr(len(result.FilesBelowThreshold) == 0)) 43 | reportIssuesForHuman(tabber, result.FilesBelowThreshold) 44 | fmt.Fprint(tabber, "\n") 45 | } 46 | 47 | if thr.Package > 0 || result.HasPackageOverrides { // Package threshold report 48 | fmt.Fprintf(tabber, "Package coverage threshold (%d%%) satisfied:\t", thr.Package) 49 | fmt.Fprint(tabber, statusStr(len(result.PackagesBelowThreshold) == 0)) 50 | reportIssuesForHuman(tabber, result.PackagesBelowThreshold) 51 | fmt.Fprint(tabber, "\n") 52 | } 53 | 54 | if thr.Total > 0 { // Total threshold report 55 | fmt.Fprintf(tabber, "Total coverage threshold (%d%%) satisfied:\t", thr.Total) 56 | fmt.Fprint(tabber, statusStr(result.MeetsTotalCoverage())) 57 | fmt.Fprint(tabber, "\n") 58 | } 59 | 60 | fmt.Fprintf(tabber, "Total test coverage: %s\n", result.TotalStats.Str()) 61 | } 62 | 63 | func reportIssuesForHuman(w io.Writer, coverageStats []coverage.Stats) { 64 | if len(coverageStats) == 0 { 65 | return 66 | } 67 | 68 | fmt.Fprintf(w, "\n below threshold:\tcoverage:\tthreshold:") 69 | 70 | for _, stats := range coverageStats { 71 | fmt.Fprintf(w, "\n %s\t%s\t%d%%", stats.Name, stats.Str(), stats.Threshold) 72 | } 73 | 74 | fmt.Fprintf(w, "\n") 75 | } 76 | 77 | func reportUncoveredLines(w io.Writer, result AnalyzeResult) { 78 | if result.Pass() || len(result.FilesWithUncoveredLines) == 0 { 79 | return 80 | } 81 | 82 | tabber := tabwriter.NewWriter(w, 1, 8, 2, '\t', 0) //nolint:mnd // relax 83 | defer tabber.Flush() 84 | 85 | fmt.Fprintf(tabber, "\nFiles with uncovered lines:") 86 | fmt.Fprintf(tabber, "\n file:\tuncovered lines:") 87 | 88 | for _, stats := range result.FilesWithUncoveredLines { 89 | if len(stats.UncoveredLines) > 0 { 90 | fmt.Fprintf(tabber, "\n %s\t", stats.Name) 91 | compressUncoveredLines(tabber, stats.UncoveredLines) 92 | } 93 | } 94 | 95 | fmt.Fprintf(tabber, "\n") 96 | } 97 | 98 | func reportDiff(w io.Writer, result AnalyzeResult) { 99 | if !result.HasBaseBreakdown { 100 | return 101 | } 102 | 103 | tabber := tabwriter.NewWriter(w, 1, 8, 2, '\t', 0) //nolint:mnd // relax 104 | defer tabber.Flush() 105 | 106 | if len(result.Diff) == 0 { 107 | fmt.Fprintf(tabber, "\nCurrent tests coverage has not changed.\n") 108 | return 109 | } 110 | 111 | td := TotalLinesDiff(result.Diff) 112 | fmt.Fprintf(tabber, "\nCurrent tests coverage has changed with %d lines missing coverage.", td) 113 | fmt.Fprintf(tabber, "\n file:\tuncovered:\tcurrent coverage:\tbase coverage:") 114 | 115 | for _, d := range result.Diff { 116 | var baseStr string 117 | if d.Base == nil { 118 | baseStr = " / " 119 | } else { 120 | baseStr = d.Base.Str() 121 | } 122 | 123 | dp := d.Current.UncoveredLinesCount() 124 | fmt.Fprintf(tabber, "\n %s\t%3d\t%s\t%s", d.Current.Name, dp, d.Current.Str(), baseStr) 125 | } 126 | 127 | fmt.Fprintf(tabber, "\n") 128 | } 129 | 130 | func ReportForGithubAction(w io.Writer, result AnalyzeResult) { 131 | out := bufio.NewWriter(w) 132 | defer out.Flush() 133 | 134 | reportLineError := func(file, title, msg string) { 135 | fmt.Fprintf(out, "::error file=%s,title=%s,line=1::%s\n", file, title, msg) 136 | } 137 | reportError := func(title, msg string) { 138 | fmt.Fprintf(out, "::error title=%s::%s\n", title, msg) 139 | } 140 | 141 | for _, stats := range result.FilesBelowThreshold { 142 | title := "File test coverage below threshold" 143 | msg := fmt.Sprintf( 144 | "%s: coverage: %s; threshold: %d%%", 145 | title, stats.Str(), stats.Threshold, 146 | ) 147 | reportLineError(stats.Name, title, msg) 148 | } 149 | 150 | for _, stats := range result.PackagesBelowThreshold { 151 | title := "Package test coverage below threshold" 152 | msg := fmt.Sprintf( 153 | "%s: package: %s; coverage: %s; threshold: %d%%", 154 | title, stats.Name, stats.Str(), stats.Threshold, 155 | ) 156 | reportError(title, msg) 157 | } 158 | 159 | if !result.MeetsTotalCoverage() { 160 | title := "Total test coverage below threshold" 161 | msg := fmt.Sprintf( 162 | "%s: coverage: %s; threshold: %d%%", 163 | title, result.TotalStats.Str(), result.Threshold.Total, 164 | ) 165 | reportError(title, msg) 166 | } 167 | } 168 | 169 | func reportGHWarning(out io.Writer, title, msg string) { // coverage-ignore 170 | fmt.Fprintf(out, "::warning title=%s::%s\n", title, msg) 171 | } 172 | 173 | const ( 174 | gaOutputFileEnv = "GITHUB_OUTPUT" 175 | gaOutputTotalCoverage = "total-coverage" 176 | gaOutputBadgeColor = "badge-color" 177 | gaOutputBadgeText = "badge-text" 178 | gaOutputReport = "report" 179 | ) 180 | 181 | func SetGithubActionOutput(result AnalyzeResult, report string) error { 182 | file, err := openGitHubOutput(os.Getenv(gaOutputFileEnv)) 183 | if err != nil { 184 | return fmt.Errorf("could not open GitHub output file: %w", err) 185 | } 186 | 187 | totalStr := strconv.Itoa(result.TotalStats.CoveredPercentage()) 188 | 189 | return errors.Join( 190 | setOutputValue(file, gaOutputTotalCoverage, totalStr), 191 | setOutputValue(file, gaOutputBadgeColor, badge.Color(result.TotalStats.CoveredPercentage())), 192 | setOutputValue(file, gaOutputBadgeText, totalStr+"%"), 193 | setOutputValue(file, gaOutputReport, multiline(report)), 194 | file.Close(), 195 | ) 196 | } 197 | 198 | func openGitHubOutput(p string) (io.WriteCloser, error) { 199 | //nolint:mnd,wrapcheck // error is wrapped at level above 200 | return os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) 201 | } 202 | 203 | func setOutputValue(w io.Writer, name, value string) error { 204 | data := []byte(fmt.Sprintf("%s=%s\n", name, value)) 205 | 206 | _, err := w.Write(data) 207 | if err != nil { 208 | return fmt.Errorf("set output for [%s]: %w", name, err) 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func multiline(s string) string { 215 | resp, _ := json.Marshal(s) //nolint:errcheck,errchkjson // relax 216 | return string(resp) 217 | } 218 | 219 | func compressUncoveredLines(w io.Writer, ull []int) { 220 | separator := "" 221 | printRange := func(a, b int) { 222 | if a == b { 223 | fmt.Fprintf(w, "%v%v", separator, a) 224 | } else { 225 | fmt.Fprintf(w, "%v%v-%v", separator, a, b) 226 | } 227 | 228 | separator = " " 229 | } 230 | 231 | last := -1 232 | for i := range ull { 233 | if last == -1 { 234 | last = ull[i] 235 | } else if ull[i-1]+1 != ull[i] { 236 | printRange(last, ull[i-1]) 237 | last = ull[i] 238 | } 239 | } 240 | 241 | if last != -1 { 242 | printRange(last, ull[len(ull)-1]) 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /pkg/testcoverage/report_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 13 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 14 | ) 15 | 16 | func Test_ReportForHuman(t *testing.T) { 17 | t.Parallel() 18 | 19 | const prefix = "organization.org" 20 | 21 | thr := Threshold{100, 100, 100} 22 | 23 | t.Run("all - pass", func(t *testing.T) { 24 | t.Parallel() 25 | 26 | buf := &bytes.Buffer{} 27 | ReportForHuman(buf, AnalyzeResult{Threshold: thr, TotalStats: coverage.Stats{}}) 28 | assertHumanReport(t, buf.String(), 3, 0) 29 | assertNoUncoveredLinesInfo(t, buf.String()) 30 | }) 31 | 32 | t.Run("total coverage - fail", func(t *testing.T) { 33 | t.Parallel() 34 | 35 | buf := &bytes.Buffer{} 36 | ReportForHuman(buf, AnalyzeResult{Threshold: thr, TotalStats: coverage.Stats{Total: 1}}) 37 | assertHumanReport(t, buf.String(), 2, 1) 38 | assertNoUncoveredLinesInfo(t, buf.String()) 39 | }) 40 | 41 | t.Run("file coverage - fail", func(t *testing.T) { 42 | t.Parallel() 43 | 44 | buf := &bytes.Buffer{} 45 | cfg := Config{Threshold: Threshold{File: 10}} 46 | statsWithError := randStats(prefix, 0, 9) 47 | statsNoError := randStats(prefix, 10, 100) 48 | allStats := mergeStats(statsWithError, statsNoError) 49 | result := Analyze(cfg, allStats, nil) 50 | ReportForHuman(buf, result) 51 | headReport, uncoveredReport := splitReport(t, buf.String()) 52 | assertHumanReport(t, headReport, 0, 1) 53 | assertContainStats(t, headReport, statsWithError) 54 | assertNotContainStats(t, headReport, statsNoError) 55 | assertHasUncoveredLinesInfo(t, uncoveredReport, 56 | coverage.StatsPluckName(coverage.StatsFilterWithUncoveredLines(allStats)), 57 | ) 58 | assertHasUncoveredLinesInfoWithout(t, uncoveredReport, 59 | coverage.StatsPluckName(coverage.StatsFilterWithCoveredLines(allStats)), 60 | ) 61 | }) 62 | 63 | t.Run("package coverage - fail", func(t *testing.T) { 64 | t.Parallel() 65 | 66 | buf := &bytes.Buffer{} 67 | cfg := Config{Threshold: Threshold{Package: 10}} 68 | statsWithError := randStats(prefix, 0, 9) 69 | statsNoError := randStats(prefix, 10, 100) 70 | allStats := mergeStats(statsWithError, statsNoError) 71 | result := Analyze(cfg, allStats, nil) 72 | ReportForHuman(buf, result) 73 | headReport, uncoveredReport := splitReport(t, buf.String()) 74 | assertHumanReport(t, headReport, 0, 1) 75 | assertContainStats(t, headReport, MakePackageStats(statsWithError)) 76 | assertNotContainStats(t, headReport, MakePackageStats(statsNoError)) 77 | assertNotContainStats(t, headReport, statsWithError) 78 | assertNotContainStats(t, headReport, statsNoError) 79 | assertHasUncoveredLinesInfo(t, uncoveredReport, 80 | coverage.StatsPluckName(coverage.StatsFilterWithUncoveredLines(allStats)), 81 | ) 82 | assertHasUncoveredLinesInfoWithout(t, uncoveredReport, 83 | coverage.StatsPluckName(coverage.StatsFilterWithCoveredLines(allStats)), 84 | ) 85 | }) 86 | 87 | t.Run("diff - no change", func(t *testing.T) { 88 | t.Parallel() 89 | 90 | stats := randStats(prefix, 10, 100) 91 | 92 | buf := &bytes.Buffer{} 93 | cfg := Config{} 94 | result := Analyze(cfg, stats, stats) 95 | ReportForHuman(buf, result) 96 | 97 | assert.Contains(t, buf.String(), "Current tests coverage has not changed") 98 | }) 99 | 100 | t.Run("diff - has change", func(t *testing.T) { 101 | t.Parallel() 102 | 103 | stats := randStats(prefix, 10, 100) 104 | base := mergeStats(make([]coverage.Stats, 0), stats) 105 | 106 | stats = append(stats, coverage.Stats{Name: "foo", Total: 9, Covered: 8}) 107 | stats = append(stats, coverage.Stats{Name: "foo-new", Total: 9, Covered: 8}) 108 | 109 | base = append(base, coverage.Stats{Name: "foo", Total: 10, Covered: 10}) 110 | 111 | buf := &bytes.Buffer{} 112 | cfg := Config{} 113 | result := Analyze(cfg, stats, base) 114 | ReportForHuman(buf, result) 115 | 116 | assert.Contains(t, buf.String(), 117 | "Current tests coverage has changed with 2 lines missing coverage", 118 | ) 119 | }) 120 | } 121 | 122 | func Test_ReportForGithubAction(t *testing.T) { 123 | t.Parallel() 124 | 125 | prefix := "organization.org/pkg/" 126 | 127 | t.Run("total coverage - pass", func(t *testing.T) { 128 | t.Parallel() 129 | 130 | buf := &bytes.Buffer{} 131 | cfg := Config{Threshold: Threshold{Total: 100}} 132 | statsNoError := randStats(prefix, 100, 100) 133 | result := Analyze(cfg, statsNoError, nil) 134 | ReportForGithubAction(buf, result) 135 | assertGithubActionErrorsCount(t, buf.String(), 0) 136 | assertNotContainStats(t, buf.String(), statsNoError) 137 | }) 138 | 139 | t.Run("total coverage - fail", func(t *testing.T) { 140 | t.Parallel() 141 | 142 | buf := &bytes.Buffer{} 143 | statsWithError := randStats(prefix, 0, 9) 144 | statsNoError := randStats(prefix, 10, 100) 145 | cfg := Config{Threshold: Threshold{Total: 10}} 146 | result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) 147 | ReportForGithubAction(buf, result) 148 | assertGithubActionErrorsCount(t, buf.String(), 1) 149 | assertNotContainStats(t, buf.String(), statsWithError) 150 | assertNotContainStats(t, buf.String(), statsNoError) 151 | }) 152 | 153 | t.Run("file coverage - pass", func(t *testing.T) { 154 | t.Parallel() 155 | 156 | buf := &bytes.Buffer{} 157 | cfg := Config{Threshold: Threshold{File: 10}} 158 | statsNoError := randStats(prefix, 10, 100) 159 | result := Analyze(cfg, statsNoError, nil) 160 | ReportForGithubAction(buf, result) 161 | assertGithubActionErrorsCount(t, buf.String(), 0) 162 | assertNotContainStats(t, buf.String(), statsNoError) 163 | }) 164 | 165 | t.Run("file coverage - fail", func(t *testing.T) { 166 | t.Parallel() 167 | 168 | buf := &bytes.Buffer{} 169 | cfg := Config{Threshold: Threshold{File: 10}} 170 | statsWithError := randStats(prefix, 0, 9) 171 | statsNoError := randStats(prefix, 10, 100) 172 | result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) 173 | ReportForGithubAction(buf, result) 174 | assertGithubActionErrorsCount(t, buf.String(), len(statsWithError)) 175 | assertContainStats(t, buf.String(), statsWithError) 176 | assertNotContainStats(t, buf.String(), statsNoError) 177 | }) 178 | 179 | t.Run("package coverage - pass", func(t *testing.T) { 180 | t.Parallel() 181 | 182 | buf := &bytes.Buffer{} 183 | cfg := Config{Threshold: Threshold{Package: 10}} 184 | statsNoError := randStats(prefix, 10, 100) 185 | result := Analyze(cfg, statsNoError, nil) 186 | ReportForGithubAction(buf, result) 187 | assertGithubActionErrorsCount(t, buf.String(), 0) 188 | assertNotContainStats(t, buf.String(), MakePackageStats(statsNoError)) 189 | assertNotContainStats(t, buf.String(), statsNoError) 190 | }) 191 | 192 | t.Run("package coverage - fail", func(t *testing.T) { 193 | t.Parallel() 194 | 195 | buf := &bytes.Buffer{} 196 | cfg := Config{Threshold: Threshold{Package: 10}} 197 | statsWithError := randStats(prefix, 0, 9) 198 | statsNoError := randStats(prefix, 10, 100) 199 | result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) 200 | ReportForGithubAction(buf, result) 201 | assertGithubActionErrorsCount(t, buf.String(), len(MakePackageStats(statsWithError))) 202 | assertContainStats(t, buf.String(), MakePackageStats(statsWithError)) 203 | assertNotContainStats(t, buf.String(), MakePackageStats(statsNoError)) 204 | assertNotContainStats(t, buf.String(), statsWithError) 205 | assertNotContainStats(t, buf.String(), statsNoError) 206 | }) 207 | 208 | t.Run("file, package and total - fail", func(t *testing.T) { 209 | t.Parallel() 210 | 211 | buf := &bytes.Buffer{} 212 | cfg := Config{Threshold: Threshold{File: 10, Package: 10, Total: 100}} 213 | statsWithError := randStats(prefix, 0, 9) 214 | statsNoError := randStats(prefix, 10, 100) 215 | totalErrorsCount := len(MakePackageStats(statsWithError)) + len(statsWithError) + 1 216 | result := Analyze(cfg, mergeStats(statsWithError, statsNoError), nil) 217 | ReportForGithubAction(buf, result) 218 | assertGithubActionErrorsCount(t, buf.String(), totalErrorsCount) 219 | assertContainStats(t, buf.String(), statsWithError) 220 | assertNotContainStats(t, buf.String(), MakePackageStats(statsNoError)) 221 | assertNotContainStats(t, buf.String(), statsNoError) 222 | }) 223 | } 224 | 225 | //nolint:paralleltest // must not be parallel because it uses env 226 | func Test_SetGithubActionOutput(t *testing.T) { 227 | if testing.Short() { 228 | return 229 | } 230 | 231 | t.Run("writing value to output with error", func(t *testing.T) { 232 | err := SetOutputValue(errWriter{}, "key", "val") 233 | assert.ErrorIs(t, err, io.ErrUnexpectedEOF) 234 | assert.Contains(t, err.Error(), "key") 235 | }) 236 | 237 | t.Run("no env file", func(t *testing.T) { 238 | t.Setenv(GaOutputFileEnv, "") 239 | 240 | err := SetGithubActionOutput(AnalyzeResult{}, "") 241 | assert.Error(t, err) 242 | }) 243 | 244 | t.Run("ok", func(t *testing.T) { 245 | testFile := t.TempDir() + "/ga.output" 246 | 247 | t.Setenv(GaOutputFileEnv, testFile) 248 | 249 | err := SetGithubActionOutput(AnalyzeResult{}, "") 250 | assert.NoError(t, err) 251 | 252 | contentBytes, err := os.ReadFile(testFile) 253 | assert.NoError(t, err) 254 | 255 | content := string(contentBytes) 256 | assert.Equal(t, 1, strings.Count(content, GaOutputTotalCoverage)) 257 | assert.Equal(t, 1, strings.Count(content, GaOutputBadgeColor)) 258 | assert.Equal(t, 1, strings.Count(content, GaOutputBadgeText)) 259 | assert.Equal(t, 1, strings.Count(content, GaOutputReport)) 260 | }) 261 | } 262 | 263 | func Test_ReportUncoveredLines(t *testing.T) { 264 | t.Parallel() 265 | 266 | // when result does not pass, there should be output 267 | buf := &bytes.Buffer{} 268 | ReportUncoveredLines(buf, AnalyzeResult{ 269 | TotalStats: coverage.Stats{Total: 1, Covered: 0}, 270 | Threshold: Threshold{Total: 100}, 271 | FilesWithUncoveredLines: []coverage.Stats{ 272 | {Name: "a.go", UncoveredLines: []int{1, 2, 3}}, 273 | {Name: "b.go", UncoveredLines: []int{3, 5, 7}}, 274 | }, 275 | }) 276 | assertHasUncoveredLinesInfo(t, buf.String(), []string{ 277 | "a.go\t\t1-3\n", 278 | "b.go\t\t3 5 7\n", 279 | }) 280 | 281 | // when result passes, there should be no output 282 | buf.Reset() 283 | ReportUncoveredLines(buf, AnalyzeResult{ 284 | TotalStats: coverage.Stats{Total: 1, Covered: 1}, 285 | Threshold: Threshold{Total: 100}, 286 | FilesWithUncoveredLines: []coverage.Stats{ 287 | {Name: "a.go", UncoveredLines: []int{1, 2, 3}}, 288 | {Name: "b.go", UncoveredLines: []int{3, 5, 7}}, 289 | }, 290 | }) 291 | assert.Empty(t, buf.String()) 292 | } 293 | 294 | func TestCompressUncoveredLines(t *testing.T) { 295 | t.Parallel() 296 | 297 | buf := &bytes.Buffer{} 298 | CompressUncoveredLines(buf, []int{ 299 | 27, 28, 29, 30, 31, 32, 300 | 59, 301 | 62, 63, 64, 65, 66, 302 | 68, 69, 70, 71, 72, 303 | 75, 76, 77, 78, 79, 304 | 81, 82, 83, 84, 85, 305 | 87, 88, 306 | }) 307 | assert.Equal(t, "27-32 59 62-66 68-72 75-79 81-85 87-88", buf.String()) 308 | 309 | buf = &bytes.Buffer{} 310 | CompressUncoveredLines(buf, []int{ 311 | 79, 312 | 81, 82, 83, 84, 85, 313 | 87, 314 | }) 315 | assert.Equal(t, "79 81-85 87", buf.String()) 316 | } 317 | 318 | type errWriter struct{} 319 | 320 | func (errWriter) Write([]byte) (int, error) { 321 | return 0, io.ErrUnexpectedEOF 322 | } 323 | -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/breakdown_nok.testcoverage: -------------------------------------------------------------------------------- 1 | pkg/testcoverage/badge.go;33;33 2 | pkg/testcoverage/badge/generate.go; -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/breakdown_ok.testcoverage: -------------------------------------------------------------------------------- 1 | pkg/testcoverage/badge.go;33;33 2 | pkg/testcoverage/badge/generate.go;8;8 3 | pkg/testcoverage/badgestorer/cdn.go;14;14 4 | pkg/testcoverage/badgestorer/file.go;5;5 5 | pkg/testcoverage/badgestorer/github.go;17;11 6 | pkg/testcoverage/check.go;47;38 7 | pkg/testcoverage/config.go;51;51 8 | pkg/testcoverage/coverage/cover.go;81;81 9 | pkg/testcoverage/coverage/profile.go;34;34 10 | pkg/testcoverage/coverage/types.go;70;69 11 | pkg/testcoverage/path/path.go;6;4 12 | pkg/testcoverage/report.go;81;65 13 | pkg/testcoverage/types.go;39;33 14 | pkg/testcoverage/utils.go;10;10 15 | -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/consts.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | const ( 4 | // this is valid profile with valid data. 5 | // it is made at earlier point in time so it does not need to reflect 6 | // the most recent profile 7 | ProfileOK = "ok.profile" 8 | 9 | // this profile is synthetically made with full coverage 10 | ProfileOKFull = "ok_full.profile" 11 | 12 | // just like `ok.profile` but does not have entries for `badge/generate.go` file 13 | ProfileOKNoBadge = "ok_no_badge.profile" 14 | 15 | // this profile has no statements for file 16 | ProfileOKNoStatements = "ok_no_statements.profile" 17 | 18 | // contains profile item with invalid file 19 | ProfileNOK = "nok.profile" 20 | 21 | // contains profile items for `badge/generate.go` file, but 22 | // does not have all profile items 23 | ProfileNOKInvalidLength = "invalid_length.profile" 24 | 25 | // contains profile items for `badge/generate.go` file, but 26 | // does not have correct profile items 27 | ProfileNOKInvalidData = "invalid_data.profile" 28 | 29 | // holds valid test coverage breakdown 30 | BreakdownOK = "breakdown_ok.testcoverage" 31 | 32 | // holds invalid test coverage breakdown 33 | BreakdownNOK = "breakdown_nok.testcoverage" 34 | ) 35 | -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/invalid_data.profile: -------------------------------------------------------------------------------- 1 | mode: atomic 2 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:115.45,21.2 1 102 3 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:23.33,25.9 1 304 4 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:26.23,27.19 1 4 5 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:28.22,29.19 1 30 6 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:30.22,31.19 1 30 7 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:32.22,33.19 1 30 8 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:34.22,35.19 1 60 9 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:36.10,37.19 1 150 10 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:23.33,25.9 1 0 11 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:26.23,27.19 1 0 12 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:28.22,29.19 1 0 13 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:30.22,31.19 1 0 14 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:32.22,33.19 1 0 15 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:34.22,35.19 1 0 16 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:36.10,37.19 1 0 17 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:23.33,25.9 1 15 18 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:26.23,27.19 1 5 19 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:28.22,29.19 1 9 20 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:30.22,31.19 1 0 21 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:32.22,33.19 1 0 22 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:34.22,35.19 1 0 23 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:36.10,37.19 1 1 24 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:23.33,25.9 1 15 25 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:26.23,27.19 1 5 26 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:28.22,29.19 1 9 27 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:30.22,31.19 1 0 28 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:32.22,33.19 1 0 29 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:34.22,35.19 1 0 30 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:36.10,37.19 1 1 -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/invalid_length.profile: -------------------------------------------------------------------------------- 1 | mode: atomic 2 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:15.45,21.2 1 12 3 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:23.33,25.9 1 15 4 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge/generate.go:26.23,27.19 1 5 -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/nok.profile: -------------------------------------------------------------------------------- 1 | mode: atomic 2 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/foo_check.go:10.16,13.3 2 3 3 | -------------------------------------------------------------------------------- /pkg/testcoverage/testdata/ok_no_statements.profile: -------------------------------------------------------------------------------- 1 | mode: atomic 2 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge.go:13.77,15.16 2 0 3 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge.go:15.16,17.3 1 0 4 | github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/badge.go:19.2,24.15 3 0 -------------------------------------------------------------------------------- /pkg/testcoverage/types.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | import ( 4 | "maps" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage/coverage" 9 | ) 10 | 11 | type AnalyzeResult struct { 12 | Threshold Threshold 13 | FilesBelowThreshold []coverage.Stats 14 | PackagesBelowThreshold []coverage.Stats 15 | FilesWithUncoveredLines []coverage.Stats 16 | TotalStats coverage.Stats 17 | HasBaseBreakdown bool 18 | Diff []FileCoverageDiff 19 | HasFileOverrides bool 20 | HasPackageOverrides bool 21 | } 22 | 23 | func (r *AnalyzeResult) Pass() bool { 24 | return r.MeetsTotalCoverage() && 25 | len(r.FilesBelowThreshold) == 0 && 26 | len(r.PackagesBelowThreshold) == 0 27 | } 28 | 29 | func (r *AnalyzeResult) MeetsTotalCoverage() bool { 30 | return r.TotalStats.Total == 0 || r.TotalStats.CoveredPercentage() >= r.Threshold.Total 31 | } 32 | 33 | func packageForFile(filename string) string { 34 | i := strings.LastIndex(filename, "/") 35 | if i == -1 { 36 | return filename 37 | } 38 | 39 | return filename[:i] 40 | } 41 | 42 | func checkCoverageStatsBelowThreshold( 43 | coverageStats []coverage.Stats, 44 | threshold int, 45 | overrideRules []regRule, 46 | ) []coverage.Stats { 47 | var belowThreshold []coverage.Stats 48 | 49 | for _, s := range coverageStats { 50 | thr := threshold 51 | if override, ok := matches(overrideRules, s.Name); ok { 52 | thr = override 53 | } 54 | 55 | if s.CoveredPercentage() < thr { 56 | s.Threshold = thr 57 | belowThreshold = append(belowThreshold, s) 58 | } 59 | } 60 | 61 | return belowThreshold 62 | } 63 | 64 | func makePackageStats(coverageStats []coverage.Stats) []coverage.Stats { 65 | packageStats := make(map[string]coverage.Stats) 66 | 67 | for _, stats := range coverageStats { 68 | pkg := packageForFile(stats.Name) 69 | 70 | var pkgStats coverage.Stats 71 | if s, ok := packageStats[pkg]; ok { 72 | pkgStats = s 73 | } else { 74 | pkgStats = coverage.Stats{Name: pkg} 75 | } 76 | 77 | pkgStats.Total += stats.Total 78 | pkgStats.Covered += stats.Covered 79 | packageStats[pkg] = pkgStats 80 | } 81 | 82 | return slices.Collect(maps.Values(packageStats)) 83 | } 84 | 85 | type FileCoverageDiff struct { 86 | Current coverage.Stats 87 | Base *coverage.Stats 88 | } 89 | 90 | func calculateStatsDiff(current, base []coverage.Stats) []FileCoverageDiff { 91 | res := make([]FileCoverageDiff, 0) 92 | baseSearchMap := coverage.StatsSearchMap(base) 93 | 94 | for _, s := range current { 95 | sul := s.UncoveredLinesCount() 96 | if sul == 0 { 97 | continue 98 | } 99 | 100 | if b, found := baseSearchMap[s.Name]; found { 101 | if sul != b.UncoveredLinesCount() { 102 | res = append(res, FileCoverageDiff{Current: s, Base: &b}) 103 | } 104 | } else { 105 | res = append(res, FileCoverageDiff{Current: s}) 106 | } 107 | } 108 | 109 | return res 110 | } 111 | 112 | func TotalLinesDiff(diff []FileCoverageDiff) int { 113 | r := 0 114 | for _, d := range diff { 115 | r += d.Current.UncoveredLinesCount() 116 | } 117 | 118 | return r 119 | } 120 | -------------------------------------------------------------------------------- /pkg/testcoverage/types_test.go: -------------------------------------------------------------------------------- 1 | package testcoverage_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | . "github.com/vladopajic/go-test-coverage/v2/pkg/testcoverage" 9 | ) 10 | 11 | func TestPackageForFile(t *testing.T) { 12 | t.Parallel() 13 | 14 | tests := []struct { 15 | file string 16 | pkg string 17 | }{ 18 | {file: "org.org/project/pkg/foo/bar.go", pkg: "org.org/project/pkg/foo"}, 19 | {file: "pkg/foo/bar.go", pkg: "pkg/foo"}, 20 | {file: "pkg/", pkg: "pkg"}, 21 | {file: "pkg", pkg: "pkg"}, 22 | } 23 | 24 | for _, tc := range tests { 25 | pkg := PackageForFile(tc.file) 26 | assert.Equal(t, tc.pkg, pkg) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/testcoverage/utils.go: -------------------------------------------------------------------------------- 1 | package testcoverage 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | type regRule struct { 8 | reg *regexp.Regexp 9 | threshold int 10 | } 11 | 12 | func matches(regexps []regRule, str string) (int, bool) { 13 | for _, r := range regexps { 14 | if r.reg.MatchString(str) { 15 | return r.threshold, true 16 | } 17 | } 18 | 19 | return 0, false 20 | } 21 | 22 | func compileOverridePathRules(cfg Config) []regRule { 23 | if len(cfg.Override) == 0 { 24 | return nil 25 | } 26 | 27 | compiled := make([]regRule, len(cfg.Override)) 28 | 29 | for i, o := range cfg.Override { 30 | compiled[i] = regRule{ 31 | reg: regexp.MustCompile(o.Path), 32 | threshold: o.Threshold, 33 | } 34 | } 35 | 36 | return compiled 37 | } 38 | --------------------------------------------------------------------------------