Test Salesforce UI end to end — LWC unit tests with Jest (@salesforce/sfdx-lwc-jest), mocking @wire adapters, Apex, LDS, and Lightning Message Service, accessibility checks with sa11y, Apex test classes written for @AuraEnabled controllers (positive, negative, bulk, sharing), and browser/E2E testing of Lightning pages and Experience Cloud with UTAM or Playwright.
When agents use itUse this whenever the user asks how to test an LWC, mock a wire, test Apex called from LWC, set up Jest for Salesforce, reach Apex coverage for a UI controller, write Playwright/UTAM tests against a Salesforce org, or fix flaky Salesforce UI tests. Also apply it when generating tests for LWC or @AuraEnabled Apex the user just wrote, even if they don't say "test".
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill salesforce-frontend-testing -a github-copilot
# or with the org installer (adds .github/skills/salesforce-frontend-testing):
npx -y github:AGCO-Global/org-skills add skill salesforce-frontend-testing
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the salesforce-lwc-skills plugin, which bundles all Salesforce / LWC skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install salesforce-lwc-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill salesforce-frontend-testing -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill salesforce-frontend-testing -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill salesforce-frontend-testing -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill salesforce-frontend-testing -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
Salesforce Frontend Testing
Salesforce UI has three test layers with three different runtimes: Jest in Node for LWC, Apex tests in the org for controllers, and a real browser against an org for the pieces only the platform can render. Know which layer a given bug lives in before writing a test.
Tests live in lwc/<component>/__tests__/<component>.test.js. Mocks for lightning/* modules that sfdx-lwc-jest doesn't ship live in force-app/test/jest-mocks/.
Core pattern:
import { createElement } from 'lwc';
import OrderList from 'c/orderList';
import getOrders from '@salesforce/apex/OrderController.getOrders';
jest.mock('@salesforce/apex/OrderController.getOrders',
() => ({ default: jest.fn() }), { virtual: true });
const flushPromises = () => new Promise(process.nextTick);
describe('c-order-list', () => {
afterEach(() => {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
jest.clearAllMocks();
});
it('renders rows returned by Apex', async () => {
getOrders.emit([{ Id: '1', Name: 'ORD-1' }]); // wired Apex
const el = createElement('c-order-list', { is: OrderList });
document.body.appendChild(el);
await flushPromises();
const rows = el.shadowRoot.querySelectorAll('lightning-datatable');
expect(rows[0].data).toHaveLength(1);
});
});
Imperative Apex → same mock; getOrders.mockResolvedValue(data) / mockRejectedValue(error).
LDS wires (getRecord, getObjectInfo) → the adapters from lightning/uiRecordApi are auto-mocked by sfdx-lwc-jest; getRecord.emit(mockRecord) with a JSON fixture in __tests__/data/.
@salesforce/schema, @salesforce/label, @salesforce/user/Id → auto-mocked; override with jest.mock when a value matters.
Lightning Message Service → publish and subscribe from lightning/messageService are mocked; assert publish was called with the channel and payload; simulate incoming messages by calling the callback captured from subscribe.mock.calls.
lightning/navigation → mock NavigationMixin; assert Navigate was dispatched with the right pageReference.
Toasts → listen for ShowToastEvent on the element: el.addEventListener('lightning__showtoast', handler).
Third-party libs via loadScript → mock lightning/platformResourceLoader to resolve immediately and put a stub on window.
What to assert:
Rendered output in shadowRoot — text, attributes, data passed to base components, lwc:if branches.
Events dispatched (el.addEventListener('select', handler) then trigger the child event with child.dispatchEvent(new CustomEvent('click'))).
Apex mock called with the exact parameters.
Error branch: emit/mockRejectedValue, then the error UI or toast.
Assert with messages: Assert.areEqual(expected, actual, 'row count for owner'); (Assert class over System.assert).
Aim for ≥ 85 % on controller/service classes, not the 75 % org minimum; coverage is a floor, assertions are the point.
3. Browser / E2E tests
Use when the behaviour depends on the platform runtime: LDS caching, App Builder configuration, Flow navigation, Experience Cloud pages, permissions.
UTAM (Salesforce's page-object framework) when the team already uses WebdriverIO and wants Salesforce-maintained page objects for standard UI.
Playwright otherwise — faster to adopt, better tooling.
Playwright against an org:
Authenticate once via sf org open --url-only (frontdoor URL) or username/password on a scratch org; save storageState.
Locate through Shadow DOM: Playwright's getByRole/getByLabel pierce shadow roots automatically; avoid CSS chains through lightning-* internals — they change every release.
Navigate with direct URLs (/lightning/r/Order/{id}/view) rather than clicking through menus.
Seed data through the REST API or sf data create record in beforeAll; delete in afterAll. Scratch org per CI run is the cleanest isolation.
Wait for Lightning to settle: await expect(page.getByRole('heading', { name })).toBeVisible(); never fixed sleeps.
Keep the E2E suite to 5–15 critical journeys (record page renders, key quick action, Flow completes, Experience site login). Everything else belongs in Jest/Apex.
Run the suite against pre-release sandboxes before each Salesforce major release.
4. Test strategy per component type
Component
Jest
Apex
Browser
Presentational LWC (@api in, events out)
✔ all states + a11y
—
—
Container LWC with wires/imperative Apex
✔ mocked adapters
✔ controller cases
smoke only
LWC in Flow screen
✔ FlowAttributeChangeEvent dispatched
—
✔ one Flow run
Experience Cloud page
✔ components
✔ guest-user sharing
✔ guest + authed journey
App Builder config (targetConfigs)
—
—
✔ page renders with defaults
Apex Service/Selector
—
✔ bulk + sharing + negative
—
5. CI pipeline
lint (eslint + prettier + pmd) → jest --coverage (threshold) → sf project deploy validate (RunLocalTests, coverage ≥ 85 % changed) → playwright smoke on scratch org → package version create
Fail on: lint errors, Jest threshold, any Apex test failure, sa11y violations, smoke failures. Nightly: full Playwright suite against a persistent QA sandbox.
6. When asked to "write tests for this LWC / controller"
List behaviours: initial render, each wire state (data/error/undefined), each user action and resulting event/Apex call, each lwc:if branch, cleanup.
Check existing jest-mocks/ and TestDataFactory — reuse.
Write Jest tests one behaviour per it, flushPromises after async; add toBeAccessible().
For the Apex controller, write positive, bulk, negative-access, and sharing tests with runAs.
Run sfdx-lwc-jest and sf apex run test; paste results.
Flaky-test triage
Symptom
Cause
Fix
Jest: element not found after emit
Missing await flushPromises() or Promise.resolve() micro-task
Add flush; for renderedCallback chains, flush twice
Jest: leakage between tests
Element left in document.body, module-level state in component
Remove children in afterEach; jest.resetModules() if needed
Apex: passes alone, fails in run
Order-dependent data, SeeAllData, hardcoded ids
@TestSetup, factory, query ids
Apex: UNABLE_TO_LOCK_ROW
Parallel tests touching the same parent record
Separate parents per test or run class serially
Playwright: intermittent timeouts
Lightning async render, LDS refresh
Role-based expect(...).toBeVisible(), increase expect timeout for record pages, avoid networkidle
Playwright: selector broke after release
Selecting into lightning-* internals
Use roles/labels; rely on UTAM page objects for standard UI
---
name: salesforce-frontend-testing
description: Test Salesforce UI end to end — LWC unit tests with Jest (@salesforce/sfdx-lwc-jest), mocking @wire adapters, Apex, LDS, and Lightning Message Service, accessibility checks with sa11y, Apex test classes written for @AuraEnabled controllers (positive, negative, bulk, sharing), and browser/E2E testing of Lightning pages and Experience Cloud with UTAM or Playwright. Use this whenever the user asks how to test an LWC, mock a wire, test Apex called from LWC, set up Jest for Salesforce, reach Apex coverage for a UI controller, write Playwright/UTAM tests against a Salesforce org, or fix flaky Salesforce UI tests. Also apply it when generating tests for LWC or @AuraEnabled Apex the user just wrote, even if they don't say "test".
metadata:
technology: Salesforce
type: testing
---
# Salesforce Frontend Testing
Salesforce UI has three test layers with three different runtimes: **Jest in Node** for LWC, **Apex tests in the org** for controllers, and **a real browser against an org** for the pieces only the platform can render. Know which layer a given bug lives in before writing a test.
## 1. LWC unit tests with Jest
Setup (once per project):
```
npm i -D @salesforce/sfdx-lwc-jest @sa11y/jest
```
`jest.config.js`:
```js
const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config');
module.exports = {
...jestConfig,
modulePathIgnorePatterns: ['<rootDir>/.localdevserver'],
setupFilesAfterEach: ['<rootDir>/jest-sa11y-setup.js'],
moduleNameMapper: {
'^lightning/(.*)$': '<rootDir>/force-app/test/jest-mocks/lightning/$1',
},
};
```
Tests live in `lwc/<component>/__tests__/<component>.test.js`. Mocks for `lightning/*` modules that `sfdx-lwc-jest` doesn't ship live in `force-app/test/jest-mocks/`.
Core pattern:
```js
import { createElement } from 'lwc';
import OrderList from 'c/orderList';
import getOrders from '@salesforce/apex/OrderController.getOrders';
jest.mock('@salesforce/apex/OrderController.getOrders',
() => ({ default: jest.fn() }), { virtual: true });
const flushPromises = () => new Promise(process.nextTick);
describe('c-order-list', () => {
afterEach(() => {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
jest.clearAllMocks();
});
it('renders rows returned by Apex', async () => {
getOrders.emit([{ Id: '1', Name: 'ORD-1' }]); // wired Apex
const el = createElement('c-order-list', { is: OrderList });
document.body.appendChild(el);
await flushPromises();
const rows = el.shadowRoot.querySelectorAll('lightning-datatable');
expect(rows[0].data).toHaveLength(1);
});
});
```
Mocking rules:
- **Wired Apex** → `jest.mock('@salesforce/apex/...', () => ({ default: jest.fn() }), { virtual: true })`, then `getOrders.emit(data)` / `getOrders.error(err)`.
- **Imperative Apex** → same mock; `getOrders.mockResolvedValue(data)` / `mockRejectedValue(error)`.
- **LDS wires** (`getRecord`, `getObjectInfo`) → the adapters from `lightning/uiRecordApi` are auto-mocked by `sfdx-lwc-jest`; `getRecord.emit(mockRecord)` with a JSON fixture in `__tests__/data/`.
- **`@salesforce/schema`, `@salesforce/label`, `@salesforce/user/Id`** → auto-mocked; override with `jest.mock` when a value matters.
- **Lightning Message Service** → `publish` and `subscribe` from `lightning/messageService` are mocked; assert `publish` was called with the channel and payload; simulate incoming messages by calling the callback captured from `subscribe.mock.calls`.
- **`lightning/navigation`** → mock `NavigationMixin`; assert `Navigate` was dispatched with the right `pageReference`.
- **Toasts** → listen for `ShowToastEvent` on the element: `el.addEventListener('lightning__showtoast', handler)`.
- **Third-party libs via `loadScript`** → mock `lightning/platformResourceLoader` to resolve immediately and put a stub on `window`.
What to assert:
- Rendered output in `shadowRoot` — text, attributes, `data` passed to base components, `lwc:if` branches.
- Events dispatched (`el.addEventListener('select', handler)` then trigger the child event with `child.dispatchEvent(new CustomEvent('click'))`).
- Apex mock called with the exact parameters.
- Error branch: `emit`/`mockRejectedValue`, then the error UI or toast.
- `disconnectedCallback` cleanup: remove element, assert `unsubscribe` called.
Accessibility in every component test:
```js
import { setup } from '@sa11y/jest'; setup(); // in setup file
await expect(el).toBeAccessible(); // in tests
```
Coverage: enforce ≥ 75 % on `lwc/` in `jest.config.js` `coverageThreshold`; run `sfdx-lwc-jest --coverage` in CI.
## 2. Apex tests for UI controllers
Every `@AuraEnabled` method gets:
| Case | What it proves |
|---|---|
| **Positive** | Returns the DTO shape the LWC expects; assert fields, not just non-null |
| **Bulk** | 200+ records in one call — no SOQL/DML in loops |
| **Negative / access** | `System.runAs(userWithoutAccess)` → `AuraHandledException` or empty result, never a raw `QueryException` |
| **Sharing** | `runAs` a user who shouldn't see records → not returned (proves `with sharing` + `USER_MODE`) |
| **Error mapping** | Forced failure (e.g. missing required field) → user-safe `AuraHandledException` message |
| **Cacheable purity** | Cacheable method performs no DML (a DML inside throws in cacheable context — test asserts the method is read-only) |
Patterns:
- `@TestSetup` for shared data; **TestDataFactory** class; never `SeeAllData=true`.
- `Test.startTest()/stopTest()` around the call to reset limits and force async.
- Callouts → `HttpCalloutMock`; Platform Events → `Test.getEventBus().deliver()`.
- Assert with messages: `Assert.areEqual(expected, actual, 'row count for owner');` (`Assert` class over `System.assert`).
- Aim for **≥ 85 % on controller/service classes**, not the 75 % org minimum; coverage is a floor, assertions are the point.
## 3. Browser / E2E tests
Use when the behaviour depends on the platform runtime: LDS caching, App Builder configuration, Flow navigation, Experience Cloud pages, permissions.
- **UTAM** (Salesforce's page-object framework) when the team already uses WebdriverIO and wants Salesforce-maintained page objects for standard UI.
- **Playwright** otherwise — faster to adopt, better tooling.
Playwright against an org:
- Authenticate once via `sf org open --url-only` (frontdoor URL) or username/password on a scratch org; save `storageState`.
- Locate through **Shadow DOM**: Playwright's `getByRole`/`getByLabel` pierce shadow roots automatically; avoid CSS chains through `lightning-*` internals — they change every release.
- Navigate with direct URLs (`/lightning/r/Order/{id}/view`) rather than clicking through menus.
- Seed data through the **REST API** or `sf data create record` in `beforeAll`; delete in `afterAll`. Scratch org per CI run is the cleanest isolation.
- Wait for Lightning to settle: `await expect(page.getByRole('heading', { name })).toBeVisible()`; never fixed sleeps.
- Keep the E2E suite to **5–15 critical journeys** (record page renders, key quick action, Flow completes, Experience site login). Everything else belongs in Jest/Apex.
- Run the suite against **pre-release sandboxes** before each Salesforce major release.
## 4. Test strategy per component type
| Component | Jest | Apex | Browser |
|---|---|---|---|
| Presentational LWC (`@api` in, events out) | ✔ all states + a11y | — | — |
| Container LWC with wires/imperative Apex | ✔ mocked adapters | ✔ controller cases | smoke only |
| LWC in Flow screen | ✔ FlowAttributeChangeEvent dispatched | — | ✔ one Flow run |
| Experience Cloud page | ✔ components | ✔ guest-user sharing | ✔ guest + authed journey |
| App Builder config (`targetConfigs`) | — | — | ✔ page renders with defaults |
| Apex Service/Selector | — | ✔ bulk + sharing + negative | — |
## 5. CI pipeline
```
lint (eslint + prettier + pmd) → jest --coverage (threshold) → sf project deploy validate (RunLocalTests, coverage ≥ 85 % changed) → playwright smoke on scratch org → package version create
```
Fail on: lint errors, Jest threshold, any Apex test failure, sa11y violations, smoke failures. Nightly: full Playwright suite against a persistent QA sandbox.
## 6. When asked to "write tests for this LWC / controller"
1. List behaviours: initial render, each wire state (data/error/undefined), each user action and resulting event/Apex call, each `lwc:if` branch, cleanup.
2. Check existing `jest-mocks/` and `TestDataFactory` — reuse.
3. Write Jest tests one behaviour per `it`, `flushPromises` after async; add `toBeAccessible()`.
4. For the Apex controller, write positive, bulk, negative-access, and sharing tests with `runAs`.
5. Run `sfdx-lwc-jest` and `sf apex run test`; paste results.
## Flaky-test triage
| Symptom | Cause | Fix |
|---|---|---|
| Jest: element not found after emit | Missing `await flushPromises()` or `Promise.resolve()` micro-task | Add flush; for `renderedCallback` chains, flush twice |
| Jest: leakage between tests | Element left in `document.body`, module-level state in component | Remove children in `afterEach`; `jest.resetModules()` if needed |
| Apex: passes alone, fails in run | Order-dependent data, `SeeAllData`, hardcoded ids | `@TestSetup`, factory, query ids |
| Apex: `UNABLE_TO_LOCK_ROW` | Parallel tests touching the same parent record | Separate parents per test or run class serially |
| Playwright: intermittent timeouts | Lightning async render, LDS refresh | Role-based `expect(...).toBeVisible()`, increase `expect` timeout for record pages, avoid `networkidle` |
| Playwright: selector broke after release | Selecting into `lightning-*` internals | Use roles/labels; rely on UTAM page objects for standard UI |