Resource library

QA How-To

ESM vs CommonJS in tests: A QA Guide (2026)

Understand ESM vs CommonJS in tests qa projects, configure Node and test runners correctly, migrate safely, and diagnose module errors with practical examples.

21 min read | 2,935 words

TL;DR

For new Node test projects, ESM is usually the forward-looking choice, while CommonJS remains valid for stable suites and dependencies. Declare the mode with `package.json` or file extensions, keep the test runner and TypeScript output aligned, and treat module mocking as a migration hotspot.

Key Takeaways

  • Make the package module mode explicit instead of relying on Node syntax detection.
  • Use ESM for standards alignment and modern packages, but migrate only when the full test toolchain supports it.
  • Remember that ESM resolution, import timing, and mocking semantics differ from CommonJS.
  • Use `.mjs` and `.cjs` as precise escape hatches inside mixed repositories.
  • Keep TypeScript compiler output, package type, and test-runner transforms aligned.
  • Diagnose the file Node actually executed before changing random configuration flags.

ESM vs CommonJS in tests qa projects is a runtime architecture decision, not a cosmetic choice between import and require. ESM follows the JavaScript standard module system, while CommonJS uses Node's original synchronous loader. Both can run reliable automation in 2026, but configuration mismatches create some of the most confusing test startup failures.

The practical answer is to prefer ESM for a new suite when its runner, reporters, loaders, and helper packages support it. Keep CommonJS when an established suite is stable and migration offers no clear payoff. Whichever you choose, declare it explicitly and use one consistent model across source, tests, generated JavaScript, and configuration files.

TL;DR

Decision ESM CommonJS
Syntax import and export require() and module.exports
Explicit file marker .mjs .cjs
package.json marker for .js "type": "module" "type": "commonjs"
Loading model Static imports link before evaluation require() loads synchronously
Top-level await Supported Not supported directly
Test mocking Runner-specific ESM APIs and timing Mature hoisted or require-based patterns
Best default New standards-aligned projects Stable legacy or compatibility-focused suites

Do not rename imports until you can explain which tool executes each file. Test runners may transform TypeScript or JavaScript before Node sees it, so the effective runtime format can differ from the source syntax.

1. ESM vs CommonJS in tests qa: The Runtime Model

ECMAScript modules, usually called ESM, are the standard module format used by modern JavaScript. Imports and exports are part of the language grammar. Static imports are resolved and linked before a module is evaluated, which enables analysis and predictable dependency graphs. ESM modules run in strict mode and provide metadata through import.meta.

CommonJS, often abbreviated CJS, is Node's original module system. A module assigns values to module.exports, and consumers call require(). Node wraps each CommonJS file, which supplies variables such as module, exports, require, __filename, and __dirname. Loading is synchronous and occurs when execution reaches the require() call.

The distinction affects QA automation because tests often load configuration, fixtures, page objects, environment adapters, and mocks before the first assertion. A loader mismatch prevents discovery of every test. A mock registered after an ESM static import cannot replace a dependency that was already linked. A missing file extension can work under one transformed setup and fail when executed directly by Node.

Treat the module format as part of the test runtime contract. Document it near the supported Node version and package manager. If you are also standardizing style, ESLint and Prettier for test automation explains how to configure files without confusing lint parser mode with actual Node execution.

2. How Node Decides a File's Module Type

Explicit markers are safer than inference. Node always treats .mjs as ESM and .cjs as CommonJS. For .js, the nearest parent package.json can declare "type": "module" or "type": "commonjs". Package authors should set type even when using CommonJS so tools do not have to guess.

An ESM package can still contain a CommonJS configuration escape hatch:

qa-suite/
  package.json          # { "type": "module" }
  src/api-client.js     # ESM
  test/api-client.test.js # ESM
  legacy-reporter.cjs   # CommonJS

Conversely, a CommonJS package can include a .mjs utility. Extensions override the package default. This is useful for gradual migration, but widespread mixing raises cognitive cost and complicates mocking. Define boundaries rather than alternating formats file by file.

Node also has syntax detection behavior for ambiguous JavaScript in current releases, but a QA suite should not depend on it. A change such as adding one import statement can alter interpretation and expose different globals. Explicit markers make CI and local behavior easier to reproduce.

Configuration files have their own rules. eslint.config.mjs, jest.config.cjs, and playwright.config.ts are loaded by different tools that may support different transforms. A package being ESM does not guarantee every plugin accepts an ESM config. Check the owning tool's documentation and name the config extension deliberately.

