Sparround

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.

AspectESM (the modern standard)CommonJS (Node's legacy system)
Syntaximport / exportrequire() / module.exports
How it is enabled"type": "module" in package.json, or the .mjs extensionNode's default; the .cjs extension
Loadingstatic — tools can see dependencies ahead of timedynamic — require() can be called anywhere
Top-level awaitsupportednot supported
File extension in importsrequired in pure ESM ('./api.js')not required
Mixing themcan import CJScannot 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 paths aliases from tsconfig.json (@pages/login) — but those aliases will not work in a plain Node script without ts-node
  • in ESM mode a JSON file must be read as import data from './users.json' with { type: 'json' }, or simply via fs.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