# Split slow test suite CI jobs without losing the test report

> To split slow test suite CI jobs you must reassemble the results: unique artifact names, one merge job, combined coverage, one aggregate check.

Source: https://latchkey.dev/learn/speed/split-a-slow-test-suite-in-ci  
Updated: 2026-09-21

You can split slow test suite CI jobs in about four lines of YAML, and the four lines are the easy half. The half that takes an afternoon is putting the results back: four uploads that do not collide, one report instead of four, a coverage total that is actually a total, and a single check the branch rule can point at.

A suite that takes twenty minutes blocks every merge for twenty minutes, and the fix is mechanical: run quarters of it on four machines. Every test runner in common use ships a flag for that, and the split itself is rarely where people get stuck.

What breaks is everything downstream. The test report is now four reports, the coverage number is four partial numbers, one artifact name is being uploaded four times, and the required status check names a job that no longer exists as a single job. This page is that second half.

## The split, briefly

Almost every runner takes the same shape, a one-based index over a total, so a matrix leg maps onto it directly: `--shard=N/4` for vitest, jest and Playwright, `--splits 4 --group N` for pytest with a splitting plugin. Take the total from the `strategy.job-total` context rather than writing the number twice, so widening the matrix cannot leave the two out of step.

Two settings are not optional. `fail-fast: false` keeps the other shards running when one fails, which is the difference between learning that one test broke and learning that the suite is broken. And each shard needs its own artifact name, for a reason the next section is entirely about. How many shards to use, and how to keep them balanced, are covered in [test sharding across runners](/learn/speed/shard-tests-across-github-actions-runners) and [parallelizing by timing](/learn/speed/parallelize-tests-by-timing-in-ci).

## Every shard needs its own artifact name

This is the first thing that breaks and the error does not say so clearly. The upload action's own README states the rule: artifact names must be unique since each created artifact is idempotent so multiple jobs cannot modify the same artifact. It adds the matrix case explicitly, warning that uploading to the same artifact will produce conflict errors and advising a prefix or suffix from the matrix.

The message you get back is assembled at runtime and exists as a literal in no source file, which is why searching for the whole line finds nothing. The client in `@actions/artifact` builds it from a fixed prefix, the HTTP status, the status message and a `msg` field returned by GitHub's artifact service, which is closed. Recognize the first fragment, then read the tail for the reason.

```Reconstructed from the two format strings in actions/toolkit packages/artifact/src/internal/shared/artifact-twirp-client.ts; the explanatory text that follows this prefix is returned by GitHub's artifact service and is in no client source
Received non-retryable error: Failed request: (409) Conflict
```

> Two things here are read from source and one is not. The prefix and the bracketed status are the client's own format strings, and `isRetryableHttpStatusCode` lists exactly 429, 500, 502, 503 and 504, so any conflict status falls through to the immediate throw rather than to a retry. The `409` itself we did not capture from a run: it is the conflict status implied by the upload action's README, which says a matrix collision produces conflict errors. There is also an `overwrite` input, documented as deleting a matching artifact before uploading; on a matrix that is a race rather than a fix, because the shards run at the same time.

## Collect them in one job

The merge job needs every shard whether it passed or not, which means `needs` on the matrix job and `if: always()`, since a failed shard would otherwise skip the very job that explains what failed. Downloading without a `name` gets all of the artifacts from the run, one directory each.

One input decides how much shell you write afterwards. Left alone, each artifact is extracted into its own named directory; `merge-multiple: true` puts them all in the same directory instead. For four files called `junit-1.xml` through `junit-4.xml` the flat layout is what you want, and it is the difference between a glob and a `find`.

```.github/workflows/ci.yml
report:
    needs: test
    if: always()
    runs-on: latchkey-small
    steps:
      - uses: actions/download-artifact@v8
        with:
          pattern: junit-*
          path: reports
          merge-multiple: true
      - run: ls -R reports && npx junit-merge -d reports -o junit.xml
```

> Input names and behavior read from the download-artifact README at v8.0.1, published 2026-03-11. Note that when a single artifact is downloaded by name or id it is always extracted directly to the path, so `merge-multiple` changes nothing in that case and only matters once there is more than one.

## One report, one coverage number, per tool

Merging is per ecosystem and the tools are not interchangeable, so the table below is the shortest honest version. The important split is between merging test results, which is mostly an XML concatenation, and merging coverage, which is not: coverage files hold per-line counters that have to be summed, and concatenating them gives a number that is wrong in a direction that flatters you.

Playwright is the one worth copying. Its blob reporter records everything that ran along with traces and attachments, and `merge-reports` turns a directory of blobs into one ordinary HTML report, so the merged artifact is the same thing a single run would have produced. Where your runner has no equivalent, emit JUnit XML for the results and a native coverage file for the counters, and merge the two separately.

