CI that keeps up

How do I run Jest in CI and split the tests with --shard?

A good Jest setup for CI runs the project’s own Jest on a supported Node version. It runs Jest in CI mode, so that a new snapshot fails the run. It lets you rerun one test file, and it splits a large suite across machines with jest --shard. You can meet each requirement with the Jest CLI and a CI job. Dagger’s official Jest module meets the same requirements in one place, and runs the same check on a laptop and in CI.

What a good Jest CI setup needs

Jest’s own docs give four requirements. This guide uses them to judge each approach, Dagger included:

  1. Use the project’s own Jest on a supported Node version. The Jest docs install Jest as a dev dependency with npm install --save-dev jest (Getting Started). Jest 30 says: “The minimum supported Node versions are now 18.x” (Upgrading to Jest 30).
  2. Run Jest in CI mode, so that new snapshots fail. With --ci, a new snapshot does not pass. Jest “will fail the test and require Jest to be run with --updateSnapshot.” The snapshot docs give the reason: “since new snapshots automatically pass, they should not pass a test run on a CI system”.
  3. Rerun one test file. jest path/to/my-test.js runs only the tests in files that match the argument (Jest CLI). A developer can then repeat one CI failure without the full suite.
  4. Split a large suite across machines. Jest’s --shard option runs one part of the suite on each machine.

Run Jest by hand in a CI job

The Jest CLI meets each requirement. Install jest as a dev dependency, then run the suite in CI mode from the project root:

npx jest --ci

You can usually leave out --ci in CI. Jest sets its ci option from the environment by default (Jest source). GitHub Actions sets CI to true in every job (variables reference). To add a snapshot, run npx jest -u on your machine and commit the snapshot file. To rerun one file, give its path: npx jest src/cart.test.js.

In CI, a job checks out the code and installs Node with actions/setup-node or the equivalent of the CI system. Then it installs the dependencies and runs Jest.

To split the suite, run one shard on each machine. Both numbers start at 1. This guide’s project has three test files, split into two shards:

npx jest --ci --shard=1/2
npx jest --ci --shard=2/2
$ npx jest --ci --shard=1/2
PASS src/sum.test.js
PASS src/receipt.test.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   1 passed, 1 total
Time:        0.42 s
Ran all test suites.
$ npx jest --ci --shard=2/2
PASS src/cart.test.js
  ✓ total of an empty cart is 0 (3 ms)
  ✓ total multiplies price and quantity

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.206 s
Ran all test suites.

In GitHub Actions, a matrix runs one job for each shard index. The Jest docs say: “When shard is specified the configured testSequencer has to implement a shard method.”

The default sequencer sorts test files by a SHA-1 hash of their paths. Then it gives each shard about the same number of files. It does not use test durations, so one shard can get more slow files than the others. To balance shards in a different way, set testSequencer to a class that extends @jest/test-sequencer. Its shard(tests, { shardIndex, shardCount }) method returns the files for one shard. The Jest docs say that sharding happens before sorting.

Where the hand-built setup strains

The commands are short, but each requirement lives in a different place:

  • The Node version is in two places. The CI job sets node-version, and each laptop has its own Node. They can drift apart, and Jest then runs on a Node version that the project did not test.
  • The shards are a fixed count of files. The matrix fixes the shard count, and the default sequencer splits by file count, not by duration. A balanced split needs a custom sequencer that you write and maintain.
  • The matrix is CI plumbing. A move to another CI system means a rewrite of the shard jobs in the syntax of that system.
  • A CI failure is hard to repeat. To rerun a failed file as CI ran it, a developer must match the Node version and the install of the CI job by hand.

Meet them with the Dagger Jest module

The Dagger Jest module meets the same four requirements, with the Node image, the install, and the Jest command defined in one place. A Dagger module is a package of functions that test, lint, or build a project. The Dagger Engine runs those functions in containers, on a laptop or in CI.

Complete the Quickstart in the repository first. Then install the module from the project root:

dagger module install dagger.io/js/jest
dagger.io/js/jest ➡️ https://github.com/dagger/jest#v0.3.0
Installed module "jest" in /work/dagger.toml

The command adds the module to dagger.toml, the workspace configuration for everyone who uses the repository:

[modules.jest]
source = "dagger.io/js/jest"

The command also writes dagger.lock, which pins the module to an exact Git commit. Commit both files.

The module treats each directory with a jest.config.js, .mjs, .cjs, .ts, .mts, .cts, or .json file as a Jest project. A jest key in package.json does not mark a project. The module’s README gives the reason: every npm package has a package.json.

A check is a function that passes or fails, such as a test run. The Jest module has one check, test, and runs it once for each Jest project. Add --all to the list command to see one row for each test file:

dagger check -l --all
MODULE  JEST-PROJECT  JEST-TEST-FILE       CHECK  DESCRIPTION
jest    web           src/cart.test.js     test   Run Jest tests.
jest    web           src/receipt.test.js  test   Run Jest tests.
jest    web           src/sum.test.js      test   Run Jest tests.

The next sections show how the module meets each requirement.

Use the project’s own Jest on a supported Node version

The module installs the project’s dependencies and runs the project’s own Jest. Run the tests:

dagger check
== CHECKS ==  ✔ 1 passed
✔ dag://?check=test&jest-project=web 10.2s OK
  == TESTS ==
    ✔ src/cart.test.js  2 passed
    ✔ src/receipt.test.js  1 passed
    ✔ src/sum.test.js  1 passed

    ✔ 4 passed

When all the files of a project are selected, the module runs jest with the project’s config. Jest’s own testMatch and other options then decide what runs. The Jest config does not change.

The module installs dependencies in a separate step. That step reads only the package files and lockfiles. A change to a source file or test file then does not start a new install (README). In this guide’s project, the first dagger check took 67.7 seconds, with the npm install. The output above comes from a later run, with the install already in the cache. A rerun with no changes took 8.8 seconds. A rerun after one new test in a test file took 10.9 seconds. These numbers come from one run on one machine.

The tests run in node:25-alpine by default. If the project uses a different Node release, set the baseImageAddress setting to that release:

dagger module settings jest baseImageAddress node:22-alpine

The setting goes into dagger.toml, so every machine uses the same Node image:

[modules.jest]
source = "dagger.io/js/jest"

[modules.jest.settings]
baseImageAddress = "node:22-alpine"

A setting applies to every Jest project in the workspace. The module reference lists the install settings, such as packageManager and installFlags.

Run Jest in CI mode, so that new snapshots fail

The module sets CI to true in its container (module source), so Jest runs in CI mode with no flag. A new snapshot then fails the check on a laptop too. In this run, src/receipt.test.js had a snapshot assertion and no snapshot file:

✘ dag://?check=test&jest-project=web 4.2s ERROR
  == TESTS ==
    ✘ src/receipt.test.js FAIL

    ✘ src/receipt.test.js › receipt FAIL
          Error: expect(received).toMatchSnapshot()
          
          Snapshot name: `receipt 1`
          
          New snapshot was not written. The update flag must be explicitly passed to write a new snapshot.
          
          This is likely because this test is run in a continuous integration (CI) environment in which snapshots are not written by default.

Write the snapshot with npx jest -u on your machine, review it, and commit it, as the Jest docs recommend.

Rerun one test file

Select a project with --jest-project and its directory, relative to the workspace root. Select a test file with --jest-test-file and its path, relative to the project. Repeat --jest-test-file to select more files:

dagger check --jest-project=web --jest-test-file=src/cart.test.js --jest-test-file=src/sum.test.js
== CHECKS ==  ✔ 1 passed
✔ dag://?check=test&jest-project=web&jest-test-file=src/cart.test.js&jest-test-file=src/sum.test.js 1.1s OK
  == TESTS ==
    ✔ src/cart.test.js  2 passed
    ✔ src/sum.test.js  1 passed

    ✔ 3 passed

When you select only some files, the module runs jest --passWithNoTests --runTestsByPath with those files. To list the keys that you can select, run dagger list jest-projects -a or dagger list jest-test-files -a.

The module finds test files without a run of Jest. It reads testMatch, testRegex, testPathIgnorePatterns, roots, and rootDir when they are literal values. For other values, it uses Jest’s defaults. It skips node_modules and the files that .gitignore excludes. To see the files that Jest itself finds, call the project’s list function:

dagger -c 'jest | projects | get web | list'
/src/web/src/cart.test.js
/src/web/src/receipt.test.js
/src/web/src/sum.test.js

The directory that you run dagger from also selects projects. Dagger selects the projects at or below it, and the nearest project that contains it.

When a test fails, the report gives a DAG link. This address selects the same files again, in the same container, on any machine:

    ✘ 2 failed
    ✔ 1 passed

== RUN LOCALLY ==
dagger check "dag://?check=test&jest-project=web&jest-test-file=src/cart.test.js&jest-test-file=src/sum.test.js"

Split a large suite across machines

The module runs the selected files of a project in one Jest process. For a large suite, Dagger splits the tests across engines instead. It balances the parts with the measured resource use, input cost, and cache state of each test (test splitting). You do not fix a shard count or write a custom sequencer.

Do not put --shard in the flags setting. The module passes flags to every jest run in the workspace, so every check runs only one shard. In a project with three test files and flags set to ["--shard=1/2"], the check ran two of the files:

dagger module settings jest flags '["--shard=1/2"]'
dagger check
== CHECKS ==  ✔ 1 passed
✔ dag://?check=test&jest-project=web 2.5s OK
  == TESTS ==
    ✔ src/receipt.test.js  1 passed
    ✔ src/sum.test.js  1 passed

    ✔ 2 passed

To remove the setting, run dagger module settings -u jest flags.

Run the tests locally and with Cloud Checks

Cloud Checks run your checks on Dagger’s Cloud Engines for each push and pull request, with no CI workflow file. Cloud Engines are Dagger Engines that Dagger Cloud runs for you. The repository must be on GitHub, and its Git origin remote must point to it. Turn on Cloud Checks from the project root:

dagger cloud checks on

If you do not have a Dagger Cloud account, the command tells you to run dagger cloud signup first. Commit dagger.toml and dagger.lock, then push. Each push then runs the same check, with the same Node image and the same CI mode.

The results show as GitHub check statuses and in Dagger Cloud (changelog). The module reports each test as its own result, so Dagger Cloud shows each test. To see recent runs from the terminal, run dagger workspace activity. If a check fails, run dagger check locally to see the same failure.

Bottom line: Run the project’s own Jest on a supported Node version with --ci, so new snapshots fail. Split a large suite with --shard, and write a custom testSequencer for a split by anything other than file count. Dagger’s Jest module meets the same requirements in one place. It pins the Node image in dagger.toml, runs in CI mode, and selects single files with --jest-test-file. It runs the same check locally and with Cloud Checks.