# How to run Jest tests in parallel cloud sandboxes Install the project once, fork the sandbox into copies, give each copy one `jest --shard=i/n`, and merge the JUnit files afterwards. **On Runtime the install happens once, not once per shard.** A fork copies a running sandbox with its files, memory and processes, so every copy starts with `node_modules` already in place. The source pauses only for the moment the copy takes, about a second for a fresh sandbox. Four shards of a 2 vCPU, 4 GiB sandbox cost $0.32 an hour in all with every CPU busy, and Runtime bills the CPU the tests use, not the CPUs held. Jest 30.5.2 was the current release on 25 September 2026. ## Why split across machines Jest runs its tests in worker processes. In a single run the default is the number of cores minus one, so on a 2 vCPU sandbox Jest uses one worker. A larger suite goes faster spread over several sandboxes than squeezed into one: | Layout | Jest workers | Where the time goes | | ----------------------------- | ------------ | -------------------------------------- | | One 2 vCPU sandbox | 1 | Every test file, one after another | | One sandbox, `--maxWorkers=2` | 2 | Two files at once, sharing two CPUs | | Four sandboxes, `--shard` | 4 (1 each) | A quarter of the files on each machine | `--shard=2/4` picks the second of four slices of the test files. Every shard sees the same list, so together they run each file once. ## Install once, then fork ```ts check import { mkdir } from "node:fs/promises"; import { Sandbox } from "withruntime"; const shards = 4; await using base = await Sandbox.create({ timeoutSeconds: 1800 }); await base.files.upload("./web", "/workspace/web"); await base.exec( "cd web && npm ci --no-fund --no-audit && npm install --no-save jest-junit@17.0.0", { check: true, timeoutMs: 600_000, }, ); const copies = await base.fork({ count: shards - 1 }); const machines = [base, ...copies]; const runs = await Promise.all( machines.map((sbx, i) => sbx.exec( `cd web && npx jest --ci --shard=${i + 1}/${shards} --reporters=default --reporters=jest-junit`, { env: { JEST_JUNIT_OUTPUT_DIR: "reports", JEST_JUNIT_OUTPUT_NAME: `shard-${i + 1}.xml` }, timeoutMs: 900_000, }, ), ), ); await mkdir("reports", { recursive: true }); await Promise.all( machines.map((sbx, i) => sbx.files.download(`/workspace/web/reports/shard-${i + 1}.xml`, `reports/shard-${i + 1}.xml`), ), ); await Promise.all(copies.map((copy) => copy.stop())); runs.forEach((run, i) => { if (run.exitCode !== 0) console.error(`shard ${i + 1} failed\n${run.stderr}`); }); process.exitCode = runs.every((run) => run.exitCode === 0) ? 0 : 1; ``` The Python client does the same with `AsyncRuntime`, so the shards run at once: ```python check import asyncio import os from withruntime import AsyncRuntime SHARDS = 4 async def main() -> int: async with AsyncRuntime() as runtime: async with await runtime.sandboxes.create(timeout_seconds=1800) as base: await base.files.upload("./web", "/workspace/web") await base.exec("cd web && npm ci --no-fund --no-audit && npm install --no-save jest-junit@17.0.0", check=True, timeout_ms=600_000) copies = await base.fork(count=SHARDS - 1) machines = [base, *copies] async def shard(sbx, i): run = await sbx.exec( f"cd web && npx jest --ci --shard={i}/{SHARDS} --reporters=default --reporters=jest-junit", env={"JEST_JUNIT_OUTPUT_DIR": "reports", "JEST_JUNIT_OUTPUT_NAME": f"shard-{i}.xml"}, timeout_ms=900_000) await sbx.files.download(f"/workspace/web/reports/shard-{i}.xml", f"reports/shard-{i}.xml") return run.exit_code os.makedirs("reports", exist_ok=True) codes = await asyncio.gather(*(shard(sbx, i + 1) for i, sbx in enumerate(machines))) await asyncio.gather(*(copy.stop() for copy in copies)) return 0 if all(code == 0 for code in codes) else 1 raise SystemExit(asyncio.run(main())) ``` - **The copies:** each is a sandbox of its own, the same size as the source and billed like one. Stop them when their shard is done. A free trial runs eight sandboxes at once, which covers this four-way split. - **The reports:** `jest-junit` writes one XML file per shard. Most CI systems and test dashboards read a folder of JUnit files as one run. - **`--ci`:** Jest then fails a test whose snapshot is missing instead of writing a new one. For code an agent wrote, that stops a new snapshot from passing just because it was recorded on the spot. ## Coverage from every shard Add `--coverage` to each shard and download its `coverage` folder under a separate name, such as `coverage/shard-1`. Each shard covers only its own files, so merge the reports with your coverage tool before reading a total. ## Bake node_modules into an image For a suite that runs many times a day, build the dependencies into a [custom image](/docs/images) from a Dockerfile. Copy the lockfile before the source: a rebuild then starts from the checkpoint after `npm ci` and reruns only the steps after the second `COPY`, so a code change does not reinstall anything. ```dockerfile no-run FROM runtime WORKDIR /workspace/web COPY package.json package-lock.json ./ RUN npm ci --no-fund --no-audit && npm install --no-save jest-junit@17.0.0 COPY . . RUN chown -R 1000:1000 /workspace/web ``` Put `node_modules` in the project's `.dockerignore` so your own machine's copy stays out of the build context. Then build and run one shard per sandbox: ```ts check import { readFile } from "node:fs/promises"; import { Runtime } from "withruntime"; const runtime = new Runtime(); await runtime.images.build({ name: "web-tests", dockerfile: await readFile("web/Dockerfile.test", "utf8"), contextDir: "web", }); const shards = 4; const runs = await Promise.all( Array.from({ length: shards }, async (_, i) => { await using sbx = await runtime.sandboxes.create({ image: "web-tests" }); return await sbx.exec(`cd web && npx jest --ci --shard=${i + 1}/${shards}`, { timeoutMs: 900_000, }); }), ); process.exitCode = runs.every((run) => run.exitCode === 0) ? 0 : 1; ``` The fork approach needs no image and suits one-off runs, such as checking the change an agent just made. The image suits a suite you run on every commit. ## Facts that shape a Jest run | Fact | Value | | ----------------------- | ---------------------------------------------------------------- | | Node.js in the image | 24.21.0; Jest 30 supports Node 18.14, 20, 22 and 24 or later | | Command timeout | 60 seconds unless you pass `timeoutMs`; at most 24 hours | | Output kept in a result | 64 KiB of each stream, all of it when you stream with `onStdout` | | Forks per call | 1 to 10 running copies | | Sandboxes at once | 8 on the trial, 100 on a paid account to start | ## Related - [pytest suites in a sandbox](/integrations/pytest) - [Vitest feedback for a coding agent](/integrations/vitest) - [What a sandbox fork is](/glossary/sandbox-fork) - [Run JavaScript and Node.js code in a sandbox](/languages/javascript) - [Snapshots and forks](/docs/javascript#snapshots-and-forks) ## Sources Checked 25 September 2026. - [Jest CLI options](https://jestjs.io/docs/cli): `--shard`, `--ci`, `--maxWorkers` (cores minus one in single-run mode) - [jest on npm](https://www.npmjs.com/package/jest): version 30.5.2, Node `^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0` - [jest-junit](https://github.com/jest-community/jest-junit): `--reporters=jest-junit`, `JEST_JUNIT_OUTPUT_DIR`, `JEST_JUNIT_OUTPUT_NAME`; version 17.0.0 on npm Facts on this page were checked on 25 September 2026.