3. Compare Resolution, Evaluation, and Available Globals

Syntax is only the visible difference. Resolution and evaluation explain why an apparently correct conversion fails. Relative ESM imports should include the output file extension when Node executes them directly. CommonJS resolution historically searches extensions and directory entry points in more situations. ESM treats specifiers more like URLs and provides import.meta.url for the current module location.

Concern ESM behavior CommonJS behavior QA impact
Relative import Usually explicit extension Extension may be inferred Transpiled path can fail in CI
Loading Static linking, plus dynamic import() Synchronous require() Setup and mocking order differs
Current file import.meta.url and related APIs __filename Fixture path helpers must change
Current directory Derive from module URL or supported metadata __dirname Snapshot and upload paths can break
JSON Follow current import attribute rules or read file require() commonly loads JSON Cross-version portability matters
Cache ESM loader cache require.cache Reset and isolation APIs differ
Top-level await Available Wrap in async function Global setup design changes

Avoid clever interop in core test helpers. A helper that behaves differently depending on whether it was imported or required is harder to type and mock. Expose a simple contract and keep the format boundary at a package edge.

Also distinguish asynchronous module loading from asynchronous tests. Dynamic import() returns a promise in both module systems. That does not automatically make assertions safe. Await imports, actions, and matchers according to their own contracts.

4. Write the Same Runnable Test in Both Formats

The built-in node:test runner provides a clean way to observe the module difference without a transpiler. Here is an ESM implementation. Save these files in a package with "type": "module", then run node --test.

// src/price.js
export function total(cents, quantity) {
  if (!Number.isInteger(cents) || !Number.isInteger(quantity)) {
    throw new TypeError('cents and quantity must be integers');
  }
  return cents * quantity;
}
// test/price.test.js
import assert from 'node:assert/strict';
import test from 'node:test';
import {total} from '../src/price.js';

test('calculates an integer total', () => {
  assert.equal(total(125, 3), 375);
});

test('rejects fractional cents', () => {
  assert.throws(() => total(1.5, 2), TypeError);
});

The CommonJS equivalent uses a package with "type": "commonjs", or .cjs files:

// src/price.cjs
function total(cents, quantity) {
  if (!Number.isInteger(cents) || !Number.isInteger(quantity)) {
    throw new TypeError('cents and quantity must be integers');
  }
  return cents * quantity;
}

module.exports = {total};
// test/price.test.cjs
const assert = require('node:assert/strict');
const test = require('node:test');
const {total} = require('../src/price.cjs');

test('calculates an integer total', () => {
  assert.equal(total(125, 3), 375);
});

Both are legitimate and runnable. The important details are consistent extensions, matching export shape, and a runner command that actually discovers the chosen filenames.

5. Configure Jest and Other Test Runners Deliberately

A test runner may execute code inside its own module system, transform files with Babel or TypeScript, or delegate more directly to Node. Therefore, node script.js succeeding does not prove jest will load the same graph, and the reverse is also true. Start from the runner's current ESM guide rather than pasting an old flag bundle.

For Jest ESM, transforms must emit ESM or be disabled for JavaScript that Node can execute. Jest's ESM support still has runner-specific constraints, including mock registration. In ESM test files, import Jest globals explicitly when needed:

import {expect, jest, test} from '@jest/globals';
import {calculateRetryDelay} from '../src/retry.js';

test('uses the base delay on the first retry', () => {
  expect(calculateRetryDelay(0, 500)).toBe(500);
  expect(jest.isMockFunction(calculateRetryDelay)).toBe(false);
});

CommonJS Jest tests often rely on injected globals and require, although explicit imports can still improve clarity. Do not assume a Jest configuration example applies to Vitest, Playwright Test, Mocha, or node:test. Each controls transforms and globals differently.

For browser automation, also account for two environments: the Node-side test file and code evaluated in the browser page. A function passed to browser evaluation cannot capture arbitrary Node module state. Module migration may expose accidental coupling that a transpiler previously hid. Review Playwright test architecture patterns before moving shared fixtures across package boundaries.

6. Align TypeScript Source With Runtime Output

TypeScript can accept import syntax and still emit CommonJS, so source appearance alone does not identify runtime format. The compiler options, package type, file extensions, and runner transformer must agree. Modern Node-oriented projects should use a Node-aware module and moduleResolution mode supported by their TypeScript version rather than a bundler mode for code that runs directly in Node.

