> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# How to collect and publish code coverage

> Collect Bazel code coverage with aspect test --coverage and publish the merged LCOV report to Codecov from Buildkite, GitLab CI, CircleCI, or GitHub Actions.

`aspect test --coverage` collects Bazel code coverage as part of a normal test run and hands the merged LCOV report to a tool of your choice — an HTML generator locally, a service like Codecov in CI.

## How this relates to `bazel coverage`

`bazel coverage` is Bazel's own command for this: it "builds and runs the specified test targets using the specified options while collecting code coverage statistics," and it accepts everything `bazel test` accepts.

`aspect test --coverage` arrives at the same place from the `test` side. It runs `bazel test` and sets the coverage flags for you, so Bazel does the same work and writes the same merged LCOV report to the same path. You don't invoke `bazel coverage` yourself, and you don't add a second Bazel pass to get coverage out of a run that was already testing.

Going through the task rather than calling Bazel directly gets you two things:

* **It's still the test task.** BES streaming, status checks and MR signal, test-log upload, automatic retries on transient Bazel failures, and `--task-key` all keep working. A raw `bazel coverage` invocation has none of that wrapped around it.
* **The report gets handled.** `--coverage-report` and `--coverage-tool` resolve the merged report's real path and hand it off. Doing that by hand is the fiddly part, because Bazel's convenience symlinks make the same report easy to discover twice.

Together that makes `aspect test --coverage` a more natural and convenient way to get coverage than a traditional `bazel coverage` run. And because it *is* `bazel test` underneath, your normal test flags and tag filters apply unchanged.

## Collecting coverage

`--coverage` adds `--collect_code_coverage` and `--combined_report=lcov` to the Bazel invocation. Bazel merges per-test coverage into a single LCOV report at `$(bazel info output_path)/_coverage/_coverage_report.dat`.

```shell theme={null}
aspect test --coverage -- //...
```

Add `--coverage-report=PATH` to copy that report somewhere predictable for a later step, creating parent directories as needed:

```shell theme={null}
aspect test --coverage --coverage-report=coverage/lcov.dat -- //...
```

Bazel only instruments targets matching `--instrumentation_filter`. If files you expected are missing from the report, widen it with `--bazel-flag=--instrumentation_filter=^//`.

## Publishing the report

`--coverage-tool` runs a binary against the report once testing finishes. `--coverage-tool-arg` supplies its arguments — repeat the flag once per argument — and `{report}` (or `{lcov}`) is substituted with the report's absolute path. If no argument contains the placeholder, the path is appended as the last argument. Both flags need `--coverage`; on their own they do nothing.

Three behaviors are worth knowing before you put this in a pipeline:

* The tool runs **whether or not the tests passed**, so an uploader still publishes partial coverage from a failing run — without a separate always-run step to arrange.
* A **non-zero exit from the tool is a warning**, not a failure. A rejected upload leaves the build green, so watch for `--coverage-tool: ... exited with code N` on stderr rather than trusting a passing job.
* If Bazel produced **no report**, the tool is skipped with a warning. That usually means nothing matched, nothing was instrumented, or the rules in play don't support coverage.

A local HTML report needs nothing else:

```shell theme={null}
aspect test --coverage \
  --coverage-tool=genhtml \
  --coverage-tool-arg=--output-directory=coverage-html \
  -- //...
```

## Codecov

There are two routes. **Option 1** runs the Codecov binary from the test task and behaves identically on every CI platform. **Option 2** writes the report to a file and lets your platform's own Codecov integration upload it, which fits repos that already have Codecov wired up that way.

Whichever you pick, **turn Codecov's file search off**. Left on, it scans the working directory for coverage files and follows Bazel's convenience symlinks, so it reports the same merged file more than once — once under `bazel-out` and once under the workspace path that links to it — alongside the per-test coverage files Bazel left behind. Naming a report is not enough on its own: in both the action and the orb, the named files are *added* to whatever the search turns up. You need the explicit name **and** search disabled.

### Option 1: run the Codecov binary from the test task

