Modern Salesforce implementations are API-first. Lightning Web Components call Apex REST endpoints, integration middleware pushes records through the Bulk API, external systems authenticate with OAuth, and CI/CD pipelines deploy metadata through the Tooling API. If your automation strategy only clicks buttons in the UI, you are testing the thin surface of a system whose real logic lives underneath.
This article is a hands-on guide to testing Salesforce APIs with Playwright and TypeScript. No motivational filler — just the authentication flows, the request patterns, the framework structure, and the pipeline configuration you need to build production-grade API tests. Every section includes runnable code you can drop into a project and adapt.
By the end you will be able to authenticate with OAuth (username-password, JWT bearer, and client credentials), exercise the REST, Composite, and Bulk APIs, drive the Tooling API for metadata and anonymous Apex, combine API setup with UI verification, and wire the whole thing into GitHub Actions and Azure DevOps.
Want the complete, book-length version? This article is a condensed walkthrough of material covered in depth in the Salesforce Automation Testing Mastery Series (2026 Edition) — three books covering Playwright + TypeScript testing, enterprise framework design, and API testing end to end. Grab the bundle here: https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries (use code
JUPITER80for the launch discount). Questions? Connect with me on LinkedIn: https://www.linkedin.com/in/himanshuai/
Table of contents
- Why test Salesforce at the API layer
- Project setup: Playwright + TypeScript
- Configuration and secrets
- OAuth authentication and Connected Apps
- The Salesforce REST API: CRUD, SOQL, and upsert
- Composite APIs: batching, dependent requests, and trees
- Bulk API 2.0: ingest and query jobs
- The Tooling API: metadata queries and anonymous Apex
- Building a reusable API client
- Fixtures: injecting authenticated clients into tests
- Combining API setup with UI validation
- Contract testing against the Salesforce schema
- Error handling, retries, and rate limits
- Test data lifecycle and cleanup
- CI/CD: GitHub Actions and Azure DevOps
- Best practices checklist
- Where to go next
1. Why test Salesforce at the API layer
UI tests are slow, brittle, and expensive to maintain. A single Lightning page can take several seconds to render, and any layout change can break a selector. API tests, by contrast, hit the platform directly. They run in milliseconds, they are deterministic, and they verify the exact contract that integrations depend on.
There are four concrete reasons to invest in API testing on Salesforce:
Speed. An API round trip to create an Account and assert on the response is an order of magnitude faster than navigating the UI to do the same. When you have thousands of assertions, that difference decides whether your suite runs in minutes or hours.
Data setup. Even for UI tests, you rarely want to build test data through the UI. Creating a fully-related Account → Contact → Opportunity graph by clicking is wasteful. The API sets up state in one call, then the UI test focuses only on the behavior under test.
Integration coverage. External systems talk to Salesforce over REST, Bulk, and SOAP. Those integration points are where production incidents happen — a field renamed, a validation rule added, a picklist value removed. API tests catch these breaks before your partners do.
Backend logic. Apex triggers, flows, validation rules, and roll-up summaries all fire on API writes. Testing through the API exercises this logic directly, without the UI as an intermediary that can mask or alter behavior.
Playwright is an excellent fit here. Although it is best known as a browser automation tool, its APIRequestContext is a first-class HTTP client with cookie handling, automatic retries, request tracing, and tight integration with the Playwright test runner. You get one framework, one config, and one report for both API and UI tests.
2. Project setup: Playwright + TypeScript
Start with a clean Node project. You need Node 18 or newer.
mkdir salesforce-api-tests && cd salesforce-api-tests
npm init -y
npm install -D @playwright/test typescript @types/node dotenv
npx playwright install
Add a tsconfig.json tuned for a test project:
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"@clients/*": ["src/clients/*"],
"@config/*": ["src/config/*"],
"@fixtures/*": ["src/fixtures/*"]
}
},
"include": ["src", "tests"]
}
Create the Playwright config. Note that we do not set a global baseURL on the top-level use block, because the Salesforce instance URL is only known after authentication — different orgs and sandboxes resolve to different *.my.salesforce.com hosts.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import 'dotenv/config';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 10_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: {
trace: 'on-first-retry',
ignoreHTTPSErrors: false,
},
projects: [
{ name: 'api', testMatch: /.*\.api\.spec\.ts/ },
{ name: 'ui', testMatch: /.*\.ui\.spec\.ts/, use: { headless: true } },
],
});
Splitting into api and ui projects lets you run npx playwright test --project=api in a fast pipeline stage and reserve browser runs for the slower stage.
The folder layout we will build toward:
salesforce-api-tests/
├── src/
│ ├── config/
│ │ └── environment.ts
│ ├── auth/
│ │ ├── oauth.ts
│ │ └── jwt.ts
│ ├── clients/
│ │ ├── salesforce-client.ts
│ │ ├── rest-client.ts
│ │ ├── bulk-client.ts
│ │ └── tooling-client.ts
│ └── fixtures/
│ └── salesforce.fixture.ts
├── tests/
│ ├── rest/
│ ├── composite/
│ ├── bulk/
│ └── ui/
├── playwright.config.ts
└── tsconfig.json
3. Configuration and secrets
Never hardcode credentials. Read everything from environment variables, validate them at startup, and fail fast with a clear message if something is missing.
// src/config/environment.ts
import 'dotenv/config';
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export interface SalesforceEnv {
loginUrl: string;
apiVersion: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
securityToken: string;
}
export const env: SalesforceEnv = {
// Use https://test.salesforce.com for sandboxes.
loginUrl: process.env.SF_LOGIN_URL ?? 'https://login.salesforce.com',
apiVersion: process.env.SF_API_VERSION ?? 'v62.0',
clientId: required('SF_CLIENT_ID'),
clientSecret: required('SF_CLIENT_SECRET'),
username: required('SF_USERNAME'),
password: required('SF_PASSWORD'),
securityToken: process.env.SF_SECURITY_TOKEN ?? '',
};
A local .env file (git-ignored) for development:
SF_LOGIN_URL=https://test.salesforce.com
SF_API_VERSION=v62.0
SF_CLIENT_ID=3MVG9...
SF_CLIENT_SECRET=1A2B3C...
SF_USERNAME=integration.user@yourorg.com.sandbox
SF_PASSWORD=SuperSecret123
SF_SECURITY_TOKEN=abcXYZ...
The apiVersion is a config value on purpose. Salesforce ships three releases a year, and each bumps the API version. Keeping it in one place means a single change when you upgrade. Pick a version your org supports; recent sandboxes accept the latest, but pinning avoids surprises when a new release changes default behavior.
4. OAuth authentication and Connected Apps
Every API call needs a bearer token, and every token comes from an OAuth flow against a Connected App. You configure a Connected App once in Setup (App Manager → New Connected App), enable OAuth, and choose the flows you need. The three flows most useful for automated testing are username-password, JWT bearer, and client credentials.
Username-password flow
The simplest flow for a service account. You send the username, the password concatenated with the security token, and the Connected App's consumer key and secret. Salesforce returns an access token and the instance URL you must use for all subsequent calls.
// src/auth/oauth.ts
import { request } from '@playwright/test';
import { env } from '@config/environment';
export interface SalesforceSession {
accessToken: string;
instanceUrl: string;
issuedAt: string;
tokenType: string;
}
export async function loginWithPassword(): Promise<SalesforceSession> {
const ctx = await request.newContext();
const response = await ctx.post(`${env.loginUrl}/services/oauth2/token`, {
form: {
grant_type: 'password',
client_id: env.clientId,
client_secret: env.clientSecret,
username: env.username,
password: `${env.password}${env.securityToken}`,
},
});
if (!response.ok()) {
const body = await response.text();
throw new Error(
`OAuth password login failed (${response.status()}): ${body}`
);
}
const data = await response.json();
await ctx.dispose();
return {
accessToken: data.access_token,
instanceUrl: data.instance_url,
issuedAt: data.issued_at,
tokenType: data.token_type,
};
}
The security token is appended directly to the password with no separator. If your integration user's IP is allow-listed in the org's login IP ranges, the security token can be empty — which is why we default SF_SECURITY_TOKEN to an empty string.
JWT bearer flow
For CI pipelines, JWT bearer is the better choice. There is no password to store or rotate; you sign a short-lived assertion with a private key whose certificate is uploaded to the Connected App. The integration user must be pre-authorized (through a permission set or the app's policies) so no interactive consent is needed.
First generate a key pair and upload server.crt to the Connected App under "Use digital signatures":
openssl req -x509 -sha256 -nodes -days 730 \
-newkey rsa:2048 -keyout server.key -out server.crt \
-subj "/CN=salesforce-ci"
Then build and sign the JWT, and exchange it for a token:
// src/auth/jwt.ts
import { createSign } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { request } from '@playwright/test';
import { env } from '@config/environment';
import type { SalesforceSession } from './oauth';
function base64url(input: Buffer | string): string {
return Buffer.from(input)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function buildAssertion(privateKeyPath: string): string {
const header = base64url(JSON.stringify({ alg: 'RS256' }));
const claims = {
iss: env.clientId, // Connected App consumer key
sub: env.username, // user to impersonate
aud: env.loginUrl, // login.salesforce.com or test.salesforce.com
exp: Math.floor(Date.now() / 1000) + 180, // 3 minutes
};
const payload = base64url(JSON.stringify(claims));
const signingInput = `${header}.${payload}`;
const privateKey = readFileSync(privateKeyPath, 'utf8');
const signature = createSign('RSA-SHA256')
.update(signingInput)
.sign(privateKey);
return `${signingInput}.${base64url(signature)}`;
}
export async function loginWithJwt(
privateKeyPath = 'server.key'
): Promise<SalesforceSession> {
const assertion = buildAssertion(privateKeyPath);
const ctx = await request.newContext();
const response = await ctx.post(`${env.loginUrl}/services/oauth2/token`, {
form: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
},
});
if (!response.ok()) {
throw new Error(`JWT login failed: ${await response.text()}`);
}
const data = await response.json();
await ctx.dispose();
return {
accessToken: data.access_token,
instanceUrl: data.instance_url,
issuedAt: data.issued_at ?? String(Date.now()),
tokenType: data.token_type ?? 'Bearer',
};
}
The JWT is signed with your private key and verified by Salesforce against the uploaded certificate. Because there is no secret in transit and the assertion expires in three minutes, this flow is far safer for shared CI runners than storing a password.
Client credentials flow
When you want a token tied to a fixed run-as user with no user impersonation logic, the client credentials flow is the cleanest. You enable it on the Connected App and designate a run-as user. The request needs only the consumer key and secret.
export async function loginWithClientCredentials(): Promise<SalesforceSession> {
const ctx = await request.newContext();
const response = await ctx.post(`${env.loginUrl}/services/oauth2/token`, {
form: {
grant_type: 'client_credentials',
client_id: env.clientId,
client_secret: env.clientSecret,
},
});
if (!response.ok()) {
throw new Error(`Client credentials login failed: ${await response.text()}`);
}
const data = await response.json();
await ctx.dispose();
return {
accessToken: data.access_token,
instanceUrl: data.instance_url,
issuedAt: data.issued_at ?? String(Date.now()),
tokenType: data.token_type ?? 'Bearer',
};
}
Whichever flow you choose, the shape of the result is identical: an access token plus an instance URL. Everything downstream depends only on SalesforceSession, so you can swap flows per environment — password locally, JWT in CI — without touching a single test.
5. The Salesforce REST API: CRUD, SOQL, and upsert
With a session in hand, all REST calls target {instanceUrl}/services/data/{apiVersion}/. Let's exercise the full lifecycle of an sObject.
Creating a record
import { request } from '@playwright/test';
import { env } from '@config/environment';
import { loginWithPassword } from '@auth/oauth';
const session = await loginWithPassword();
const api = await request.newContext({
baseURL: session.instanceUrl,
extraHTTPHeaders: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
});
const create = await api.post(
`/services/data/${env.apiVersion}/sobjects/Account`,
{ data: { Name: 'Playwright Test Corp', Industry: 'Technology' } }
);
// 201 Created, body: { id, success, errors }
const { id } = await create.json();
A successful create returns a 201 with the new record's Id. That Id drives every subsequent operation.
Querying with SOQL
SOQL runs through a GET with a URL-encoded query string. Playwright encodes query params for you when you pass them as an object.
const query = await api.get(`/services/data/${env.apiVersion}/query`, {
params: { q: `SELECT Id, Name, Industry FROM Account WHERE Id = '${id}'` },
});
const result = await query.json();
// result.totalSize, result.done, result.records[]
For large result sets Salesforce paginates. When done is false, follow result.nextRecordsUrl until it is exhausted:
async function queryAll(soql: string, api) {
let records: any[] = [];
let res = await api.get(`/services/data/${env.apiVersion}/query`, {
params: { q: soql },
});
let page = await res.json();
records = records.concat(page.records);
while (!page.done) {
res = await api.get(page.nextRecordsUrl);
page = await res.json();
records = records.concat(page.records);
}
return records;
}
Updating and deleting
Updates use PATCH against the record URL and return 204 No Content on success. Deletes use DELETE and also return 204.
await api.patch(
`/services/data/${env.apiVersion}/sobjects/Account/${id}`,
{ data: { Industry: 'Finance' } }
);
await api.delete(
`/services/data/${env.apiVersion}/sobjects/Account/${id}`
);
Upsert on an external ID
Integrations rarely know Salesforce Ids; they know their own keys. Upsert against an external ID field creates or updates in a single idempotent call. A 201 means the record was created, a 204 means it already existed and was updated.
const upsert = await api.patch(
`/services/data/${env.apiVersion}/sobjects/Account/External_Id__c/EXT-1001`,
{ data: { Name: 'Upserted Corp', Industry: 'Retail' } }
);
// upsert.status() === 201 (created) or 204 (updated)
A complete REST test
Pulling it together into a Playwright spec:
// tests/rest/account-lifecycle.api.spec.ts
import { test, expect, request, APIRequestContext } from '@playwright/test';
import { env } from '@config/environment';
import { loginWithPassword } from '@auth/oauth';
let api: APIRequestContext;
let createdId: string;
test.beforeAll(async () => {
const session = await loginWithPassword();
api = await request.newContext({
baseURL: session.instanceUrl,
extraHTTPHeaders: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
});
});
test.afterAll(async () => {
if (createdId) {
await api.delete(`/services/data/${env.apiVersion}/sobjects/Account/${createdId}`);
}
await api.dispose();
});
test('creates, reads, updates and deletes an Account', async () => {
const create = await api.post(
`/services/data/${env.apiVersion}/sobjects/Account`,
{ data: { Name: 'Lifecycle Corp', Industry: 'Technology' } }
);
expect(create.status()).toBe(201);
createdId = (await create.json()).id;
const read = await api.get(
`/services/data/${env.apiVersion}/sobjects/Account/${createdId}`
);
expect(read.ok()).toBeTruthy();
expect((await read.json()).Industry).toBe('Technology');
const update = await api.patch(
`/services/data/${env.apiVersion}/sobjects/Account/${createdId}`,
{ data: { Industry: 'Finance' } }
);
expect(update.status()).toBe(204);
const verify = await api.get(
`/services/data/${env.apiVersion}/sobjects/Account/${createdId}`
);
expect((await verify.json()).Industry).toBe('Finance');
});
This single test verifies create, read, update, and the persistence of the update — the core contract of any sObject.
6. Composite APIs: batching, dependent requests, and trees
Making one HTTP call per record wastes time and API quota. Salesforce offers a family of Composite endpoints that bundle many operations into a single round trip. There are three you will reach for constantly.
Composite: dependent subrequests
The /composite endpoint runs up to 25 subrequests in order, and — crucially — later subrequests can reference the output of earlier ones using @{referenceId.field} syntax. This lets you create a parent and child in one atomic call.
const composite = await api.post(
`/services/data/${env.apiVersion}/composite`,
{
data: {
allOrNone: true,
compositeRequest: [
{
method: 'POST',
url: `/services/data/${env.apiVersion}/sobjects/Account`,
referenceId: 'newAccount',
body: { Name: 'Composite Parent Inc' },
},
{
method: 'POST',
url: `/services/data/${env.apiVersion}/sobjects/Contact`,
referenceId: 'newContact',
body: {
LastName: 'Composite',
AccountId: '@{newAccount.id}',
},
},
],
},
}
);
const body = await composite.json();
// body.compositeResponse[] — one entry per subrequest, in order
With allOrNone: true, if any subrequest fails the whole batch rolls back. This is exactly what you want when setting up related test data: you never end up with an orphaned parent because the child failed.
Composite batch: independent subrequests
When subrequests are independent and you just want to save round trips, /composite/batch runs up to 25 unrelated requests. There is no cross-referencing, and each succeeds or fails on its own.
const batch = await api.post(
`/services/data/${env.apiVersion}/composite/batch`,
{
data: {
batchRequests: [
{ method: 'GET', url: `/services/data/${env.apiVersion}/sobjects/Account/${idA}` },
{ method: 'GET', url: `/services/data/${env.apiVersion}/sobjects/Account/${idB}` },
],
},
}
);
sObject Tree: nested record graphs
To insert a parent with multiple children in one payload, the /composite/tree/{sObject} endpoint accepts up to 200 records with nested child relationships.
const tree = await api.post(
`/services/data/${env.apiVersion}/composite/tree/Account`,
{
data: {
records: [
{
attributes: { type: 'Account', referenceId: 'acc1' },
Name: 'Tree Account',
Contacts: {
records: [
{
attributes: { type: 'Contact', referenceId: 'con1' },
LastName: 'Child One',
},
{
attributes: { type: 'Contact', referenceId: 'con2' },
LastName: 'Child Two',
},
],
},
},
],
},
}
);
sObject Collections: bulk-ish CRUD without a job
For up to 200 records of the same operation, sObject Collections give you Bulk-like throughput with synchronous simplicity. POST to /composite/sobjects to insert many at once.
const collection = await api.post(
`/services/data/${env.apiVersion}/composite/sobjects`,
{
data: {
allOrNone: false,
records: [
{ attributes: { type: 'Account' }, Name: 'Bulk Insert 1' },
{ attributes: { type: 'Account' }, Name: 'Bulk Insert 2' },
{ attributes: { type: 'Account' }, Name: 'Bulk Insert 3' },
],
},
}
);
// returns an array of { id, success, errors } in input order
The rule of thumb: use /composite when records depend on each other, /composite/tree for nested graphs, and /composite/sobjects for a flat list of the same object. All three collapse many HTTP calls into one, which matters enormously when your suite is creating thousands of records.
7. Bulk API 2.0: ingest and query jobs
When you are loading tens of thousands of records — or querying millions — the synchronous APIs will not do. Bulk API 2.0 is asynchronous and CSV-based. You create a job, upload data, close the job, and poll until Salesforce finishes processing.
Ingest: loading records
The ingest flow has four steps. Create the job, PUT the CSV, patch the state to UploadComplete, then poll for completion.
// src/clients/bulk-client.ts
import { APIRequestContext } from '@playwright/test';
import { env } from '@config/environment';
export class BulkClient {
constructor(private api: APIRequestContext) {}
async ingestCsv(object: string, operation: string, csv: string) {
// 1. Create the ingest job
const createRes = await this.api.post(
`/services/data/${env.apiVersion}/jobs/ingest`,
{
data: {
object,
operation, // insert | update | upsert | delete
contentType: 'CSV',
lineEnding: 'LF',
},
}
);
const job = await createRes.json();
// 2. Upload the CSV payload
await this.api.put(
`/services/data/${env.apiVersion}/jobs/ingest/${job.id}/batches`,
{
headers: { 'Content-Type': 'text/csv' },
data: csv,
}
);
// 3. Mark the job as ready for processing
await this.api.patch(
`/services/data/${env.apiVersion}/jobs/ingest/${job.id}`,
{ data: { state: 'UploadComplete' } }
);
// 4. Poll until the job finishes
return this.pollJob(job.id);
}
private async pollJob(jobId: string, timeoutMs = 120_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const res = await this.api.get(
`/services/data/${env.apiVersion}/jobs/ingest/${jobId}`
);
const status = await res.json();
if (status.state === 'JobComplete') return status;
if (status.state === 'Failed' || status.state === 'Aborted') {
throw new Error(`Bulk job ${jobId} ended in state ${status.state}`);
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`Bulk job ${jobId} did not complete within timeout`);
}
async getSuccessfulResults(jobId: string): Promise<string> {
const res = await this.api.get(
`/services/data/${env.apiVersion}/jobs/ingest/${jobId}/successfulResults`
);
return res.text(); // CSV of results
}
async getFailedResults(jobId: string): Promise<string> {
const res = await this.api.get(
`/services/data/${env.apiVersion}/jobs/ingest/${jobId}/failedResults`
);
return res.text();
}
}
Using it in a test:
const bulk = new BulkClient(api);
const csv = [
'Name,Industry',
'Bulk Corp A,Technology',
'Bulk Corp B,Finance',
'Bulk Corp C,Retail',
].join('\n');
const job = await bulk.ingestCsv('Account', 'insert', csv);
expect(job.numberRecordsProcessed).toBe(3);
expect(job.numberRecordsFailed).toBe(0);
Always assert on both numberRecordsProcessed and numberRecordsFailed. A Bulk job can complete "successfully" while silently failing half its rows on validation rules — the job state is JobComplete either way.
Query: extracting large data sets
For pulling large volumes out, the Bulk query flow mirrors ingest: create a query job, poll, then download results.
async bulkQuery(soql: string) {
const createRes = await this.api.post(
`/services/data/${env.apiVersion}/jobs/query`,
{ data: { operation: 'query', query: soql } }
);
const job = await createRes.json();
// poll /jobs/query/{id} until state === 'JobComplete'
const status = await this.pollQueryJob(job.id);
const results = await this.api.get(
`/services/data/${env.apiVersion}/jobs/query/${job.id}/results`
);
return results.text(); // CSV
}
Bulk API is the right tool when volume is high and latency is acceptable. For a handful of records, the synchronous Composite endpoints are simpler and faster. Choose deliberately based on data size.
8. The Tooling API: metadata queries and anonymous Apex
The Tooling API exposes the org's metadata as queryable objects and lets you run anonymous Apex. This is invaluable in testing: you can assert that a validation rule exists, check that a field has the expected metadata, or execute a snippet of Apex to set up state that no standard API exposes.
Everything lives under /services/data/{apiVersion}/tooling/.
Querying metadata
// src/clients/tooling-client.ts
import { APIRequestContext } from '@playwright/test';
import { env } from '@config/environment';
export class ToolingClient {
constructor(private api: APIRequestContext) {}
async query(soql: string) {
const res = await this.api.get(
`/services/data/${env.apiVersion}/tooling/query`,
{ params: { q: soql } }
);
if (!res.ok()) {
throw new Error(`Tooling query failed: ${await res.text()}`);
}
return res.json();
}
}
Assert that a required Apex class is deployed:
const tooling = new ToolingClient(api);
const classes = await tooling.query(
"SELECT Id, Name FROM ApexClass WHERE Name = 'AccountService'"
);
expect(classes.size).toBeGreaterThan(0);
Or verify that a validation rule is active before running tests that depend on it:
const rules = await tooling.query(
"SELECT Id, ValidationName, Active FROM ValidationRule " +
"WHERE EntityDefinition.QualifiedApiName = 'Account'"
);
Running anonymous Apex
The executeAnonymous endpoint runs an Apex snippet in the org's context. This is the escape hatch for setup that no declarative API covers — resetting a custom setting, invoking a batch class, or forcing a record into a specific state.
async executeApex(apexBody: string) {
const res = await this.api.get(
`/services/data/${env.apiVersion}/tooling/executeAnonymous`,
{ params: { anonymousBody: apexBody } }
);
const result = await res.json();
if (!result.compiled || !result.success) {
throw new Error(
`Apex failed. Compiled=${result.compiled} ` +
`Line=${result.line} ${result.compileProblem ?? result.exceptionMessage}`
);
}
return result;
}
await tooling.executeApex(
`Account a = new Account(Name='Apex Seeded', Industry='Energy'); insert a;`
);
Handle the response carefully. executeAnonymous returns HTTP 200 even when the Apex fails to compile or throws — you must inspect compiled, success, compileProblem, and exceptionMessage yourself. The helper above turns any of those into a thrown error so your test fails loudly instead of continuing on bad state.
Use the Tooling API sparingly and deliberately. It is powerful, but leaning on anonymous Apex for everything makes tests opaque. Prefer the standard REST and Composite APIs, and reach for Tooling only when nothing else can set up the state you need.
9. Building a reusable API client
Scattering raw api.post(...) calls across dozens of specs is a maintenance trap. Wrap the platform in a thin client that owns authentication, the base URL, versioning, and error handling. Tests then read as intent, not plumbing.
// src/clients/salesforce-client.ts
import { request, APIRequestContext } from '@playwright/test';
import { env } from '@config/environment';
import { loginWithPassword, SalesforceSession } from '@auth/oauth';
import { BulkClient } from './bulk-client';
import { ToolingClient } from './tooling-client';
export class SalesforceClient {
private constructor(
public readonly session: SalesforceSession,
public readonly api: APIRequestContext
) {}
static async create(
login: () => Promise<SalesforceSession> = loginWithPassword
): Promise<SalesforceClient> {
const session = await login();
const api = await request.newContext({
baseURL: session.instanceUrl,
extraHTTPHeaders: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
});
return new SalesforceClient(session, api);
}
private path(resource: string): string {
return `/services/data/${env.apiVersion}/${resource}`;
}
async create(sobject: string, fields: Record<string, unknown>) {
const res = await this.api.post(this.path(`sobjects/${sobject}`), {
data: fields,
});
if (res.status() !== 201) {
throw new Error(`Create ${sobject} failed: ${await res.text()}`);
}
return (await res.json()).id as string;
}
async retrieve(sobject: string, id: string) {
const res = await this.api.get(this.path(`sobjects/${sobject}/${id}`));
if (!res.ok()) {
throw new Error(`Retrieve ${sobject}/${id} failed: ${await res.text()}`);
}
return res.json();
}
async update(sobject: string, id: string, fields: Record<string, unknown>) {
const res = await this.api.patch(this.path(`sobjects/${sobject}/${id}`), {
data: fields,
});
if (res.status() !== 204) {
throw new Error(`Update ${sobject}/${id} failed: ${await res.text()}`);
}
}
async delete(sobject: string, id: string) {
const res = await this.api.delete(this.path(`sobjects/${sobject}/${id}`));
if (res.status() !== 204 && res.status() !== 404) {
throw new Error(`Delete ${sobject}/${id} failed: ${await res.text()}`);
}
}
async query<T = any>(soql: string): Promise<T[]> {
let records: T[] = [];
let res = await this.api.get(this.path('query'), { params: { q: soql } });
let page = await res.json();
records = records.concat(page.records);
while (!page.done) {
res = await this.api.get(page.nextRecordsUrl);
page = await res.json();
records = records.concat(page.records);
}
return records;
}
get bulk(): BulkClient {
return new BulkClient(this.api);
}
get tooling(): ToolingClient {
return new ToolingClient(this.api);
}
async dispose() {
await this.api.dispose();
}
}
Now a test reads cleanly:
const sf = await SalesforceClient.create();
const accountId = await sf.create('Account', { Name: 'Client Corp' });
const account = await sf.retrieve('Account', accountId);
expect(account.Name).toBe('Client Corp');
await sf.delete('Account', accountId);
await sf.dispose();
The client centralizes every cross-cutting concern. Change the API version, swap the auth flow, or add a default header, and every test benefits without edits. This is the single most important structural decision in an enterprise suite.
10. Fixtures: injecting authenticated clients into tests
Creating a SalesforceClient in every beforeAll is repetitive and logs in far more than necessary. Playwright fixtures solve this elegantly — you define the client once, and the runner injects it into any test that asks for it, sharing a single authenticated session per worker.
// src/fixtures/salesforce.fixture.ts
import { test as base } from '@playwright/test';
import { SalesforceClient } from '@clients/salesforce-client';
import { loginWithJwt } from '@auth/jwt';
type Fixtures = {
sf: SalesforceClient;
};
// Worker-scoped: one login shared across all tests in a worker.
type WorkerFixtures = {
sfWorker: SalesforceClient;
};
export const test = base.extend<Fixtures, WorkerFixtures>({
sfWorker: [
async ({}, use) => {
const client = await SalesforceClient.create(
process.env.CI ? loginWithJwt : undefined
);
await use(client);
await client.dispose();
},
{ scope: 'worker' },
],
sf: async ({ sfWorker }, use) => {
await use(sfWorker);
},
});
export { expect } from '@playwright/test';
Tests now import from the fixture and receive a ready-to-use client:
// tests/rest/opportunity.api.spec.ts
import { test, expect } from '@fixtures/salesforce.fixture';
test('creates an Opportunity tied to an Account', async ({ sf }) => {
const accountId = await sf.create('Account', { Name: 'Fixture Corp' });
const oppId = await sf.create('Opportunity', {
Name: 'Q1 Deal',
StageName: 'Prospecting',
CloseDate: '2026-03-31',
AccountId: accountId,
});
const opp = await sf.retrieve('Opportunity', oppId);
expect(opp.AccountId).toBe(accountId);
expect(opp.StageName).toBe('Prospecting');
await sf.delete('Opportunity', oppId);
await sf.delete('Account', accountId);
});
The worker-scoped fixture authenticates once per parallel worker rather than once per test. With four workers and two hundred tests, you go from two hundred logins to four. That is a meaningful reduction in both runtime and API consumption — Salesforce counts every login against org limits.
11. Combining API setup with UI validation
The highest-value pattern in Salesforce test automation is hybrid: set up state through the API, then verify behavior through the UI. The API builds a complex data graph in milliseconds; the browser test then focuses purely on what the user actually sees.
Consider verifying that a newly created Opportunity appears on its Account's related list in Lightning. Building the Account and Opportunity through the UI would take thirty seconds and a dozen brittle clicks. Through the API it takes one call, and the UI test does only what UI tests are good at.
// tests/ui/opportunity-related-list.ui.spec.ts
import { test, expect } from '@fixtures/salesforce.fixture';
test('shows the Opportunity on the Account related list', async ({ sf, page }) => {
// --- Setup via API (fast, deterministic) ---
const accountId = await sf.create('Account', { Name: 'Hybrid Test Corp' });
await sf.create('Opportunity', {
Name: 'Visible Deal',
StageName: 'Prospecting',
CloseDate: '2026-06-30',
AccountId: accountId,
});
// --- Authenticate the browser session using the same token ---
await page.goto(
`${sf.session.instanceUrl}/secur/frontdoor.jsp` +
`?sid=${sf.session.accessToken}` +
`&retURL=/lightning/r/Account/${accountId}/view`
);
// --- Verify via UI (the part that needs a browser) ---
await expect(page.getByText('Visible Deal')).toBeVisible();
// --- Teardown ---
const opps = await sf.query(
`SELECT Id FROM Opportunity WHERE AccountId = '${accountId}'`
);
for (const o of opps) await sf.delete('Opportunity', o.Id);
await sf.delete('Account', accountId);
});
The frontdoor.jsp trick logs the browser in with the OAuth access token you already have, skipping the login screen entirely. Your UI test starts already authenticated on the exact record page you care about. This single technique cuts UI test setup time dramatically and eliminates the flakiest part of most Salesforce browser suites — the login flow.
The principle generalizes: any state that a user would laboriously build up should be created by the API, so the browser only ever exercises the behavior genuinely under test.
12. Contract testing against the Salesforce schema
Integrations break when the org's schema drifts — a field is deleted, a picklist value removed, a required field added. Contract tests catch this by asserting on the describe metadata Salesforce exposes for every object. When a field your integration depends on disappears, the contract test fails immediately, long before a partner's nightly load does.
The describe endpoint returns full metadata for an sObject:
async function describe(sf: SalesforceClient, sobject: string) {
const res = await sf.api.get(
`/services/data/${process.env.SF_API_VERSION}/sobjects/${sobject}/describe`
);
return res.json();
}
A contract test asserting that the fields your integration relies on still exist with the expected types:
// tests/contract/account-contract.api.spec.ts
import { test, expect } from '@fixtures/salesforce.fixture';
import { env } from '@config/environment';
test('Account exposes the fields the integration depends on', async ({ sf }) => {
const res = await sf.api.get(
`/services/data/${env.apiVersion}/sobjects/Account/describe`
);
const describe = await res.json();
const fieldsByName = new Map(
describe.fields.map((f: any) => [f.name, f])
);
const requiredContract = [
{ name: 'Name', type: 'string' },
{ name: 'Industry', type: 'picklist' },
{ name: 'AnnualRevenue', type: 'currency' },
{ name: 'External_Id__c', type: 'string' },
];
for (const expected of requiredContract) {
const actual = fieldsByName.get(expected.name) as any;
expect(actual, `Field ${expected.name} is missing`).toBeDefined();
expect(actual.type, `Field ${expected.name} changed type`).toBe(expected.type);
}
});
You can extend this to assert on picklist values, on whether a field is createable or updateable, or on required flags. Contract tests are cheap to run and catch an entire class of production incidents that functional tests miss, because they verify the shape of the schema rather than any single behavior.
13. Error handling, retries, and rate limits
Salesforce enforces API limits, and transient errors happen. Production-grade tests treat error handling as a first-class concern, not an afterthought.
Reading the standard error format
Salesforce errors come back as an array of objects with an errorCode and message. Parse them consistently:
async function assertOk(res: import('@playwright/test').APIResponse, context: string) {
if (res.ok()) return;
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
if (Array.isArray(parsed)) {
detail = parsed.map((e) => `${e.errorCode}: ${e.message}`).join('; ');
}
} catch {
// leave detail as raw text
}
throw new Error(`${context} failed (${res.status()}): ${detail}`);
}
Retrying transient failures
A 500, 503, or a REQUEST_LIMIT_EXCEEDED can be worth retrying with backoff. Wrap network operations in a small retry helper — but never retry non-idempotent creates blindly, or you will create duplicates.
async function withRetry<T>(
fn: () => Promise<T>,
attempts = 3,
baseDelayMs = 1000
): Promise<T> {
let lastError: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
lastError = err;
const wait = baseDelayMs * Math.pow(2, i);
await new Promise((r) => setTimeout(r, wait));
}
}
throw lastError;
}
Respecting API limits
Salesforce returns your remaining daily API allowance in the Sforce-Limit-Info response header. In a large suite it is worth watching this so you fail with a clear message rather than a cryptic REQUEST_LIMIT_EXCEEDED deep in a run.
function logRemainingApi(res: import('@playwright/test').APIResponse) {
const info = res.headers()['sforce-limit-info'];
if (info) {
// e.g. "api-usage=10432/15000"
const match = info.match(/api-usage=(\d+)\/(\d+)/);
if (match) {
const [, used, total] = match;
const remaining = Number(total) - Number(used);
if (remaining < 500) {
console.warn(`Low Salesforce API allowance: ${remaining} calls left`);
}
}
}
}
The Composite and Bulk patterns from earlier are your first defense against limits: fewer, fatter calls consume less quota than many small ones. When a suite starts bumping into daily limits, batching is almost always the fix.
14. Test data lifecycle and cleanup
Tests that leave data behind poison the next run and slowly fill the org. Every record a test creates must be tracked and removed. The cleanest approach is a per-test registry that deletes in reverse dependency order.
// src/fixtures/tracker.fixture.ts
import { test as base } from '@fixtures/salesforce.fixture';
import { SalesforceClient } from '@clients/salesforce-client';
class CreatedRecordTracker {
private records: Array<{ sobject: string; id: string }> = [];
constructor(private sf: SalesforceClient) {}
async create(sobject: string, fields: Record<string, unknown>) {
const id = await this.sf.create(sobject, fields);
this.records.push({ sobject, id });
return id;
}
async cleanup() {
// Delete in reverse creation order so children go before parents.
for (const rec of this.records.reverse()) {
await this.sf.delete(rec.sobject, rec.id);
}
this.records = [];
}
}
export const test = base.extend<{ data: CreatedRecordTracker }>({
data: async ({ sf }, use) => {
const tracker = new CreatedRecordTracker(sf);
await use(tracker);
await tracker.cleanup();
},
});
export { expect } from '@playwright/test';
Any test using this fixture gets automatic cleanup:
import { test, expect } from '@fixtures/tracker.fixture';
test('cleans up automatically', async ({ data }) => {
const accountId = await data.create('Account', { Name: 'Tracked Corp' });
const contactId = await data.create('Contact', {
LastName: 'Tracked',
AccountId: accountId,
});
expect(contactId).toBeTruthy();
// No manual delete — the fixture removes the Contact then the Account.
});
Because deletion runs in reverse order, children are removed before their parents, avoiding "cannot delete — child records exist" errors. Reverse-order teardown is a small detail that prevents a large category of flaky cleanup failures.
For suites that occasionally leave orphans anyway — a crashed worker, a killed pipeline — schedule a periodic sweep that deletes records matching a test-data naming convention (for example, names ending in Corp or tagged with a custom Test_Run__c field) so the org never accumulates junk.
15. CI/CD: GitHub Actions and Azure DevOps
Tests deliver value only when they run automatically on every change. Both major platforms integrate cleanly with Playwright. The key concerns are the same in each: install dependencies, provide secrets, restore the JWT key, run the API project, and publish results.
GitHub Actions
# .github/workflows/salesforce-api-tests.yml
name: Salesforce API Tests
on:
pull_request:
push:
branches: [main]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Restore JWT signing key
run: echo "${{ secrets.SF_JWT_KEY }}" > server.key
- name: Run API tests
env:
CI: true
SF_LOGIN_URL: https://test.salesforce.com
SF_API_VERSION: v62.0
SF_CLIENT_ID: ${{ secrets.SF_CLIENT_ID }}
SF_USERNAME: ${{ secrets.SF_USERNAME }}
run: npx playwright test --project=api
- name: Publish HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
The JWT private key is stored as a repository secret and written to server.key at runtime — it never lives in the repo. Because CI uses the JWT flow, no password is needed, and the if: always() on the artifact step ensures you get the report even when tests fail (which is exactly when you most want it).
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: echo "$(SF_JWT_KEY)" > server.key
displayName: Restore JWT key
- script: npx playwright test --project=api
displayName: Run API tests
env:
CI: true
SF_LOGIN_URL: https://test.salesforce.com
SF_API_VERSION: v62.0
SF_CLIENT_ID: $(SF_CLIENT_ID)
SF_USERNAME: $(SF_USERNAME)
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: 'results/junit.xml'
testRunTitle: 'Salesforce API Tests'
The JUnit reporter we configured in playwright.config.ts feeds directly into Azure's PublishTestResults task, giving you a native test-results tab with pass/fail trends over time. Secrets are stored as pipeline variables marked secret, mirroring the GitHub Actions setup.
Pipeline strategy
Structure the pipeline in stages. Run the fast API project on every pull request as a gate. Run the slower UI project on merges to main or on a schedule. This keeps feedback tight for developers while still exercising the full browser suite regularly. Because the API and UI projects are already separated in the Playwright config, splitting stages is just two --project invocations.
16. Best practices checklist
Everything above distills into a set of principles worth keeping in front of you:
Prefer JWT bearer for CI. No password to rotate, short-lived assertions, and no interactive consent. Reserve the username-password flow for local development.
Authenticate once per worker. Worker-scoped fixtures turn hundreds of logins into a handful, saving time and API quota that Salesforce meters aggressively.
Batch aggressively. Composite and Bulk endpoints collapse many calls into one. This is faster and consumes far less of your daily API allowance than record-by-record operations.
Set up data by API, verify by UI. Never build complex state through the browser. Let the API construct the graph, then use frontdoor.jsp to drop the browser onto the exact page under test.
Track and clean up every record. A registry that deletes in reverse dependency order keeps the org clean and prevents cross-test contamination. Add a periodic sweep for orphans.
Assert on Bulk sub-results, not just job state. A job can complete while silently failing rows. Check numberRecordsFailed, and read the failed-results CSV when it is non-zero.
Add contract tests. Describe-based assertions catch schema drift — deleted fields, changed types, removed picklist values — before integrations break in production.
Handle the real error format. Salesforce returns error arrays with errorCode and message. Parse them and surface them in failures so debugging is fast.
Watch the limit header. Sforce-Limit-Info tells you how much API allowance remains. Warn early rather than failing cryptically deep in a run.
Pin the API version in config. One place to change when a new Salesforce release lands, and no surprises from default-behavior shifts between versions.
17. Where to go next
You now have a complete, code-backed foundation: OAuth across three flows, the full REST lifecycle, Composite batching and trees, asynchronous Bulk jobs, Tooling API metadata and Apex, a reusable client, worker-scoped fixtures, hybrid API+UI validation, contract testing, error handling, disciplined cleanup, and CI/CD on both major platforms. That is the core of an enterprise-grade Salesforce API testing framework.
The natural next steps are deepening each area: parameterizing tests across sandboxes, building a data-factory layer on top of the tracker, adding Apex REST endpoint testing for your custom services, layering in performance thresholds on API response times, and integrating test results into your release gates so a schema break blocks a deploy automatically.
If you want the full, structured path — from your first Playwright test through enterprise framework design and complete API coverage — that is exactly what the book series below was written to give you.
Get the complete series
This article covers the essentials, but the full material goes much deeper: complete framework architectures, hundreds of runnable code examples, page-object and fixture design patterns, parallel-execution tuning, reporting, and production-ready CI/CD pipelines.
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.
- 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 — the full depth behind this article.
Get the bundle: https://himanshuai.gumroad.com/l/SalesforceAutomationTestingMasterySeries
Apply code JUPITER80 at checkout for the launch discount.
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)