PRACTICAL GUIDE / Selenium JavaScript TypeScript automation framework

Build a TypeScript Selenium suite that survives parallel CI

Design a TypeScript Selenium framework with explicit async calls, one driver per test, reliable teardown, useful artifacts, and safe CI checks.

By The Testing AcademyUpdated August 4, 202627 min read
All field guides
In this guide7 sections
  1. Why a small async omission corrupts the whole run
  2. Give every test a driver it can own
  3. Keep page components typed and assertions in the test
  4. Diagnose the look-alike failures before changing timeouts
  5. Wire compile checks and evidence into CI
  6. Roll the design into an existing suite without hiding risk
  7. When a fresh-driver framework is the wrong tool

What you will learn

  • Why a small async omission corrupts the whole run
  • Give every test a driver it can own
  • Keep page components typed and assertions in the test
  • Diagnose the look-alike failures before changing timeouts

The checkout test passes alone, then the full CI shard fails with NoSuchSessionError after another test signs out. Both tests imported the same driver, and the first one to finish closed a browser that the second still owned only by accident. Adding a retry makes the report greener, but it leaves the lifecycle bug in place.

A dependable Selenium JavaScript TypeScript automation framework is less about inheritance and more about making asynchronous work and session ownership impossible to misunderstand. The useful design question is simple: which test created this driver, which promises must settle before it ends, and what evidence survives if either the application or cleanup fails?

Why a small async omission corrupts the whole run

WebDriver is a remote-control protocol. The JavaScript binding turns operations such as navigation, element lookup, clicks, state reads, screenshots, and session deletion into asynchronous calls. Selenium's JavaScript API documents those calls as promises or promise-like results. JavaScript does not wait for one merely because the next source line looks related. The caller must await it or return it to something that will.

That detail becomes dangerous inside a friendly helper. Suppose signIn is declared async and performs three awaited operations internally. A test that calls signIn without await immediately receives a Promise and moves on. The helper is still doing work while the test reads the heading, begins teardown, or returns control to the runner. TypeScript knows the return type is Promise<void>, but ordinary type checking does not reject every ignored promise. The code can compile and still race its own cleanup.

The missing await is often several layers away from the symptom. A page method calls a component method. The component maps a list of elements through an async callback. The caller forgets to await Promise.all, or uses forEach with an async callback whose returned promises are discarded. The assertion then reads a pre-action state. On a fast laptop the browser may complete in time; under CI load it may not. The application did not become flaky. The test stopped defining when its action was complete.

A shared driver creates a different race with a similar surface. An exported singleton, a static BasePage.driver field, or a before hook scoped to an entire file lets multiple tests address the same session. One test changes the current URL while another is finding an element. One clears cookies that another test expects. One calls quit while another command is in flight. The W3C WebDriver model associates commands with a session identifier, and deleting that session makes the client unusable for later commands. No page-object pattern can repair ownership after two tests already share the identifier.

NoSuchSessionError is therefore evidence, not a diagnosis. It can follow an explicit quit in the wrong test. It can also appear after a browser process or remote session disappears. The discriminating fact is whether another local test recorded the same session ID and then began teardown. If every test had a different ID and no local quit preceded the failure, investigate the remote end, browser process, or CI worker instead. Exception text alone cannot tell those cases apart.

The same discipline applies to waits. An explicit wait polls a condition until it returns a useful value or the supplied timeout expires. It does not make an earlier unawaited action safe. Increasing the wait can hide a slow transition, but it cannot establish ordering if the test never awaited the function that initiated the transition. Selenium also warns that mixing implicit and explicit waits can produce unpredictable total wait durations. This framework sets the implicit timeout to zero and places explicit waits next to the state transition they protect.

Treat each async method signature as a contract. A method that performs browser work returns Promise<T>. Its caller awaits that promise. Array work uses a loop with await or constructs promises and awaits Promise.all deliberately. Teardown is awaited in a finally block. Artifact collection is awaited before teardown because screenshots and current-URL reads are WebDriver commands too. These are ordinary JavaScript rules, but browser timing makes violations unusually expensive to diagnose.

