Modules: import and export
Every file is a separate module: everything inside is private by default and only what you export is visible outside. A test project's structure is built on exactly this — page objects, helpers and test data live in separate files.
Named exports — as many per file as you like:
- at the declaration:
export const BASE_URL = '...',export class LoginPage {} - import:
import { LoginPage, BASE_URL } from './pages/login.page' - rename:
import { LoginPage as Login } from '...' - everything under one name:
import * as pages from './pages'
Default export — at most one per file:
export default class LoginPage {}- import:
import LoginPage from './pages/login.page'— the name here can be ANYTHING
That freedom is precisely the weakness of default exports: the same class gets imported under different names across the project, making search and refactoring harder. Most teams prefer named exports in test projects.
| Aspect | ESM (the modern standard) | CommonJS (Node's legacy system) |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| How it is enabled | "type": "module" in package.json, or the .mjs extension | Node's default; the .cjs extension |
| Loading | static — tools can see dependencies ahead of time | dynamic — require() can be called anywhere |
| Top-level await | supported | not supported |
| File extension in imports | required in pure ESM ('./api.js') | not required |
| Mixing them | can import CJS | cannot require() ESM (only dynamic import()) |
A barrel file is an index.ts acting as a folder's "front door":
export { LoginPage } from './login.page'; — then tests can write import { LoginPage, CartPage } from '../pages'.
The upside: shorter import lines, and you can reshuffle the internal file structure without touching the tests. The downside: importing one thing through a barrel loads the whole folder and sharply increases the risk of circular imports.
A circular import is file A importing B while B imports A. The result: one module gets used before it is fully loaded and its value is undefined. The classic POM symptom: BasePage imports LoginPage for a navigation helper while LoginPage extends BasePage — and the test dies with Class extends value undefined is not a constructor.
The fixes: (1) move the shared part into a third file (types.ts, constants.ts); (2) never let a parent class know its children — pass the object in as a parameter; (3) last resort: a dynamic await import('./login.page') inside the function.
Test frameworks resolve imports themselves, and that differs from plain Node behaviour. What you should know:
- Playwright transpiles TypeScript files and supports
pathsaliases fromtsconfig.json(@pages/login) — but those aliases will not work in a plain Node script withoutts-node - in ESM mode a JSON file must be read as
import data from './users.json' with { type: 'json' }, or simply viafs.readFileSync - Jest uses its own
moduleNameMapper— aliases configured for Playwright are not picked up there automatically
A useful interview line: "Import errors almost always come from three causes: an ESM/CJS mix, an alias configured in only one tool, or a circular dependency."
📚 Sources and documentation
- JavaScript modulesofficialdeveloper.mozilla.org
- Node.js ECMAScript modulesofficialnodejs.org
The official list of differences between CommonJS and ESM.