DEV Community

Cover image for Salesforce Testing with Playwright + TypeScript: The Complete Practical Guide (2026 Edition)
Himanshu Agarwal
Himanshu Agarwal

Posted on

Salesforce Testing with Playwright + TypeScript: The Complete Practical Guide (2026 Edition)

Salesforce is the hardest mainstream web application to automate. The DOM is deeply nested, IDs are auto-generated and change on every render, components hide inside shadow roots, toasts vanish before you can read them, and a single Lightning page can fire a dozen asynchronous XHR calls before it settles. Teams that succeed at Salesforce test automation don't have better selectors — they have a better strategy.

This is that strategy, written as a practical guide with runnable code. No motivational filler, no "why testing matters" preamble. We go straight into the setup, the authentication approach that eliminates login flakiness, the locator patterns that survive Salesforce re-renders, a Page Object architecture that scales to hundreds of tests, and the handling of every awkward Lightning component you will actually meet: modals, toasts, lookups, comboboxes, datatables, and Visualforce iframes.

By the end you will have a working framework: reliable login via saved session state, robust locators, page objects, data setup through the API, real end-to-end scenarios, parallel execution, and CI/CD on GitHub Actions and Azure DevOps.

Want the complete, book-length version? This article condenses material covered in full in the Salesforce Automation Testing Mastery Series (2026 Edition) — three books spanning Playwright + TypeScript from zero, enterprise framework design, and complete API testing. Get the bundle: https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries. Questions? Connect on LinkedIn: https://www.linkedin.com/in/himanshuai/


Table of contents

  1. Why Salesforce breaks ordinary automation
  2. Project setup
  3. Authentication: log in once, reuse everywhere
  4. Locator strategy for Lightning
  5. The Page Object Model, done right
  6. Handling Lightning components
  7. Waiting: killing flakiness at the source
  8. Fast test data with the REST API
  9. Writing real end-to-end scenarios
  10. SOQL assertions from the UI layer
  11. Cross-browser and parallel execution
  12. Reporting and debugging
  13. CI/CD integration
  14. Organizing tests as the suite grows
  15. Testing across permissions and personas
  16. More Lightning component patterns
  17. Diagnosing and eliminating flaky tests
  18. Visual and accessibility checks
  19. Best practices checklist
  20. Where to go next

1. Why Salesforce breaks ordinary automation

Before writing a single test, understand what you are fighting. Every flaky Salesforce suite fails for the same handful of reasons, and each has a specific countermeasure.

Auto-generated IDs. Lightning renders element IDs like input-42 or combobox-button-171. They change on every page load and every re-render. Any test that keys off these IDs is broken before it starts. The countermeasure is to never use generated IDs — locate by role, label, or visible text instead.

Shadow DOM. Lightning Web Components encapsulate their internals in shadow roots. A CSS selector that works in the browser console often returns nothing from a naive automation tool because it cannot pierce the shadow boundary. Playwright's role- and text-based locators pierce open shadow DOM automatically, which is a large part of why it suits Salesforce so well.

Deep nesting. A single field can sit twelve <div> layers deep inside nested components. Brittle XPath chains that encode this structure snap the moment Salesforce ships a UI tweak in its thrice-yearly release. Semantic locators ignore structure entirely.

Asynchronous rendering. Clicking a button often triggers a spinner, an XHR call, a re-render, and a toast — all asynchronous. Tests that assume the page is ready the instant an action completes race against the framework and fail intermittently. Auto-waiting and explicit state assertions solve this.

Ephemeral toasts. Success and error toasts appear and auto-dismiss within a few seconds. A test that navigates away before reading the toast, or reads it after it disappears, produces false negatives. You must assert on the toast in the narrow window it exists.

Session and login friction. Logging in through the UI on every test is slow and is itself a common flake source — MFA prompts, "verify your identity" screens, and session timeouts all interfere. The fix is to authenticate once and reuse the session, which we cover in detail below.

Playwright addresses most of these at the framework level: it auto-waits for elements to be actionable, pierces open shadow DOM, retries assertions until they pass or time out, and lets you persist and reuse authenticated sessions. The rest is discipline in how you write locators and structure tests.


2. Project setup

You need Node 18 or newer. Start clean:

mkdir salesforce-playwright && cd salesforce-playwright
npm init -y
npm install -D @playwright/test typescript @types/node dotenv
npx playwright install
Enter fullscreen mode Exit fullscreen mode

Add a tsconfig.json with path aliases so imports stay readable as the project grows:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": {
      "@pages/*": ["src/pages/*"],
      "@fixtures/*": ["src/fixtures/*"],
      "@utils/*": ["src/utils/*"],
      "@data/*": ["src/data/*"]
    }
  },
  "include": ["src", "tests"]
}
Enter fullscreen mode Exit fullscreen mode

Now the Playwright config. The important pieces for Salesforce are a generous timeout (Lightning is slow), a baseURL pointing at your org, storageState for session reuse, and trace capture on retry so you can debug failures.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import 'dotenv/config';

export default defineConfig({
  testDir: './tests',
  timeout: 90_000,
  expect: { timeout: 15_000 },
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['list'],
    ['html', { open: 'never' }],
    ['junit', { outputFile: 'results/junit.xml' }],
  ],
  use: {
    baseURL: process.env.SF_INSTANCE_URL,
    storageState: 'storage/session.json',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    actionTimeout: 20_000,
    navigationTimeout: 45_000,
  },
  // Global setup logs in once and saves the session before any test runs.
  globalSetup: require.resolve('./src/global-setup'),
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
  ],
});
Enter fullscreen mode Exit fullscreen mode

A .env file (git-ignored) holds the org details:

SF_INSTANCE_URL=https://your-domain.my.salesforce.com
SF_LOGIN_URL=https://test.salesforce.com
SF_USERNAME=qa.user@yourorg.com.sandbox
SF_PASSWORD=SuperSecret123
SF_API_VERSION=v62.0
Enter fullscreen mode Exit fullscreen mode

The SF_API_VERSION is deliberately a config value. Salesforce ships three releases a year and each bumps the API version; keeping it in one place means one edit at upgrade time.

The target folder structure:

salesforce-playwright/
├── src/
│   ├── global-setup.ts
│   ├── pages/
│   │   ├── base.page.ts
│   │   ├── login.page.ts
│   │   ├── account.page.ts
│   │   └── opportunity.page.ts
│   ├── fixtures/
│   │   └── pages.fixture.ts
│   ├── utils/
│   │   ├── salesforce-api.ts
│   │   └── lightning.ts
│   └── data/
│       └── factories.ts
├── tests/
├── storage/
└── playwright.config.ts
Enter fullscreen mode Exit fullscreen mode

3. Authentication: log in once, reuse everywhere

The single biggest improvement you can make to a Salesforce suite is to stop logging in through the UI on every test. Do it once in global setup, save the browser storage state, and let every test start already authenticated. This removes the slowest and flakiest step from every spec.

Global setup with saved storage state

// src/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
import { mkdirSync } from 'node:fs';

async function globalSetup(config: FullConfig) {
  const loginUrl = process.env.SF_LOGIN_URL!;
  const instanceUrl = process.env.SF_INSTANCE_URL!;
  const username = process.env.SF_USERNAME!;
  const password = process.env.SF_PASSWORD!;

  mkdirSync('storage', { recursive: true });

  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Log in through the standard Salesforce login page.
  await page.goto(`${loginUrl}/`);
  await page.getByLabel('Username').fill(username);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Log In' }).click();

  // Wait until we land on an authenticated Lightning page.
  await page.waitForURL(/lightning|my\.salesforce\.com/, { timeout: 60_000 });
  await page.waitForLoadState('networkidle');

  // Persist cookies + storage so tests skip the login flow entirely.
  await page.context().storageState({ path: 'storage/session.json' });
  await browser.close();
}

export default globalSetup;
Enter fullscreen mode Exit fullscreen mode

Because playwright.config.ts sets storageState: 'storage/session.json', every test now opens with a live session. Tests navigate straight to the record or list page they need — no login screen, no MFA prompt, no wasted seconds.

The faster alternative: frontdoor.jsp with an OAuth token

If you already obtain an OAuth access token (for API-driven data setup, covered later), you can skip the UI login entirely using Salesforce's frontdoor.jsp endpoint, which exchanges a session token for an authenticated browser session and redirects wherever you want.

// src/utils/lightning.ts
import { Page } from '@playwright/test';

export async function openWithToken(
  page: Page,
  instanceUrl: string,
  accessToken: string,
  retURL = '/lightning/page/home'
) {
  await page.goto(
    `${instanceUrl}/secur/frontdoor.jsp` +
    `?sid=${accessToken}&retURL=${encodeURIComponent(retURL)}`
  );
  await page.waitForLoadState('networkidle');
}
Enter fullscreen mode Exit fullscreen mode

This is the most robust login method available for Salesforce automation. There is no password typed into a form, no login page to break, and no MFA interruption — you arrive pre-authenticated on the exact page under test. Reserve the UI login in global setup for when you specifically want to test the login experience itself.


4. Locator strategy for Lightning

Locators are where Salesforce suites live or die. The rule is simple: locate the way a human describes an element, not the way the DOM happens to nest it. Playwright's semantic locators do exactly this and pierce open shadow DOM as a bonus.

Prefer role, label, and text

// Good — semantic, stable, shadow-DOM-piercing
page.getByRole('button', { name: 'New' });
page.getByLabel('Account Name');
page.getByRole('link', { name: 'Acme Corporation' });
page.getByPlaceholder('Search Accounts and more');
page.getByText('Your changes were saved');

// Bad — generated IDs and structural XPath that break on every release
page.locator('#input-42');
page.locator('//div[3]/div/div[2]/span/input');
Enter fullscreen mode Exit fullscreen mode

The semantic locators read like the interface, so anyone can understand them, and they survive the structural churn that Salesforce introduces with each seasonal release.

When you must use CSS, anchor on stable attributes

Some Lightning elements have no accessible name. When you fall back to CSS, anchor on attributes Salesforce keeps stable across renders — data-* hooks, field-name, and title attributes — never on generated IDs.

// Salesforce keeps field-name stable on record form fields.
page.locator('[field-name="Industry"] input');

// Datatable cells expose the API name via data-label / data-col-key.
page.locator('[data-label="Account Name"] a');

// Toasts carry a stable data-key.
page.locator('.slds-notify_toast');
Enter fullscreen mode Exit fullscreen mode

Scope locators to a container

Salesforce pages often show the same label in multiple places — a field in the highlights panel and again in the details tab. Scope your locator to the relevant region so it resolves to exactly one element.

const detailPanel = page.getByRole('tabpanel', { name: 'Details' });
await detailPanel.getByLabel('Phone').fill('+1 415 555 0100');
Enter fullscreen mode Exit fullscreen mode

Build a small locator vocabulary

Rather than repeat raw locators everywhere, centralize the recurring Lightning patterns in a helper. This keeps every page object consistent and gives you one place to adjust if Salesforce changes a class.

// src/utils/lightning.ts (continued)
import { Page, Locator } from '@playwright/test';

export const lightning = {
  toast: (page: Page): Locator => page.locator('.slds-notify_toast'),

  spinner: (page: Page): Locator => page.locator('.slds-spinner'),

  modal: (page: Page): Locator =>
    page.locator('.slds-modal__container, section[role="dialog"]'),

  recordField: (page: Page, apiName: string): Locator =>
    page.locator(`[field-name="${apiName}"]`),

  actionButton: (page: Page, name: string): Locator =>
    page.getByRole('button', { name }),
};
Enter fullscreen mode Exit fullscreen mode

5. The Page Object Model, done right

The Page Object Model keeps locators and page interactions out of your test bodies. Tests describe intent; page objects know how to fulfill it. Done well, a Salesforce UI change touches one page object, not fifty tests.

A base page for shared behavior

Start with a base class that every page object extends. It owns the Page handle and the cross-cutting behavior that every Salesforce page shares — waiting for the app to settle, reading toasts, and navigating to records.

// src/pages/base.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { lightning } from '@utils/lightning';

export abstract class BasePage {
  constructor(protected readonly page: Page) {}