The [Codecov CLI](https://docs.codecov.com/docs/the-codecov-cli) is a downloaded binary, so install it first:

```shell theme={null}
curl -Os https://cli.codecov.io/latest/linux/codecov   # or .../latest/macos/codecov
chmod +x codecov
```

```shell theme={null}
aspect test --coverage \
  --coverage-tool=./codecov \
  --coverage-tool-arg=upload-process \
  --coverage-tool-arg=--disable-search \
  --coverage-tool-arg=-f \
  --coverage-tool-arg={report} \
  -- //...
```

Codecov reads its upload token from the `CODECOV_TOKEN` environment variable, so set it in the job environment rather than passing it as an argument. Both work, but the CLI echoes the coverage tool's command line to the log before spawning it — a token passed as `--coverage-tool-arg=-t ...` ends up in your build output, where it's only as protected as your provider's log masking. The environment variable keeps it out entirely.

<Tabs>
  <Tab title="Buildkite">
    ```yaml title=.buildkite/pipeline.yaml theme={null}
    env:
      ASPECT_API_TOKEN: your-buildkite-secret-ref

    steps:
      - key: test
        label: ":bazel: Test"
        command: |
          curl -fsSL https://install.aspect.build | bash
          curl -Os https://cli.codecov.io/latest/linux/codecov
          chmod +x codecov
          export CODECOV_TOKEN="$(buildkite-agent secret get CODECOV_TOKEN)"
          aspect test --task-key test \
            --coverage \
            --coverage-tool=./codecov \
            --coverage-tool-arg=upload-process \
            --coverage-tool-arg=--disable-search \
            --coverage-tool-arg=-f \
            --coverage-tool-arg={report} \
            -- //...
    ```
  </Tab>

  <Tab title="GitLab CI">
    ```yaml title=.gitlab-ci.yml theme={null}
    variables:
      ASPECT_API_TOKEN: $ASPECT_API_TOKEN  # masked CI/CD variable
      CODECOV_TOKEN: $CODECOV_TOKEN        # masked CI/CD variable

    test:
      before_script:
        - curl -fsSL https://install.aspect.build | bash
        - export PATH="$HOME/.local/bin:$PATH"
      script:
        - curl -Os https://cli.codecov.io/latest/linux/codecov
        - chmod +x codecov
        - |
          aspect test --task-key test \
            --coverage \
            --coverage-tool=./codecov \
            --coverage-tool-arg=upload-process \
            --coverage-tool-arg=--disable-search \
            --coverage-tool-arg=-f \
            --coverage-tool-arg={report} \
            -- //...
    ```
  </Tab>

  <Tab title="CircleCI">
    ```yaml title=.circleci/config.yml theme={null}
    version: 2.1

    jobs:
      test:
        docker:
          - image: cimg/base:current
        # CODECOV_TOKEN comes from the `codecov` context attached in the workflow below.
        steps:
          - checkout
          - run: curl -fsSL https://install.aspect.build | bash
          - run: |
              curl -Os https://cli.codecov.io/latest/linux/codecov
              chmod +x codecov
          - run: |
              aspect test --task-key test \
                --coverage \
                --coverage-tool=./codecov \
                --coverage-tool-arg=upload-process \
                --coverage-tool-arg=--disable-search \
                --coverage-tool-arg=-f \
                --coverage-tool-arg={report} \
                -- //...

    workflows:
      ci:
        jobs:
          - test:
              context: codecov
    ```

    A [context](https://circleci.com/docs/contexts/) holding `CODECOV_TOKEN` is the usual way to expose it; a project environment variable works too.
  </Tab>

  <Tab title="GitHub Actions">
    ```yaml title=.github/workflows/aspect.yaml theme={null}
    jobs:
      test:
        runs-on: ubuntu-latest
        permissions:
          id-token: write
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
        steps:
          - uses: actions/checkout@v6
          - uses: aspect-build/setup-aspect@b1e9d142e86f63c10d603304daa22bf80492e24c # v2026.25.1
            with:
              aspect-api-token: ${{ secrets.ASPECT_API_TOKEN }}
          - run: |
              curl -Os https://cli.codecov.io/latest/linux/codecov
              chmod +x codecov
          - run: |
              aspect test --task-key test \
                --coverage \
                --coverage-tool=./codecov \
                --coverage-tool-arg=upload-process \
                --coverage-tool-arg=--disable-search \
                --coverage-tool-arg=-f \
                --coverage-tool-arg={report} \
                -- //...
    ```
  </Tab>
</Tabs>

### Option 2: use your platform's Codecov integration

Write the report to a known path with `--coverage-report`, then let the platform's own Codecov integration upload it. What that integration is — and whether one exists — varies by platform.

<Tabs>
  <Tab title="Buildkite">
    Codecov publishes no official Buildkite plugin; their Buildkite guidance is to run an uploader in a command step, which is Option 1 above. Community plugins such as [`joscha/codecov`](https://github.com/joscha/codecov-buildkite-plugin) wrap the same CLI if you'd rather express it as a plugin:

    ```yaml title=.buildkite/pipeline.yaml theme={null}
    steps:
      - key: test
        label: ":bazel: Test"
        command: aspect test --task-key test --coverage --coverage-report=coverage/lcov.dat -- //...
        plugins:
          - joscha/codecov#v4.0.2:
              args: ["upload-process", "--disable-search", "-f", "coverage/lcov.dat"]
    ```

    Codecov does not support tokenless uploads from Buildkite, so `CODECOV_TOKEN` must be in the step environment either way.
  </Tab>

  <Tab title="GitLab CI">
    Codecov ships no GitLab CI/CD component or template — their documented GitLab path is running the CLI binary, which is exactly Option 1 above.

    If you want the upload in a separate job anyway, hand the report over as an artifact and let the upload job run even when tests failed:

    ```yaml title=.gitlab-ci.yml theme={null}
    test:
      stage: CI
      script:
        - aspect test --task-key test --coverage --coverage-report=coverage/lcov.dat -- //...
      artifacts:
        when: always
        paths: [coverage/lcov.dat]

    codecov:
      stage: CI
      needs: [test]
      when: always
      script:
        - curl -Os https://cli.codecov.io/latest/linux/codecov
        - chmod +x codecov
        - ./codecov upload-process --disable-search -f coverage/lcov.dat
    ```
  </Tab>

  <Tab title="CircleCI">
    The [`codecov/codecov` orb](https://circleci.com/developer/orbs/orb/codecov/codecov) provides a `codecov/upload` command:

    ```yaml title=.circleci/config.yml theme={null}
    version: 2.1

    orbs:
      codecov: codecov/codecov@6.0.0

    jobs:
      test:
        docker:
          - image: cimg/base:current
        steps:
          - checkout
          - run: curl -fsSL https://install.aspect.build | bash
          - run: aspect test --task-key test --coverage --coverage-report=coverage/lcov.dat -- //...
          - codecov/upload:
              files: coverage/lcov.dat
              disable_search: true

    workflows:
      ci:
        jobs:
          - test:
              context: codecov
    ```

    The orb reads `CODECOV_TOKEN` from the environment by default, and its `when` parameter already defaults to `always`, so the upload runs even when the test step failed. Add `fail_on_error: true` if a rejected upload should fail the job.
  </Tab>

  <Tab title="GitHub Actions">
    [`codecov/codecov-action`](https://github.com/codecov/codecov-action) takes the report as an input:

    ```yaml title=.github/workflows/aspect.yaml theme={null}
    - run: aspect test --task-key test --coverage --coverage-report=coverage/lcov.dat -- //...

    - uses: codecov/codecov-action@v5
      if: ${{ always() }}
      with:
        files: coverage/lcov.dat
        disable_search: true
        token: ${{ secrets.CODECOV_TOKEN }}
    ```

    Unlike the CircleCI orb, the action does not run after a failed step on its own — `if: ${{ always() }}` is what keeps the upload happening when tests fail. Add `fail_ci_if_error: true` if a rejected upload should fail the job.
  </Tab>
</Tabs>

On [Aspect Workflows](/docs/aspect-workflows/overview) CI runners `aspect` and `bazel` are pre-installed — drop the launcher install from these examples and keep the Codecov download.