For a direct Node ESM suite, a representative configuration looks like this:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

noEmit assumes a runner or loader handles TypeScript, or that type checking is separate from another build. If tsc emits runnable JavaScript, use an output directory and test the emitted graph. With NodeNext semantics, relative ESM source imports may reference .js because that is the runtime output specifier. This surprises people who expect .ts, but Node eventually executes JavaScript.

A bundler-aware setting can permit extensionless imports that direct Node ESM rejects. That is correct for bundled browser code and risky for unbundled test helpers. Match the resolution mode to the executor. Keep a small smoke command that starts the runner from a clean checkout so editor auto-import behavior cannot conceal the mismatch.

7. Understand Why Module Mocking Changes

CommonJS tests can often register a Jest factory through jest.mock() and then load a consumer with require(). Babel-backed Jest setups may hoist mock calls. ESM static imports, however, are linked before the test body runs, so writing a mock call below a static import cannot retroactively replace the dependency.

Jest provides jest.unstable_mockModule() for ESM module mocking in its current ESM workflow. The factory is required and can be async. Register the mock first, then dynamically import the subject. The API name signals that teams should isolate its use and check upgrade notes.

import {jest, test, expect} from '@jest/globals';

jest.unstable_mockModule('../src/clock.js', () => ({
  nowIso: jest.fn(() => '2026-07-13T00:00:00.000Z'),
}));

const {buildAuditRecord} = await import('../src/audit.js');

test('uses the clock dependency', () => {
  expect(buildAuditRecord('created')).toEqual({
    event: 'created',
    at: '2026-07-13T00:00:00.000Z',
  });
});

Dependency injection can reduce loader-specific mocking. Pass a clock, transport, or data provider into the unit under test, then use a plain object in tests. This improves portability and makes dependencies visible. Do not redesign every stable module solely to avoid a single mock, but treat extensive module interception as a migration risk. The companion Jest module mocking guide covers partial, manual, and isolated mocks in more depth.

8. Migrate a Test Suite in Controlled Slices

Begin with inventory, not renaming. List the Node version, runner, transformer, TypeScript settings, configuration extensions, custom reporters, setup files, and dependencies imported by tests. Search for require, module.exports, __dirname, __filename, require.resolve, require.cache, and module mock calls. These are migration hotspots.

Create a branch where the package mode changes explicitly. Convert one vertical slice containing a source module, its test, and its helper. Run it directly through the real test command. Then test coverage, reporting, retries, global setup, and CI arguments. A suite that discovers zero tests can return a different status depending on configuration, so verify counts rather than trusting a green icon.

Use .cjs for a configuration or reporter that cannot migrate yet. Record why the boundary remains. Avoid dual-loading the same stateful helper through both systems, which can produce separate instances and confusing caches. If publishing internal test utilities, define package exports carefully and test them from consumer fixtures instead of importing private paths.

Keep the migration commit focused. Combining module conversion, runner upgrade, formatter rewrite, and assertion refactor makes failures difficult to attribute. A compatibility step can be temporary if it has an owner and removal condition. The goal is a coherent runtime, not an impressive diff.

9. Handle Interoperability and Package Exports Safely

Node supports interoperability, but it is not perfect symmetry. ESM can import CommonJS, commonly receiving module.exports as the default export, while named export detection depends on analyzable patterns. CommonJS can use dynamic import() to load ESM and receives a promise. Current Node releases also support more require() of synchronous ESM than older versions, but QA libraries often support multiple Node lines, so relying on the newest interop can break CI or consumers.

Prefer documented public exports from dependencies. Do not import a package's internal file simply because autocomplete finds it. An exports map can intentionally block that path, and a minor package refactor can move it. When an ESM-only dependency enters a CommonJS suite, choose among dynamic import at a boundary, a compatible package version, or a planned migration. Do not blindly transpile node_modules.

For JSON fixtures, using node:fs and JSON.parse is portable and explicit when import-attribute support varies across environments:

import {readFile} from 'node:fs/promises';

export async function loadFixture(url) {
  const text = await readFile(url, 'utf8');
  return JSON.parse(text);
}

const user = await loadFixture(new URL('../fixtures/user.json', import.meta.url));

Validate the parsed shape before trusting external or hand-edited data. Module loading success does not make fixture contents type-safe.