  /** Wait until Lightning has finished its spinners and network activity. */
  async waitUntilReady(): Promise<void> {
    await this.page.waitForLoadState('networkidle');
    // Spinners come and go; wait for none to be visible.
    const spinner = lightning.spinner(this.page);
    await expect(spinner).toHaveCount(0, { timeout: 30_000 }).catch(() => {});
  }

  /** Read and assert the success toast within its brief lifetime. */
  async expectSuccessToast(expected?: string | RegExp): Promise<void> {
    const toast = lightning.toast(this.page);
    await expect(toast).toBeVisible({ timeout: 15_000 });
    if (expected) {
      await expect(toast).toContainText(expected);
    }
  }

  /** Navigate directly to a record page by object and Id. */
  async gotoRecord(objectApiName: string, recordId: string): Promise<void> {
    await this.page.goto(`/lightning/r/${objectApiName}/${recordId}/view`);
    await this.waitUntilReady();
  }

  /** Navigate to an object's list view. */
  async gotoList(objectApiName: string): Promise<void> {
    await this.page.goto(`/lightning/o/${objectApiName}/list`);
    await this.waitUntilReady();
  }
}
Enter fullscreen mode Exit fullscreen mode

A concrete page object

Now a page object for the Account record page. Locators are declared once as class properties; methods express the operations a test cares about.

// src/pages/account.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './base.page';

export class AccountPage extends BasePage {
  private readonly newButton: Locator;
  private readonly nameInput: Locator;
  private readonly saveButton: Locator;

  constructor(page: Page) {
    super(page);
    this.newButton = page.getByRole('button', { name: 'New' });
    this.nameInput = page.getByLabel('Account Name');
    this.saveButton = page.getByRole('button', { name: 'Save', exact: true });
  }

  async openNewForm(): Promise<void> {
    await this.gotoList('Account');
    await this.newButton.click();
    await expect(this.nameInput).toBeVisible();
  }

  async fillName(name: string): Promise<void> {
    await this.nameInput.fill(name);
  }

  async selectIndustry(value: string): Promise<void> {
    // Lightning picklists are comboboxes — open then choose.
    await this.page.getByLabel('Industry').click();
    await this.page
      .getByRole('option', { name: value, exact: true })
      .click();
  }

  async save(): Promise<void> {
    await this.saveButton.click();
    await this.expectSuccessToast(/was (created|saved)/i);
  }

  async getFieldValue(fieldLabel: string): Promise<string> {
    const field = this.page
      .locator('records-record-layout-item', { hasText: fieldLabel })
      .locator('lightning-formatted-text, lightning-formatted-number')
      .first();
    return (await field.textContent())?.trim() ?? '';
  }
}
Enter fullscreen mode Exit fullscreen mode

The test reads as a story, with no locator noise:

const account = new AccountPage(page);
await account.openNewForm();
await account.fillName('Playwright Corp');
await account.selectIndustry('Technology');
await account.save();
Enter fullscreen mode Exit fullscreen mode

Inject page objects with fixtures

Instantiating page objects by hand in every test is repetitive. Playwright fixtures inject ready-made page objects into any test that declares them.

// src/fixtures/pages.fixture.ts
import { test as base } from '@playwright/test';
import { AccountPage } from '@pages/account.page';
import { OpportunityPage } from '@pages/opportunity.page';

type Pages = {
  accountPage: AccountPage;
  opportunityPage: OpportunityPage;
};

export const test = base.extend<Pages>({
  accountPage: async ({ page }, use) => {
    await use(new AccountPage(page));
  },
  opportunityPage: async ({ page }, use) => {
    await use(new OpportunityPage(page));
  },
});

export { expect } from '@playwright/test';
Enter fullscreen mode Exit fullscreen mode

Tests import from the fixture and receive fully-built page objects:

import { test, expect } from '@fixtures/pages.fixture';

test('creates an Account', async ({ accountPage }) => {
  await accountPage.openNewForm();
  await accountPage.fillName('Fixture Corp');
  await accountPage.selectIndustry('Finance');
  await accountPage.save();
});
Enter fullscreen mode Exit fullscreen mode

6. Handling Lightning components

Standard automation patterns fall short on Salesforce's custom components. Each of the tricky ones has a reliable pattern. Learn these once and you handle 95% of what Lightning throws at you.

Comboboxes and picklists

Lightning picklists are not native <select> elements; they are custom comboboxes. You open them with a click and choose an option by its role.

async function selectComboboxOption(page: Page, label: string, option: string) {
  await page.getByLabel(label).click();
  await page.getByRole('option', { name: option, exact: true }).click();
}
Enter fullscreen mode Exit fullscreen mode

Lookup fields

Lookups are search-as-you-type comboboxes that query related records. Type part of the name, wait for the dropdown, then pick the result.

async function selectLookup(page: Page, fieldLabel: string, searchText: string) {
  const combobox = page.getByRole('combobox', { name: fieldLabel });
  await combobox.click();
  await combobox.fill(searchText);
  // Wait for the async search results, then click the matching option.
  await page
    .getByRole('option', { name: new RegExp(searchText, 'i') })
    .first()
    .click();
}
Enter fullscreen mode Exit fullscreen mode

The key is patience: the dropdown is populated by an XHR call, so the option does not exist the instant you finish typing. Locating by role and letting Playwright auto-wait handles the timing without a hardcoded sleep.

Modals and dialogs

Actions like New, Edit, and Change Owner open modals. Scope your interactions to the modal container so you don't accidentally match a same-named element on the page behind it.

async function actInModal(page: Page, action: () => Promise<void>) {
  const modal = page.getByRole('dialog');
  await expect(modal).toBeVisible();
  await action();
  // Modal should close after a successful save.
  await expect(modal).toBeHidden({ timeout: 15_000 });
}
Enter fullscreen mode Exit fullscreen mode

Toast messages

Toasts are the most timing-sensitive element in Salesforce. They appear after an action and auto-dismiss within a few seconds. Assert on them immediately after the triggering action, and never insert navigation between the action and the assertion.

async function expectToast(page: Page, message: string | RegExp) {
  const toast = page.locator('.slds-notify_toast');
  await expect(toast).toBeVisible({ timeout: 12_000 });
  await expect(toast).toContainText(message);
}
Enter fullscreen mode Exit fullscreen mode

