Reporting Lerna Monorepo Test Coverage to Codecov

Christian Emmer
Christian Emmer
Feb 20, 2023 · 3 min read
Reporting Lerna Monorepo Test Coverage to Codecov

It's not straightforward, and you likely can't use preexisting CI tasks.

As part of my migration of 15 Node.js projects to Lerna monorepo, I wanted to keep Codecov test coverage working for each package. That means I wanted to be able to report on each package separately, in the form of a badge.

Migrating Existing Repos to a Lerna Monorepo
Migrating Existing Repos to a Lerna Monorepo
Feb 20, 2023 · 9 min read

As of writing, I maintain 15 Metalsmith plugins, and it has become a pain to manage all of them independently.

Lerna setup

Each of your packages need a uniform way to output a test coverage report, such as:

// packages/*/package.json
  "scripts": {
    "test:coverage": "jest --verbose --coverage"

Then, we can make ourselves an easy-to-use alias in the root package.json:

// package.json
  "private": true,
  "scripts": {
    "test:coverage": "lerna run test:coverage"

If you have Lerna caching turned on, then make sure your script has its outputs configured:

// nx.json
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": [
  "targetDefaults": {
    "test:coverage": {
      "outputs": [

Codecov setup

Repositories in Codecov mirror those in GitHub. That means that you are only going to have one Codecov repository for your monorepo, not one per package.

"Flags " is Codecov's term for named groups of coverage reports. When uploading our coverage reports, each Lerna package will get its own "flag."

Go ahead and set up your monorepo in Codecov, and copy the unique CODECOV_TOKEN UUID.

GitHub setup

I'm going to use GitHub Actions workflow syntax to write a CI workflow, but this should work for other CI platforms as well.

Add that CODECOV_TOKEN UUID from the Codecov setup to your repository's secrets .

Then, create a new GitHub Actions workflow at .github/workflows/codecov.yml (feel free to change the filename):

# Requires repo secret: CODECOV_TOKEN ("repository upload token")

name: Codecov

      - opened
      - synchronize  # HEAD has changed, e.g. a push happened
      - reopened
      - 'main'

  # First, lint the root codecov.yml if it exists
    runs-on: ubuntu-latest
      - uses: actions/checkout@v3
      - uses: formsort/action-check-codecov-config@v1

  # Then, generate every package's coverage report, and upload them individually
      - codecov-lint
    runs-on: ubuntu-latest
      # Clone the GitHub repository
      - uses: actions/checkout@v3

      # Set up our test runner to use the LTS version of Node.js
      - uses: actions/setup-node@v3
          node-version: lts/*
          cache: 'npm'
          # Use the Lerna package's lockfiles to generate a cache key
          cache-dependency-path: 'packages/**/package-lock.json'

      # Install the dependencies of every Lerna package
      - run: npm ci

      # Generate test coverage for every Lerna package, using our alias from above
      - run: npm run test:coverage

      # Individually upload each Lerna package's test coverage
      - run: |
          curl -Os && chmod +x codecov
          for dir in packages/*; do
            ./codecov --dir "${dir}" --flags "$(basename "${dir}")" --token "${CODECOV_TOKEN}" --verbose
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

You can see this workflow in action in my metalsmith-plugins project.

Note: you want to use the --dir flag rather than --rootDir in order to retain the packages/*/ directory prefix on files, otherwise all of your different index.js may report as the same file.