A useful review starts at the test's last line and walks backward. Find the promise that proves the assertion's prerequisite completed. Follow every helper in that chain. Then inspect the catch and finally paths. If any path can return while browser work remains unsettled, the test boundary is false even when the happy path is green.

Give every test a driver it can own

Configuration deserves a real boundary because process.env is not typed application data. Every value begins as a string or undefined. A misspelled browser, a zero timeout, or a malformed remote URL should stop the run before a browser session is requested. Silently falling back to Chrome is especially harmful in a matrix because the report can claim Firefox coverage that never happened.

The following parser defines project-owned environment variables. TEST_BASE_URL, TEST_BROWSER, and UI_TIMEOUT_MS are not Selenium flags; they are this test suite's contract. SELENIUM_REMOTE_URL is also understood by Selenium's Builder, but the factory below disables automatic environment overrides and applies the validated value itself. That prevents an unreviewed environment value from taking precedence over the typed configuration.

TypeScript
// src/framework/config.ts
export type SupportedBrowser = 'chrome' | 'firefox';

export interface FrameworkConfig {
  readonly baseUrl: string;
  readonly browser: SupportedBrowser;
  readonly remoteUrl: string | undefined;
  readonly uiTimeoutMs: number;
}

function httpUrl(name: string, value: string | undefined): string {
  if (!value) {
    throw new Error(name + ' is required');
  }

  const parsed = new URL(value);
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new Error(name + ' must use http or https');
  }
  return parsed.toString();
}

function browser(value: string | undefined): SupportedBrowser {
  const selected = value ?? 'chrome';
  if (selected === 'chrome' || selected === 'firefox') {
    return selected;
  }
  throw new Error('TEST_BROWSER must be chrome or firefox, received ' + selected);
}

function positiveInteger(
  name: string,
  value: string | undefined,
  fallback: number,
): number {
  const parsed = Number(value ?? fallback);
  if (!Number.isInteger(parsed) || parsed <= 0) {
    throw new Error(name + ' must be a positive integer');
  }
  return parsed;
}

export function readConfig(env: NodeJS.ProcessEnv): FrameworkConfig {
  return {
    baseUrl: httpUrl('TEST_BASE_URL', env.TEST_BASE_URL),
    browser: browser(env.TEST_BROWSER),
    remoteUrl: env.SELENIUM_REMOTE_URL
      ? httpUrl('SELENIUM_REMOTE_URL', env.SELENIUM_REMOTE_URL)
      : undefined,
    uiTimeoutMs: positiveInteger('UI_TIMEOUT_MS', env.UI_TIMEOUT_MS, 10_000),
  };
}

Validation does not prove that the application is reachable or that Grid has capacity. It does prove exactly what the suite requested. Log the sanitized effective configuration once per worker, not every environment variable. Environment dumps routinely expose tokens, proxy credentials, and unrelated secrets. A base URL may also carry credentials or query parameters, so reject such forms or redact them before recording it.

The session factory should have one public operation: create a driver for a test, run that test's callback, and close the same driver. Returning a naked driver from a global getDriver function invites callers to store it. Hiding quit in a process exit handler is also too late; a worker may execute several tests before the process ends, and a crashed process may never run the handler.

This fixture records the session ID as soon as creation succeeds. It captures a test failure while the session is still available. If quit fails after a test failure, it reports the cleanup problem without replacing the original exception. If the test passed and quit fails, cleanup becomes the failure because the suite has leaked a session. The failure hook is injected so the lifecycle code does not need to know how a particular CI system stores artifacts.

TypeScript
// src/framework/driver-fixture.ts
import { Builder, type WebDriver } from 'selenium-webdriver';
import type { FrameworkConfig } from './config.js';

export interface FailureContext {
  readonly driver: WebDriver;
  readonly testId: string;
  readonly sessionId: string;
  readonly error: unknown;
}

export type FailureHook = (context: FailureContext) => Promise<void>;

function errorFields(error: unknown): { name: string; message: string } {
  if (error instanceof Error) {
    return { name: error.name, message: error.message };
  }
  return { name: 'NonErrorThrown', message: String(error) };
}