If your test navigates away before checking the toast, you will get intermittent failures that are maddening to diagnose. The discipline is: act, assert the toast, then move on.

Datatables and list views

List views and related lists render as datatables. Locate rows by their content and cells by column, using the stable data-label attribute Salesforce puts on each cell.

async function clickRowLink(page: Page, rowText: string) {
  const row = page.getByRole('row', { name: new RegExp(rowText) });
  await row.getByRole('link').first().click();
}

async function getCellText(page: Page, rowText: string, columnLabel: string) {
  const row = page.getByRole('row', { name: new RegExp(rowText) });
  const cell = row.locator(`[data-label="${columnLabel}"]`);
  return (await cell.textContent())?.trim() ?? '';
}
Enter fullscreen mode Exit fullscreen mode

Visualforce iframes

Some legacy pages embed Visualforce inside an iframe. Playwright's frameLocator steps into the frame so you can interact with its contents.

async function fillVisualforceField(page: Page, label: string, value: string) {
  const frame = page.frameLocator('iframe[title*="content"]');
  await frame.getByLabel(label).fill(value);
}
Enter fullscreen mode Exit fullscreen mode

Tabs and record detail sections

Record pages organize fields into tabs. Activate the tab before asserting on fields inside it, since inactive tab panels may not render their contents.

async function openTab(page: Page, tabName: string) {
  await page.getByRole('tab', { name: tabName }).click();
  await expect(page.getByRole('tabpanel', { name: tabName })).toBeVisible();
}
Enter fullscreen mode Exit fullscreen mode

7. Waiting: killing flakiness at the source

Flakiness in Salesforce almost always traces back to a test asserting on state before the framework has produced it. The cure is never a fixed waitForTimeout — that either wastes time or fails under load. The cure is asserting on the specific condition you are actually waiting for.

Assert on state, not on time

// Fragile — guesses how long rendering takes.
await page.waitForTimeout(3000);
await expect(page.getByText('Acme Corp')).toBeVisible();

// Robust — waits exactly as long as needed, no more.
await expect(page.getByText('Acme Corp')).toBeVisible({ timeout: 20_000 });
Enter fullscreen mode Exit fullscreen mode

Playwright's web-first assertions retry automatically until they pass or the timeout expires. Give them a generous timeout for Lightning and they absorb the platform's variable rendering speed without a single hardcoded pause.

Wait for spinners to clear

Lightning shows a spinner during saves and navigations. Wait for it to disappear before interacting further.

async function waitForSpinnersGone(page: Page) {
  await expect(page.locator('.slds-spinner')).toHaveCount(0, {
    timeout: 30_000,
  });
}
Enter fullscreen mode Exit fullscreen mode

Wait for network to settle after navigation

After navigating to a record, wait for networkidle so all the component XHR calls that populate the page have completed.

await page.goto(`/lightning/r/Account/${id}/view`);
await page.waitForLoadState('networkidle');
Enter fullscreen mode Exit fullscreen mode

Retry only what should be retried

Configure retries in the config so genuinely transient failures get a second chance, but never wrap assertions in manual retry loops that hide real bugs. A test that only passes on retry is telling you something — investigate patterns of retried tests rather than accepting them silently.

The mindset shift is this: every wait should name the condition it is waiting for. If you cannot name the condition, you are guessing, and guesses are what make suites flaky.


8. Fast test data with the REST API

Building test data through the UI is slow and pointless. The API creates a fully-related data graph in one call, and your UI test starts with exactly the state it needs. This is the biggest speed and stability win available after session reuse.

A minimal API helper

// src/utils/salesforce-api.ts
import { request, APIRequestContext } from '@playwright/test';

export class SalesforceApi {
  private constructor(
    private api: APIRequestContext,
    public instanceUrl: string,
    public accessToken: string,
    private version: string
  ) {}

  static async login(): Promise<SalesforceApi> {
    const loginUrl = process.env.SF_LOGIN_URL!;
    const version = process.env.SF_API_VERSION ?? 'v62.0';
    const ctx = await request.newContext();

    const res = await ctx.post(`${loginUrl}/services/oauth2/token`, {
      form: {
        grant_type: 'password',
        client_id: process.env.SF_CLIENT_ID!,
        client_secret: process.env.SF_CLIENT_SECRET!,
        username: process.env.SF_USERNAME!,
        password: `${process.env.SF_PASSWORD}${process.env.SF_SECURITY_TOKEN ?? ''}`,
      },
    });
    if (!res.ok()) throw new Error(`Login failed: ${await res.text()}`);
    const data = await res.json();

    const authed = await request.newContext({
      baseURL: data.instance_url,
      extraHTTPHeaders: {
        Authorization: `Bearer ${data.access_token}`,
        'Content-Type': 'application/json',
      },
    });
    return new SalesforceApi(authed, data.instance_url, data.access_token, version);
  }

  async create(sobject: string, fields: Record<string, unknown>): Promise<string> {
    const res = await this.api.post(
      `/services/data/${this.version}/sobjects/${sobject}`,
      { data: fields }
    );
    if (res.status() !== 201) {
      throw new Error(`Create ${sobject} failed: ${await res.text()}`);
    }
    return (await res.json()).id;
  }

  async remove(sobject: string, id: string): Promise<void> {
    await this.api.delete(`/services/data/${this.version}/sobjects/${sobject}/${id}`);
  }

  async query<T = any>(soql: string): Promise<T[]> {
    const res = await this.api.get(`/services/data/${this.version}/query`, {
      params: { q: soql },
    });
    return (await res.json()).records;
  }
}
Enter fullscreen mode Exit fullscreen mode

Data factories

Wrap record creation in factories that provide sensible defaults, so tests specify only what matters to them.

// src/data/factories.ts
import { SalesforceApi } from '@utils/salesforce-api';

export async function createAccount(
  api: SalesforceApi,
  overrides: Record<string, unknown> = {}
): Promise<string> {
  return api.create('Account', {
    Name: `Test Account ${Date.now()}`,
    Industry: 'Technology',
    ...overrides,
  });
}

