Test sharding splits one test suite into M slices and runs each slice in its own CI job at the same time, so a suite that takes twelve minutes on one runner takes roughly three, plus setup, on four. renmy runtime, a local Salesforce Apex runtime made by Renmy UG in Berlin, executes Apex tests on the CI runner itself against an embedded PostgreSQL with no org, which is what makes sharding possible for Apex at all: renmy test --shard 2/4 runs the second of four slices. A slice is a set of whole test classes, balanced by the durations recorded in .nimbus/test-durations.json, so @testSetup stays correct and the shards finish at about the same time. With --results-xml, each shard’s JUnit XML carries a shard="N/M" attribute so a test reporter can merge the files. Running on a CI runner is part of the Team tier, free during the public beta.
renmy runtime was called Nimbus until October 2026. Same tool, same binary; the record of the rename is at Nimbus is now renmy runtime.
Who this is for
DevOps engineers and platform teams whose Apex test suite has outgrown a single CI job: thousands of tests, several packages in one repository, a pull-request queue where the time to a green check is the bottleneck. You should know your way around a GitHub Actions matrix; you do not need to have used a local runtime before. If your suite already finishes in under two minutes on one runner, read the section on when sharding is not worth it first.
What is test sharding?
A suite has N test classes and you have M runners. Sharding assigns every class to exactly one runner, each runner executes its assignment, and the results are merged into one report. Nothing runs twice or is skipped, and the wall-clock time drops to roughly the slowest shard plus setup. It does not make the suite cheaper, since the compute is the same plus M times the setup; it makes the suite shorter, which is what the developer waiting for a pull-request check cares about.
Every other ecosystem does this routinely: a JavaScript team puts jest --shard=1/4 in a matrix, a Python team splits pytest across containers. Two conditions make it work. The tests must be independent of each other, and the runner must be able to execute the tests itself, so that adding runners adds capacity.
Why Apex tests could never be sharded in a Salesforce org
In an org, “run the tests” means: deploy the source, ask the org to run the tests, wait in the org’s test queue, download the results. The CI runner’s CPU is idle the whole time; the work happens in Salesforce’s data centre. The CI page puts the org-based cycle at 12 to 20 minutes per run, 4 to 8 of them the deploy and another 3 to 5 the test run. That second condition is the one Apex failed: adding a runner adds nothing, because it would need a second org, a second deploy and a second place in the same queue. The only lever left was to run fewer tests.
renmy runtime removes the constraint. It is a local runtime for Salesforce Apex: an interpreter that executes Apex classes, interfaces, enums, generics and triggers, SOQL and DML, and Salesforce Flows (autolaunched, record-triggered and screen) against an embedded PostgreSQL, on whatever machine it runs on. One binary for macOS, Linux and Windows; no org, no scratch org, no sandbox, no Docker, no JVM. Governor limits are enforced, Test.startTest/Test.stopTest, @testSetup, per-test transaction isolation and the Stub API behave as they do on the platform. It is not a mock framework and not a transpiler; how closely it matches the org is a public record, renmy.net/parity, and the idea from the beginning is in What is a local Apex runtime?
Once the tests execute on the runner, they are a process like any other and the ordinary CI arithmetic applies. The published benchmark runs four open-source suites, 837 tests, in 2.4 s locally against about 1 min 46 s in an org (method and numbers); for a suite that size one job is plenty, and sharding is for the suites that are not. “I already have a CI sandbox” is the usual objection, and you keep it: it stays where the payload is validated and deployed. What moves to the runner is the part that ran on every push.
How does renmy test --shard N/M split the suite?
# Four shards. This runner takes the second one.
renmy test --shard 2/4 --results-xml results-2.xmlJob N runs only its assigned slice of the test classes. Three rules decide what that slice is.
Classes stay whole
All methods of a class stay together on one shard. @testSetup runs once per class, and its records are what every method in that class sees; splitting one class across two shards would mean two setups and a result that can differ from an unsharded run. The unit of distribution is the class, not the method.
Balanced by recorded duration
Test classes are not equal: one has eighty methods and a heavy setup, another has three one-liners, and an even split by count leaves one shard running long after the others. renmy runtime balances classes by recorded test duration, read from .nimbus/test-durations.json, so shards finish in roughly equal time. A project with no duration history yet falls back to an even split by class name.
Every shard reads the same file
Each shard computes the whole assignment on its own and takes its slice, so the partition is only consistent if every shard sees the same durations file. If shard 1 has it and shard 3 does not, they compute different partitions and a class can run twice or not at all. Commit .nimbus/test-durations.json or restore it from a cache in every job; committing is the simplest guarantee, because every shard checks out the same revision.
The GitHub Actions matrix
This is the sharded workflow from the CI/CD page, with fail-fast turned off so that one red shard does not cancel the other three and you see every failure in one pass.
name: Apex tests (sharded)
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- name: Install renmy runtime
run: curl -fsSL https://renmy.net/install.sh | sh
- name: Run tests (shard ${{ matrix.shard }}/4)
run: |
renmy test \
--shard ${{ matrix.shard }}/4 \
--results-xml results-${{ matrix.shard }}.xml
env:
NIMBUS_LICENSE_KEY: ${{ secrets.NIMBUS_LICENSE_KEY }}
NIMBUS_PROFILE: ci
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: results-${{ matrix.shard }}.xmlstrategy.matrix.shard fans the job out into four, and --shard ${{ matrix.shard }}/4 tells the runtime which slice to take. --results-xml results-N.xml gives each shard its own JUnit file, and if: always() publishes a failed shard’s results too. NIMBUS_LICENSE_KEY is the only secret in the job: no JWT certificate, no connected app, no Dev Hub. NIMBUS_PROFILE: ci activates the %ci. overrides in your committed renmy.properties.
Merging the results
When --shard is combined with --results-xml, each shard’s JUnit XML includes a shard="N/M" attribute, so a reporter that reads several files knows they are slices of one run and can merge them. For one combined report instead of a check per shard, upload each file as an artifact and publish once from a job that waits for the matrix:
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-${{ matrix.shard }}
path: results-${{ matrix.shard }}.xml
report:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: results-*
merge-multiple: true
- uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: results-*.xmlCoverage is a separate question: a shard measures coverage only for the tests it ran, so one shard’s Cobertura file is a partial picture. Measure a whole-suite number where the whole suite runs in one process. renmy deploy --min-coverage refuses a payload below the floor before the org sees it, and renmy coverage diff reports the line and branch delta for a pull request (renmy.net/coverage).
Cache what the shards share
Every shard starts cold. renmy runtime caches parsed ASTs in .nimbus/cache/, which on a project with hundreds of classes brings startup from several seconds to under a second. Restore it in every shard, and the binary with it:
- name: Cache renmy runtime
uses: actions/cache@v4
with:
path: |
~/.nimbus
.nimbus/cache
key: renmy-${{ hashFiles('force-app/**/*.cls', 'force-app/**/*.trigger') }}
restore-keys: renmy-
- name: Cache binary
uses: actions/cache@v4
with:
path: /usr/local/bin/renmy
key: renmy-bin-${{ runner.os }}-v1When is sharding worth it, and when is it not?
Measure the test step on its own before deciding. The CI page puts a single unsharded job at 30 to 90 seconds in total, install included, and a typical Apex test completes in tens of milliseconds with SOQL and DML. For most projects the suite is not the long pole; the checkout and the install are. Four shards means four checkouts, four installs, four embedded PostgreSQL start-ups and four reporter runs; shard a ninety-second job and you will wait longer.
Three things to try first, all from the CI page:
- Parallel workers inside one job.
%ci.renmy.test.parallel=4inrenmy.propertiesruns the suite on four workers within the same process (Pro, free during the beta): parallelism without the per-job overhead. - The cache.
.nimbus/cache/and the binary, as above. - An external PostgreSQL for large schemas. A
postgres:16-alpineservice container withDATABASE_URLremoves the embedded database start-up and costs nothing extra, because it starts in parallel with checkout and install.
Shard when the test step itself, after those three, still runs for several minutes: many thousands of tests, classes with heavy @testSetup, several packages in one repository. Then the run costs the slowest shard plus about a minute of setup, instead of the whole suite.
Two caveats. Sharding does not make a slow test class faster; it hides it. A four-minute class sets a four-minute floor for its shard, and the same recorded durations that balance the shards tell you which classes dominate. And sharding changes which classes run next to each other. Each test runs in its own transaction, so a class’s result should not depend on its shard; if it does, you have found an order-dependent test. renmy history --flaky records run conditions for that search, and Flaky Apex tests covers the usual causes.
What running sharded Apex tests in CI costs
Running the suite on a CI runner is part of the Team tier, which includes a CI runner licence with its own machine pool. JUnit XML and Cobertura XML output are Pro features. Pro and Team are both free during the public beta; the list prices are $19 per month and $49 per developer per month. The Free tier, $0 and no card, covers unlimited local test runs on one machine and gated deploys. Pricing: renmy.net/#pricing.
Try it
curl -fsSL https://renmy.net/install.sh | sh # macOS, Linux
irm https://renmy.net/install.ps1 | iex # Windows PowerShell
cd your-sfdx-project
renmy test "*"The quickstart takes you from install to a first green run; the unsharded CI job, for GitLab CI as well as GitHub Actions, is in Apex CI without an org; the sharded workflow is on the CI/CD page. Issues, docs, examples and the released binaries are at github.com/renmy-solutions/renmy; questions go to the Slack.
Frequently asked questions
Does --shard split Apex test classes or test methods?
Classes. All methods of a test class stay together on one shard, so @testSetup runs once for the class and every method sees the same records, exactly as in an unsharded run. Shards are balanced by recorded class duration, not method count, so a class with a heavy setup counts for what it costs.
What happens if .nimbus/test-durations.json is missing?
If no shard has the file, renmy runtime falls back to an even split by class name; the run is still correct, just less evenly balanced. If some shards have it and others do not, they compute different partitions and a class can run twice or be skipped, which is why the file should be committed or restored from the same cache key in every job.
How do I merge JUnit XML from sharded Apex test runs?
Pass --results-xml to every shard with its own file name, such as results-2.xml. When --shard is in effect, each file carries a shard="N/M" attribute so a CI aggregator knows the files are slices of one run. Publish per shard with if: always(), or upload the files as artifacts and publish them together from a job that runs after the matrix.
How many shards should an Apex test suite use?
Time the test step alone first. If it is under a minute, use one job and turn on parallel workers with %ci.renmy.test.parallel instead; each extra shard costs a checkout, an install and a database start-up. If the step runs for several minutes, start with four and add shards while the slowest shard is still much longer than the setup.
One suite, four jobs, no org
renmy runtime runs your Apex tests on the CI runner against an embedded PostgreSQL, so a suite can be split across a matrix like any other language’s. The sharded workflow, the CI profile and the caching steps are on the CI/CD page.