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
Enter fullscreen mode Exit fullscreen mode
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"]
}
Enter fullscreen mode Exit fullscreen mode
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 } },
],
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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 ?? '',
};
Enter fullscreen mode Exit fullscreen mode
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=[email protected]
SF_PASSWORD=SuperSecret123
SF_SECURITY_TOKEN=abcXYZ...
Enter fullscreen mode Exit fullscreen mode
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,
};
}
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
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',
};
}
Enter fullscreen mode Exit fullscreen mode
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',
};
}
Enter fullscreen mode Exit fullscreen mode
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();
Enter fullscreen mode Exit fullscreen mode
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[]
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode
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}`
);
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
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');
});
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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}` },
],
},
}
);
Enter fullscreen mode Exit fullscreen mode
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',
},
],
},
},
],
},
}
);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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<
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.