function emitLifecycle(
  event: string,
  testId: string,
  sessionId: string,
): void {
  process.stdout.write(JSON.stringify({
    event,
    testId,
    sessionId,
    at: new Date().toISOString(),
  }) + '\n');
}

export async function usingDriver<T>(
  config: FrameworkConfig,
  testId: string,
  run: (driver: WebDriver) => Promise<T>,
  onFailure: FailureHook = async () => {},
): Promise<T> {
  let builder = new Builder()
    .disableEnvironmentOverrides()
    .forBrowser(config.browser);

  if (config.remoteUrl) {
    builder = builder.usingServer(config.remoteUrl);
  }

  const driver = await builder.build();
  let testFailed = false;
  let sessionId = 'unavailable';

  try {
    sessionId = (await driver.getSession()).getId();
    emitLifecycle('webdriver_session_created', testId, sessionId);
    await driver.manage().setTimeouts({
      implicit: 0,
      pageLoad: 60_000,
      script: 15_000,
    });
    return await run(driver);
  } catch (error) {
    testFailed = true;
    emitLifecycle('test_failed', testId, sessionId);
    try {
      await onFailure({ driver, testId, sessionId, error });
    } catch (artifactError) {
      process.stderr.write(JSON.stringify({
        event: 'artifact_capture_failed',
        testId,
        sessionId,
        error: errorFields(artifactError),
      }) + '\n');
    }
    throw error;
  } finally {
    emitLifecycle('webdriver_quit_started', testId, sessionId);
    try {
      await driver.quit();
      emitLifecycle('webdriver_quit_succeeded', testId, sessionId);
    } catch (cleanupError) {
      if (!testFailed) {
        throw cleanupError;
      }
      process.stderr.write(JSON.stringify({
        event: 'webdriver_quit_failed',
        testId,
        sessionId,
        error: errorFields(cleanupError),
      }) + '\n');
    }
  }
}

There is a real cost. A fresh session starts a browser and creates a profile for every test. Local execution consumes more time, and parallel CI consumes more CPU and Grid slots. The Selenium project still recommends a new WebDriver instance per test because it simplifies isolation. That recommendation does not mean unlimited concurrency. Set worker concurrency to the capacity you own, then improve application setup so tests do not spend their browser lifetime creating data through the UI.

Per-test ownership also changes how retries work. A retry is another test attempt, so it needs another driver and another artifact directory. Reusing the failed session carries cookies, open windows, storage, and half-completed application state into the retry. A passing retry on that contaminated state says little about the original failure. Preserve attempt one, create attempt two independently, and report both outcomes.

Keep page components typed and assertions in the test

Page objects earn their keep when they hide volatile UI mechanics while leaving the behavior under test visible. A login object can own locators, waits, and the order of entering credentials. It should not decide that an authentication error is correct. That expectation belongs in the test, where a reviewer can see the input and the promised product outcome together.

Selenium's page-object guidance makes the same separation and allows one narrow exception: a page object may verify that the expected page and critical elements are loaded. In a TypeScript suite, I prefer an explicit open or ready method because constructors cannot await. The test sees when readiness is checked, and object construction stays free of work that can fail later without a visible await.

Locate elements close to the action. Caching a WebElement field looks efficient, but modern interfaces replace nodes during rendering. A handle found before a React or Vue update can refer to an element that is no longer attached, producing StaleElementReferenceError even though an identical-looking input is on screen. Keeping a By locator and resolving it after the transition costs another protocol command. In return, the object describes the current DOM rather than a historical node.

TypeScript
// src/pages/login-page.ts
import {
  By,
  until,
  type WebDriver,
  type WebElement,
} from 'selenium-webdriver';

export interface Credentials {
  readonly email: string;
  readonly password: string;
}

export class LoginPage {
  private readonly form = By.css('[data-testid="login-form"]');
  private readonly email = By.css('[data-testid="email"]');
  private readonly password = By.css('[data-testid="password"]');
  private readonly submit = By.css('[data-testid="submit-login"]');
  private readonly error = By.css('[role="alert"]');

  public constructor(
    private readonly driver: WebDriver,
    private readonly baseUrl: string,
    private readonly timeoutMs: number,
  ) {}