10. ESM vs CommonJS in tests qa: Diagnose Loader Errors

Read the first loader error, not the cascade. Cannot use import statement outside a module usually means the executor classified a file as CommonJS. require is not defined in ES module scope means the opposite. ERR_MODULE_NOT_FOUND may indicate an omitted extension, a wrong emitted path, or a package export restriction. A named export error can be an interop shape mismatch.

Use a repeatable diagnostic order:

  1. Identify the exact file in the first stack frame and its final extension.
  2. Find its nearest package.json and inspect type.
  3. Determine whether Node, the test runner, or a transformer executed it.
  4. Inspect TypeScript or Babel output rather than assuming source syntax survives.
  5. Run one file with the normal runner and verbose configuration output.
  6. Check that CI uses the same Node and install lockfile.
  7. Reduce the import graph until the failing boundary is visible.

Do not respond by adding multiple loaders and flags at once. Each layer can make the original error disappear while producing a less obvious format mismatch later. Capture a minimal reproduction containing the package marker, config, one dependency, and one test. Once it works, apply the same boundary to the suite and remove experimental changes that were not required.

A compatibility decision record

Record the module decision in a short architecture note. Include the package type, supported Node line, test runner mode, TypeScript module settings, configuration file extensions, and any intentional CommonJS or ESM islands. State why the chosen format fits the suite and what would trigger reconsideration. This note prevents future maintainers from treating a deliberate .cjs file as forgotten cleanup.

Add runtime smoke cases for the boundaries most likely to regress. One case should import the primary test helper through the public path. Another should load configuration and a custom reporter through the real runner. If the repository publishes utilities, create a small consumer fixture for each supported import style rather than testing only internal source paths. Consumer tests catch export maps, file extensions, and declaration mismatches that ordinary unit tests miss.

Check startup failures separately from assertion failures in CI reporting. A loader error can stop discovery before test hooks or reporters initialize. The pipeline should preserve stderr, the executed Node version, runner version, and discovered test count. It should fail when zero tests are collected unless a command explicitly permits that result. These checks make module incidents diagnosable from the first artifact.

Module choice should not leak into business assertions. Page objects, API clients, and fixture builders should expose stable functions and data contracts. When format-specific code is limited to configuration and package edges, a later tool upgrade affects fewer files. That separation is the best evidence that the migration achieved architectural consistency instead of merely replacing one keyword with another.

Interview Questions and Answers

Q: What is the key difference between ESM and CommonJS?

ESM is the language-standard module system with static import and export linking. CommonJS is Node's original module system built around synchronous require() and module.exports. The difference affects resolution, evaluation order, globals, caching, and test mocking, not just syntax.

Q: How does Node classify a .js file?

The nearest package type field is the clearest control: module selects ESM and commonjs selects CommonJS. .mjs and .cjs are explicit per-file markers. I avoid relying on syntax detection in test infrastructure.

Q: Why can a Jest mock work in CommonJS but fail after ESM migration?

ESM static imports are linked before test-body code executes. A later mock registration cannot replace an already linked dependency. Jest's ESM workflow registers unstable_mockModule before a dynamic import, or I use dependency injection where appropriate.

Q: Can TypeScript imports run as CommonJS?

Yes. TypeScript source syntax does not determine emitted format by itself. Compiler module settings or a runner transformer can emit CommonJS, so I align compiler output, package type, and executor configuration.

Q: Which module format would you choose for a new QA framework?

I normally choose ESM if all required runners, reporters, and integrations support it because it aligns with the JavaScript standard and modern package ecosystem. I choose CommonJS when compatibility requirements are stronger. I document the decision and make it explicit.

Q: How would you migrate a large CommonJS suite?

I inventory loader-sensitive code, convert a vertical slice, and validate discovery, reports, setup, and CI. I use .cjs for justified temporary boundaries and keep unrelated upgrades separate. Test counts and failure behavior matter as much as a successful process exit.

Q: What causes ERR_MODULE_NOT_FOUND after migration?

Common causes include missing relative extensions, paths that do not match emitted JavaScript, and package subpaths blocked by exports. I inspect the executed file and runtime graph before changing aliases or transforms.

