Skip to content

feat(jest-snapshot): support ESM snapshotResolver and snapshotSerializers - #16402

Open
soltonigiri wants to merge 8 commits into
jestjs:mainfrom
soltonigiri:feat/esm-snapshot-modules-11167
Open

feat(jest-snapshot): support ESM snapshotResolver and snapshotSerializers#16402
soltonigiri wants to merge 8 commits into
jestjs:mainfrom
soltonigiri:feat/esm-snapshot-modules-11167

Conversation

@soltonigiri

@soltonigiri soltonigiri commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Jest loads both snapshot configuration options inside the test sandbox, where native ECMAScript module configuration files need the VM modules flag.

This change loads both options outside the sandbox with Jest's existing transform-aware loader. CommonJS and transformed TypeScript modules still work. The configured module order stays unchanged, and the project configuration ID scopes the snapshot resolver cache.

The tests cover the default and legacy test runners, including a .js resolver with ESM syntax that runs through Babel without a fixture install or the VM modules flag. The configuration docs include ECMAScript module examples and explain the transform and default-export behavior.

Related to #11167.
Closes #12014.

Test plan

  • yarn build
  • Focused snapshot unit tests: 2 suites, 18 tests, and 4 snapshots passed.
  • Snapshot configuration integration tests under both the default and legacy runners: 2 suites, 6 tests, and 2 snapshots passed per runner.
  • ESM resolver transform integration test with Babel under both runners: 1 test passed per runner.
  • yarn test-types: 24 files, 83 tests, and 1,585 assertions passed.
  • Type checking, lint, repository constraints, changelog validation, dependency checks, copyright header validation, package manager verification, and whitespace checks passed.

@netlify

netlify Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploy Preview for jestjs ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit b9194a4
🔍 Latest deploy log https://app.netlify.com/projects/jestjs/deploys/6a97e63e02e11b00088b807d
😎 Deploy Preview https://deploy-preview-16402--jestjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions github-actions Bot added the require-changelog If a PR does requires a changelog entry label Aug 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

babel-jest

npm i https://pkg.pr.new/babel-jest@16402

babel-plugin-jest-hoist

npm i https://pkg.pr.new/babel-plugin-jest-hoist@16402

babel-preset-jest

npm i https://pkg.pr.new/babel-preset-jest@16402

create-jest

npm i https://pkg.pr.new/create-jest@16402

@jest/diff-sequences

npm i https://pkg.pr.new/@jest/diff-sequences@16402

expect

npm i https://pkg.pr.new/expect@16402

@jest/expect-utils

npm i https://pkg.pr.new/@jest/expect-utils@16402

jest

npm i https://pkg.pr.new/jest@16402

jest-changed-files

npm i https://pkg.pr.new/jest-changed-files@16402

jest-circus

npm i https://pkg.pr.new/jest-circus@16402

jest-cli

npm i https://pkg.pr.new/jest-cli@16402

jest-config

npm i https://pkg.pr.new/jest-config@16402

@jest/console

npm i https://pkg.pr.new/@jest/console@16402

@jest/core

npm i https://pkg.pr.new/@jest/core@16402

@jest/create-cache-key-function

npm i https://pkg.pr.new/@jest/create-cache-key-function@16402

jest-diff

npm i https://pkg.pr.new/jest-diff@16402

jest-docblock

npm i https://pkg.pr.new/jest-docblock@16402

jest-each

npm i https://pkg.pr.new/jest-each@16402

@jest/environment

npm i https://pkg.pr.new/@jest/environment@16402

jest-environment-jsdom

npm i https://pkg.pr.new/jest-environment-jsdom@16402

@jest/environment-jsdom-abstract

npm i https://pkg.pr.new/@jest/environment-jsdom-abstract@16402

jest-environment-node

npm i https://pkg.pr.new/jest-environment-node@16402

@jest/expect

npm i https://pkg.pr.new/@jest/expect@16402

@jest/fake-timers