  public async open(): Promise<void> {
    await this.driver.get(new URL('/login', this.baseUrl).toString());
    await this.visible(this.form);
  }

  public async signIn(credentials: Credentials): Promise<void> {
    const emailInput = await this.visible(this.email);
    await emailInput.clear();
    await emailInput.sendKeys(credentials.email);

    const passwordInput = await this.visible(this.password);
    await passwordInput.clear();
    await passwordInput.sendKeys(credentials.password);

    await (await this.visible(this.submit)).click();
  }

  public async errorText(): Promise<string> {
    return (await this.visible(this.error)).getText();
  }

  private async visible(locator: By): Promise<WebElement> {
    const element = await this.driver.wait(
      until.elementLocated(locator),
      this.timeoutMs,
      'Element was not located: ' + locator.toString(),
    );
    return this.driver.wait(
      until.elementIsVisible(element),
      this.timeoutMs,
      'Element was located but not visible: ' + locator.toString(),
    );
  }
}

The test now owns the assertion and the driver lifetime. Its oracle can fail if the application accepts invalid credentials, changes the error copy, or navigates away from the login route. It does not assert that a hard-coded fixture contains another hard-coded value. It reads two independent product observations after performing a user action.

TypeScript
// test/login.test.ts
import assert from 'node:assert/strict';
import test from 'node:test';
import { readConfig } from '../src/framework/config.js';
import { collectFailure } from '../src/framework/failure-artifacts.js';
import { usingDriver } from '../src/framework/driver-fixture.js';
import { LoginPage } from '../src/pages/login-page.js';

test('rejects invalid credentials', async () => {
  const config = readConfig(process.env);

  await usingDriver(
    config,
    'login-rejects-invalid-credentials',
    async (driver) => {
      const login = new LoginPage(
        driver,
        config.baseUrl,
        config.uiTimeoutMs,
      );

      await login.open();
      await login.signIn({
        email: 'missing-user-' + String(process.pid) + '@example.test',
        password: 'definitely-wrong',
      });

      assert.equal(
        await login.errorText(),
        'Email or password is incorrect',
      );
      assert.equal(
        new URL(await driver.getCurrentUrl()).pathname,
        '/login',
      );
    },
    collectFailure,
  );
});

This shape prevents assertion leakage, but it does not prevent every poor abstraction. A BasePage with click, type, wait, JavaScript execution, API clients, database access, and reporting becomes a service container with a driver attached. Tests end up calling generic mechanics rather than business actions, and every page inherits dependencies it does not use. Prefer small page or component objects that accept the driver they need and expose language from the product: signIn, chooseShippingMethod, removeLineItem.

A second worked failure shows why that distinction matters. Imagine a cart test that calls cartPage.clickButton('remove-3') and then asserts a total. When the locator breaks, the stack says a generic button helper failed. A CartLine component with remove() and displayedPrice() tells you which part of the interface owned the action. The test can ask the page for the line matching a product code, remove it, then assert the remaining product codes and displayed total. Locator details remain local, while the oracle stays capable of catching an incorrect removal or arithmetic bug.

A third case involves navigation results. It is tempting for signIn to always return DashboardPage. Invalid credentials, mandatory password changes, and account lockouts make that return type dishonest. Split the services by intended result, or let signIn submit and expose observable page state through separate objects. TypeScript is valuable when a method signature narrows a real possibility. It is harmful when a confident type casts away product states that tests exist to discover.

Do not expose WebDriver from every page merely to make unusual tests possible. Add the missing domain observation to the page component first. For rare browser-level checks, the test already receives its driver from the fixture and can use it directly. That escape hatch is explicit at the call site instead of becoming the normal design.

Diagnose the look-alike failures before changing timeouts

A failed run needs evidence from before the session is closed. The minimum useful bundle is the test ID, session ID, original error class and message, a sanitized current URL when available, browser capabilities relevant to reproduction, and a screenshot when policy permits it. Record failures from each command independently. A dead session may reject the screenshot, but the session ID and original exception are still worth keeping.

