QA Interview Hub · Knowledge base · Quizzes

Patterns

Singleton and global state

Singleton in Node and the Playwright worker model

A Singleton guarantees a single instance of a class and a global access point. In TypeScript it is often made not as a class with static getInstance(), but simply as an export of an object from a module, because a module is cached and executed once per process.

The key Playwright nuance: tests run in parallel in several worker processes (separate Node processes). Each worker loads modules anew. So:

  • A singleton is "one instance per worker", not for the whole run.
  • State written in one worker isn't visible in another.
  • Code in globalSetup runs in a separate process, so the variables it filled aren't visible to tests. Data must be passed through a file, environment variables or storageState.
// db.ts - a "singleton"
let pool: Pool | undefined;
export function getDb() {
  pool ??= new Pool({ connectionString: process.env.DB_URL });
  return pool;
}

With workers: 4 there will be 4 pools. That is fine, but someone has to close them, and an ordinary module has no teardown hook. It is even worse with mutable state: a currentUser in a global module that tests change. The order of tests in a worker isn't guaranteed after a retry, and a test failure and a worker restart resets the state.

In an interview: In Playwright each worker is a separate Node process, so a module-level singleton lives separately in each worker, and shared state between them is impossible. So I don't keep mutable global state, and I make expensive resources worker fixtures.

The alternative: a worker fixture instead of a Singleton

Everything candidates make a Singleton for (a DB connection, an API client, config) is better done in Playwright as a worker fixture. It gives the same "one instance per worker", but with a managed lifecycle.

export const test = base.extend<{}, { db: Pool }>({
  db: [async ({}, use) => {
    const pool = new Pool({ connectionString: process.env.DB_URL });
    await use(pool);
    await pool.end();
  }, { scope: 'worker' }],
});

What we get:

  • An explicit dependency: it is visible which tests use the DB.
  • Guaranteed closing of the resource.
  • Easy to replace in a separate file through a new extend.

Unique data per worker can be built from workerInfo.workerIndex or test.info().parallelIndex. parallelIndex lies in the range from 0 to workers - 1 and is preserved when a worker restarts, so it is convenient to use for a pool of pre-created test accounts.

When a Singleton is acceptable: an immutable config read once from env. There is no state there that can be spoiled.

A typical mistake: "I made a Singleton for the browser so as not to open it in every test". Playwright already reuses browser at the worker level, and test isolation is given by a new context. A custom singleton browser breaks isolation and parallelism.

In an interview: Instead of a Singleton I make worker fixtures: the same one instance per worker, but with an explicit dependency and a guaranteed teardown. I leave Singleton only for immutable config.