export async function createOpportunity(
  api: SalesforceApi,
  accountId: string,
  overrides: Record<string, unknown> = {}
): Promise<string> {
  return api.create('Opportunity', {
    Name: `Test Opp ${Date.now()}`,
    StageName: 'Prospecting',
    CloseDate: '2026-12-31',
    AccountId: accountId,
    ...overrides,
  });
}
Enter fullscreen mode Exit fullscreen mode

Wiring API setup into a UI test

Combine the API for setup and the browser for verification. The API builds the Account and Opportunity instantly; the browser only checks what a user would see.

import { test, expect } from '@fixtures/pages.fixture';
import { SalesforceApi } from '@utils/salesforce-api';
import { createAccount, createOpportunity } from '@data/factories';

test('Opportunity appears on the Account related list', async ({ page }) => {
  const api = await SalesforceApi.login();
  const accountId = await createAccount(api, { Name: 'Related List Corp' });
  await createOpportunity(api, accountId, { Name: 'Visible Deal' });

  // Jump straight to the record — session is already authenticated.
  await page.goto(`/lightning/r/Account/${accountId}/view`);
  await page.waitForLoadState('networkidle');

  await page.getByRole('tab', { name: /Related/ }).click();
  await expect(page.getByRole('link', { name: 'Visible Deal' })).toBeVisible();

  // Cleanup — remove children before parents.
  const opps = await api.query<{ Id: string }>(
    `SELECT Id FROM Opportunity WHERE AccountId = '${accountId}'`
  );
  for (const o of opps) await api.remove('Opportunity', o.Id);
  await api.remove('Account', accountId);
});
Enter fullscreen mode Exit fullscreen mode

Setting up through the API turned what would be a two-minute click-fest into a sub-second operation, and the browser now tests only the behavior that genuinely needs a browser.


9. Writing real end-to-end scenarios

With the building blocks in place, real business flows become readable, reliable tests. Consider a sales flow: create an Account, add an Opportunity, move it through stages, and confirm it closes. This is the kind of scenario that matters to the business and that regression suites should guard.

// tests/e2e/sales-cycle.ui.spec.ts
import { test, expect } from '@fixtures/pages.fixture';
import { SalesforceApi } from '@utils/salesforce-api';
import { createAccount } from '@data/factories';

test.describe('Sales cycle', () => {
  let api: SalesforceApi;
  let accountId: string;

  test.beforeAll(async () => {
    api = await SalesforceApi.login();
  });

  test.beforeEach(async () => {
    accountId = await createAccount(api, { Name: `Cycle Corp ${Date.now()}` });
  });

  test.afterEach(async () => {
    const opps = await api.query<{ Id: string }>(
      `SELECT Id FROM Opportunity WHERE AccountId = '${accountId}'`
    );
    for (const o of opps) await api.remove('Opportunity', o.Id);
    await api.remove('Account', accountId);
  });

  test('takes an Opportunity from Prospecting to Closed Won', async ({ page }) => {
    await page.goto(`/lightning/r/Account/${accountId}/view`);
    await page.waitForLoadState('networkidle');

    // Create a new Opportunity from the Account.
    await page.getByRole('tab', { name: /Related/ }).click();
    await page
      .getByRole('list')
      .getByRole('button', { name: 'New' })
      .first()
      .click();

    const modal = page.getByRole('dialog');
    await modal.getByLabel('Opportunity Name').fill('Enterprise Deal');
    await modal.getByLabel('Close Date').fill('2026-12-31');
    await modal.getByLabel('Stage').click();
    await page.getByRole('option', { name: 'Prospecting' }).click();
    await modal.getByRole('button', { name: 'Save', exact: true }).click();

    await expect(page.locator('.slds-notify_toast')).toContainText(/created/i);

    // Advance the stage via the path component to Closed Won.
    await page.getByRole('link', { name: 'Enterprise Deal' }).click();
    await page.waitForLoadState('networkidle');

    await page.getByRole('button', { name: /Closed Won/ }).click();
    await page.getByRole('button', { name: 'Mark as Complete' }).click();

    await expect(page.locator('.slds-notify_toast')).toContainText(/success|updated/i);

    // Verify the outcome through the data layer for certainty.
    const [opp] = await api.query<{ StageName: string }>(
      `SELECT StageName FROM Opportunity WHERE AccountId = '${accountId}' LIMIT 1`
    );
    expect(opp.StageName).toBe('Closed Won');
  });
});
Enter fullscreen mode Exit fullscreen mode

Notice the pattern: the UI performs the actions a user performs, and the final assertion confirms the outcome through the API. This double-check — behavior through the browser, result through the data layer — is far stronger than a UI-only assertion, because it verifies that the action actually persisted rather than just that a toast appeared.


10. SOQL assertions from the UI layer

UI assertions confirm what a user sees; SOQL assertions confirm what was actually written. The most reliable Salesforce tests use both. After a UI action, query the record and assert on its true state.

test('Account industry persists after edit', async ({ page }) => {
  const api = await SalesforceApi.login();
  const id = await createAccount(api, { Name: 'Edit Corp', Industry: 'Retail' });

  await page.goto(`/lightning/r/Account/${id}/view`);
  await page.waitForLoadState('networkidle');

  // Edit Industry inline.
  await page.getByRole('button', { name: 'Edit Industry' }).click();
  await page.getByRole('combobox', { name: 'Industry' }).click();
  await page.getByRole('option', { name: 'Finance' }).click();
  await page.getByRole('button', { name: 'Save', exact: true }).click();
  await expect(page.locator('.slds-notify_toast')).toContainText(/saved/i);

  // Assert on the persisted value, not just the on-screen text.
  const [account] = await api.query<{ Industry: string }>(
    `SELECT Industry FROM Account WHERE Id = '${id}'`
  );
  expect(account.Industry).toBe('Finance');

  await api.remove('Account', id);
});
Enter fullscreen mode Exit fullscreen mode

This pattern catches a whole class of bugs the UI alone misses: cases where the screen shows a value but the save silently failed, or where a validation rule quietly reverted the change. Querying the source of truth removes all ambiguity.


11. Cross-browser and parallel execution