The collector below does not save page source, cookies, local storage, request bodies, or the full query string. Those fields often contain secrets or personal data. A screenshot can still expose sensitive content, so artifact access and retention need the same review as any production log. For authentication, payment, health, and admin tests, disable screenshots or mask the application before capture when your policy requires it.

TypeScript
// src/framework/failure-artifacts.ts
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type { FailureContext } from './driver-fixture.js';

function safeName(value: string): string {
  return value.replace(/[^a-zA-Z0-9._-]+/g, '_');
}

function errorRecord(error: unknown): { name: string; message: string } {
  if (error instanceof Error) {
    return { name: error.name, message: error.message };
  }
  return { name: 'NonErrorThrown', message: String(error) };
}

function sanitizeUrl(value: string): string {
  const url = new URL(value);
  if (url.protocol === 'about:' && url.pathname === 'blank') {
    return 'about:blank';
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    return url.protocol + '[redacted]';
  }
  url.username = '';
  url.password = '';
  url.search = '';
  url.hash = '';
  return url.toString();
}

export async function collectFailure(
  context: FailureContext,
): Promise<void> {
  const attemptKey = context.sessionId === 'unavailable'
    ? String(process.pid) + '-' + randomUUID()
    : safeName(context.sessionId);
  const directory = join(
    'artifacts',
    safeName(context.testId),
    attemptKey,
  );
  await mkdir(directory, { recursive: true });

  const record: Record<string, unknown> = {
    testId: context.testId,
    sessionId: context.sessionId,
    processId: process.pid,
    capturedAt: new Date().toISOString(),
    failure: errorRecord(context.error),
  };

  try {
    const capabilities = await context.driver.getCapabilities();
    record.browserName = capabilities.get('browserName');
    record.browserVersion = capabilities.get('browserVersion');
    record.platformName = capabilities.get('platformName');
  } catch (error) {
    record.capabilitiesCaptureFailure = errorRecord(error);
  }

  try {
    record.url = sanitizeUrl(await context.driver.getCurrentUrl());
  } catch (error) {
    record.urlCaptureFailure = errorRecord(error);
  }

  try {
    const png = await context.driver.takeScreenshot();
    await writeFile(join(directory, 'failure.png'), png, 'base64');
  } catch (error) {
    record.screenshotCaptureFailure = errorRecord(error);
  }

  await writeFile(
    join(directory, 'failure.json'),
    JSON.stringify(record, null, 2) + '\n',
    'utf8',
  );
}

Because the code writes the runtime's actual error name and message, the report does not need to imitate version-specific wording. Read the class and the command context together. TimeoutError from an explicit wait means its condition did not become truthy within that wait's budget. It does not prove the application was slow. The locator may target the wrong page, the action that reveals the element may not have been awaited, or the expected state may be wrong.

Here are three common look-alikes and the evidence that separates them.

First, NoSuchSessionError after a shared-driver race and a vanished remote session both mean the client can no longer use that session. Search all records for the same session ID. If test A and test B logged the same ID and A's teardown event came first, ownership is broken locally. If the failing ID belongs only to B and there is no local teardown, correlate it with Grid and browser logs. Do not add a retry until you know which boundary removed the session.

Second, an element wait that expires after login may be a locator problem or an environment problem. Inspect the sanitized path and screenshot. Seeing /login with a visible validation message means navigation to the dashboard was never a valid expectation for that input. Seeing an identity-provider path suggests a redirect or environment configuration difference. Seeing the dashboard without the target element points back to the locator or product rendering. The same TimeoutError can represent three different fixes.

Third, StaleElementReferenceError after removing a cart row may come from a cached element, or it may reveal an actual repeated action. If the component stored a WebElement before the removal and reused it afterward, relocate the row from a stable page locator. If the command log shows remove was called twice, fix the caller instead. Re-locating on every retry would hide the duplicate action and could delete the wrong line.

Reproduce one failing test without parallel scheduling as a diagnostic, not as the permanent repair. Compile first so the command exercises the emitted code the same way CI does. Keep the same browser and base URL as the failing job. The Node test runner can select by test name and limit concurrency; the resulting failure.json contains the unmodified runtime fields captured above.

Shell
export TEST_BASE_URL="http://127.0.0.1:3000"
export TEST_BROWSER="chrome"
export UI_TIMEOUT_MS="10000"