npm i https://pkg.pr.new/@jest/fake-timers@16402

@jest/get-type

npm i https://pkg.pr.new/@jest/get-type@16402

@jest/globals

npm i https://pkg.pr.new/@jest/globals@16402

jest-haste-map

npm i https://pkg.pr.new/jest-haste-map@16402

jest-jasmine2

npm i https://pkg.pr.new/jest-jasmine2@16402

jest-leak-detector

npm i https://pkg.pr.new/jest-leak-detector@16402

jest-matcher-utils

npm i https://pkg.pr.new/jest-matcher-utils@16402

jest-message-util

npm i https://pkg.pr.new/jest-message-util@16402

jest-mock

npm i https://pkg.pr.new/jest-mock@16402

@jest/pattern

npm i https://pkg.pr.new/@jest/pattern@16402

jest-phabricator

npm i https://pkg.pr.new/jest-phabricator@16402

jest-regex-util

npm i https://pkg.pr.new/jest-regex-util@16402

@jest/reporters

npm i https://pkg.pr.new/@jest/reporters@16402

jest-resolve

npm i https://pkg.pr.new/jest-resolve@16402

jest-resolve-dependencies

npm i https://pkg.pr.new/jest-resolve-dependencies@16402

jest-runner

npm i https://pkg.pr.new/jest-runner@16402

jest-runtime

npm i https://pkg.pr.new/jest-runtime@16402

@jest/schemas

npm i https://pkg.pr.new/@jest/schemas@16402

jest-snapshot

npm i https://pkg.pr.new/jest-snapshot@16402

@jest/snapshot-utils

npm i https://pkg.pr.new/@jest/snapshot-utils@16402

@jest/source-map

npm i https://pkg.pr.new/@jest/source-map@16402

@jest/test-result

npm i https://pkg.pr.new/@jest/test-result@16402

@jest/test-sequencer

npm i https://pkg.pr.new/@jest/test-sequencer@16402

@jest/transform

npm i https://pkg.pr.new/@jest/transform@16402

@jest/types

npm i https://pkg.pr.new/@jest/types@16402

jest-util

npm i https://pkg.pr.new/jest-util@16402

jest-validate

npm i https://pkg.pr.new/jest-validate@16402

jest-watcher

npm i https://pkg.pr.new/jest-watcher@16402

jest-worker

npm i https://pkg.pr.new/jest-worker@16402

pretty-format

npm i https://pkg.pr.new/pretty-format@16402

commit: b9194a4

@soltonigiri soltonigiri reopened this Aug 28, 2026
@SimenB

SimenB commented Sep 1, 2026

Copy link
Copy Markdown
Member

Thanks for picking this up! Docs tabs and extra tests are great 👍

A few things I'd like carried over / cleaned up before merge:

  • Nothing covers a transformed ESM config module. feat(snapshot): support snapshotResolver and snapshotSerializers written in ESM #12014 had e2e/transform/transform-esm-snapshotResolver/ plus a case in e2e/__tests__/transform.test.ts. The test plan mentions "Transformed TypeScript resolver integration test under both runners". I can't find it in the diff, did it get lost?
  • createCustomSnapshotResolver still does interopRequireDefault(await localRequire(path, true)).default. Passing applyInteropRequireDefault: true already unwraps it, so the outer call is a no-op that happens to work out. Drop the interopRequireDefault import.
  • LocalRequire is typed (...) => unknown | Promise<unknown>, which collapses to plain unknown. It's always async now - make it generic and Promise<T>-returning.

@soltonigiri

Copy link
Copy Markdown
Contributor Author

Thanks! I've addressed all three points 👍

I've kept the transformed TypeScript coverage in transform-snapshotResolver and added the transformed ESM fixture and its transform.test.ts coverage alongside it. Both cases pass under Circus and Jasmine2.

I also removed the redundant interopRequireDefault call and made LocalRequire generic with a Promise<T> return type.

