Org Skills
Referenceapex-testing

Mocks, async tests and CI commands

Supporting material for apex-testing. Agents load it on demand; it ships inside the skill folder.

RawSource

Target: Spring '26. Sources: Apex Stub API, Testing HTTP callouts, Testing Platform Events, sf apex run test, sf flow run test, sf logic run test.

Stub API (no library)

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

@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

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

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

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