Common Mistakes

  • Changing source syntax without declaring a package module type.
  • Assuming TypeScript import syntax means the runtime receives ESM.
  • Omitting relative file extensions in JavaScript executed directly as Node ESM.
  • Copying Jest ESM flags from an old article without checking the installed release.
  • Registering an ESM module mock after statically importing the subject.
  • Replacing every config extension even when a plugin still expects CommonJS.
  • Mixing module conversion with a runner upgrade and large test refactor.
  • Trusting a zero-test green run instead of checking discovered test counts.
  • Importing undocumented dependency internals that a package export map blocks.
  • Using the newest Node interop behavior while CI or consumers run older supported releases.

Conclusion

The ESM vs CommonJS in tests qa decision should produce a coherent, explicit runtime. ESM is a strong default for new standards-aligned suites, while CommonJS remains a sound choice for compatible, stable automation. Reliability comes from aligning package markers, file extensions, compiler output, runner transforms, and mocking strategy.

Before migrating, build one minimal test that travels through the real CI command. If its module graph, discovery, failure status, and reporting are correct, expand in vertical slices. If there is no meaningful benefit to migration, make CommonJS explicit and spend the engineering time on test coverage instead.

Interview Questions and Answers

Explain ESM versus CommonJS in a test framework.

ESM uses standard static imports and exports, while CommonJS uses synchronous `require()` and `module.exports`. They differ in resolution, evaluation timing, available globals, and caching. Those differences affect setup files and module mocks before they affect test assertions.

How does Node know whether JavaScript is ESM?

The explicit controls are a `.mjs` extension or a nearest `package.json` with `type` set to `module`. `.cjs` or `type: commonjs` select CommonJS. I declare the package type rather than depending on ambiguous syntax detection.

Why is ESM module mocking different in Jest?

Static ESM imports link before the test body runs, so ordinary runtime ordering cannot replace an imported dependency. Jest's ESM pattern registers `unstable_mockModule` first and dynamically imports the subject. I also consider dependency injection for long-lived test design.

Can TypeScript hide a module mismatch?

Yes. A transformer can accept ESM-looking TypeScript and emit CommonJS, or use bundler-style resolution that direct Node does not support. I inspect compiler and runner output and align it with the package type.

How would you choose a module system for a new suite?

I check support across the runner, reporters, coverage, custom loaders, and critical packages. If they support ESM, I generally choose it for standards alignment. Otherwise, explicit CommonJS is preferable to an unstable mixed setup.

What is your ESM migration strategy?

I inventory loader-sensitive APIs, convert one complete slice, and run the real CI workflow. I verify test discovery, failure exit codes, reporting, coverage, and setup. Temporary `.cjs` boundaries are documented and kept at clear edges.

How do you debug a module loader error?

I start with the first failing file, its extension, nearest package type, and the tool that executes it. Then I inspect transformed output and relative specifiers. I change one layer at a time and keep a minimal reproduction.

Frequently Asked Questions

Is ESM better than CommonJS for tests in 2026?

ESM is usually the forward-looking choice for a new suite because it is the JavaScript standard and many packages publish ESM. CommonJS is still valid when a mature toolchain depends on it. Consistency and explicit configuration matter more than changing syntax for its own sake.

What does `type: module` do in package.json?

It tells Node to interpret `.js` files in that package scope as ESM. `.cjs` remains CommonJS and `.mjs` remains ESM regardless of that default. The nearest parent package file controls the scope.

Why does `require` fail in an ESM test?

ESM does not provide the CommonJS `require` variable by default. Use static `import`, dynamic `import()`, or an intentional compatibility boundary. Avoid recreating `require` merely to preserve an unclear mixed architecture.

Do ESM relative imports need file extensions?

For JavaScript executed directly by Node ESM, relative specifiers should name the runtime file, including its extension. A bundler or runner may apply different resolution, which is why direct and transformed setups must not be confused.

Can Jest mock ESM modules?

Jest supports an ESM workflow using `jest.unstable_mockModule()` followed by dynamic import of the subject. The API remains explicitly marked unstable, so isolate it and review Jest upgrade notes. Dependency injection is another option for controllable boundaries.

How do I use `__dirname` in ESM test helpers?

Use module metadata such as `import.meta.url` and construct file URLs relative to it. Current Node versions also expose additional `import.meta` conveniences, but URL-based code is broadly understood. Pass URLs directly to Node file APIs when supported.

Should a TypeScript test suite use NodeNext?

NodeNext module and resolution settings are appropriate when TypeScript code follows Node's ESM and CommonJS rules. A bundled application may need a different mode. Match TypeScript to the executor and verify either transformed or emitted JavaScript.

Related Guides