@SimenB SimenB left a comment

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.

Thanks, all three points from last round look good.

The transform fixture is the one real problem - see the comment on it (and apologies for sending you on a wrong path). loadSnapshotSetup I'd also like moved before merge. Everything else is small.

@@ -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 🙂

Comment thread packages/jest-runtime/src/index.ts Outdated
}

/** Loads snapshot config modules outside the test sandbox. */
async loadSnapshotSetup(): Promise<SnapshotSetup> {

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.

Only reads this._config, and skips the sandbox by design. Runtime is the documented subclass seam and everything on it loads through the sandbox - this looks like an override point and isn't.

Should be a free function in jest-snapshot instead. loadSnapshotSetup(config)?.

localRequire?: Promise<LocalRequire> | LocalRequire,
): Promise<SnapshotResolver> => {
const key = config.rootDir;
const key = `${config.rootDir}\0${config.snapshotResolver ?? ''}`;

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.

Use config.id instead. It's sha1(rootDir + configPath + projectIndex) from normalize.ts:365, and it's already what jest-runner keys its resolvers on. What you have here still collides for two projects that share a rootDir and a resolver but differ in transform, and switching lets the comment above disappear as well.

localRequire: Promise<LocalRequire> | LocalRequire = createTranspilingRequire(
config,
),
localRequire?: Promise<LocalRequire> | LocalRequire,

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.

Seems 100% unused now (outside of tests). add a TODO comment to mark it for removal in Jest 31?

Comment thread docs/Configuration.md Outdated
};
```

Jest loads the module outside the test sandbox, so it does not use `jest.mock()` or `moduleNameMapper`. Your `transform` still applies. Jest loads `.mjs` and `.mts` files as native ESM, so they must be valid JavaScript as written.

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.

"Your transform still applies" contradicts the sentence that follows it, and the second one is the accurate one: .mjs and .mts skip the transform entirely. Worth stating that outright rather than leaving a reader to reconcile the two.

This paragraph is also missing the default-export requirement that the snapshotSerializers section spells out, even though both paths go through applyInteropRequireDefault: true.

Comment thread docs/Configuration.md Outdated

`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`. Your `transform` still applies. Jest loads `.mjs` and `.mts` files as native ESM, so they must be valid JavaScript as written. An ESM serializer must use a `default` export.

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.

The transform sentence has the same problem here.

Comment thread CHANGELOG.md Outdated

### Features

- `[jest-circus, jest-jasmine2, jest-runtime, jest-snapshot]` Support ESM `snapshotResolver` and `snapshotSerializers` configuration modules ([#16402](https://github.com/jestjs/jest/pull/16402))

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.

Please call out the behaviour change in this entry. Loading these modules outside the sandbox means their transitive imports no longer go through moduleNameMapper or jest.mock, and as written it reads like pure feature work. Anyone whose serializer pulls in a mapped module will find out the hard way.

Also, while it hits circus and jasmine, I don't think it's relevant to people reading the changelog?

Comment thread e2e/__tests__/transform.test.ts Outdated

it('should transform the snapshotResolver', () => {
const result = runJest(dir, ['-w=1', '--no-cache', '--ci=false'], {
nodeOptions: '--experimental-vm-modules --no-warnings',

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.

I don't see why --experimental-vm-modules would be needed to run the tests?

@soltonigiri

Copy link
Copy Markdown
Contributor Author

Thanks for the review, and no worries! I've addressed all eight comments 👍

The transform fixture now uses a .js resolver with ESM syntax, so it goes through Babel without the install step or VM modules flag. I moved loadSnapshotSetup to jest-snapshot, and both runners now call it before entering the test sandbox.

The resolver cache now keys on config.id, and the docs spell out the .mjs/.mts and default-export behavior. The changelog now notes that transitive imports no longer use jest.mock() or moduleNameMapper.

The focused snapshot tests and transform E2Es are green with both Circus and Jasmine2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

require-changelog If a PR does requires a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants