# Mocks, async tests and CI commands

Target: Spring '26. Sources: [Apex Stub API](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_stub_api.htm), [Testing HTTP callouts](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_restful_http_testing.htm), [Testing Platform Events](https://developer.salesforce.com/docs/atlas.en-us.platform_events.meta/platform_events/platform_events_apex_test.htm), [`sf apex run test`](https://developer.salesforce.com/docs/platform/salesforce-cli-reference/guide/cli_reference_apex_run_test.html), [`sf flow run test`](https://developer.salesforce.com/docs/platform/salesforce-cli-reference/guide/cli_reference_flow_run_test.html), [`sf logic run test`](https://developer.salesforce.com/docs/platform/salesforce-cli-reference/guide/cli_reference_logic_run_test.html).

## Stub API (no library)

```apex
// production
public interface OrderRepository { List<Order__c> openOrders(Set<Id> accountIds); }

public with sharing class OrderService {
    private final OrderRepository repo;
    public OrderService() { this(new OrderSelectorRepository()); }
    @TestVisible OrderService(OrderRepository repo) { this.repo = repo; }
    public Integer countOpen(Set<Id> accountIds) { return repo.openOrders(accountIds).size(); }
}

// test
@IsTest
private class OrderServiceStubTest {
    private class RepoStub implements System.StubProvider {
        public Object handleMethodCall(Object stubbedObject, String methodName, Type returnType,
                                       List<Type> paramTypes, List<String> paramNames, List<Object> args) {
            if (methodName == 'openOrders') {
                return new List<Order__c>{ new Order__c(Name = 'A'), new Order__c(Name = 'B') };
            }
            return null;
        }
    }

    @IsTest
    static void countOpen_returnsRepositoryCount() {
        OrderRepository repo = (OrderRepository) Test.createStub(OrderRepository.class, new RepoStub());
        Assert.areEqual(2, new OrderService(repo).countOpen(new Set<Id>()), 'count from stubbed repository');
    }
}
```

Stub API limits: cannot stub static methods, private methods, or classes in a different namespace; the stubbed type must be an interface or a non-final virtual/abstract class. Use ApexMocks when you need argument matching and call verification without hand-written providers.

## HttpCalloutMock

```apex
@IsTest
public class HttpMock implements HttpCalloutMock {
    private final Integer status; private final String body; private final Boolean timeout;
    public HttpMock(Integer status, String body) { this(status, body, false); }
    public HttpMock(Integer status, String body, Boolean timeout) { this.status = status; this.body = body; this.timeout = timeout; }
    public HttpResponse respond(HttpRequest req) {
        if (timeout) { throw new CalloutException('Read timed out'); }
        HttpResponse res = new HttpResponse();
        res.setStatusCode(status); res.setHeader('Content-Type', 'application/json'); res.setBody(body);
        return res;
    }
}

// in the test
Test.setMock(HttpCalloutMock.class, new HttpMock(503, '{"error":"unavailable"}'));
Test.startTest();
System.enqueueJob(new OrderSyncQueueable(acc.Id));
Test.stopTest();
Assert.areEqual(1, [SELECT COUNT() FROM Integration_Log__c WHERE Status__c = 'Retry'], 'retry logged on 503');
```

Cover four responses per external system: 200 with a realistic body, 4xx (no retry), 5xx (retry), timeout (CalloutException). Assert the retry/dead-letter path, not only the happy path.

## Platform Events

```apex
Test.startTest();
Database.SaveResult sr = EventBus.publish(new Order_Event__e(Order_Id__c = order.Id, Action__c = 'SHIPPED'));
Assert.isTrue(sr.isSuccess(), 'event published');
Test.getEventBus().deliver();          // runs the subscriber trigger now
Test.stopTest();
Assert.areEqual('Shipped', [SELECT Status__c FROM Order__c WHERE Id = :order.Id].Status__c, 'subscriber updated status');
```

Publish the same event twice and assert the subscriber stays idempotent (`EventUuid` dedupe).

## Queueable, Finalizer, Batch, Schedulable

```apex
// Queueable + AsyncOptions dedupe
Test.startTest();
OrderSyncQueueable.enqueueOnce(acc.Id);
OrderSyncQueueable.enqueueOnce(acc.Id);     // second call swallows DuplicateMessageException
Test.stopTest();
Assert.areEqual(1, [SELECT COUNT() FROM AsyncApexJob WHERE ApexClass.Name = 'OrderSyncQueueable'], 'one job for one signature');

// Finalizer path
OrderSyncQueueable.forceFailure = true;     // @TestVisible static
Test.startTest();
System.enqueueJob(new OrderSyncQueueable(acc.Id));
Test.stopTest();
Assert.areEqual(1, [SELECT COUNT() FROM Log__c WHERE Message__c LIKE 'OrderSync failed%'], 'finalizer logged the failure');

// Batch — one chunk executes
Test.startTest();
Database.executeBatch(new ContactCleanupBatch(), 200);
Test.stopTest();

// Schedulable
Test.startTest();
String jobId = System.schedule('nightly test', '0 0 0 1 1 ? 2099', new NightlySchedulable());
Test.stopTest();
Assert.isNotNull([SELECT Id FROM CronTrigger WHERE Id = :jobId], 'job scheduled');
```

Apex Cursors: test `ArchiveService.archive(List<Contact>)` directly with 5 records; the cursor loop is platform plumbing.

## `@AuraEnabled` and `@InvocableMethod`

- Call the static method directly; for wrappers assert every field the LWC reads.
- Negative: `System.runAs(restrictedUser)` → expect `AuraHandledException`; assert the message is user-safe (no stack trace, no SOQL).
- Invocable: build `List<Request>` with 200 entries, assert `List<Result>` size and content; Flow calls it in bulk.

## CI commands

```bash
# Apex
sf apex run test --target-org ci --test-level RunLocalTests --code-coverage \
  --result-format junit --output-dir test-results --wait 30

# Flow tests for one flow (plugin-flow, GA Summer '25)
sf flow run test --target-org ci --class-names Order_After_Save --synchronous \
  --code-coverage --result-format junit --output-dir test-results

# Apex + Flow in one run (Beta; requires View All Data)
sf logic run test --target-org ci --test-level RunLocalTests --test-category Apex --test-category Flow \
  --synchronous --code-coverage --result-format junit --output-dir test-results
```

`--class-names` on `sf flow run test` takes flow API names; `--tests` takes flow test names. `sf logic run test --tests` mixes Apex class names and flow tests named `FlowTesting.<FlowApiName>.<TestApiName>` (list them with `sf logic run test --synchronous --test-category Flow --test-level RunAllTestsInOrg`); a synchronous run of specific tests must stay within one Apex class or one flow test, so CI runs by category instead. Both flow commands need the *View All Data* permission on the CI user.
