Skip to content

Commit

Permalink
feat: implement --shard option #6270
Browse files Browse the repository at this point in the history
  • Loading branch information
marionebl committed Mar 4, 2022
1 parent 3f3aa80 commit 3052d3b
Show file tree
Hide file tree
Showing 11 changed files with 308 additions and 37 deletions.
78 changes: 72 additions & 6 deletions .github/workflows/nodejs.yml
Expand Up @@ -65,8 +65,42 @@ jobs:
run: yarn lint:prettier:ci
- name: check copyright headers
run: yarn check-copyright-headers
test:
name: Node v${{ matrix.node-version }} on ${{ matrix.os }}

test-1:
name: Node v${{ matrix.node-version }} on ${{ matrix.os }} (1/2)
strategy:
fail-fast: false
matrix:
node-version: [12.x, 14.x, 16.x, 17.x]
os: [ubuntu-latest, macOS-latest, windows-latest]
runs-on: ${{ matrix.os }}
needs: prepare-yarn-cache

steps:
- name: Set git config
shell: bash
run: |
git config --global core.autocrlf false
git config --global core.symlinks true
if: runner.os == 'Windows'
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: yarn
- name: install
run: yarn --immutable
- name: build
run: yarn build:js
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests
run: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=1/2

test-2:
name: Node v${{ matrix.node-version }} on ${{ matrix.os }} (2/2)
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -96,10 +130,42 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests
run: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }}
run: yarn test-ci-partial:parallel --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=2/2

test-jasmine-1:
name: Node LTS on ${{ matrix.os }} using jest-jasmine2 (1/2)
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
runs-on: ${{ matrix.os }}
needs: prepare-yarn-cache

