Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### Features

- `[@jest/transform]` Strip TypeScript types with Node when no transformer claims a `.ts`, `.mts` or `.cts` file ([#16421](https://github.com/jestjs/jest/pull/16421))
- `[jest-runtime, jest-snapshot]` Support ESM `snapshotResolver` and `snapshotSerializers` configuration modules by loading them outside the test sandbox. Their transitive imports no longer use `jest.mock()` or `moduleNameMapper` ([#16402](https://github.com/jestjs/jest/pull/16402))

### Fixes

Expand Down
37 changes: 35 additions & 2 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1906,7 +1906,7 @@ Default: `undefined`

The path to a module that can resolve test\<->snapshot path. This config option lets you customize where Jest stores snapshot files on disk.

```js title="custom-resolver.js"
```js tab={"label":"CommonJS"} title="custom-resolver.js"
module.exports = {
// resolves from test to snapshot path
resolveSnapshotPath: (testPath, snapshotExtension) =>
Expand All @@ -1923,6 +1923,25 @@ module.exports = {
};
```

```js tab={"label":"ESM"} title="custom-resolver.mjs"
export default {
// resolves from test to snapshot path
resolveSnapshotPath: (testPath, snapshotExtension) =>
testPath.replace('__tests__', '__snapshots__') + snapshotExtension,

// resolves from snapshot to test path
resolveTestPath: (snapshotFilePath, snapshotExtension) =>
snapshotFilePath
.replace('__snapshots__', '__tests__')
.slice(0, -snapshotExtension.length),

// Example test path, used for preflight consistency check of the implementation above
testPathForConsistencyCheck: 'some/__tests__/example.test.js',
};
```

Jest loads the module outside the test sandbox, so it does not use `jest.mock()` or `moduleNameMapper`. Jest applies the configured `transform` when a file matches a transform pattern, except for `.mjs` and `.mts` files, which it loads as native ESM without transformation. An ESM snapshot resolver must use a `default` export.

### `snapshotSerializers` \[array&lt;string&gt;]

Default: `[]`
Expand All @@ -1931,7 +1950,7 @@ A list of paths to snapshot serializer modules Jest should use for snapshot test

Jest has default serializers for built-in JavaScript types, HTML elements (Jest 20.0.0+), ImmutableJS (Jest 20.0.0+) and for React elements. See [snapshot test tutorial](TutorialReactNative.md#snapshot-test) for more information.

```js tab title="custom-serializer.js"
```js tab={"label":"CommonJS"} title="custom-serializer.js"
module.exports = {
serialize(val, config, indentation, depth, refs, printer) {
return `Pretty foo: ${printer(val.foo, config, indentation, depth, refs)}`;
Expand Down Expand Up @@ -1959,8 +1978,22 @@ const plugin: Plugin = {
export default plugin;
```

```js tab={"label":"ESM"} title="custom-serializer.mjs"
export default {
serialize(val, config, indentation, depth, refs, printer) {
return `Pretty foo: ${printer(val.foo, config, indentation, depth, refs)}`;
},

test(val) {
return val && Object.prototype.hasOwnProperty.call(val, 'foo');
},
};
```

`printer` is a function that serializes a value using existing plugins.

Jest loads serializers outside the test sandbox, so they do not use `jest.mock()` or `moduleNameMapper`. Jest applies the configured `transform` when a file matches a transform pattern, except for `.mjs` and `.mts` files, which it loads as native ESM without transformation. An ESM serializer must use a `default` export.

Add `custom-serializer` to your Jest configuration:

```js tab title="jest.config.js"
Expand Down
7 changes: 7 additions & 0 deletions e2e/__tests__/__snapshots__/snapshotSerializers.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,10 @@ Object {
"snapshot serializers works with second plugin 1": "bar - 2",
}
`;

exports[`Snapshot serializers written in ESM renders snapshot 1`] = `
Object {
"loads every configured serializer 1": "second: value",
"uses the first matching serializer 1": "first: value",
}
`;
55 changes: 36 additions & 19 deletions e2e/__tests__/snapshotResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,50 @@ import * as path from 'path';
import * as fs from 'graceful-fs';
import runJest from '../runJest';

const snapshotDir = path.resolve(
__dirname,
'../snapshot-resolver/__snapshots__',
);
const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap');
const makeCleanup = (directory: string) => () => {
const snapshotDir = path.resolve(__dirname, `../${directory}/__snapshots__`);
const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap');

if (fs.existsSync(snapshotFile)) {
fs.unlinkSync(snapshotFile);
}
if (fs.existsSync(snapshotDir)) {
fs.rmdirSync(snapshotDir);
}
};

const assertResolvesToCustomLocation = (directory: string) => {
const result = runJest(directory, ['-w=1', '--ci=false']);

expect(result.stderr).toMatch('1 snapshot written from 1 test suite');

const content = require(
path.resolve(
__dirname,
`../${directory}/__snapshots__/snapshot.test.js.snap`,
),
);
expect(content).toHaveProperty('snapshots are written to custom location 1');
};

describe('Custom snapshot resolver', () => {
const cleanup = () => {
if (fs.existsSync(snapshotFile)) {
fs.unlinkSync(snapshotFile);
}
if (fs.existsSync(snapshotDir)) {
fs.rmdirSync(snapshotDir);
}
};
const cleanup = makeCleanup('snapshot-resolver');

beforeEach(cleanup);
afterAll(cleanup);

it('Resolves snapshot files using custom resolver', () => {
const result = runJest('snapshot-resolver', ['-w=1', '--ci=false']);
assertResolvesToCustomLocation('snapshot-resolver');
});
});

expect(result.stderr).toMatch('1 snapshot written from 1 test suite');
describe('Custom snapshot resolver written in ESM', () => {
const cleanup = makeCleanup('snapshot-resolver-esm');

const content = require(snapshotFile);
expect(content).toHaveProperty(
'snapshots are written to custom location 1',
);
beforeEach(cleanup);
afterAll(cleanup);

it('Resolves snapshot files using custom resolver', () => {
assertResolvesToCustomLocation('snapshot-resolver-esm');
});
});
34 changes: 27 additions & 7 deletions e2e/__tests__/snapshotSerializers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ const testDir = path.resolve(__dirname, '../snapshot-serializers');
const snapshotsDir = path.resolve(testDir, '__tests__/__snapshots__');
const snapshotPath = path.resolve(snapshotsDir, 'snapshot.test.js.snap');

const runAndAssert = () => {
const {exitCode, json} = runWithJson('snapshot-serializers', [
const esmTestDir = path.resolve(__dirname, '../snapshot-serializers-esm');
const esmSnapshotsDir = path.resolve(esmTestDir, '__tests__/__snapshots__');
const esmSnapshotPath = path.resolve(esmSnapshotsDir, 'snapshot.test.js.snap');

const runAndAssert = (directory: string, expectedTests: number) => {
const {exitCode, json} = runWithJson(directory, [
'-w=1',
'--ci=false',
'--no-cache',
]);
expect(json.numTotalTests).toBe(9);
expect(json.numPassedTests).toBe(9);
expect(json.numTotalTests).toBe(expectedTests);
expect(json.numPassedTests).toBe(expectedTests);
expect(json.numFailedTests).toBe(0);
expect(json.numPendingTests).toBe(0);
expect(exitCode).toBe(0);
Expand All @@ -31,14 +35,30 @@ describe('Snapshot serializers', () => {
afterEach(() => cleanup(snapshotsDir));

it('renders snapshot', () => {
runAndAssert();
runAndAssert('snapshot-serializers', 9);
const snapshot = require(snapshotPath);
expect(snapshot).toMatchSnapshot();
});

it('compares snapshots correctly', () => {
// run twice, second run compares result with snapshot from first run
runAndAssert();
runAndAssert();
runAndAssert('snapshot-serializers', 9);
runAndAssert('snapshot-serializers', 9);
});
});

describe('Snapshot serializers written in ESM', () => {
beforeEach(() => cleanup(esmSnapshotsDir));
afterEach(() => cleanup(esmSnapshotsDir));

it('renders snapshot', () => {
runAndAssert('snapshot-serializers-esm', 2);
const snapshot = require(esmSnapshotPath);
expect(snapshot).toMatchSnapshot();
});

it('compares snapshots correctly', () => {
runAndAssert('snapshot-serializers-esm', 2);
runAndAssert('snapshot-serializers-esm', 2);
});
});
33 changes: 33 additions & 0 deletions e2e/__tests__/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,39 @@ describe('transform-snapshotResolver', () => {
});
});

describe('transform-esm-snapshotResolver', () => {
const dir = path.resolve(
__dirname,
'..',
'transform/transform-esm-snapshotResolver',
);
const snapshotDir = path.resolve(dir, '__snapshots__');
const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap');

const cleanupTest = () => {
if (fs.existsSync(snapshotFile)) {
fs.unlinkSync(snapshotFile);
}
if (fs.existsSync(snapshotDir)) {
fs.rmdirSync(snapshotDir);
}
};

beforeEach(cleanupTest);
afterAll(cleanupTest);

it('should transform the snapshotResolver', () => {
const result = runJest(dir, ['-w=1', '--no-cache', '--ci=false']);

expect(result.stderr).toMatch('1 snapshot written from 1 test suite');

const contents = require(snapshotFile);
expect(contents).toHaveProperty(
'snapshots are written to custom location 1',
);
});
});

describe('transform-environment', () => {
const dir = path.resolve(__dirname, '../transform/transform-environment');

Expand Down
10 changes: 10 additions & 0 deletions e2e/snapshot-resolver-esm/__tests__/snapshot.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

test('snapshots are written to custom location', () => {
expect('foobar').toMatchSnapshot();
});
18 changes: 18 additions & 0 deletions e2e/snapshot-resolver-esm/customSnapshotResolver.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

export default {
resolveSnapshotPath: (testPath, snapshotExtension) =>
testPath.replace('__tests__', '__snapshots__') + snapshotExtension,

resolveTestPath: (snapshotFilePath, snapshotExtension) =>
snapshotFilePath
.replace('__snapshots__', '__tests__')
.slice(0, -snapshotExtension.length),

testPathForConsistencyCheck: 'foo/__tests__/bar.test.js',
};
6 changes: 6 additions & 0 deletions e2e/snapshot-resolver-esm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"jest": {
"testEnvironment": "node",
"snapshotResolver": "<rootDir>/customSnapshotResolver.mjs"
}
}
14 changes: 14 additions & 0 deletions e2e/snapshot-serializers-esm/__tests__/snapshot.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

test('uses the first matching serializer', () => {
expect({kind: 'both', value: 'value'}).toMatchSnapshot();
});

test('loads every configured serializer', () => {
expect({kind: 'second', value: 'value'}).toMatchSnapshot();
});
9 changes: 9 additions & 0 deletions e2e/snapshot-serializers-esm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"jest": {
"testEnvironment": "node",
"snapshotSerializers": [
"<rootDir>/plugins/first.mjs",
"<rootDir>/plugins/second.mjs"
]
}
}
11 changes: 11 additions & 0 deletions e2e/snapshot-serializers-esm/plugins/first.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

export default {
serialize: value => `first: ${value.value}`,
test: value => value?.kind === 'both',
};
11 changes: 11 additions & 0 deletions e2e/snapshot-serializers-esm/plugins/second.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

export default {
serialize: value => `second: ${value.value}`,
test: value => value?.kind === 'both' || value?.kind === 'second',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

test('snapshots are written to custom location', () => {
expect('foobar').toMatchSnapshot();
});
18 changes: 18 additions & 0 deletions e2e/transform/transform-esm-snapshotResolver/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

module.exports = {
presets: [
[
'@babel/preset-env',
{
modules: 'commonjs',
targets: {node: 'current'},
},
],
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

export default {
resolveSnapshotPath: (testPath, snapshotExtension) =>
testPath.replace('__tests__', '__snapshots__') + snapshotExtension,

resolveTestPath: (snapshotFilePath, snapshotExtension) =>
snapshotFilePath
.replace('__snapshots__', '__tests__')
.slice(0, -snapshotExtension.length),

testPathForConsistencyCheck: 'foo/__tests__/bar.test.js',
};
6 changes: 6 additions & 0 deletions e2e/transform/transform-esm-snapshotResolver/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.mjs can't be transformed at all - requireOrImportModule picks the extension branch and hands off to native import() before the hook exists. There's also no transform key, no babel config and no deps here, and setting "transform": {} doesn't change the result.

My bad for essentially asking you to copy the test in #12014 verbatim without reading it closer.

Give it an extension the pipeline can claim. .ts, or .js with ESM syntax and no type: module, both go through require() and hit babel.

Note that the current tests doesn't need the yarn install thing. that might change dependening on how you do the transform ofc 🙂

"jest": {
"testEnvironment": "node",
"snapshotResolver": "<rootDir>/customSnapshotResolver.js"
}
}
Loading
Loading