Q22 of 40 · JavaScript
What is the difference between ES Modules and CommonJS, and why does it matter for test tooling?
Short answer
Short answer: CommonJS (`require`/`module.exports`) is synchronous and dynamic. ES Modules (`import`/`export`) are static, asynchronous, and tree-shakeable. Jest historically defaults to CJS via Babel; Playwright and Vitest are ESM-native. Mixing systems causes interop friction that breaks test setups.
Detail
JavaScript has two module systems with different semantics.
CommonJS (CJS): Used by Node.js since its inception. require() is synchronous and evaluated at runtime — you can require inside an if block. module.exports carries the export. CJS modules execute once and cache; subsequent require() calls return the cached result.
ES Modules (ESM): The TC39 standard. import/export are static — parsed and resolved before any code runs. This enables static analysis and tree-shaking (unused exports eliminated at build time). Top-level await is supported. Use .mjs extension or "type": "module" in package.json.
Interop pain points:
- Jest uses Babel to transform ESM to CJS by default; native ESM requires explicit Jest config.
- Libraries shipping only ESM cannot be
require()d without a workaround. __dirnameand__filenameare absent in ESM (useimport.meta.url+fileURLToPathinstead).- Dynamic
import()works in both systems and is the bridge for conditional/lazy imports.
// EXAMPLE
// CommonJS
const path = require("path");
module.exports = { helper };
function helper() { return "cjs"; }
// ES Module
import path from "path";
export function helper() { return "esm"; }
export default class Config {}
// __dirname in ESM
import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Dynamic import — works in both contexts
const { default: Plugin } = await import(`./plugins/${name}.js`);// WHAT INTERVIEWERS LOOK FOR
// COMMON PITFALL
// Related questions