steps:
- name: Set git config
shell: bash
run: |
git config --global core.autocrlf false
git config --global core.symlinks true
if: runner.os == 'Windows'
- uses: actions/checkout@v3
- name: Use Node.js LTS
uses: actions/setup-node@v3
with:
node-version: lts/*
cache: yarn
- name: install
run: yarn --immutable
- name: build
run: yarn build:js
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests using jest-jasmine
run: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=1/2

test-jasmine:
name: Node LTS on ${{ matrix.os }} using jest-jasmine2
test-jasmine-2:
name: Node LTS on ${{ matrix.os }} using jest-jasmine2 (2/2)
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -128,7 +194,7 @@ jobs:
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: run tests using jest-jasmine
run: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }}
run: yarn jest-jasmine-ci --max-workers ${{ steps.cpu-cores.outputs.count }} --shard=2/2

test-coverage:
name: Node LTS on Ubuntu with coverage
Expand Down
10 changes: 10 additions & 0 deletions docs/CLI.md
Expand Up @@ -338,6 +338,16 @@ Run only the tests of the specified projects. Jest uses the attribute `displayNa

A list of paths to modules that run some code to configure or to set up the testing framework before each test. Beware that files imported by the setup scripts will not be mocked during testing.

### `--shard`

Shard suite to execute in on multiple machines. For example, to split the suite into three shards, each running one third of the tests:

```
jest --shard=1/3
jest --shard=2/3
jest --shard=3/3
```

### `--showConfig`

Print your Jest config and then exits.
Expand Down
98 changes: 67 additions & 31 deletions packages/jest-cli/src/__tests__/cli/args.test.ts
Expand Up @@ -11,84 +11,78 @@ import {constants} from 'jest-config';
import {buildArgv} from '../../cli';
import {check} from '../../cli/args';

const argv = (input: Partial<Config.Argv>): Config.Argv => input as Config.Argv;

describe('check', () => {
it('returns true if the arguments are valid', () => {
const argv = {} as Config.Argv;
expect(check(argv)).toBe(true);
expect(check(argv({}))).toBe(true);
});

it('raises an exception if runInBand and maxWorkers are both specified', () => {
const argv = {maxWorkers: 2, runInBand: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({maxWorkers: 2, runInBand: true}))).toThrow(
'Both --runInBand and --maxWorkers were specified',
);
});

it('raises an exception if onlyChanged and watchAll are both specified', () => {
const argv = {onlyChanged: true, watchAll: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({onlyChanged: true, watchAll: true}))).toThrow(
'Both --onlyChanged and --watchAll were specified',
);
});

it('raises an exception if onlyFailures and watchAll are both specified', () => {
const argv = {onlyFailures: true, watchAll: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({onlyFailures: true, watchAll: true}))).toThrow(
'Both --onlyFailures and --watchAll were specified',
);
});

it('raises an exception when lastCommit and watchAll are both specified', () => {
const argv = {lastCommit: true, watchAll: true} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({lastCommit: true, watchAll: true}))).toThrow(
'Both --lastCommit and --watchAll were specified',
);
});

it('raises an exception if findRelatedTests is specified with no file paths', () => {
const argv = {
_: [] as Array<string>,
findRelatedTests: true,
} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() =>
check(
argv({
_: [],
findRelatedTests: true,
}),
),
).toThrow(
'The --findRelatedTests option requires file paths to be specified',
);
});

it('raises an exception if maxWorkers is specified with no number', () => {
const argv = {maxWorkers: undefined} as unknown as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({maxWorkers: undefined}))).toThrow(
'The --maxWorkers (-w) option requires a number or string to be specified',
);
});

it('allows maxWorkers to be a %', () => {
const argv = {maxWorkers: '50%'} as unknown as Config.Argv;
expect(() => check(argv)).not.toThrow();
expect(() => check(argv({maxWorkers: '50%'}))).not.toThrow();
});

test.each(constants.JEST_CONFIG_EXT_ORDER.map(e => e.substring(1)))(
'allows using "%s" file for --config option',
ext => {
expect(() => check(argv({config: `jest.config.${ext}`}))).not.toThrow();
expect(() =>
check({config: `jest.config.${ext}`} as Config.Argv),
).not.toThrow();
expect(() =>
check({config: `../test/test/my_conf.${ext}`} as Config.Argv),
check(argv({config: `../test/test/my_conf.${ext}`})),
).not.toThrow();
},
);

it('raises an exception if selectProjects is not provided any project names', () => {
const argv: Config.Argv = {selectProjects: []} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({selectProjects: []}))).toThrow(
'The --selectProjects option requires the name of at least one project to be specified.\n',
);
});

it('raises an exception if config is not a valid JSON string', () => {
const argv = {config: 'x:1'} as Config.Argv;
expect(() => check(argv)).toThrow(
expect(() => check(argv({config: 'x:1'}))).toThrow(
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .json',
);
});
Expand All @@ -97,13 +91,55 @@ describe('check', () => {
const message =
'The --config option requires a JSON string literal, or a file path with one of these extensions: .js, .ts, .mjs, .cjs, .json';

expect(() => check({config: 'jest.configjs'} as Config.Argv)).toThrow(
message,
expect(() => check(argv({config: 'jest.configjs'}))).toThrow(message);
expect(() => check(argv({config: 'jest.config.exe'}))).toThrow(message);
});

it('raises an exception if shard has wrong format', () => {
expect(() => check(argv({shard: 'mumblemuble'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if shard pair has to many items', () => {
expect(() => check(argv({shard: '1/2/2'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if shard has floating points', () => {
expect(() => check(argv({shard: '1.0/1'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if first item in shard pair is no number', () => {
expect(() => check(argv({shard: 'a/1'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if second item in shard pair is no number', () => {
expect(() => check(argv({shard: '1/a'}))).toThrow(
/string in the format of <n>\/<m>/,
);
});

it('raises an exception if shard is zero-indexed', () => {
expect(() => check(argv({shard: '0/1'}))).toThrow(
/requires 1-based values, received 0/,
);
expect(() => check({config: 'jest.config.exe'} as Config.Argv)).toThrow(
message,
});

it('raises an exception if shard index is larger than shard count', () => {
expect(() => check(argv({shard: '2/1'}))).toThrow(
/requires <n> to be lower or equal than <m>/,
);
});

it('allows valid shard format', () => {
expect(() => check(argv({shard: '1/2'}))).not.toThrow();
});
});

describe('buildArgv', () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/jest-cli/src/cli/args.ts
Expand Up @@ -87,6 +87,34 @@ export function check(argv: Config.Argv): true {
);
}

if (argv.shard) {
const shardPair = argv?.shard
.split('/')
.filter(d => /^\d+$/.test(d))
.map(d => parseInt(d, 10))
.filter((shard: number) => !Number.isNaN(shard));

if (shardPair.length !== 2) {
throw new Error(
'The --shard option requires a string in the format of <n>/<m>.\n Example usage jest --shard=1/5',
);
}

const [shardIndex, shardCount] = shardPair;

if (shardIndex === 0 || shardCount === 0) {
throw new Error(
'The --shard option requires 1-based values, received 0 in the pair.\n Example usage jest --shard=1/5',
);
}

if (shardIndex > shardCount) {
throw new Error(
'The --shard option <n>/<m> requires <n> to be lower or equal than <m>.\n Example usage jest --shard=1/5',
);
}
}

return true;
}

Expand Down Expand Up @@ -521,6 +549,12 @@ export const options = {
string: true,
type: 'array',
},
shard: {
description:
'Shard tests and execute only the selected shard, specify in ' +
'the form "current/all". 1-based, for example "3/5"',
type: 'string',
},
showConfig: {
description: 'Print your jest config and then exits.',
type: 'boolean',
Expand Down
10 changes: 10 additions & 0 deletions packages/jest-config/src/__tests__/normalize.test.ts
Expand Up @@ -1972,3 +1972,13 @@ describe('moduleLoader', () => {
expect(console.warn).toMatchSnapshot();
});
});

describe('shards', () => {
it('should be object if defined', async () => {
const {options} = await normalize({rootDir: '/root/'}, {
shard: '1/2',
} as Config.Argv);

expect(options.shard).toEqual({shardCount: 2, shardIndex: 1});
});
});
1 change: 1 addition & 0 deletions packages/jest-config/src/index.ts
Expand Up @@ -154,6 +154,7 @@ const groupOptions = (
reporters: options.reporters,
rootDir: options.rootDir,
runTestsByPath: options.runTestsByPath,
shard: options.shard,
silent: options.silent,
skipFilter: options.skipFilter,
snapshotFormat: options.snapshotFormat,
Expand Down
13 changes: 13 additions & 0 deletions packages/jest-config/src/normalize.ts
Expand Up @@ -1220,6 +1220,19 @@ export default async function normalize(
newOptions.logHeapUsage = false;
}

if (argv.shard) {
const [shardIndex, shardCount] = argv?.shard
.split('/')
.filter(d => /^\d+$/.test(d))
.map(d => parseInt(d, 10))
.filter((shard: number) => !Number.isNaN(shard));

newOptions.shard = {
shardCount,
shardIndex,
};
}

return {
hasDeprecationWarnings,
options: newOptions,
Expand Down
1 change: 1 addition & 0 deletions packages/jest-core/src/runJest.ts
Expand Up @@ -192,6 +192,7 @@ export default async function runJest({
}),
);

allTests = sequencer.shard?.(allTests, globalConfig.shard) ?? allTests;
allTests = await sequencer.sort(allTests);

if (globalConfig.listTests) {
Expand Down

0 comments on commit 3052d3b

Please sign in to comment.