---
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.