| Runner | Per-shard output | Merge the results with | Merge the coverage with |
| --- | --- | --- | --- |
| Playwright | Blob report directory | `playwright merge-reports` | Its own coverage tooling, separately |
| pytest | `--junitxml` plus `.coverage` | A JUnit merge step | `coverage combine` then `coverage report` |
| Jest or Vitest | JUnit XML plus `coverage-final.json` | A JUnit merge step | `nyc merge` then a report step |
| Go | `go test -json` output | `go tool test2json` consumers | `go tool covdata merge` |

> Playwright's blob reporter and `merge-reports` are read from its sharding guide, 2026-09-21. The other rows name the standard tool in each ecosystem rather than a specific version, because the command names have been stable far longer than any version we could usefully date.

## The required check has to be the aggregate

Before the split, the branch rule named one job. After it, that name belongs to four jobs with matrix suffixes, and the rule matches none of them. The usual fix is a small job that needs the matrix and reports the verdict, which the branch rule names instead.

Write the condition carefully, because the default is wrong in a way that passes. A job whose `needs` failed is itself skipped, and a skipped job reports "Success" to a required check, so an aggregate written without `if: always()` will go green precisely when the suite went red. Check the results explicitly rather than trusting the job to have been reached at all.

```.github/workflows/ci.yml
verdict:
    needs: test
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Fail unless every shard succeeded
        run: |
          echo "shards reported: ${{ needs.test.result }}"
          test "${{ needs.test.result }}" = "success"
```

> The `result` of a matrix job is the aggregate over its legs, so one failing shard makes it `failure`. GitHub documents the underlying behavior on both sides: a job skipped by a conditional reports "Success", and a job that depends on a failed job is skipped and may not block merging, which is why the documentation recommends `always()` with `needs` for required checks.

## Where the split stops being worth it

Each shard repeats the whole setup, so the question is whether your work is large compared with that setup. It is easy to underestimate. On a runner we measured, running a single one-second test file took 1.887 seconds end to end, which puts the test runner's own startup at about 0.9 seconds, and a dependency install cost 1.714 seconds cold against 0.854 with a warm cache. That is roughly a second and three quarters per shard warm, and two and a half cold, before a single test of yours runs.

Then the bill rounds. Each job is billed up to the whole minute, so four shards that each finish in forty seconds are four billed minutes against one, no matter how good the split is. Short suites lose that trade and long ones barely notice it, which is the arithmetic rather than an opinion.

## Why no recorded run backs this page

The timings in the section above are quoted from `job-n.log`, committed under content/repro/timings/shard-tests-across-github-actions-runners/ from a run on a Latchkey `latchkey-small` runner on 20 September 2026, with its script and a status file naming the job id and exit code. No new runner job was run for this page, and the merge machinery above was not exercised on one.

That is a real gap and worth naming precisely. What a harness would have to record here is a four-way matrix, a deliberate name collision, and a branch rule, and the last of those is repository configuration rather than anything a job can print. So the artifact behavior above is read out of the two actions' own READMEs and the error prefix out of the toolkit source, and the merge commands are named rather than timed.

## FAQ

### Why do my matrix jobs fail when uploading the same artifact name?

Because artifact names have to be unique within a run. The upload action documents that each created artifact is idempotent, so multiple jobs cannot modify the same one, and its README warns specifically about matrix scenarios, advising a prefix or suffix from the matrix in the name. The error comes back as a 409 conflict that the client treats as non-retryable, so it fails immediately.

### How do I merge JUnit reports from parallel CI jobs?

Upload one uniquely named report per shard, then add a job that needs the matrix, runs with `if: always()` so a failed shard does not skip it, and downloads with `merge-multiple: true` so everything lands in one directory. Merge the XML there. Playwright is the exception worth using: its blob reporter plus `merge-reports` rebuilds the report a single run would have produced.

### How do I combine code coverage across shards?

Not by concatenating the files. Coverage formats carry per-line counters that have to be summed, so use the ecosystem tool: `coverage combine` for Python, `nyc merge` for JavaScript, `go tool covdata merge` for Go. Merge the coverage separately from the test results, because the two use different files and different tools even when one job does both.

### What do I point a required status check at after sharding?

A small aggregate job that needs the matrix, since the branch rule can no longer match the per-leg names. Give it `if: always()` and test `needs.<job>.result` explicitly, because a job whose dependency failed is skipped, and a skipped job reports "Success" to a required check. Without that condition the aggregate goes green exactly when the suite went red.

## References

- [actions/upload-artifact v7: the unique-name rule and the matrix warning (verified 2026-09-21)](https://github.com/actions/upload-artifact)
- [actions/download-artifact v8: pattern, merge-multiple and the all-artifacts default (verified 2026-09-21)](https://github.com/actions/download-artifact)
- [actions/toolkit: the artifact client error format and the retryable status list (verified 2026-09-21)](https://github.com/actions/toolkit/blob/main/packages/artifact/src/internal/shared/artifact-twirp-client.ts)
- [Playwright: sharding, the blob reporter and merge-reports (verified 2026-09-21)](https://playwright.dev/docs/test-sharding)
- [GitHub Docs: troubleshooting required status checks, skipped jobs and always() with needs (verified 2026-09-21)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