npm run typecheck
npm run build:test
node --test \
  --test-concurrency=1 \
  --test-name-pattern="rejects invalid credentials" \
  dist/test/login.test.js

find artifacts -name failure.json -print

A pass under concurrency one is not proof of a race. It only strengthens that hypothesis. Run the same selected test repeatedly against the same deployed version, then compare it with the parallel shard while keeping browser, data, and configuration fixed. A genuine data collision often survives fresh drivers because the shared state lives in the application database. A capacity problem often tracks the number of simultaneous session requests. An async omission can fail even with one test if the action is slow enough. Change one dimension at a time.

Screenshots are supporting evidence, not the oracle. They capture a viewport at one point after the error and may fail if the browsing context is gone. The current URL is similarly incomplete. The most persuasive record combines the product assertion, session identity, ordered lifecycle events, and the remote evidence for that same session. If those identities cannot be joined, say that the cause is unconfirmed.

Wire compile checks and evidence into CI

CI should reject structural errors before spending a browser slot. Run the TypeScript compiler first, then compile the test output, then execute the browser tests. Keep typecheck and build as separate commands if build tooling skips noEmit checks or transpiles despite type errors. Type checking catches incompatible page contracts and misspelled fields. It will not catch every ignored promise, invalid selector, or wrong expectation, so browser execution still matters.

The workflow below assumes package.json defines typecheck, build:test, and test:ui. Those are repository script names, not Selenium options. test:ui should run the compiled tests rather than silently transpiling TypeScript with different settings. The job uses an explicit browser and a repository variable for the application URL. If the target is a remote Grid, add a validated SELENIUM_REMOTE_URL through the same configuration boundary rather than branching inside test code.

YAML
name: selenium-ui

on:
  workflow_dispatch:
  pull_request:

jobs:
  chrome:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    env:
      TEST_BASE_URL: ${{ vars.TEST_BASE_URL }}
      TEST_BROWSER: chrome
      UI_TIMEOUT_MS: "10000"

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v6
        with:
          node-version: "22"
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Check TypeScript contracts
        run: npm run typecheck

      - name: Compile browser tests
        run: npm run build:test

      - name: Run with a virtual display
        run: xvfb-run -a npm run test:ui

      - name: Preserve failed-attempt evidence
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: selenium-${{ github.run_id }}-${{ github.run_attempt }}
          path: artifacts/
          if-no-files-found: ignore
          retention-days: 7

Pinning Node is not ceremonial. Selenium's JavaScript binding publishes a Node support policy, and current API documentation states its runtime requirement. Your lockfile controls the binding version; the workflow's Node version must satisfy that installed version. Upgrade the runtime and dependency as a reviewed change, compile the suite, and run a small browser matrix before broad rollout. Do not let a developer laptop's globally installed tools define the CI contract.

Artifact upload belongs after the test step with a failure condition. Capture happens inside the fixture before quit, while upload happens after the runner exits. These are different stages. Uploading only a final screenshot from a reporter may be too late because the session has already ended. Conversely, uploading on every passing run adds storage cost and expands access to browser data without diagnostic value.

A job timeout is an outer safety net, not a substitute for test budgets. It can terminate the process before finally completes, leaving the remote end to reap a session later. Keep explicit page-load, script, and UI wait budgets below the job timeout, and make the runner stop accepting new work when its own deadline is close. The exact values depend on the application and environment. The sample values above are configuration examples, not measured recommendations.

Parallel execution needs a capacity contract. If CI starts twelve workers against a Grid with four available slots, session creation delay is expected. Increasing UI element waits will not add slots because those waits begin after session creation. Either limit workers, add capacity, or schedule browser matrices separately. Record the point at which a session ID becomes available so queue time is not mislabeled as application latency.

Keep retries outside the page objects and session fixture. A runner or CI retry policy can create a new attempt with a fresh session and separate artifacts. A helper that catches TimeoutError and repeats a click changes product behavior: the application may have processed the first click even though the response was late. Retrying that action inside a page method can submit an order twice. Only repeat an operation when the application contract makes it safe and the test explicitly intends to exercise that behavior.

