# 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 while the copy is captured. The call returns when the source runs again. Four shards of a 2 vCPU, 4 GiB sandbox cost $0.32 an hour in all, with every CPU busy. Runtime bills the CPU the tests use, not the CPUs held. Jest 30.5.2 was the current release on 25 October 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 runs faster when spread over several sandboxes than when 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 a time, 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 the same time: ```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. An account without credit runs four sandboxes at the same time, 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 only 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`. It 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 100 running copies | | Sandboxes at the same time | four without credit, 100 on a paid account | ## 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 October 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 October 2026.