Salesforce UI test automation
When to use
- A regression escaped Apex tests and Jest because it lives in the rendered page.
- A committed end-to-end suite is needed for a flow a user clicks through.
- An existing UI suite is flaky, slow, or authenticates by pasting a password into a config file.
- An Agentforce agent needs behavioural tests.
- Not for configuring an org through a browser - that is
sf-setup-automation. - Not for component-level assertions, which belong in
sf-lwc-jest-testing.
Pick the tier that can actually catch the bug
A browser test is the most expensive assertion in the codebase. Work up the table, not down.
| Tier | Tool | Catches | Cost |
|---|---|---|---|
| Apex unit | sf apex run test |
Server logic, sharing, triggers, governor limits | Seconds, gated by vf-check apex |
| LWC unit | @salesforce/sfdx-lwc-jest 7.9.0 |
Component rendering, wire adapters, DOM events | Seconds, gated by vf-check jest |
| End-to-end | UTAM + WebdriverIO | Navigation, Lightning shell, cross-component flows, permissions as a user sees them | Minutes, needs an org |
| Agent behaviour | sf agent test run |
Agentforce topic and action selection | Minutes, needs an org |
| Exploratory | Playwright MCP browser tools | "What does this page even do" - never committed as a test | Interactive |
If a Jest test can fail on the bug, write the Jest test. Everything below assumes it cannot.
1. The UTAM stack, with versions
UTAM is Salesforce's UI Test Automation Model: page objects declared as .utam.json, compiled into
JavaScript classes, driven by a runtime that resolves shadow DOM for you.
| Package | Latest | Role |
|---|---|---|
@utam/core |
3.3.0 | Runtime API - utam.load, utam.getCurrentDocument |
wdio-utam-service |
3.3.0 | WebdriverIO service that wires the runtime into a run |
salesforce-pageobjects |
12.0.0 | "Page objects provided by Salesforce to test Lightning Experience using the UI Test Automation Model" |
@salesforce/sfdx-lwc-jest |
7.9.0 | The tier below; present in the same project |
salesforce-pageobjects is the part that makes this viable: the Lightning shell, app navigation and
Flexipage internals are already modelled, so your own page objects only describe your components.
There is no @utam/cli package - the compiler is invoked as utam -c utam.config.js.
2. Project shape
utam.config.js - what to compile and where the classes land:
module.exports = {
pageObjectsFileMask: ['force-app/**/__utam__/**/*.utam.json'],
pageObjectsOutputDir: 'pageObjects',
alias: { 'utam-sfdx/': 'utam-js-recipes/', 'utam-*/': 'utam-preview/' },
lint: { printToConsole: false },
};
wdio.conf.js - the service, and the Salesforce page objects injected into the runtime:
const { UtamWdioService } = require('wdio-utam-service');
exports.config = {
specs: ['force-app/test/**/*.spec.js'],
capabilities: [{ browserName: 'chrome', 'goog:chromeOptions': { args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] } }],
services: [[UtamWdioService, { implicitTimeout: 0, injectionConfigs: ['salesforce-pageobjects/ui-global-components.config.json'] }]],
framework: 'jasmine',
waitforTimeout: 60 * 1000,
};
implicitTimeout: 0 is deliberate: UTAM waits on conditions, and an implicit wait on top of that
turns one slow element into a multi-minute run. Full files and the npm scripts:
references/utam-project.md.
3. Authenticate without a credential in the repo
Salesforce's own recipes get a login URL from the CLI and write it to .env - no password anywhere:
const getUrlCmd = 'sf org open -p /lightning -r --json'; // -r is --url-only
const { url } = JSON.parse(stdout).result; // frontdoor URL, single use
Two rules that follow from what that URL is. sf org open --url-only prints "This command will
expose sensitive information that allows for subsequent activity using your current authenticated
session", and the URL is minted single-use through /services/oauth2/singleaccess:
- Regenerate it per run. A stored URL is both a credential and, after one navigation, useless.
.envholding it is gitignored, and the value never goes into a log line or a CI artefact.
In CI, run against a scratch org created for the job and deleted with it - see
sf-scratch-orgs-sandboxes for the ephemeral-org script.
4. Page objects describe structure, specs describe behaviour
A page object is JSON: a root selector, elements typed by the page object that models them, and composed methods for multi-step interactions.
{
"root": true,
"selector": { "css": "body" },
"elements": [
{ "name": "navigationBar", "type": "salesforce-pageobjects/global/pageObjects/appNav",
"public": true, "selector": { "css": "one-appnav" } }
]
}
The spec then reads as navigation plus assertions, with waits expressed as conditions:
await browser.navigateTo(testEnvironment.sfdxLoginUrl);
const domDocument = utam.getCurrentDocument();
await domDocument.waitFor(async () => (await domDocument.getUrl()).includes('Hello'));
const appHomePage = await utam.load(HomePage);
const item = await (await (await appHomePage.getNavigationBar()).getAppNavBar()).getNavItem('Wire');
await item.clickAndWaitForUrl('lightning/n/Wire');
No CSS selector for a Lightning internal ever appears in a spec. When Salesforce restyles the shell,
salesforce-pageobjects changes and your specs do not.
5. Agentforce agent tests
Agent behaviour is tested by definition, not by clicking: AiEvaluationDefinition metadata plus the
CLI.
| Command | Does |
|---|---|
sf agent generate test-spec |
Scaffold the test spec |
sf agent test create |
Create the test in the org from the spec |
sf agent test run |
Run it |
sf agent test resume |
Reattach to a run |
sf agent test results |
Fetch results |
sf agent test run-eval |
Run an evaluation |
Details of the metadata type live in sf-agentforce-development.
Anti-patterns
| Anti-pattern | Why it hurts | Instead |
|---|---|---|
| Raw CSS selectors for Lightning internals in a spec | Break on every Salesforce release, and shadow DOM makes them unreachable anyway | salesforce-pageobjects page objects, own .utam.json for own components |
browser.pause(5000) |
Passes on a fast org, fails in CI, hides the real condition | waitFor on a URL, a text, or a loaded page object |
Username and password in wdio.conf.js or a CI secret used by the browser |
A standing credential for a test run | sf org open -p /lightning -r --json, regenerated per run |
| Reusing one login URL across specs | Single-use; the second spec lands on a login screen | One URL per run, or per spec if the run logs out |
| A UI test for logic a Jest test can reach | Minutes of CI for a seconds-long assertion | Push the assertion down a tier |
| UI suite in the blocking pre-merge gate | An org outage becomes a merge outage | Nightly or on-demand; vf-check local stays offline |
| Asserting on a toast only | Lightning shows a saved-looking page before validation completes | Assert the persisted state, by query or by reload |
--disable-web-security copied into a shared config |
Masks real CORS and CSP failures | Keep the recipes' --no-sandbox --disable-dev-shm-usage --disable-gpu and nothing more |
Verification
npm run build # utam -c utam.config.js, then compile the page objects
npx wdio run wdio.conf.js --spec force-app/test/<one>.spec.js
sf apex run test --target-org <alias> --test-level RunLocalTests --code-coverage --result-format human
node "$CLAUDE_PLUGIN_ROOT/scripts/checks/vf-check.mjs" jest --changed
sf agent test run --target-org <alias> # when the project has agents
A UI suite is healthy when the same spec passes twice in a row on a cold org, and its failure output names a condition that timed out rather than an element that vanished.
References
- references/utam-project.md - dependency table, the recipes' npm
scripts verbatim, full
wdio.conf.jsandutam.config.js, page object grammar, CI layout. - Sibling skills:
sf-lwc-jest-testing(the tier below),sf-apex-testing,sf-scratch-orgs-sandboxes(ephemeral org for a run),sf-agentforce-development,sf-setup-automation(a browser for configuration, not for tests). - UTAM recipes (source of every verbatim config here): https://github.com/salesforce/utam-js-recipes
salesforce-pageobjectson npm: https://www.npmjs.com/package/salesforce-pageobjectssf org openflags and its security warning: https://github.com/salesforcecli/plugin-org/blob/main/messages/open.mdsf agent test *commands: https://github.com/salesforcecli/plugin-agent