The cost of this CI design is visible. Type checking and compilation add pipeline time. Fresh sessions consume browser startup time. Failure artifacts consume storage and demand access controls. Limiting concurrency can lengthen wall-clock duration. Those costs buy a report in which a passing test had one owned session, a failing test kept its first exception, and a reviewer can connect local evidence to the correct remote session.

Roll the design into an existing suite without hiding risk

A framework rewrite fails when it changes lifecycle, locators, waits, assertions, data setup, and CI topology in one pull request. When the suite changes behavior, nobody can tell whether the new structure fixed a race or merely stopped reaching the old assertion. Migrate by boundary and keep the same product oracle until ownership is stable.

Start with an inventory. Search for driver construction, exports, suite-wide hooks, static fields, quit calls, async array callbacks, and helpers that return no declared Promise even though they use WebDriver. The command below is intentionally a search, not an automatic verdict. A beforeEach hook can be correct, and the word driver can appear in harmless types. Review each result by tracing creation to teardown.

Shell
rg -n \
  "new Builder|beforeAll|afterAll|beforeEach|afterEach|\.quit\(" \
  src test

rg -n \
  "export (const|let|var).*driver|static .*driver|globalThis.*driver" \
  src test

rg -n \
  "\.forEach\(async|\.map\(async" \
  src test

Next, introduce the configuration parser without changing browser behavior. Feed it the current base URL, browser, remote URL, and timeout values. Add unit tests for invalid input so a future fallback cannot silently undo the contract. These assertions can fail when the parser accepts an unsupported browser or converts zero into a usable timeout.

TypeScript
// test/config.test.ts
import assert from 'node:assert/strict';
import test from 'node:test';
import { readConfig } from '../src/framework/config.js';

test('rejects a browser outside the supported matrix', () => {
  assert.throws(
    () => readConfig({
      TEST_BASE_URL: 'https://test.example/',
      TEST_BROWSER: 'safari',
    }),
    /TEST_BROWSER must be chrome or firefox/,
  );
});

test('rejects a zero UI timeout', () => {
  assert.throws(
    () => readConfig({
      TEST_BASE_URL: 'https://test.example/',
      UI_TIMEOUT_MS: '0',
    }),
    /UI_TIMEOUT_MS must be a positive integer/,
  );
});

test('preserves the selected Firefox configuration', () => {
  const config = readConfig({
    TEST_BASE_URL: 'https://test.example/',
    TEST_BROWSER: 'firefox',
    UI_TIMEOUT_MS: '7500',
  });

  assert.equal(config.browser, 'firefox');
  assert.equal(config.uiTimeoutMs, 7500);
});

Then move one low-risk test to usingDriver. Choose a test that neither opens multiple windows nor depends on a persisted profile. Record its test and session IDs, prove quit runs on pass and failure, and compare its existing assertion before and after migration. Do not enable parallel execution yet. The first goal is ownership, not speed.

Move a vertical slice of page code next. Start with one user journey and its components rather than creating a universal BasePage. Change methods that perform browser work to explicit Promise return types. At every call site, await them. Replace cached WebElement fields only where the DOM can change. Keep stable locators and working waits until the lifecycle change is proven; redesigning every selector at once destroys the comparison.

The first migrated failures will expose ambiguous helpers. A method named waitAndClick may mix locating, visibility, enabled state, scrolling, and an action. Resist translating it mechanically. Ask what user service the caller needs and what postcondition the test will observe. Sometimes the right migration is a page method with one explicit wait. Sometimes the test should call the driver directly because the operation is genuinely browser-level. The point is to clarify responsibility, not maximize wrapper coverage.

After several tests use the fixture, add failure artifacts and verify their privacy controls. Force a known test assertion to fail in a disposable branch or local change, confirm failure.json contains the assertion error and session ID, confirm the screenshot policy behaves as intended, and confirm the test still reports the original assertion when quit is deliberately made unavailable. Remove that temporary fault before merging. This is a controlled verification of the harness, not a fabricated production measurement.

