Design and write Salesforce Apex tests for Spring '26 — the `Assert` class, TestDataFactory and builders, `@TestSetup`, `System.runAs`, the Stub API vs a mocking library, `HttpCalloutMock`, `Test.getEventBus().deliver()`, Queueable/Finalizer/Batch/Cursor tests, `@AuraEnabled` and `@InvocableMethod` controller tests, flow tests with `sf flow run test` and the unified `sf logic run test`, and coverage gates.
When agents use itUse this whenever the user asks how to test an Apex class, trigger, handler, service, selector, Queueable, Batch, Schedulable, invocable or controller; writes or fixes an `@IsTest` class; mentions `Test.startTest`, `Test.setMock`, `Test.createStub`, `SeeAllData`, `TestDataFactory`, "75% coverage", "FlowTesting", `sf apex run test`, a failing test in CI or `UNABLE_TO_LOCK_ROW` in tests; or asks "what should I test". Also apply it when generating tests for Apex the user just wrote, even if they don't say "test". For LWC Jest and browser tests use `salesforce-frontend-testing`.
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 apex-testing -a github-copilot
# or with the org installer (adds .github/skills/apex-testing):
npx -y github:AGCO-Global/org-skills add skill apex-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-apex-skills plugin, which bundles all Salesforce / Apex skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install salesforce-apex-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill apex-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 apex-testing -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill apex-testing -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill apex-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.
Try it — example prompts
Prompts this skill is tested against, and what a good answer includes.
Write the test class for this OrderService.rollUpToAccounts method (trigger-driven roll-up to Account).
@TestSetup with a TestDataFactorybulk test with 200 records inside Test.startTest/stopTestAssert.areEqual with a message on the queried AccountSystem.runAs negative caseno SeeAllData
How do I test a Queueable that makes a callout and retries through a Finalizer?
Test.setMock(HttpCalloutMock.class, ...)enqueue inside Test.startTest/stopTestforce the failure and assert the Finalizer's log or re-enqueueAsyncOptions duplicate case if used
Our after-save flow on Case has Flow Tests, but the delete path and the async path are untested. What do we do?
Flow Tests do not support delete-triggered flows or async pathsApex tests that delete/update the Case and assert the outcomesf flow run test and/or sf logic run test in CI
Write a test proving the OrderEvent__e subscriber trigger is idempotent.
EventBus.publish twice with the same EventUuid/correlation keyTest.getEventBus().deliver()assert one resulting record
Mock the repository dependency of this service without adding a library.
interface for the dependencySystem.StubProvider implementationTest.createStubconstructor injection with @TestVisible
Skill contents
Apex Testing
Targets: Salesforce Spring '26 (API 66.0) · Verified: 2026-09 against https://developer.salesforce.com (Apex Developer Guide "Testing Apex", Apex Reference Assert, Test) and the plugin-apex / plugin-flow command references
The org's bar is 90 %+, and the platform's 75 % is only a deployment floor — but the percentage is the cheapest thing about a test suite. A good Apex test builds its own data, calls the code once as 200 records would, and asserts an outcome with a message that explains the failure. Tests that only prove "no exception" are noise.
1. Decide first
Question
Default
Change when
What does one test class cover?
One @IsTest class per production class (OrderServiceTest)
Trigger handler + service tested together through DML when the handler is routing-only
Where does data come from?
TestDataFactory builders called in @TestSetup
Per-method data when tests need conflicting setups; never SeeAllData=true
How to isolate a dependency?
Interface + Test.createStub (Stub API, no library)
A mocking library (fflib ApexMocks) when the team already uses it; HttpCalloutMock for every callout
Add limit assertions (Limits.getQueries()) on hot paths
Async code?
Wrap in Test.startTest()/Test.stopTest() so jobs run synchronously
Finalizer paths: force the exception and assert the re-enqueue/log
Flow logic on the object?
Flow Tests in Flow Builder + Apex tests that insert/update the object
Delete-triggered and async paths: Apex tests only (Flow Tests can't cover them)
Coverage target
90 %+ — the org's bar, well above the platform's 75 % deployment floor
Generated DTO classes may sit lower with an explicit note
2. Anatomy of a test
@IsTest
private class OrderServiceTest {
@TestSetup
static void setup() {
Account acc = TestDataFactory.account().withName('Acme').persist();
TestDataFactory.orders(acc.Id, 200).persist();
}
@IsTest
static void rollUp_updatesAccountTotal_for200Orders() {
List<Order__c> orders = [SELECT Id, Amount__c FROM Order__c];
for (Order__c o : orders) { o.Amount__c += 10; }
Test.startTest();
update orders;
Test.stopTest();
Account acc = [SELECT Total_Order_Amount__c FROM Account LIMIT 1];
Assert.areEqual(200 * 110, acc.Total_Order_Amount__c, 'total after bulk amount change');
Assert.isTrue(Limits.getQueries() < 10, 'query count stays flat for 200 records');
}
@IsTest
static void rollUp_withoutAccess_throwsHandled() {
User restricted = TestDataFactory.user('Minimum Access - Salesforce');
System.runAs(restricted) {
try {
OrderService.rollUpToAccounts([SELECT Id, Account__c, Amount__c FROM Order__c LIMIT 1], null);
Assert.fail('expected a DmlException for a user without Account edit');
} catch (DmlException e) {
Assert.isTrue(e.getMessage().contains('INSUFFICIENT_ACCESS'), e.getMessage());
}
}
}
}
Method names read unit_scenario_expectation. One behaviour per method.
Assert.areEqual / areNotEqual / isTrue / isFalse / isNull / isNotNull / isInstanceOfType / fail — always with the message argument. System.assert* is legacy.
Query the record back after the call; never assert on the in-memory object you passed in.
Test.startTest() resets limits and, at stopTest(), runs Queueable/Batch/future/scheduled jobs and delivers Platform Events.
3. Test data
TestDataFactory with fluent builders (account().withName().persist()) that set every required field and take record type Ids from Utils.getRecordTypeId(...), never hard-coded Ids. Pattern in references/test-data-factory.md.
Bulk means 200 records: that is the trigger chunk size and the number where SOQL-in-loop fails.
Users: TestDataFactory.user(profileName) builds a User with a unique username; create in @TestSetup or inside System.runAs(new User(Id = UserInfo.getUserId())) to avoid MIXED_DML_OPERATION.
Custom Metadata: read directly (deployed records are visible in tests); when the test must control values, inject them through a @TestVisible static or a settings interface.
@IsTest(SeeAllData=true) is never acceptable; IsParallel=true on classes that touch no shared parents to speed up runs.
4. Mocks and async
Stub API: (OrderRepository) Test.createStub(OrderRepository.class, new OrderRepositoryStub()) with a System.StubProvider — works for interfaces and non-final virtual classes, no library. Full example in references/mocking.md.
Callouts: Test.setMock(HttpCalloutMock.class, new ErpMock(200, '{"ok":true}')); one mock class with status/body constructor covers success, 4xx, 5xx and timeout (throw new CalloutException()).
Platform Events: publish in the test, then Test.getEventBus().deliver(); inside startTest/stopTest to run the subscriber trigger; assert EventBus.publish results with SaveResult.isSuccess().
Queueable: enqueue inside startTest/stopTest; assert the job's side effects. For AsyncOptions dedupe, enqueue twice and assert DuplicateMessageException.
Finalizer: make the Queueable throw (test-visible flag), stopTest(), assert the log record or the re-enqueued AsyncApexJob.
Batch: Database.executeBatch(new Job(), 200) inside startTest/stopTest; one execute chunk runs. Apex Cursors: call the chunk-processing method directly with a small list; cursor plumbing needs no test.
Schedulable: System.schedule('test', '0 0 0 1 1 ? 2099', new Job()), then assert a CronTrigger exists.
Invocables / @AuraEnabled: call the static method with a List<Request>; assert the List<Result>; negative case with runAs expects AuraHandledException.
5. Flow tests and the unified runner
Flow Tests are created in Flow Builder (View Tests / Convert debug run to test) for record-triggered, autolaunched and Data 360-triggered flows; limits: no delete-triggered flows, no async paths, no callouts/wait elements, max 200 tests per flow (help). Cover those gaps with Apex tests that insert/update the object.
CI: sf apex run test --test-level RunLocalTests --code-coverage --result-format junit --output-dir test-results --wait 30 and sf flow run test --class-names Order_After_Save --synchronous --result-format junit --output-dir test-results.
Unified (Beta, needs View All Data): sf logic run test --test-level RunLocalTests --test-category Apex --test-category Flow --synchronous --code-coverage --result-format junit --output-dir test-results. Command details in references/mocking.md § CI commands.
6. Deliverable format
## Test plan — table: method · positive · bulk 200 · negative · runAs · async/mocks
## Test classes — complete @IsTest classes, factory calls, Assert with messages
## Gaps — what Flow Tests cannot cover here and which Apex test covers it
## Run — the exact sf commands and expected coverage
Checklist
No SeeAllData=true; data from TestDataFactory in @TestSetup
Every test asserts an outcome queried back from the database, with a message
Bulk test with 200 records on every trigger path; Limits.getQueries() asserted on hot paths
Negative test per validation/exception branch; Assert.fail guards the try block
System.runAs test proves sharing/USER_MODE for one restricted user
Callouts mocked with HttpCalloutMock; events delivered with Test.getEventBus().deliver()
Async wrapped in Test.startTest()/Test.stopTest()
Flow Tests exist for record-triggered flows; delete/async paths covered in Apex
sf apex run test (and sf flow run test) green in CI with JUnit output
Anti-patterns
System.assert(true) or tests with no asserts → assert queried outcomes with messages; Code Analyzer flags ApexUnitTestClassShouldHaveAsserts.
Single-record tests only → add the 200-record case; that is where limits break.
Testing a cacheable=true method "does no DML" from Apex → not enforceable in a test class; enforce it in review and Jest.
Hard-coded record type or profile Ids in tests → describe calls and TestDataFactory.
Creating a User outside runAs next to standard DML → MIXED_DML_OPERATION; wrap user creation in System.runAs.
Test.stopTest() missing around a Queueable → job never runs; assertions pass vacuously.
Mocking the class under test → mock its dependencies through interfaces only.
Go deeper
references/test-data-factory.md — builder-style factory, user creation, bulk helpers.
references/mocking.md — Stub API provider, HttpCalloutMock, event bus, Queueable/Finalizer/Batch/Schedulable tests, CI commands.
Sibling skills: apex-development for the code under test, salesforce-automation for Flow Tests, salesforce-devops for the pipeline, salesforce-frontend-testing for Jest and browser tests, salesforce-code-review for reviews, salesforce-org-conventions for the org's naming and mandated patterns.
---
name: apex-testing
description: >
Design and write Salesforce Apex tests for Spring '26 — the `Assert` class, TestDataFactory and builders, `@TestSetup`, `System.runAs`, the Stub API vs a mocking library, `HttpCalloutMock`, `Test.getEventBus().deliver()`, Queueable/Finalizer/Batch/Cursor tests, `@AuraEnabled` and `@InvocableMethod` controller tests, flow tests with `sf flow run test` and the unified `sf logic run test`, and coverage gates. Use this whenever the user asks how to test an Apex class, trigger, handler, service, selector, Queueable, Batch, Schedulable, invocable or controller; writes or fixes an `@IsTest` class; mentions `Test.startTest`, `Test.setMock`, `Test.createStub`, `SeeAllData`, `TestDataFactory`, "75% coverage", "FlowTesting", `sf apex run test`, a failing test in CI or `UNABLE_TO_LOCK_ROW` in tests; or asks "what should I test". Also apply it when generating tests for Apex the user just wrote, even if they don't say "test". For LWC Jest and browser tests use `salesforce-frontend-testing`.
metadata:
technology: Salesforce
type: testing
---
# Apex Testing
> **Targets:** Salesforce Spring '26 (API 66.0) · **Verified:** 2026-09 against https://developer.salesforce.com (Apex Developer Guide "Testing Apex", Apex Reference `Assert`, `Test`) and the `plugin-apex` / `plugin-flow` command references
The org's bar is 90 %+, and the platform's 75 % is only a deployment floor — but the percentage is the cheapest thing about a test suite. A good Apex test builds its own data, calls the code once as 200 records would, and asserts an outcome with a message that explains the failure. Tests that only prove "no exception" are noise.
## 1. Decide first
| Question | Default | Change when |
|---|---|---|
| What does one test class cover? | One `@IsTest` class per production class (`OrderServiceTest`) | Trigger handler + service tested together through DML when the handler is routing-only |
| Where does data come from? | `TestDataFactory` builders called in `@TestSetup` | Per-method data when tests need conflicting setups; never `SeeAllData=true` |
| How to isolate a dependency? | Interface + `Test.createStub` (Stub API, no library) | A mocking library (fflib ApexMocks) when the team already uses it; `HttpCalloutMock` for every callout |
| Which cases per method? | Positive · bulk (200) · negative/exception · `System.runAs` sharing | Add limit assertions (`Limits.getQueries()`) on hot paths |
| Async code? | Wrap in `Test.startTest()`/`Test.stopTest()` so jobs run synchronously | Finalizer paths: force the exception and assert the re-enqueue/log |
| Flow logic on the object? | Flow Tests in Flow Builder + Apex tests that insert/update the object | Delete-triggered and async paths: Apex tests only (Flow Tests can't cover them) |
| Coverage target | **90 %+** — the org's bar, well above the platform's 75 % deployment floor | Generated DTO classes may sit lower with an explicit note |
## 2. Anatomy of a test
```apex
@IsTest
private class OrderServiceTest {
@TestSetup
static void setup() {
Account acc = TestDataFactory.account().withName('Acme').persist();
TestDataFactory.orders(acc.Id, 200).persist();
}
@IsTest
static void rollUp_updatesAccountTotal_for200Orders() {
List<Order__c> orders = [SELECT Id, Amount__c FROM Order__c];
for (Order__c o : orders) { o.Amount__c += 10; }
Test.startTest();
update orders;
Test.stopTest();
Account acc = [SELECT Total_Order_Amount__c FROM Account LIMIT 1];
Assert.areEqual(200 * 110, acc.Total_Order_Amount__c, 'total after bulk amount change');
Assert.isTrue(Limits.getQueries() < 10, 'query count stays flat for 200 records');
}
@IsTest
static void rollUp_withoutAccess_throwsHandled() {
User restricted = TestDataFactory.user('Minimum Access - Salesforce');
System.runAs(restricted) {
try {
OrderService.rollUpToAccounts([SELECT Id, Account__c, Amount__c FROM Order__c LIMIT 1], null);
Assert.fail('expected a DmlException for a user without Account edit');
} catch (DmlException e) {
Assert.isTrue(e.getMessage().contains('INSUFFICIENT_ACCESS'), e.getMessage());
}
}
}
}
```
- Method names read `unit_scenario_expectation`. One behaviour per method.
- `Assert.areEqual / areNotEqual / isTrue / isFalse / isNull / isNotNull / isInstanceOfType / fail` — always with the message argument. `System.assert*` is legacy.
- Query the record back after the call; never assert on the in-memory object you passed in.
- `Test.startTest()` resets limits and, at `stopTest()`, runs Queueable/Batch/future/scheduled jobs and delivers Platform Events.
## 3. Test data
- `TestDataFactory` with fluent builders (`account().withName().persist()`) that set every required field and take record type Ids from `Utils.getRecordTypeId(...)`, never hard-coded Ids. Pattern in `references/test-data-factory.md`.
- Bulk means **200** records: that is the trigger chunk size and the number where SOQL-in-loop fails.
- Users: `TestDataFactory.user(profileName)` builds a `User` with a unique username; create in `@TestSetup` or inside `System.runAs(new User(Id = UserInfo.getUserId()))` to avoid `MIXED_DML_OPERATION`.
- Custom Metadata: read directly (deployed records are visible in tests); when the test must control values, inject them through a `@TestVisible` static or a settings interface.
- `@IsTest(SeeAllData=true)` is never acceptable; `IsParallel=true` on classes that touch no shared parents to speed up runs.
## 4. Mocks and async
- **Stub API**: `(OrderRepository) Test.createStub(OrderRepository.class, new OrderRepositoryStub())` with a `System.StubProvider` — works for interfaces and non-final virtual classes, no library. Full example in `references/mocking.md`.
- **Callouts**: `Test.setMock(HttpCalloutMock.class, new ErpMock(200, '{"ok":true}'))`; one mock class with status/body constructor covers success, 4xx, 5xx and timeout (`throw new CalloutException()`).
- **Platform Events**: publish in the test, then `Test.getEventBus().deliver();` inside `startTest/stopTest` to run the subscriber trigger; assert `EventBus.publish` results with `SaveResult.isSuccess()`.
- **Queueable**: enqueue inside `startTest/stopTest`; assert the job's side effects. For `AsyncOptions` dedupe, enqueue twice and assert `DuplicateMessageException`.
- **Finalizer**: make the Queueable throw (test-visible flag), `stopTest()`, assert the log record or the re-enqueued `AsyncApexJob`.
- **Batch**: `Database.executeBatch(new Job(), 200)` inside `startTest/stopTest`; one execute chunk runs. **Apex Cursors**: call the chunk-processing method directly with a small list; cursor plumbing needs no test.
- **Schedulable**: `System.schedule('test', '0 0 0 1 1 ? 2099', new Job())`, then assert a `CronTrigger` exists.
- **Invocables / `@AuraEnabled`**: call the static method with a `List<Request>`; assert the `List<Result>`; negative case with `runAs` expects `AuraHandledException`.
## 5. Flow tests and the unified runner
- Flow Tests are created in Flow Builder (View Tests / Convert debug run to test) for record-triggered, autolaunched and Data 360-triggered flows; limits: no delete-triggered flows, no async paths, no callouts/wait elements, max 200 tests per flow ([help](https://help.salesforce.com/s/articleView?id=platform.automate_flow_test_record_data_cloud_triggered.htm&language=en_US&type=5)). Cover those gaps with Apex tests that insert/update the object.
- CI: `sf apex run test --test-level RunLocalTests --code-coverage --result-format junit --output-dir test-results --wait 30` and `sf flow run test --class-names Order_After_Save --synchronous --result-format junit --output-dir test-results`.
- Unified (Beta, needs View All Data): `sf logic run test --test-level RunLocalTests --test-category Apex --test-category Flow --synchronous --code-coverage --result-format junit --output-dir test-results`. Command details in `references/mocking.md` § CI commands.
## 6. Deliverable format
```
## Test plan — table: method · positive · bulk 200 · negative · runAs · async/mocks
## Test classes — complete @IsTest classes, factory calls, Assert with messages
## Gaps — what Flow Tests cannot cover here and which Apex test covers it
## Run — the exact sf commands and expected coverage
```
## Checklist
- [ ] No `SeeAllData=true`; data from `TestDataFactory` in `@TestSetup`
- [ ] Every test asserts an outcome queried back from the database, with a message
- [ ] Bulk test with 200 records on every trigger path; `Limits.getQueries()` asserted on hot paths
- [ ] Negative test per validation/exception branch; `Assert.fail` guards the try block
- [ ] `System.runAs` test proves sharing/`USER_MODE` for one restricted user
- [ ] Callouts mocked with `HttpCalloutMock`; events delivered with `Test.getEventBus().deliver()`
- [ ] Async wrapped in `Test.startTest()`/`Test.stopTest()`
- [ ] Flow Tests exist for record-triggered flows; delete/async paths covered in Apex
- [ ] `sf apex run test` (and `sf flow run test`) green in CI with JUnit output
## Anti-patterns
- **`System.assert(true)` or tests with no asserts** → assert queried outcomes with messages; Code Analyzer flags `ApexUnitTestClassShouldHaveAsserts`.
- **Single-record tests only** → add the 200-record case; that is where limits break.
- **Testing a `cacheable=true` method "does no DML" from Apex** → not enforceable in a test class; enforce it in review and Jest.
- **Hard-coded record type or profile Ids in tests** → describe calls and `TestDataFactory`.
- **Creating a User outside `runAs` next to standard DML** → `MIXED_DML_OPERATION`; wrap user creation in `System.runAs`.
- **`Test.stopTest()` missing around a Queueable** → job never runs; assertions pass vacuously.
- **Mocking the class under test** → mock its dependencies through interfaces only.
## Go deeper
- `references/test-data-factory.md` — builder-style factory, user creation, bulk helpers.
- `references/mocking.md` — Stub API provider, `HttpCalloutMock`, event bus, Queueable/Finalizer/Batch/Schedulable tests, CI commands.
- Sibling skills: `apex-development` for the code under test, `salesforce-automation` for Flow Tests, `salesforce-devops` for the pipeline, `salesforce-frontend-testing` for Jest and browser tests, `salesforce-code-review` for reviews, `salesforce-org-conventions` for the org's naming and mandated patterns.