Salesforce officially supports Chrome, Edge, Firefox, and Safari. Playwright runs all of them from one config, and its parallel model lets a large suite finish in a fraction of the wall-clock time.

Multiple browser projects

The config already defines Chromium and Edge projects. Add Firefox and WebKit to cover the full matrix:

projects: [
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  { name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
  { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  { name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
Enter fullscreen mode Exit fullscreen mode

Run a single browser during development and the full matrix in CI:

npx playwright test --project=chromium          # fast local loop
npx playwright test                              # all browsers in CI
Enter fullscreen mode Exit fullscreen mode

Parallelism and data isolation

With fullyParallel: true and multiple workers, tests run simultaneously. The one rule that makes parallelism safe on Salesforce is data isolation: every test must create its own records and never depend on data another test might be mutating. The factories from earlier, combined with per-test cleanup, give you exactly that. Unique names (using a timestamp or random suffix) prevent collisions when many workers create similar records at once.

Because each test owns its data and cleans up after itself, you can scale workers up freely. The limiting factor becomes your org's API and login limits, not test interference — which is another reason to reuse the session and set up data through batched API calls.


12. Reporting and debugging

When a Salesforce test fails at 2 a.m. in CI, the report is all you have. Configure rich artifacts so failures are diagnosable without re-running anything.

The trace viewer

The config sets trace: 'on-first-retry'. On a failed retry, Playwright records a full trace — DOM snapshots, network calls, console logs, and a frame-by-frame timeline. Open it with:

npx playwright show-trace trace.zip
Enter fullscreen mode Exit fullscreen mode

The trace viewer is the single most valuable debugging tool for Salesforce, because it lets you scrub through exactly what the page looked like at each step and see which XHR call was still pending when an assertion failed. Most "impossible" Lightning flakes become obvious in the trace.

Screenshots and video

screenshot: 'only-on-failure' and video: 'retain-on-failure' capture visual evidence of every failure with no cost on passing runs. Combined with the trace, you rarely need to reproduce a failure locally.

The HTML report

The HTML reporter produces a browsable report with each test's steps, timings, and attached artifacts:

npx playwright show-report
Enter fullscreen mode Exit fullscreen mode

Targeted debugging during development

For local debugging, run a single test headed with the inspector:

npx playwright test sales-cycle --project=chromium --debug
Enter fullscreen mode Exit fullscreen mode

This opens the Playwright Inspector, which steps through the test and highlights each locator on the page — invaluable when a Lightning locator is not matching what you expect.


13. CI/CD integration

Tests earn their keep only when they run automatically on every change. Both major platforms integrate cleanly. The shared concerns are: install dependencies and browsers, supply credentials as secrets, run the suite, and publish artifacts.

GitHub Actions

# .github/workflows/salesforce-tests.yml
name: Salesforce UI Tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - name: Run tests
        env:
          CI: true
          SF_INSTANCE_URL: ${{ secrets.SF_INSTANCE_URL }}
          SF_LOGIN_URL: https://test.salesforce.com
          SF_USERNAME: ${{ secrets.SF_USERNAME }}
          SF_PASSWORD: ${{ secrets.SF_PASSWORD }}
          SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
          SF_CLIENT_SECRET: ${{ secrets.SF_CLIENT_SECRET }}
          SF_API_VERSION: v62.0
        run: npx playwright test --project=chromium

      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14
Enter fullscreen mode Exit fullscreen mode

The if: always() on the upload step ensures you get the report even when tests fail — which is precisely when you need it. All credentials come from repository secrets, never the repo itself.

Azure DevOps

# azure-pipelines.yml
trigger:
  branches:
    include: [main]

pool:
  vmImage: ubuntu-latest

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'

  - script: npm ci
    displayName: Install dependencies

  - script: npx playwright install --with-deps chromium
    displayName: Install browsers

  - script: npx playwright test --project=chromium
    displayName: Run tests
    env:
      CI: true
      SF_INSTANCE_URL: $(SF_INSTANCE_URL)
      SF_LOGIN_URL: https://test.salesforce.com
      SF_USERNAME: $(SF_USERNAME)
      SF_PASSWORD: $(SF_PASSWORD)
      SF_CLIENT_ID: $(SF_CLIENT_ID)
      SF_CLIENT_SECRET: $(SF_CLIENT_SECRET)
      SF_API_VERSION: v62.0

  - task: PublishTestResults@2
    condition: always()
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: 'results/junit.xml'
      testRunTitle: 'Salesforce UI Tests'
Enter fullscreen mode Exit fullscreen mode

The JUnit reporter configured earlier feeds Azure's native test-results tab, giving pass/fail trends over time. Store credentials as secret pipeline variables.

Pipeline strategy

Run a fast single-browser pass on every pull request as a merge gate, and reserve the full cross-browser matrix for merges to main or a nightly schedule. This keeps developer feedback tight while still exercising the complete matrix regularly. Because browsers are separate Playwright projects, splitting stages is a matter of which --project flags you pass.


14. Organizing tests as the suite grows

A dozen tests are easy. Three hundred are not, unless you impose structure from the start. Salesforce suites grow fast because every object, every flow, and every permission set is a candidate for coverage. Organization is what keeps that growth maintainable.

Group by feature, tag by purpose

Structure test files by business feature — accounts, opportunities, cases, quotes — not by technical layer. Within them, use tags to slice the suite for different pipeline stages.

import { test, expect } from '@fixtures/pages.fixture';

test.describe('Opportunity management', () => {
  test('creates an opportunity @smoke @regression', async ({ opportunityPage }) => {
    // ...
  });

  test('recalculates amount from line items @regression', async ({ opportunityPage }) => {
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

Run just the smoke set as a fast merge gate, and the full regression set nightly:

npx playwright test --grep @smoke        # fast gate on every PR
npx playwright test --grep @regression   # full suite nightly
Enter fullscreen mode Exit fullscreen mode

Tags let one suite serve multiple purposes without duplicating tests. A @smoke subset gives developers feedback in a couple of minutes, while @regression runs the exhaustive matrix when time is less critical.

Share setup with describe-level hooks

Use beforeAll for expensive one-time setup like API login, and beforeEach for per-test data. Keep hooks small and push real logic into helpers and factories so the intent of each test stays visible.

test.describe('Case escalation', () => {
  let api: SalesforceApi;

  test.beforeAll(async () => {
    api = await SalesforceApi.login();
  });

  test.beforeEach(async ({ page }, testInfo) => {
    // Attach the run context to the report for easier debugging.
    testInfo.annotations.push({ type: 'org', description: process.env.SF_INSTANCE_URL! });
  });
});
Enter fullscreen mode Exit fullscreen mode

Keep tests independent

Every test must pass in isolation and in any order. A test that depends on a record another test created is a latent failure waiting for the day parallelism or a --grep filter changes the execution order. The factories and per-test cleanup shown earlier enforce this independence, and it is worth treating as non-negotiable: no test reads or mutates data owned by another.


15. Testing across permissions and personas

Salesforce behavior is profile- and permission-driven. The same page shows different fields, buttons, and record access depending on who is logged in. A serious suite tests more than one persona — at minimum a standard user and an admin, and often specific personas like a sales rep, a support agent, and a read-only auditor.

Multiple saved sessions

Extend global setup to log in as each persona and save a separate storage state per role.

// src/global-setup.ts (multi-persona)
import { chromium } from '@playwright/test';
import { mkdirSync } from 'node:fs';

const personas = [
  { name: 'admin', user: process.env.SF_ADMIN_USER!, pass: process.env.SF_ADMIN_PASS! },
  { name: 'sales', user: process.env.SF_SALES_USER!, pass: process.env.SF_SALES_PASS! },
];

async function loginAs(loginUrl: string, user: string, pass: string, file: string) {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto(`${loginUrl}/`);
  await page.getByLabel('Username').fill(user);
  await page.getByLabel('Password').fill(pass);
  await page.getByRole('button', { name: 'Log In' }).click();
  await page.waitForURL(/lightning|my\.salesforce\.com/, { timeout: 60_000 });
  await page.context().storageState({ path: file });
  await browser.close();
}

export default async function globalSetup() {
  mkdirSync('storage', { recursive: true });
  const loginUrl = process.env.SF_LOGIN_URL!;
  for (const p of personas) {
    await loginAs(loginUrl, p.user, p.pass, `storage/${p.name}.json`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Select a persona per test

Override storageState at the test or describe level to run as a specific role.

test.describe('Admin-only actions', () => {
  test.use({ storageState: 'storage/admin.json' });

  test('admin can change record owner', async ({ page }) => {
    // ...
  });
});

test.describe('Sales rep view', () => {
  test.use({ storageState: 'storage/sales.json' });

  test('sales rep cannot see the Delete button', async ({ page }) => {
    await page.goto(`/lightning/o/Account/list`);
    await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing negative cases — that a persona cannot do something — is as important as testing that an admin can. Permission regressions are among the most dangerous Salesforce bugs because they silently expose or hide data, and a UI-only manual test rarely catches them across every profile.


16. More Lightning component patterns

Beyond the core components covered earlier, a handful of specialized inputs come up often enough to keep patterns ready for them.

Date pickers

Lightning date fields accept typed input directly, which is more reliable than clicking through the calendar grid.

async function setDate(page: Page, label: string, value: string) {
  // value formatted per the org's locale, e.g. '12/31/2026'.
  const input = page.getByRole('combobox', { name: label });
  await input.click();
  await input.fill(value);
  await page.keyboard.press('Escape'); // close the calendar overlay
}
Enter fullscreen mode Exit fullscreen mode

Multi-select dual list boxes

Some fields render as dual list boxes where you move options from available to selected. Locate options by text and use the move buttons.

async function selectFromDualListbox(page: Page, option: string) {
  await page.getByRole('option', { name: option }).click();
  await page.getByRole('button', { name: 'Move selection to Chosen' }).click();
}
Enter fullscreen mode Exit fullscreen mode

File uploads

Lightning file uploads use a hidden native file input. Set the file directly on it rather than trying to drive the drag-and-drop overlay.

async function uploadFile(page: Page, filePath: string) {
  await page.locator('input[type="file"]').setInputFiles(filePath);
  // Wait for the upload to register in the files related list.
  await expect(page.getByText(/1 of 1 file uploaded/i)).toBeVisible();
}
Enter fullscreen mode Exit fullscreen mode

Rich text editors

Rich text fields render inside a contenteditable region. Target it and type.

async function fillRichText(page: Page, label: string, text: string) {
  const editor = page
    .locator('lightning-input-rich-text', { hasText: label })
    .locator('[contenteditable="true"]');
  await editor.click();
  await editor.fill(text);
}
Enter fullscreen mode Exit fullscreen mode

Global search

The global search box drives navigation across the whole org. Type, submit, and select from results.

async function globalSearch(page: Page, term: string) {
  const search = page.getByPlaceholder(/Search.*and more/i);
  await search.click();
  await search.fill(term);
  await page.keyboard.press('Enter');
  await page.waitForLoadState('networkidle');
}
Enter fullscreen mode Exit fullscreen mode

Each of these follows the same philosophy as the core patterns: locate by role or a stable attribute, let Playwright auto-wait for the element to be actionable, and assert on a resulting state rather than sleeping. Once the patterns are in your utilities, they are one function call away in any test.


17. Diagnosing and eliminating flaky tests

A flaky test — one that passes and fails without any code change — is worse than a failing test, because it erodes trust in the whole suite. On Salesforce, flakiness is common but almost always traceable to a small set of causes. Here is how to hunt each one down.

Find the pattern first

Before touching a flaky test, gather data. Configure retries and let CI record which tests are retried; a test that consistently needs a retry is telling you something specific. Playwright's HTML report flags flaky tests distinctly — ones that failed then passed on retry. Treat that list as a backlog, not as background noise.

// Run a suspect test many times to reproduce intermittent failure.
// npx playwright test suspect.spec --repeat-each=20 --project=chromium
Enter fullscreen mode Exit fullscreen mode

Reproducing locally with --repeat-each is the fastest way to confirm a fix actually works rather than getting lucky once.

The usual causes and their fixes

Racing the render. The test asserts before Lightning finishes rendering. Fix: replace any waitForTimeout with a web-first assertion on the specific element or state, and wait for spinners to clear after actions.

Toast timing. The test reads a toast after it auto-dismissed, or navigates before reading it. Fix: assert on the toast immediately after the triggering action, with no intervening navigation.

Stale locators after re-render. Lightning re-renders a component and the previously resolved element detaches. Fix: let Playwright re-resolve the locator at action time — never store a resolved element handle across an action that triggers a re-render.

Shared or leftover data. A prior run left a record that collides with this run, or two parallel tests touch the same data. Fix: unique names per record and strict per-test cleanup, so no test depends on external state.

Session expiry. A long suite outlives the saved session. Fix: keep the integration user's session timeout generous, and re-run global setup if a run is expected to exceed it.

Make the fix, then prove it

After a fix, run the test twenty times with --repeat-each. If it passes every time, the fix holds. If it fails even once, the root cause is still there. This discipline — reproduce, fix, prove — is what separates a suite teams trust from one they learn to ignore. A flaky test that is quarantined and forgotten is a coverage gap in disguise; hunt each one to root cause rather than adding blanket retries that mask real product bugs.


18. Visual and accessibility checks

Functional tests confirm behavior, but two other dimensions are cheap to add and catch bugs functional tests miss: visual regressions and accessibility violations.

Visual regression snapshots

Playwright can capture a screenshot and compare it against a stored baseline, failing if the pixels drift beyond a threshold. This catches layout breaks and unintended styling changes that no functional assertion would notice.

test('Account page layout is visually stable', async ({ page }) => {
  await page.goto(`/lightning/o/Account/home`);
  await page.waitForLoadState('networkidle');
  // Mask dynamic regions so timestamps and record counts don't cause false diffs.
  await expect(page).toHaveScreenshot('account-home.png', {
    maxDiffPixelRatio: 0.02,
    mask: [page.locator('.slds-notify_toast'), page.locator('[data-label="Last Modified"]')],
  });
});
Enter fullscreen mode Exit fullscreen mode

The mask option is essential on Salesforce, where timestamps, record counts, and toasts change between runs. Mask them so the comparison focuses on the stable structure of the page rather than volatile data. Update baselines deliberately with --update-snapshots when a change is intentional.

Accessibility assertions

Salesforce ships accessible components, but customizations and custom Lightning Web Components can introduce violations. Integrate an accessibility scanner to catch these in CI.

npm install -D @axe-core/playwright
Enter fullscreen mode Exit fullscreen mode
import AxeBuilder from '@axe-core/playwright';

test('Account creation form has no critical a11y violations', async ({ page }) => {
  await page.goto(`/lightning/o/Account/new`);
  await page.waitForLoadState('networkidle');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();

  const critical = results.violations.filter((v) => v.impact === 'critical');
  expect(critical, JSON.stringify(critical, null, 2)).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

Scoping to critical violations keeps the check actionable rather than drowning you in minor warnings on standard Salesforce markup. As custom components enter your org, this scan becomes a genuine safety net for a class of defects that functional and visual tests both miss.


19. Best practices checklist

Everything above distills to a set of principles worth keeping in front of you:

Reuse the session. Log in once in global setup and save storage state, or use frontdoor.jsp with an OAuth token. Never log in through the UI in every test.

Locate semantically. Prefer role, label, and text locators. They pierce shadow DOM and survive Salesforce's seasonal UI changes. Never use auto-generated IDs.

Scope your locators. Salesforce repeats labels across regions. Anchor locators to a tab panel, modal, or related-list container so they resolve to exactly one element.

Wait on conditions, not on time. Every wait should name the state it awaits. Replace waitForTimeout with web-first assertions that retry until the condition holds.

Set up data by API. Build test data with batched API calls and start the browser on the exact page under test. This is faster and far more stable than clicking data into existence.

Assert on both layers. Confirm behavior through the UI and the persisted result through SOQL. This catches silent save failures the UI alone would miss.

Isolate test data. Every test creates and cleans up its own records with unique names, so parallel workers never collide.

Read toasts immediately. Assert on toasts in the narrow window they exist, before any navigation.

Capture traces on failure. The trace viewer turns unreproducible Lightning flakes into obvious, diagnosable failures.

Pin the API version in config. One place to change when a new Salesforce release lands, and no surprises from default-behavior shifts.


20. Where to go next

You now have a complete, code-backed foundation for Salesforce UI and end-to-end testing: session reuse that eliminates login flakiness, semantic locators that survive re-renders, a scalable Page Object architecture, reliable handling of every awkward Lightning component, condition-based waiting, API-driven data setup, dual-layer assertions, cross-browser parallel execution, rich reporting, and CI/CD on both major platforms.

The natural next steps are deepening each area: building a richer data-factory layer with related-record graphs, adding visual regression checks on key pages, layering in accessibility assertions, parameterizing tests across sandboxes, integrating results into release gates so a failing regression blocks a deploy, and extending coverage into the API layer to test the integration points directly.

If you want the full, structured path — from your first Playwright test through enterprise framework design and complete API testing — that is exactly what the series below was written to deliver.


Get the complete series

This article covers the essentials of Salesforce UI testing. The full material goes much deeper: complete framework architectures, hundreds of runnable examples, advanced page-object and fixture patterns, parallel-execution tuning, reporting pipelines, and production-ready CI/CD.

Salesforce Automation Testing Mastery Series (2026 Edition) — three books, one bundle:

  • Book 1 — Salesforce Testing with Playwright + TypeScript: from zero to Automation Engineer. Lightning UI, Page Object design, SOQL basics, and end-to-end scenarios — the full depth behind this article.
  • Book 2 — Enterprise Salesforce Framework Design: scalable framework architecture, fixtures and utilities, parallel execution, and CI/CD integration.
  • Book 3 — Salesforce API Testing with Playwright + TypeScript: OAuth, REST, Composite, Bulk, and Tooling APIs, plus API + UI validation.

Get the bundle: https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries

Have questions or want to talk shop? Connect with me on LinkedIn: https://www.linkedin.com/in/himanshuai/

Build. Automate. Scale. Deliver — like an enterprise.

Top comments (0)