Only then turn on parallelism for the migrated group. Give tests unique backend data and avoid account reuse. Fresh browser profiles do not isolate orders, users, rate limits, email inboxes, or feature-flag assignments stored by the application. If two tests still collide on one customer record, reducing browser state sharing solved the wrong layer. Build data ownership into the test case or keep that group serial until the application offers a safe setup route.

Retire the singleton last. Leaving it available as a compatibility escape hatch guarantees new tests will keep importing it. Once all callers in a slice use the fixture, remove the export and let compilation reveal stragglers. Do the same for global page objects that captured the old driver. A compile failure at an unmigrated call site is useful evidence; casting it away with any only delays the runtime race.

Track rollout by owned tests, not lines converted. A test counts as migrated when it validates config, creates one session, awaits its full browser chain, asserts product behavior, captures correlated failure evidence, and awaits teardown. It does not count because its page class extends the new base class. That checklist measures the boundary the framework exists to protect.

When a fresh-driver framework is the wrong tool

Do not reach for a browser framework when the behavior can be proved below the browser. Price calculations, permission rules, schema validation, and pure rendering decisions are usually faster and easier to diagnose in unit, service, or component tests. Keep a smaller set of browser journeys for integration risks that only a real browser exposes. Session isolation improves browser tests, but it does not make them the cheapest test layer.

Do not split one genuine end-to-end scenario into several tests merely to claim a fresh driver for each assertion. If the requirement is that a user can add an item, pay, and see the same order in history, that journey owns one session from its first browser action through its final assertion. The test boundary is the scenario. Independent tests should not depend on its intermediate browser state, but steps inside the scenario can share the driver's lifetime.

A new driver per test is also a poor excuse for testing third-party systems you do not control. CAPTCHA, live payment authorization, real email providers, and social sign-in can be unreliable, costly, or prohibited to automate through their public UI. Use vendor-supported test modes or controlled substitutes where available, and keep only the contract check your product owns. A perfect fixture cannot turn an unsuitable target into a stable test.

Avoid automatic screenshots where the page can display secrets, personal records, or regulated data and you cannot secure the artifact path. The safer choice may be a metadata-only failure record with session ID, error, sanitized route, and application correlation ID. Diagnostic richness has a privacy cost. Make that trade consciously rather than discovering it after CI artifacts are broadly downloadable.

Do not add a page object for every HTML page by rule. A short, stable test used once may be clearer with a few direct locators. An object becomes valuable when it names a reusable product service, contains changing UI knowledge, or represents a repeated component. Empty wrappers create more files and indirection while leaving the real behavior in generic helpers.

Be cautious with fresh sessions when the purpose of the test is session continuity itself. Tests for remembered consent, restored tabs, a browser extension, or a deliberately persistent profile need a controlled profile lifecycle that matches the feature. Isolate that suite and document its cleanup. Do not weaken every other test by making persistent state the global default.

Finally, do not turn the fixture into a universal recovery system. It should own creation, evidence capture, and teardown. It should not refresh on assertion failure, recreate sessions behind a page method, swallow protocol errors, or retry user actions. Those choices alter what the test did. Keep them at the test or runner policy boundary, where a reviewer can see the cost to coverage and meaning.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why does my Selenium TypeScript test finish before the browser action?

Most WebDriver calls return promises, so an omitted await lets the caller continue before the action settles. Return promises from helpers and await every action, state read, wait, artifact capture, and quit call.

Should every JavaScript Selenium test create a new driver?

One fresh driver per test is the safest default because ownership and cleanup stay unambiguous. A deliberately long scenario can keep one session for that scenario, but unrelated tests should not borrow it.

Where should assertions live in a TypeScript page object framework?

Keep outcome assertions in the test so the expected behavior remains visible. Page objects should expose user actions and observable state, although a page object may reject construction or use when its required page is not loaded.

How do I keep the original test error if driver.quit also fails?

Collect evidence before teardown, then treat the test exception as the primary failure. Record the cleanup exception separately when another error already exists, and fail on the cleanup exception when the test body itself passed.

Can fresh WebDriver sessions remove all Selenium flakiness?

A new session removes browser-state coupling between tests, not unstable locators, shared backend data, slow services, or exhausted Grid capacity. Session IDs and ordered failure records help distinguish those causes.