DEV Community

Cover image for Salesforce API Testing with Playwright + TypeScript (2026 Edition)
Himanshu Agarwal
Himanshu Agarwal

Posted on

Salesforce API Testing with Playwright + TypeScript (2026 Edition)

REST, OAuth, Bulk API & Integration Testing

Written by Himanshu Agarwal


Introduction

Most teams treat Salesforce API testing as an afterthought. They ship a handful of happy-path POST /sobjects/Account checks, wire them into a nightly job, and declare the integration "covered." Then a Winter release lands, a Named Credential rotates, a Bulk job silently drops ten thousand records because of a malformed CSV column, and the on-call engineer spends a weekend reconstructing what the pipeline actually did.

If you have spent five to fifteen years building distributed systems, you already know why that happens. Salesforce is not a REST API you test. It is a multi-tenant platform with governor limits, asynchronous jobs, a proprietary query language, an authentication surface that spans six OAuth flows, and a data model that other enterprise systems — SAP, Oracle, MuleSoft, Kafka, payment gateways — write into constantly. Testing it well means testing the seams between those systems, not just the endpoints.

This article is about building a production-grade Salesforce API testing framework with Playwright and TypeScript. Playwright's APIRequestContext has quietly become one of the best HTTP clients available for test engineering: it is fast, it lives in the same runtime as your UI tests, it has first-class fixtures, tracing, and reporting, and it does not force you into the ceremony of a separate contract tool just to fire an authenticated request. We will use it as the request layer and build everything an enterprise needs on top: token lifecycle management, retry and rate-limit handling, correlation IDs, schema validation, Bulk API orchestration, security assertions, and CI/CD wiring.

This is a 2026 edition, and the specifics matter. As of the current Salesforce release train, Winter '26 shipped as API version 65.0 and Spring '26 as version 66.0, with the platform continuing its three-releases-per-year cadence and a minimum three-year version support window. Playwright's APIRequestContext now supports options like failOnStatusCode and improved tracing that change how you structure a framework. OWASP's API Security Top 10 remains on its 2023 edition, which reorganized the risk landscape around authorization and business-flow abuse. We will build against these realities, not against a 2021 mental model.

Everything here is implementation-oriented. You will see folder structures, fixtures, retry logic, JWT signing, Bulk ingest orchestration, and CI pipelines you can adapt directly. The goal is a framework you would actually run against a production org.

HimanshuAI August Sale — FLAT 95% OFF

The HimanshuAI August Sale is now live.

For a limited time, get FLAT 95% OFF on my complete collection of premium AI Engineering digital playbooks.

New Bundles:

• GenAI Engineering Vault — 16 Books
https://himanshuai.gumroad.com/l/GenAIEngineeringVault16Books

• THE BUNDLE — LLM & Generative AI Testing Pro
https://himanshuai.gumroad.com/l/THEBUNDLE-LLMGenerativeAITestingPro

• AI Coding Agents Mastery — Volume 1
https://himanshuai.gumroad.com/l/Bundle-AICodingAgentsMastery-Volume1

• Ollama & Local LLMs — Complete 4 Book Series
https://himanshuai.gumroad.com/l/Ollama-Local-LLMs-The-Complete4-Book-Series

• AWS Cloud Tester Bundle
https://himanshuai.gumroad.com/l/The-Complete-AWS-Cloud-Tester-3-Books-Bundle

• Salesforce Automation Testing Mastery Series
https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries

• AI Playwright + TypeScript Mastery Bundle
https://himanshuai.gumroad.com/l/The-Complete-AI-Playwright-TypeScript-Mastery-Bundle

Coupon Code

AI95

Flat 95% OFF

Explore

https://himanshuai.gumroad.com/


Why Enterprise Salesforce API Testing Is Different

A generic REST API returns predictable status codes and behaves the same for every caller. Salesforce does not, and the differences are exactly where enterprise test suites break.

The first difference is governor limits. Salesforce enforces per-org, rolling 24-hour API request allocations tied to edition and license count, plus concurrent long-running request limits, plus per-transaction limits inside Apex. A test suite that hammers the org in parallel does not just risk flakiness — it can exhaust the org's daily allocation and take down real integrations sharing that org. Your framework has to be a good tenant.

The second is that Salesforce does not fail the way you expect. There is no clean, universal 429 Too Many Requests. When you exceed the daily API request limit, the classic response is HTTP 403 with an error code of REQUEST_LIMIT_EXCEEDED in the body. Concurrent request ceilings surface differently again, and only some newer platform surfaces emit a true 429 with a Retry-After header. If your retry logic keys purely on the 429 status, it will miss the most common Salesforce throttling case entirely. Robust handling parses the Salesforce error code, not just the HTTP status.

The third is asynchronicity. Bulk API 2.0, the Metadata API, Platform Events, and Change Data Capture are all eventually consistent. You submit a job, you get an accepted response, and the actual work happens later. A test that asserts immediately after submission is testing the queue, not the outcome. Real coverage means polling job state, reconciling successful and failed record sets, and validating data integrity after the fact.

The fourth is the authentication surface. A typical service has one auth mechanism. A serious Salesforce integration touches several: Authorization Code with PKCE for user-facing apps, JWT Bearer for server-to-server automation, Client Credentials for headless services, refresh tokens for long-lived sessions, and Named Credentials abstracting all of it for Apex callouts. Each has a different token lifecycle, and each fails differently.

The fifth is the ecosystem. Nobody runs Salesforce in isolation. Leads flow in from marketing platforms, orders sync to SAP, entitlements arrive from a billing system, events stream through Kafka or MuleSoft. The bugs that hurt most in production are not inside Salesforce — they are in the translation layer between Salesforce and everything else. Enterprise testing has to assert on those contracts.

The Modern Salesforce API Ecosystem

Before writing a single test, you need a working mental map of which API does what, because choosing the wrong one is the most common architectural mistake in Salesforce test design.

The REST API is the workhorse for synchronous, record-level CRUD. It exposes /services/data/vXX.0/sobjects/{Object}, SOQL queries via /query, and search via /search. It is what you reach for by default, and what most of your functional tests will use.

The SOAP API predates REST and is still heavily used by legacy middleware and by tools that consume the Enterprise or Partner WSDL. If you are testing an integration built on an older MuleSoft or Boomi connector, you may be asserting against SOAP payloads whether you like it or not. Playwright can send raw XML bodies, so it handles SOAP fine, but expect verbose envelopes.

The Composite API is the efficiency play. /composite batches up to 25 subrequests into one round trip and lets later subrequests reference earlier ones by referenceId — invaluable for creating a parent and child in a single call. /composite/tree/{Object} inserts nested record trees up to 200 records. The sObject Collections endpoints (/composite/sobjects) operate on up to 200 records of the same type in one request. /composite/graph handles more complex dependency graphs with transactional boundaries.

Bulk API 2.0 is for volume. It is CSV-based and fully asynchronous: you create an ingest job, upload data, mark it complete, then poll for results. It is the correct tool for anything above a few thousand records, and it has entirely different failure semantics from REST.

The Streaming API covers event-driven surfaces: PushTopics, generic events, Platform Events, and Change Data Capture, delivered over CometD/long-polling. Testing it means subscribing, triggering a change, and asserting the event arrives — a genuinely different pattern from request/response.

The Tooling API is for developer and metadata-adjacent operations: Apex execution, code coverage, symbol tables, and, as of recent releases, unified test discovery and execution endpoints. Test infrastructure tooling often leans on it.

The Metadata API deploys and retrieves org configuration. You rarely assert business logic through it, but deployment validation tests and environment-drift checks live here.

The GraphQL API, available at /services/data/vXX.0/graphql, lets clients request exactly the fields they need across related objects in one query. It is increasingly used by Lightning components and mobile clients, and it deserves its own contract tests because the shape of the response is client-defined.

Choosing the Correct API

The decision rules are simple once stated plainly. Use REST for single-record and small-batch synchronous work. Use Composite when you would otherwise make several dependent REST calls and want them atomic or want to save round trips. Use Bulk 2.0 once record counts cross into the thousands or when you are validating a data migration. Use Streaming when the behavior under test is event delivery. Use GraphQL when the client controls the response shape and you need to guard against over- or under-fetching. Reach for SOAP only when the integration you are testing already speaks it. The wrong choice does not just make tests slow — it makes them lie, because a Bulk job that "succeeded" at the HTTP layer can still have failed every row.

Enterprise Authentication

Authentication is where most Salesforce test frameworks either stay simple and fragile, or become robust and reusable. The difference is treating token acquisition as a first-class, cached, observable subsystem rather than a copy-pasted helper.

The Flows You Actually Test Against

Authorization Code (with PKCE) is the user-facing flow. Your automated suite rarely drives the full browser redirect for API tests, but you do test the token exchange and refresh behavior of apps that use it. RFC 6749 defines the flow; PKCE (RFC 7636) is now expected even for confidential clients.

JWT Bearer is the backbone of headless CI automation against Salesforce. You register a Connected App with a digital certificate, sign a JWT with the matching private key, and exchange it for an access token. There is no user interaction and no refresh token — you simply mint a new assertion when the token expires. This is almost always the right flow for a test framework.

Client Credentials is Salesforce's server-to-server flow for integrations with no user context. You enable it on the Connected App and designate a run-as user. It returns an access token with that user's permissions and, like JWT, issues no refresh token.

Refresh Token flow keeps long-lived sessions alive for apps that did an initial interactive login. In tests you validate that a refresh yields a new access token and that the old one is invalidated per policy.

Named Credentials are a Salesforce-side abstraction: they store the endpoint and auth for outbound callouts made from Apex or Flow, so developers never handle raw tokens. You do not authenticate through them from Playwright, but when you test an Apex-driven integration, the Named Credential is the thing that can be misconfigured, so your negative tests should account for it.

JWT Bearer in Practice

The JWT Bearer flow is worth showing end to end because it is the one your framework will lean on. The assertion is a signed JWT whose claims identify the Connected App (iss), the user to impersonate (sub), the login audience (aud), and an expiry (exp) a few minutes out. It is signed RS256 with the private key that matches the certificate uploaded to the Connected App.

// src/auth/jwt-bearer.ts
import { createSign } from 'crypto';
import { readFileSync } from 'fs';

interface JwtBearerConfig {
  clientId: string;        // Connected App consumer key
  username: string;        // user to impersonate (sub)
  loginUrl: string;        // https://login.salesforce.com or My Domain / test.salesforce.com
  privateKeyPath: string;  // PEM private key matching the app certificate
}

function base64url(input: Buffer | string): string {
  return Buffer.from(input)
    .toString('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
}

export function buildSignedAssertion(cfg: JwtBearerConfig): string {
  const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claims = base64url(
    JSON.stringify({
      iss: cfg.clientId,
      sub: cfg.username,
      aud: cfg.loginUrl,
      exp: Math.floor(Date.now() / 1000) + 180, // 3 minute window
    }),
  );

  const signingInput = `${header}.${claims}`;
  const privateKey = readFileSync(cfg.privateKeyPath, 'utf8');
  const signature = createSign('RSA-SHA256')
    .update(signingInput)
    .sign(privateKey);

  return `${signingInput}.${base64url(signature)}`;
}
Enter fullscreen mode Exit fullscreen mode

The token exchange itself is a single form POST. Notice that we never pass a client secret in JWT Bearer — the signature is the proof.

// src/auth/token-service.ts
import { APIRequestContext, request as playwrightRequest } from '@playwright/test';
import { buildSignedAssertion } from './jwt-bearer';

export interface SalesforceSession {
  accessToken: string;
  instanceUrl: string;
  issuedAt: number;
  expiresInMs: number;
}

const JWT_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';

export class TokenService {
  private cached?: SalesforceSession;
  // Refresh a little before the real expiry to avoid mid-test 401s.
  private readonly safetyWindowMs = 120_000;

  async getSession(): Promise<SalesforceSession> {
    if (this.cached && !this.isExpiring(this.cached)) {
      return this.cached;
    }
    this.cached = await this.mintSession();
    return this.cached;
  }

  private isExpiring(s: SalesforceSession): boolean {
    return Date.now() > s.issuedAt + s.expiresInMs - this.safetyWindowMs;
  }

  private async mintSession(): Promise<SalesforceSession> {
    const assertion = buildSignedAssertion({
      clientId: process.env.SF_CLIENT_ID!,
      username: process.env.SF_USERNAME!,
      loginUrl: process.env.SF_LOGIN_URL!,
      privateKeyPath: process.env.SF_JWT_KEY_PATH!,
    });

    const ctx: APIRequestContext = await playwrightRequest.newContext();
    const res = await ctx.post(`${process.env.SF_LOGIN_URL}/services/oauth2/token`, {
      form: { grant_type: JWT_GRANT, assertion },
    });

    if (!res.ok()) {
      const body = await res.text();
      await ctx.dispose();
      throw new Error(`JWT token exchange failed ${res.status()}: ${body}`);
    }

    const json = await res.json();
    await ctx.dispose();

    // Salesforce access tokens do not carry a numeric TTL in this response;
    // treat them as session-lifetime and cap our own cache conservatively.
    return {
      accessToken: json.access_token,
      instanceUrl: json.instance_url,
      issuedAt: Date.now(),
      expiresInMs: 60 * 60 * 1000,
    };
  }

  invalidate(): void {
    this.cached = undefined;
  }
}
Enter fullscreen mode Exit fullscreen mode

Token Lifecycle, Expiration, and Secret Management

Two failure modes dominate real suites. The first is the mid-run 401: a token acquired at the start of a long parallel run expires before the last test uses it. The safety-window cache above handles this, and the request layer we build next will additionally re-mint on a 401 and retry once. The second is leaked secrets. Never commit private keys, consumer secrets, or usernames. In CI they belong in the runner's secret store; locally they belong in an untracked .env or, better, pulled at runtime from a vault.

A vault integration keeps the same interface but sources secrets externally, which means rotation never requires a code change:

// src/auth/secret-provider.ts
export interface SecretProvider {
  get(key: string): Promise<string>;
}

// Vault-backed provider (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault
// all fit this shape). Fetch once, cache in-process, never log the value.
export class VaultSecretProvider implements SecretProvider {
  private cache = new Map<string, string>();
  constructor(private readonly fetcher: (k: string) => Promise<string>) {}

  async get(key: string): Promise<string> {
    if (!this.cache.has(key)) {
      this.cache.set(key, await this.fetcher(key));
    }
    return this.cache.get(key)!;
  }
}
Enter fullscreen mode Exit fullscreen mode

The discipline that matters: secrets are read once per process, cached in memory, and never written to logs, reports, or trace files. Playwright traces capture request bodies, so scrub the Authorization header and any token in your logging layer before it reaches disk.

Playwright API Testing Architecture

A framework is not a folder of test files. It is a set of layers with clear responsibilities, so that a business-facing test reads like business language and the plumbing lives underneath it.

Folder Structure

salesforce-api-tests/
  src/
    auth/
      jwt-bearer.ts
      token-service.ts
      secret-provider.ts
    core/
      sf-client.ts          # request layer over APIRequestContext
      retry.ts              # retry + backoff policy
      correlation.ts        # correlation id generation
      logger.ts             # structured, secret-scrubbed logging
      errors.ts             # typed Salesforce error parsing
    domain/
      accounts.ts           # Account-specific request helpers
      leads.ts
      opportunities.ts
      bulk.ts               # Bulk API 2.0 orchestration
    schemas/
      account.schema.json
      lead.schema.json
    config/
      env.ts                # typed environment loader
  tests/
    rest/
    composite/
    bulk/
    contract/
    security/
    performance/
  fixtures/
    sf-fixtures.ts
  playwright.config.ts
Enter fullscreen mode Exit fullscreen mode

The separation is deliberate. core knows nothing about Accounts or Leads. domain knows nothing about retry mechanics. Tests know nothing about tokens. When Salesforce bumps an API version or a limit changes, you touch one layer.

Configuration and Environment Management

Environment drift — a test that passes in QA and fails in staging because a URL, limit, or feature flag differs — is one of the top causes of "flaky" Salesforce suites. Kill it by making configuration typed and explicit, failing fast when something is missing.

// src/config/env.ts
function required(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required env var: ${name}`);
  return v;
}

export const env = {
  loginUrl: required('SF_LOGIN_URL'),
  apiVersion: process.env.SF_API_VERSION ?? 'v65.0',
  clientId: required('SF_CLIENT_ID'),
  username: required('SF_USERNAME'),
  jwtKeyPath: required('SF_JWT_KEY_PATH'),
  maxRetries: Number(process.env.SF_MAX_RETRIES ?? 3),
  requestTimeoutMs: Number(process.env.SF_TIMEOUT_MS ?? 30_000),
} as const;

export type Env = typeof env;
Enter fullscreen mode Exit fullscreen mode

Pin the API version explicitly rather than always chasing the newest. Salesforce guarantees a multi-year support window per version, and pinning means a release upgrade cannot silently change response shapes underneath your assertions. You upgrade the version deliberately, run the suite, and only then move forward.

Fixtures and Dependency Injection

Playwright fixtures are the cleanest dependency-injection mechanism available to a test engineer. We build a single authenticated Salesforce client fixture that every test can request by name, and Playwright handles construction and teardown.

// fixtures/sf-fixtures.ts
import { test as base } from '@playwright/test';
import { TokenService } from '../src/auth/token-service';
import { SalesforceClient } from '../src/core/sf-client';

type SfFixtures = {
  sf: SalesforceClient;
};

// Worker-scoped token service so we mint one session per worker, not per test.
const tokenService = new TokenService();

export const test = base.extend<SfFixtures>({
  sf: async ({ playwright }, use) => {
    const session = await tokenService.getSession();
    const client = await SalesforceClient.create(playwright, session, tokenService);
    await use(client);
    await client.dispose();
  },
});

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

Now a test simply asks for sf and receives a fully authenticated, retry-aware, logging client. No test ever touches a token.

Enterprise API Framework Design

This is the heart of the framework: a request layer that turns Playwright's raw APIRequestContext into something a large team can rely on. It owns headers, retries, rate-limit awareness, correlation IDs, and structured logging.

The Request Layer

// src/core/sf-client.ts
import { APIRequestContext, APIResponse, Playwright } from '@playwright/test';
import { SalesforceSession, TokenService } from '../auth/token-service';
import { withRetry } from './retry';
import { newCorrelationId } from './correlation';
import { logger } from './logger';
import { parseSalesforceError } from './errors';
import { env } from '../config/env';

export interface SfRequestOptions {
  headers?: Record<string, string>;
  data?: unknown;
  params?: Record<string, string | number>;
}

export class SalesforceClient {
  private constructor(
    private ctx: APIRequestContext,
    private session: SalesforceSession,
    private tokens: TokenService,
  ) {}

  static async create(
    pw: Playwright,
    session: SalesforceSession,
    tokens: TokenService,
  ): Promise<SalesforceClient> {
    const ctx = await pw.request.newContext({
      baseURL: session.instanceUrl,
      timeout: env.requestTimeoutMs,
      // failOnStatusCode stays false: we want to inspect and classify errors,
      // not throw blindly on the first non-2xx.
    });
    return new SalesforceClient(ctx, session, tokens);
  }

  private path(resource: string): string {
    return `/services/data/${env.apiVersion}/${resource.replace(/^\//, '')}`;
  }

  private baseHeaders(correlationId: string): Record<string, string> {
    return {
      Authorization: `Bearer ${this.session.accessToken}`,
      'Content-Type': 'application/json',
      'X-Correlation-Id': correlationId,
    };
  }

  async send(
    method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
    resource: string,
    opts: SfRequestOptions = {},
  ): Promise<APIResponse> {
    const correlationId = newCorrelationId();
    const url = this.path(resource);

    return withRetry(
      async () => {
        const res = await this.ctx.fetch(url, {
          method,
          headers: { ...this.baseHeaders(correlationId), ...opts.headers },
          data: opts.data as any,
          params: opts.params,
        });

        logger.info('sf.request', {
          correlationId,
          method,
          url,
          status: res.status(),
        });

        // Re-mint on auth failure, then let retry re-run once with a fresh token.
        if (res.status() === 401) {
          this.tokens.invalidate();
          this.session = await this.tokens.getSession();
          throw new RetryableError('token_expired', correlationId);
        }

        const err = await parseSalesforceError(res);
        if (err?.retryable) {
          throw new RetryableError(err.code, correlationId);
        }
        return res;
      },
      { correlationId },
    );
  }

  async dispose(): Promise<void> {
    await this.ctx.dispose();
  }
}

export class RetryableError extends Error {
  constructor(public code: string, public correlationId: string) {
    super(`retryable:${code}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Typed Error Parsing

The single most valuable piece of Salesforce-specific logic in the framework is correctly classifying errors. Salesforce encodes the real reason in the response body, not just the status line.

// src/core/errors.ts
import { APIResponse } from '@playwright/test';

const RETRYABLE_SF_CODES = new Set([
  'REQUEST_LIMIT_EXCEEDED',      // daily API allocation (HTTP 403)
  'SERVER_UNAVAILABLE',
  'UNABLE_TO_LOCK_ROW',          // row-lock contention, transient
]);

export interface ParsedSfError {
  code: string;
  message: string;
  status: number;
  retryable: boolean;
  retryAfterMs?: number;
}

export async function parseSalesforceError(
  res: APIResponse,
): Promise<ParsedSfError | undefined> {
  if (res.ok()) return undefined;

  const status = res.status();
  const retryAfter = res.headers()['retry-after'];
  let code = `HTTP_${status}`;
  let message = res.statusText();

  try {
    const body = await res.json();
    const first = Array.isArray(body) ? body[0] : body;
    if (first?.errorCode) code = first.errorCode;
    if (first?.message) message = first.message;
  } catch {
    // Non-JSON body (e.g. HTML error page); keep HTTP-derived code.
  }

  const retryable =
    status === 429 ||
    status === 503 ||
    RETRYABLE_SF_CODES.has(code);

  return {
    code,
    message,
    status,
    retryable,
    retryAfterMs: retryAfter ? Number(retryAfter) * 1000 : undefined,
  };
}
Enter fullscreen mode Exit fullscreen mode

This is the detail beginners miss and staff engineers insist on: a 403 REQUEST_LIMIT_EXCEEDED is retryable with backoff, while a 403 INSUFFICIENT_ACCESS is a hard permission failure that must never be retried. Keying only on the HTTP status conflates them.

Retry, Backoff, and Rate Limiting

// src/core/retry.ts
import { env } from '../config/env';
import { logger } from './logger';
import { RetryableError } from './sf-client';

interface RetryCtx { correlationId: string; }

export async function withRetry<T>(
  fn: () => Promise<T>,
  ctx: RetryCtx,
): Promise<T> {
  let attempt = 0;
  let lastErr: unknown;

  while (attempt <= env.maxRetries) {
    try {
      return await fn();
    } catch (e) {
      lastErr = e;
      if (!(e instanceof RetryableError)) throw e;

      attempt += 1;
      if (attempt > env.maxRetries) break;

      // Exponential backoff with full jitter, capped.
      const base = Math.min(1000 * 2 ** (attempt - 1), 15_000);
      const delay = Math.random() * base;
      logger.warn('sf.retry', {
        correlationId: ctx.correlationId,
        attempt,
        code: e.code,
        delayMs: Math.round(delay),
      });
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastErr;
}
Enter fullscreen mode Exit fullscreen mode

Full jitter matters at enterprise scale. If forty parallel workers all hit REQUEST_LIMIT_EXCEEDED and back off on identical fixed intervals, they retry in lockstep and re-trigger the limit. Randomized backoff spreads the load.

Correlation IDs, Tracing, and Observability

Every request carries an X-Correlation-Id. When a test fails in CI at 3 a.m., that ID is what lets you grep the structured logs, find the exact request, its retries, and its final status, and — if the org's event monitoring is enabled — correlate it with the server-side API event log. Combined with Playwright's built-in trace (trace: 'retain-on-failure' in the config), you get client-side timing, request/response bodies, and the full retry timeline for any failure, without instrumenting each test.

// src/core/correlation.ts
import { randomUUID } from 'crypto';
export const newCorrelationId = (): string => `pw-${randomUUID()}`;
Enter fullscreen mode Exit fullscreen mode

Observability is not an add-on here; it is the difference between a suite you can operate and one you merely run.

REST API Automation

With the framework in place, functional REST tests become short and readable. Here is a realistic Lead creation scenario with validation, business-rule assertions, and cleanup.

// tests/rest/lead-crud.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';

test.describe('Lead lifecycle', () => {
  let leadId: string;

  test('creates a Lead and enforces required fields', async ({ sf }) => {
    const res = await sf.send('POST', 'sobjects/Lead', {
      data: {
        LastName: 'Agarwal',
        Company: 'Northwind Traders',
        Email: 'lead.northwind@example.com',
        LeadSource: 'Web',
        Status: 'Open - Not Contacted',
      },
    });

    expect(res.status()).toBe(201);
    const body = await res.json();
    expect(body.success).toBe(true);
    expect(body.id).toMatch(/^00Q/); // Lead key prefix
    leadId = body.id;
  });

  test('reads the Lead back with expected field values', async ({ sf }) => {
    const res = await sf.send('GET', `sobjects/Lead/${leadId}`);
    expect(res.status()).toBe(200);
    const lead = await res.json();
    expect(lead.Company).toBe('Northwind Traders');
    expect(lead.IsConverted).toBe(false);
  });

  test('rejects creation without Company (business rule)', async ({ sf }) => {
    const res = await sf.send('POST', 'sobjects/Lead', {
      data: { LastName: 'NoCompany' },
    });
    expect(res.status()).toBe(400);
    const [err] = await res.json();
    expect(err.errorCode).toBe('REQUIRED_FIELD_MISSING');
  });

  test.afterAll(async ({ sf }) => {
    if (leadId) await sf.send('DELETE', `sobjects/Lead/${leadId}`);
  });
});
Enter fullscreen mode Exit fullscreen mode

Pagination, Filtering, and Sorting

SOQL queries return a first page plus a nextRecordsUrl when results exceed the batch size. A correct test framework follows the cursor rather than assuming one page.

// src/domain/query.ts
import { SalesforceClient } from '../core/sf-client';

export async function queryAll<T>(
  sf: SalesforceClient,
  soql: string,
): Promise<T[]> {
  const records: T[] = [];
  let res = await sf.send('GET', 'query', { params: { q: soql } });
  let page = await res.json();
  records.push(...page.records);

  while (!page.done && page.nextRecordsUrl) {
    // nextRecordsUrl is an absolute path already scoped to the API version.
    res = await sf.send('GET', page.nextRecordsUrl.replace(/^\/services\/data\/[^/]+\//, ''));
    page = await res.json();
    records.push(...page.records);
  }
  return records;
}
Enter fullscreen mode Exit fullscreen mode

Filtering and sorting are expressed in SOQL (WHERE, ORDER BY, LIMIT), which means your tests are asserting the platform's query semantics, not a REST query-string convention. Guard against SOQL injection in any test helper that interpolates user-like input — bind or escape it, because the same injection risk that hurts production hurts test fixtures that seed data.

Composite API Testing

The Composite API's superpower is dependent creation in one transaction. This test creates an Account and a contact that references it, using referenceId, and asserts atomicity.

// tests/composite/account-contact.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';

test('creates Account and related Contact atomically', async ({ sf }) => {
  const res = await sf.send('POST', 'composite', {
    data: {
      allOrNone: true,
      compositeRequest: [
        {
          method: 'POST',
          url: `/services/data/v65.0/sobjects/Account`,
          referenceId: 'newAccount',
          body: { Name: 'Contoso Ltd', Industry: 'Technology' },
        },
        {
          method: 'POST',
          url: `/services/data/v65.0/sobjects/Contact`,
          referenceId: 'newContact',
          body: {
            LastName: 'Sharma',
            AccountId: '@{newAccount.id}',
            Email: 'sharma.contoso@example.com',
          },
        },
      ],
    },
  });

  expect(res.status()).toBe(200);
  const body = await res.json();
  const results = body.compositeResponse;
  expect(results[0].httpStatusCode).toBe(201);
  expect(results[1].httpStatusCode).toBe(201);

  // With allOrNone true, a failure in either subrequest rolls back both.
  const accountId = results.find((r: any) => r.referenceId === 'newAccount').body.id;
  const contactId = results.find((r: any) => r.referenceId === 'newContact').body.id;
  expect(accountId).toBeTruthy();
  expect(contactId).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

The negative test — send a Contact with an invalid field and assert that allOrNone rolls back the Account too — is the one that actually protects you, because partial-commit bugs are what corrupt production data.

Bulk API Testing

Bulk API 2.0 is where synchronous testing habits break. The flow has four distinct phases, and every one of them can fail independently: create the job, upload CSV data, mark the upload complete, then poll until the job reaches a terminal state and reconcile the per-row results.

// src/domain/bulk.ts
import { SalesforceClient } from '../core/sf-client';

export interface BulkJob {
  id: string;
  state: string;
}

export async function createIngestJob(
  sf: SalesforceClient,
  object: string,
  operation: 'insert' | 'update' | 'upsert' | 'delete',
  externalIdField?: string,
): Promise<BulkJob> {
  const res = await sf.send('POST', 'jobs/ingest', {
    data: {
      object,
      operation,
      contentType: 'CSV',
      lineEnding: 'LF',
      ...(externalIdField ? { externalIdFieldName: externalIdField } : {}),
    },
  });
  const job = await res.json();
  return { id: job.id, state: job.state };
}

export async function uploadCsv(
  sf: SalesforceClient,
  jobId: string,
  csv: string,
): Promise<void> {
  // The batches endpoint expects text/csv, not JSON.
  const res = await sf.send('PUT', `jobs/ingest/${jobId}/batches`, {
    headers: { 'Content-Type': 'text/csv' },
    data: csv,
  });
  if (res.status() !== 201) {
    throw new Error(`CSV upload failed: ${res.status()} ${await res.text()}`);
  }
}

export async function closeJob(sf: SalesforceClient, jobId: string): Promise<void> {
  await sf.send('PATCH', `jobs/ingest/${jobId}`, {
    data: { state: 'UploadComplete' },
  });
}

export async function pollUntilComplete(
  sf: SalesforceClient,
  jobId: string,
  timeoutMs = 120_000,
): Promise<BulkJob> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await sf.send('GET', `jobs/ingest/${jobId}`);
    const job = await res.json();
    if (['JobComplete', 'Failed', 'Aborted'].includes(job.state)) {
      return { id: job.id, state: job.state };
    }
    await new Promise((r) => setTimeout(r, 3000));
  }
  throw new Error(`Bulk job ${jobId} did not complete within ${timeoutMs}ms`);
}
Enter fullscreen mode Exit fullscreen mode

Large Dataset Validation and Data Integrity

A job state of JobComplete does not mean every row succeeded. Bulk 2.0 can complete with a mix of successful and failed records, so integrity validation means fetching and reconciling all three result sets.

// tests/bulk/account-import.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';
import { createIngestJob, uploadCsv, closeJob, pollUntilComplete } from '../../src/domain/bulk';

test('imports 5,000 Accounts and reconciles results', async ({ sf }) => {
  const rows = ['Name,Industry'];
  for (let i = 0; i < 5000; i++) {
    rows.push(`Bulk Account ${i},Manufacturing`);
  }
  const csv = rows.join('\n');

  const job = await createIngestJob(sf, 'Account', 'insert');
  await uploadCsv(sf, job.id, csv);
  await closeJob(sf, job.id);
  const final = await pollUntilComplete(sf, job.id);
  expect(final.state).toBe('JobComplete');

  const failedRes = await sf.send('GET', `jobs/ingest/${job.id}/failedResults`);
  const failedCsv = await failedRes.text();
  const failedRowCount = failedCsv.trim().split('\n').length - 1; // minus header
  expect(failedRowCount).toBe(0);

  const successRes = await sf.send('GET', `jobs/ingest/${job.id}/successfulResults`);
  const successCsv = await successRes.text();
  const successRowCount = successCsv.trim().split('\n').length - 1;
  expect(successRowCount).toBe(5000);
});
Enter fullscreen mode Exit fullscreen mode

Failure Handling and Retry Strategy for Bulk

Bulk failures are almost never "retry the whole job." They are "identify the failed rows, understand why, and re-submit only those." The failedResults CSV includes an sf__Error column with the per-row error. The correct retry strategy parses that column, filters transient errors (row locks, storage limits) from permanent ones (validation failures, missing required fields), builds a new CSV of only the transient failures, and submits a fresh job. Blindly re-running the whole file risks creating duplicates for the rows that already succeeded.

Contract Testing

Functional tests prove behavior against a live org. Contract tests prove the shape of the exchange, and they catch a different, sneakier class of bug: a downstream consumer silently breaking because a field type changed or a nullable field started returning null.

Schema Validation with JSON Schema

Every important response should be validated against a JSON Schema. Use a schema validator such as Ajv so a drift in the response contract fails a test loudly.

// src/core/schema.ts
import Ajv, { JSONSchemaType } from 'ajv';
import addFormats from 'ajv-formats';

const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);

export function assertSchema<T>(schema: object, data: unknown): asserts data is T {
  const validate = ajv.compile(schema);
  if (!validate(data)) {
    throw new Error(`Schema validation failed: ${JSON.stringify(validate.errors)}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
// tests/contract/account-schema.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';
import accountSchema from '../../src/schemas/account.schema.json';
import { assertSchema } from '../../src/core/schema';

test('Account response conforms to contract', async ({ sf }) => {
  const res = await sf.send('GET', 'query', {
    params: { q: 'SELECT Id, Name, Industry, AnnualRevenue FROM Account LIMIT 1' },
  });
  const page = await res.json();
  const record = page.records[0];
  assertSchema(accountSchema, record);
});
Enter fullscreen mode Exit fullscreen mode

OpenAPI and Consumer-Driven Contracts

For custom Apex REST endpoints and integration middleware, an OpenAPI specification becomes the source of truth. Generate request/response validation from the spec so any endpoint that drifts from its documented contract fails CI. When Salesforce is the provider and an external service is the consumer, consumer-driven contract testing with a tool like Pact lets the consumer publish its expectations and the provider verify them independently. Pact's value in a Salesforce context is decoupling: the MuleSoft team can evolve their consumer, publish an updated contract to a broker, and your provider verification catches an incompatibility before either side deploys. It is not a replacement for integration tests — it is insurance against the two teams disagreeing about the interface.

Security Testing

Security assertions belong in the same suite as functional ones, mapped to the OWASP API Security Top 10 (2023 edition), whose most critical categories are all about authorization and business-flow abuse rather than injection.

Broken Object Level Authorization (API1) is the number-one API risk. In Salesforce terms, it maps to record-level sharing. Test it by authenticating as a low-privilege user and attempting to read a record they should not see; a correctly configured org returns a 404 or an empty result, never the record.

// tests/security/bola.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';

test('low-privilege user cannot read a restricted Account', async ({ sf }) => {
  // sfLowPriv is a second client fixture authenticated as a restricted user.
  const res = await sf.send('GET', `sobjects/Account/${process.env.RESTRICTED_ACCOUNT_ID}`);
  expect([403, 404]).toContain(res.status());
});
Enter fullscreen mode Exit fullscreen mode

Broken Authentication (API2) tests confirm that expired, malformed, and tampered tokens are all rejected with 401, and that a token minted for one org cannot be replayed against another.

Broken Object Property Level Authorization (API3) merges the old Excessive Data Exposure and Mass Assignment risks. On the exposure side, assert that field-level security actually hides sensitive fields — a query for a restricted field should not return it. On the mass-assignment side, attempt to set a field the user should not control (for example, an OwnerId or an audit field) and assert the platform ignores or rejects it.

Unrestricted Resource Consumption (API4) is where rate limiting and payload-size limits live. Confirm the org enforces limits and that your client handles the enforcement gracefully.

Injection in the Salesforce context is primarily SOQL injection through poorly built query strings in custom endpoints. Test any Apex REST endpoint that accepts input by sending crafted values (' OR Name != ') and asserting they are treated as literals, not query fragments.

The pattern that matters: security tests are negative tests that must fail closed. A passing security test is one where the malicious request was correctly denied.

Performance Testing

Playwright is not a load-testing tool in the way k6 or Gatling are, but its APIRequestContext is excellent for latency assertions, concurrency behavior, and catching response-time regressions inside your functional suite. For true sustained load, generate traffic with a purpose-built tool; for guardrail checks that run every build, Playwright is ideal.

// tests/performance/latency.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';

test('single Account read stays under latency budget', async ({ sf }) => {
  const start = performance.now();
  const res = await sf.send('GET', `sobjects/Account/${process.env.SAMPLE_ACCOUNT_ID}`);
  const elapsed = performance.now() - start;
  expect(res.ok()).toBeTruthy();
  expect(elapsed).toBeLessThan(1500); // p-latency budget for this endpoint
});

test('handles 20 concurrent reads without errors', async ({ sf }) => {
  const calls = Array.from({ length: 20 }, () =>
    sf.send('GET', `sobjects/Account/${process.env.SAMPLE_ACCOUNT_ID}`),
  );
  const results = await Promise.all(calls);
  for (const r of results) expect(r.ok()).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

Keep two things honest here. First, latency budgets should be percentile-based over many runs, not a single-shot assertion — a single slow call proves nothing. Second, respect the org. Concurrency tests against a shared org can trip concurrent-request limits and affect other users; run heavy concurrency only against dedicated performance sandboxes. Response-time trends belong in a dashboard, tracked over time, so a gradual regression is visible before it becomes an incident.

HimanshuAI August Sale — FLAT 95% OFF

The HimanshuAI August Sale is now live.

For a limited time, get FLAT 95% OFF on my complete collection of premium AI Engineering digital playbooks.

New Bundles:

• GenAI Engineering Vault — 16 Books
https://himanshuai.gumroad.com/l/GenAIEngineeringVault16Books

• THE BUNDLE — LLM & Generative AI Testing Pro
https://himanshuai.gumroad.com/l/THEBUNDLE-LLMGenerativeAITestingPro

• AI Coding Agents Mastery — Volume 1
https://himanshuai.gumroad.com/l/Bundle-AICodingAgentsMastery-Volume1

• Ollama & Local LLMs — Complete 4 Book Series
https://himanshuai.gumroad.com/l/Ollama-Local-LLMs-The-Complete4-Book-Series

• AWS Cloud Tester Bundle
https://himanshuai.gumroad.com/l/The-Complete-AWS-Cloud-Tester-3-Books-Bundle

• Salesforce Automation Testing Mastery Series
https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries

• AI Playwright + TypeScript Mastery Bundle
https://himanshuai.gumroad.com/l/The-Complete-AI-Playwright-TypeScript-Mastery-Bundle

Coupon Code

AI95

Flat 95% OFF

Explore

https://himanshuai.gumroad.com/


Integration Testing

The highest-value tests in an enterprise Salesforce landscape are the ones that cross system boundaries. Salesforce is rarely the system of record for everything; it is one node in a graph that includes SAP for finance and orders, Oracle for legacy master data, payment gateways for billing, and middleware — MuleSoft, Boomi, Azure Logic Apps — moving data between them, often with Kafka as the event backbone.

The architectural principle for testing these flows is to assert at the seams. A Lead-to-Opportunity flow that starts in a marketing platform, lands in Salesforce, and triggers an order in SAP has three seams, and each is a place data can be lost or mangled. You test each seam independently and then end to end.

Consider a Salesforce-to-SAP order sync mediated by MuleSoft. The realistic test does three things: it creates the Order in Salesforce through the REST API, it waits for the middleware to process (which is asynchronous, so you poll rather than assume), and it verifies the record materialized correctly on the SAP side through SAP's own API. Playwright handles all three because it is just an HTTP client with good ergonomics — the SAP call is another APIRequestContext with different auth.

// tests/integration/order-sync.spec.ts
import { test, expect } from '../../fixtures/sf-fixtures';
import { pollFor } from '../../src/core/poll';

test('Order created in Salesforce syncs to SAP via MuleSoft', async ({ sf, sapClient }) => {
  // 1. Create the Order in Salesforce.
  const createRes = await sf.send('POST', 'sobjects/Order', {
    data: {
      AccountId: process.env.SAMPLE_ACCOUNT_ID,
      Status: 'Draft',
      EffectiveDate: '2026-08-01',
    },
  });
  expect(createRes.status()).toBe(201);
  const { id: sfOrderId } = await createRes.json();

  // 2. Poll SAP for the synced order (middleware is asynchronous).
  const sapOrder = await pollFor(
    () => sapClient.getOrderBySalesforceRef(sfOrderId),
    (o) => o !== null,
    { timeoutMs: 60_000, intervalMs: 3000 },
  );

  // 3. Assert data integrity across the seam.
  expect(sapOrder.externalRef).toBe(sfOrderId);
  expect(sapOrder.status).toBe('CREATED');
});
Enter fullscreen mode Exit fullscreen mode

For event-driven integrations — Salesforce Platform Events or Change Data Capture flowing into Kafka — the test subscribes to the downstream topic, triggers the change in Salesforce, and asserts the event arrives with the right payload within a timeout. The same pattern applies to Salesforce plus Azure (via Logic Apps or Service Bus), Salesforce plus AWS (via EventBridge or an API Gateway endpoint), and Salesforce plus an AI platform (where a record change triggers an enrichment call and you assert the enriched fields come back). The constant across all of them is: create on one side, poll on the other, reconcile the payload. Never assert synchronously across an asynchronous seam.

CI/CD Integration

A framework that only runs on a laptop is a prototype. Production value comes from running on every pull request and every deploy, in parallel, with secrets handled safely.

GitHub Actions

# .github/workflows/sf-api-tests.yml
name: Salesforce API Tests
on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'   # nightly regression

jobs:
  api-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      # API tests need no browser download; keep the job lean.
      - name: Run Playwright API tests
        run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          SF_LOGIN_URL: ${{ secrets.SF_LOGIN_URL }}
          SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
          SF_USERNAME: ${{ secrets.SF_USERNAME }}
          SF_JWT_KEY_PATH: ./sf_key.pem
          SF_JWT_KEY: ${{ secrets.SF_JWT_KEY }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/
Enter fullscreen mode Exit fullscreen mode

The private key is injected as a secret and written to a file in a pre-step; it is never committed. Sharding runs the suite across four parallel jobs, which cuts wall-clock time and, importantly, spreads API load rather than concentrating it in one worker.

Azure DevOps and Jenkins

The same shape ports directly. In Azure DevOps, secrets come from a variable group backed by Azure Key Vault, and parallelism uses a matrix strategy in the pipeline YAML. In Jenkins, credentials come from the Credentials plugin (or a Vault plugin), and parallelism uses either a declarative matrix block or parallel stages. Across all three, the non-negotiables are identical: secrets from a managed store, environment-specific configuration injected at runtime, parallel execution to control both time and load, and artifacts (reports, traces, logs) published on every run — especially failures.

Parallel Execution and the Org as a Shared Resource

The subtlety that separates senior CI design from naive CI design is remembering the org is shared. Uncontrolled parallelism can exhaust the daily API allocation or trip concurrency limits, turning a green suite red for reasons that have nothing to do with the code under test. Cap workers in the Playwright config to a number the target org can absorb, use a dedicated CI integration user so its API consumption is attributable, and stagger heavy suites (Bulk, performance) away from peak sandbox usage.

Reporting

Reporting is how a failing test becomes an actionable ticket. Playwright's built-in reporters cover most needs, and enterprise suites usually layer a richer view on top.

The HTML reporter gives an interactive, per-test view with embedded traces — the first place an engineer looks. The JUnit reporter emits XML that every CI system understands, feeding native test dashboards and gating merges. Allure adds historical trends, severity tagging, and step-level detail that leadership and QA managers actually read.

// playwright.config.ts (reporter excerpt)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  workers: 4,
  timeout: 60_000,
  retries: process.env.CI ? 2 : 0,
  reporter: [
    ['list'],
    ['html', { open: 'never' }],
    ['junit', { outputFile: 'results/junit.xml' }],
    ['allure-playwright'],
  ],
  use: {
    trace: 'retain-on-failure',
  },
});
Enter fullscreen mode Exit fullscreen mode

Beyond the standard reporters, retain artifacts that make debugging fast: structured API logs keyed by correlation ID, the request/response bodies captured in traces (with secrets scrubbed), and any screenshots from the rare UI-plus-API hybrid tests. For pure API suites there are no screenshots, but the correlation-ID logs and traces together reconstruct exactly what happened without re-running anything.

Best Practices

The practices that hold up across large Salesforce test suites are consistent regardless of team or industry.

Treat authentication as infrastructure, not per-test code. One cached, observable token service used by every test eliminates an entire category of flakiness.

Pin the API version and upgrade it deliberately. Chasing the newest version on every release is how response-shape changes ambush you.

Classify errors by Salesforce error code, not just HTTP status. REQUEST_LIMIT_EXCEEDED and INSUFFICIENT_ACCESS are both 403 and demand opposite responses.

Back off with jitter. Fixed-interval retries at scale re-create the exact limit condition you are trying to escape.

Poll, never sleep-and-assume, for anything asynchronous. Bulk jobs, Platform Events, and middleware syncs are eventually consistent.

Make tests self-cleaning. Every test that creates data deletes it, ideally in an afterEach or afterAll, so the org does not accumulate junk that skews later runs.

Carry a correlation ID on every request. When something fails in CI, the ID is the thread you pull to find the truth.

Isolate test data. Use unique, namespaced values (a run ID in record names) so parallel workers never collide and assertions never match another test's data.

Respect the org as a shared tenant. Cap concurrency, use a dedicated integration user, and keep heavy suites off peak sandbox hours.

Anti-Patterns

The failures repeat across organizations. Hardcoding tokens or instance URLs guarantees a broken suite the moment anything rotates. Asserting synchronously after an async submission tests the queue, not the result, and produces intermittent green that means nothing. Retrying on raw HTTP status alone retries permission failures forever and never retries the throttling that actually needs it. Sharing mutable test data between tests creates order-dependent suites that pass locally and fail in parallel CI. Ignoring failedResults on a Bulk job and trusting JobComplete lets silent data loss ship. Logging full request bodies with tokens intact leaks credentials into artifacts. And building one giant sf-helper.ts with no layering means every Salesforce change touches every file.

Production Lessons Learned

A few lessons only arrive after a suite has run against real orgs for a while. Sandbox refreshes reset data and sometimes configuration, so a suite that assumes seeded reference data breaks the morning after a refresh — seed defensively or create what you need. Field-level security and sharing rules differ between sandboxes and production, so a security test that passes in a permissive sandbox can give false confidence; test authorization in an environment that mirrors production access. Governor limits are shared across everything hitting the org, including other teams' integrations, so your suite's failures are sometimes caused by neighbors — correlation IDs and the org's API event monitoring are what let you prove it. And Bulk API result files can be large; stream and parse them rather than loading multi-megabyte CSVs into memory in a single worker.

Enterprise Checklist

Before calling a Salesforce API test framework production-ready, confirm each of the following. Authentication is centralized, cached, and secret-scrubbed. The API version is pinned and documented. Error handling classifies by Salesforce error code and distinguishes retryable from terminal. Retries use exponential backoff with jitter. Every asynchronous operation is validated by polling to a terminal state and reconciling results. Bulk tests assert on failedResults, not just job state. Contract tests validate response schemas for every consumed object. Security tests cover the OWASP API Top 10 categories relevant to your org, and they fail closed. Tests are self-cleaning and data-isolated. CI runs the suite in parallel with managed secrets and publishes reports and traces on every run. Concurrency is capped to protect the shared org. Correlation IDs flow through logs and traces for post-mortem debugging.

Common Failures and How to Handle Them

Certain failures recur so often they deserve named handling. Token expiration mid-run is solved by the safety-window cache plus re-mint-on-401. The REQUEST_LIMIT_EXCEEDED 403 (Salesforce's real throttling, not a 429) is solved by error-code-aware retry with backoff. True 429s and 503s on newer surfaces honor the Retry-After header. Bulk upload failures are triaged by parsing failedResults and re-submitting only transient rows. Invalid schema failures are caught early by contract tests rather than discovered downstream. Data mismatches across integrations are caught by seam-level reconciliation. Authentication failures are made debuggable by never swallowing the Salesforce error body. Timeouts and network instability are absorbed by bounded retries. Environment drift is prevented by typed, fail-fast configuration. The theme is that none of these are handled per test — they are handled once, in the framework, and every test inherits the resilience.

The Future of Salesforce API Automation

The direction of travel in 2026 is toward AI-assisted testing, and the useful version of it is narrower and more practical than the hype suggests.

AI-assisted API testing today means using models to generate test scaffolding from a schema or an OpenAPI spec, to propose edge cases a human might miss, and to summarize failure clusters across a large run. The generation is a starting point that an engineer reviews, not an oracle. LLM-generated tests are most valuable for breadth — quickly covering the combinatorial space of field validations — while humans still own the high-value integration and security scenarios that require domain judgment.

Self-healing is the more speculative frontier. For UI tests, self-healing locators are already mainstream. For APIs, the analog is a framework that detects a contract drift — a renamed field, a changed type — proposes the corresponding test update, and flags it for human approval rather than silently adapting, because an API contract change is usually a real event someone needs to know about, not noise to paper over.

AI agents that plan and execute multi-step test workflows are emerging, and Salesforce's own platform is adding AI capabilities that themselves need testing. That last point is the durable one: as orgs adopt AI-driven automation and agent features, the surface that needs API-level validation grows, and the discipline in this article — auth, retries, contracts, reconciliation, observability — becomes more important, not less. The enterprise roadmap is less about replacing test engineers and more about engineers directing AI to cover more surface, faster, while keeping human judgment on the seams that matter.

Advanced Interview Questions

These are the questions that actually separate engineers who have run Salesforce API automation in production from those who have only read about it.

1. Why is the JWT Bearer flow usually preferred over Authorization Code for a CI test framework?
JWT Bearer is headless and needs no user interaction or refresh-token storage. You sign an assertion with a private key and exchange it for an access token on demand, which fits CI perfectly. Authorization Code requires a browser redirect and a user session, which is awkward to automate and unnecessary when no human is present.

2. Salesforce returns a 403 with REQUEST_LIMIT_EXCEEDED. Should you retry, and how?
Yes, but with backoff. It signals the rolling 24-hour API allocation is exhausted or nearly so. Retry with exponential backoff and jitter, and if the limit is genuinely hit, fail the run cleanly rather than hammering the org. Critically, do not treat every 403 this way — INSUFFICIENT_ACCESS is also a 403 and must never be retried.

3. Why should retry logic key on the Salesforce error code rather than the HTTP status?
Because Salesforce overloads HTTP statuses. Multiple distinct conditions share 403, and the real cause lives in the errorCode field of the response body. Retrying on status alone conflates transient throttling with permanent permission failures.

4. What is wrong with asserting immediately after submitting a Bulk API 2.0 job?
Bulk 2.0 is asynchronous. The submission response tells you the job was accepted, not that any record was written. You must poll until the job reaches JobComplete, Failed, or Aborted, then reconcile successfulResults and failedResults, because a completed job can still contain failed rows.

5. How do you handle a token expiring in the middle of a long parallel run?
Cache the token with a safety window so you re-mint before the real expiry, and additionally re-mint on a 401 and retry the request once. A worker-scoped token service mints one session per worker rather than per test.

6. Explain the difference between /composite, /composite/tree, and sObject Collections.
/composite batches up to 25 subrequests that can reference each other's results, optionally atomic. /composite/tree/{Object} inserts nested record trees up to 200 records. sObject Collections operate on up to 200 records of one object type in a single call. You choose based on whether you need cross-request references, nested trees, or homogeneous bulk-ish operations under REST.

7. How would you test that field-level security is enforced?
Authenticate as a user without access to a sensitive field, query a record that has it, and assert the field is absent from the response. This maps to OWASP API3, Broken Object Property Level Authorization, on the data-exposure side.

8. What is mass assignment in a Salesforce context and how do you test for it?
Mass assignment is a client setting fields it should not control, such as OwnerId or an audit field. Test it by attempting to set such a field as a restricted user and asserting the platform rejects or ignores the value.

9. Why is fixed-interval retry dangerous at scale?
If many workers hit a limit simultaneously and retry on identical intervals, they retry in lockstep and re-trigger the limit, creating a thundering herd. Full jitter randomizes delays so load spreads out.

10. How do correlation IDs help in production test debugging?
A unique ID on every request lets you trace a single logical operation across client logs, retries, and — with API event monitoring enabled — server-side logs. When a test fails in CI, the correlation ID is the key that reconstructs exactly what happened without re-running anything.

11. When is Playwright the wrong tool for Salesforce API performance testing?
For sustained, high-volume load testing. Playwright excels at latency budgets, concurrency behavior, and regression guardrails inside a functional suite, but purpose-built tools like k6 or Gatling are correct for sustained load with proper percentile reporting.

12. How do you test an asynchronous Salesforce-to-SAP integration through MuleSoft?
Create the record in Salesforce, poll the SAP side until the synced record appears (respecting the middleware's asynchronicity), then reconcile the payload across the seam. You assert at each boundary, never synchronously across an async hop.

13. What is the value of consumer-driven contract testing here?
It decouples provider and consumer release cycles. A consumer (say a MuleSoft flow) publishes its expectations to a broker; the Salesforce-side provider verifies them independently. Incompatibilities surface before either side deploys, without a full integration environment.

14. How do you keep secrets out of Playwright traces and logs?
Traces capture request bodies and headers, so scrub the Authorization header and any token before logging, source secrets from a managed store read once per process, and never write raw keys to artifacts. Treat the trace as a potential leak surface.

15. Why pin the Salesforce API version instead of always using the latest?
Salesforce supports each version for a multi-year window. Pinning means a release upgrade cannot silently change response shapes under your assertions. You upgrade deliberately, run the suite, then move forward, converting an ambush into a controlled change.

16. What does allOrNone do in a Composite request and why test the false path?
With allOrNone: true, a failure in any subrequest rolls back all of them. You test both paths because partial-commit behavior (false) is exactly what corrupts data — you must know and assert which mode your integration relies on.

17. How do you retry a partially failed Bulk job correctly?
Parse the failedResults CSV, separate transient errors (row locks, storage) from permanent ones (validation), build a new CSV of only the transient failures, and submit a fresh job. Re-running the whole file risks duplicating rows that already succeeded.

18. What is BOLA and how does it map to Salesforce?
Broken Object Level Authorization, OWASP's top API risk, is accessing an object you should not be allowed to. In Salesforce it maps to record-level sharing. Test it by attempting to read a restricted record as a low-privilege user and asserting a 403 or 404.

19. How should CI concurrency be bounded for a shared org?
Cap Playwright workers to what the org can absorb, use a dedicated integration user for attributable consumption, and shard across CI jobs to spread rather than concentrate load. Uncontrolled parallelism can exhaust the daily allocation and break unrelated integrations.

20. How do you prevent test data collisions across parallel workers?
Namespace all created data with a unique run or worker ID embedded in record names or external IDs, and make every test self-cleaning. Assertions then match only their own data, and parallel workers never interfere.

Frequently Asked Questions

1. Can Playwright really replace Postman or REST Assured for Salesforce API testing?
For most teams, yes. Playwright's APIRequestContext is a full HTTP client with fixtures, tracing, retries, parallelism, and unified reporting, and it lives in the same TypeScript runtime as any UI tests. Postman remains better for exploratory, GUI-driven work and REST Assured for JVM shops, but for a codified, CI-run Salesforce suite, Playwright is a strong default.

2. Do I need a browser for Playwright API tests?
No. API tests use APIRequestContext directly and need no browser download. In CI you can skip the browser install entirely, which makes the job faster and lighter.

3. Which OAuth flow should my automated suite use against Salesforce?
JWT Bearer in almost all cases. It is headless, needs no refresh-token storage, and suits CI. Use Client Credentials when you specifically want a run-as-user service context with no impersonation of a named user.

4. How do I handle Salesforce's daily API request limits in a large suite?
Cache tokens, cap concurrency, use a dedicated integration user, shard load across CI jobs, and implement error-code-aware backoff on REQUEST_LIMIT_EXCEEDED. Treat the org as a shared tenant with a finite budget.

5. Why does Salesforce not always return a 429 for rate limiting?
Salesforce predates the widespread 429 convention and encodes throttling in error codes. The classic daily-limit response is a 403 with REQUEST_LIMIT_EXCEEDED. Some newer platform surfaces do emit a true 429 with Retry-After, so handle both.

6. What is the right way to test Bulk API 2.0?
Create the ingest job, upload CSV, mark it complete, poll to a terminal state, then reconcile successfulResults, failedResults, and unprocessedRecords. Never trust JobComplete alone as proof of success.

7. How do I validate large data migrations?
Use Bulk 2.0 for volume, assert row counts across success and failure result sets, and reconcile a sample (or all) of the migrated records against the source. Stream large result CSVs rather than loading them fully into memory.

8. How should I store the JWT private key in CI?
As a secret in the runner's secret store, written to a file in a pre-step and referenced by path. Never commit it. Rotate by updating the secret and the Connected App certificate, with no code change required.

9. What is the difference between the request fixture and playwright.request.newContext()?
The request fixture gives a ready-made context per test. newContext() creates a longer-lived context you control, useful for a shared authenticated client with a fixed base URL and headers across a file or worker.

10. How do I test authorization and sharing rules?
Authenticate as users with different permission sets and assert each can only access what they should. Restricted reads should return 403 or 404, restricted fields should be absent, and restricted writes should be rejected. These are negative tests that must fail closed.

11. Can I test Platform Events and Change Data Capture with Playwright?
Playwright is HTTP-oriented, so for streaming you typically pair it with a CometD or event-bus client: subscribe downstream, trigger the change via Playwright's REST calls, and assert delivery. The trigger-and-verify pattern still applies.

12. How do I keep tests from interfering with each other in parallel?
Isolate and namespace test data with unique run IDs, make every test self-cleaning, and avoid shared mutable state. Design so no test depends on another's side effects.

13. Should security tests live in the same suite as functional tests?
Yes. Mapping a handful of OWASP API Top 10 checks into the same suite means they run on every build and regressions surface immediately, rather than waiting for a periodic pen test.

14. How do I validate response schemas?
Compile JSON Schemas with a validator like Ajv and assert every important response against its schema. For custom endpoints and middleware, drive validation from an OpenAPI specification so drift fails CI.

15. What is the role of Pact in a Salesforce landscape?
Pact enables consumer-driven contract testing between Salesforce and the services that integrate with it. Consumers publish expectations; providers verify them independently, catching interface disagreements before deployment without a full integration environment.

16. How do I make failures debuggable in CI?
Carry correlation IDs on every request, enable Playwright tracing on failure, emit structured secret-scrubbed logs, and publish reports and traces as artifacts on every run. Together these reconstruct any failure without re-running it.

17. How do I test integrations with SAP, Oracle, or payment gateways?
Treat each external system as another HTTP client with its own auth. Create data on one side through Salesforce, poll the other side for the synced result, and reconcile the payload. Assert at each seam and then end to end.

18. What API version should I target in 2026?
Pin to a specific recent version — Winter '26 shipped as v65.0 and Spring '26 as v66.0 — and document it. Upgrade deliberately after running the suite against the new version, rather than always chasing the newest.

19. How do I handle timeouts and network instability?
Set sensible per-request timeouts, wrap requests in bounded retries with backoff, and classify transient failures as retryable. Do not retry indefinitely; fail cleanly after the cap so a genuinely broken environment surfaces.

20. Can I mix UI and API tests in one Playwright project?
Yes, that is a core Playwright strength. You can authenticate via API, reuse the session state in a browser context, and assert backend side effects and UI behavior in one suite. For Salesforce, API-first setup makes UI tests far faster and less flaky.

21. How do I avoid governor-limit surprises during test runs?
Understand which limits your suite exercises (API requests, concurrent long-running requests, Bulk record limits), keep concurrency within budget, and monitor consumption. A dedicated integration user makes your suite's usage attributable and easier to reason about.

22. Is it safe to run the suite against production?
Run functional and destructive tests against sandboxes. Reserve production for carefully scoped, read-mostly smoke checks with strong data isolation. Never run heavy Bulk or destructive tests against a live production org.

23. How do I test the Salesforce GraphQL API?
Send POST requests to the GraphQL endpoint with a query, and contract-test the response shape. Because the client defines the shape, assert on over-fetching and under-fetching explicitly, and validate that field-level security still applies within GraphQL responses.

24. How does AI fit into Salesforce API testing today?
Practically, AI helps generate test scaffolding from schemas, propose edge cases, and cluster failures. Engineers review the output and own the high-judgment integration and security scenarios. As orgs adopt AI and agent features, the surface needing disciplined API testing grows.

25. What is the single most common mistake in Salesforce API test frameworks?
Treating Salesforce like a generic REST API. The governor limits, non-standard throttling responses, asynchronous jobs, and multi-flow authentication all demand Salesforce-specific handling. Frameworks that ignore this look fine until the first real load or the first release upgrade, then fail in ways generic assumptions cannot explain.

Resources

These are genuine, authoritative references worth keeping close.

Salesforce REST API Developer Guide — developer.salesforce.com/docs (REST API atlas)

Salesforce Bulk API 2.0 Developer Guide — developer.salesforce.com/docs (Bulk API atlas)

Salesforce Composite and Connect REST resources — developer.salesforce.com/docs

Salesforce OAuth and Connected Apps documentation — help.salesforce.com and the Identity implementation guides

Playwright Documentation, including API testing and APIRequestContext — playwright.dev

OAuth 2.0 Authorization Framework — RFC 6749

OAuth 2.0 Bearer Token Usage — RFC 6750

JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants — RFC 7523

OpenAPI Specification — spec.openapis.org

JSON Schema — json-schema.org

OWASP API Security Top 10 (2023) — owasp.org/API-Security

Pact (consumer-driven contract testing) — docs.pact.io

Postman Learning Center — learning.postman.com

TypeScript Documentation — typescriptlang.org/docs

Node.js Documentation — nodejs.org/docs

Google SRE Book — sre.google/books

Martin Fowler on testing and integration (ContractTest, TestPyramid) — martinfowler.com

Microsoft REST API Guidelines — github.com/microsoft/api-guidelines

Azure Architecture Center — learn.microsoft.com/azure/architecture

AWS Well-Architected Framework — aws.amazon.com/architecture/well-architected

HimanshuAI August Sale — FLAT 95% OFF

The HimanshuAI August Sale is now live.

For a limited time, get FLAT 95% OFF on my complete collection of premium AI Engineering digital playbooks.

New Bundles:

• GenAI Engineering Vault — 16 Books
https://himanshuai.gumroad.com/l/GenAIEngineeringVault16Books

• THE BUNDLE — LLM & Generative AI Testing Pro
https://himanshuai.gumroad.com/l/THEBUNDLE-LLMGenerativeAITestingPro

• AI Coding Agents Mastery — Volume 1
https://himanshuai.gumroad.com/l/Bundle-AICodingAgentsMastery-Volume1

• Ollama & Local LLMs — Complete 4 Book Series
https://himanshuai.gumroad.com/l/Ollama-Local-LLMs-The-Complete4-Book-Series

• AWS Cloud Tester Bundle
https://himanshuai.gumroad.com/l/The-Complete-AWS-Cloud-Tester-3-Books-Bundle

• Salesforce Automation Testing Mastery Series
https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries

• AI Playwright + TypeScript Mastery Bundle
https://himanshuai.gumroad.com/l/The-Complete-AI-Playwright-TypeScript-Mastery-Bundle

Coupon Code

AI95

Flat 95% OFF

Explore

https://himanshuai.gumroad.com/


Summary

Salesforce API testing done well is a discipline, not a folder of scripts. The platform's realities — governor limits, non-standard throttling responses, asynchronous Bulk and event APIs, and a six-flow authentication surface — mean a generic REST testing mindset breaks the moment it meets real load or a release upgrade. Everything in this article was aimed at replacing that mindset with an engineered one.

The framework we built layers responsibilities cleanly. A cached, observable token service treats authentication as infrastructure. A request layer over Playwright's APIRequestContext owns headers, correlation IDs, structured logging, and error handling. A retry policy with exponential backoff and full jitter, keyed on Salesforce error codes rather than raw HTTP status, absorbs transient failure without hammering the org. Domain helpers keep business-facing tests readable, and fixtures inject a fully authenticated client so no test ever touches a token.

On top of that foundation, REST tests cover CRUD, pagination, and business rules; Composite tests cover atomic dependent creation; Bulk tests orchestrate the full asynchronous lifecycle and — crucially — reconcile per-row results instead of trusting job state. Contract tests guard response shapes with JSON Schema and OpenAPI, and consumer-driven contracts with Pact decouple release cycles across teams. Security tests map to the OWASP API Top 10 (2023) and fail closed. Performance guardrails catch regressions inside the functional suite. Integration tests assert at every seam between Salesforce and SAP, Oracle, MuleSoft, Kafka, Azure, AWS, and payment systems, always polling across asynchronous boundaries rather than assuming synchrony. CI runs it all in parallel, with managed secrets, capped concurrency, and reports and traces published on every run.

The payoff is a suite you can operate at 3 a.m., not just run at noon: every failure carries a correlation ID, a trace, and a classified error, so the path from red build to root cause is short. As AI-assisted testing matures, this discipline becomes more valuable, not less — because AI can generate breadth, but the seams, the contracts, and the authorization boundaries still need engineered judgment. Build the foundation once, and every future test, human- or AI-authored, inherits its resilience.


Written by Himanshu Agarwal

Enterprise Test Architect

AI Engineering Author

Generative AI Educator

Automation Architect


Top comments (0)