afv-library/skills/apex-test-class/references/async-testing.md
Mohan Raj Rajamanickam 2320c50264
ci: publish skills as @salesforce/afv-skills npm package @W-21534193@ (#30)
* ci: update package details and add GitHub workflows for skills validation and release

* chore: update Node version, change license, and refactor skills validation script to TypeScript

* chore: remove unused deps

* fix: npm package

* fix: flatten skills to pass validation

* chore: add validation script to package.json and update GitHub workflow to use it

* chore: enhance skills validation and update workflows for npm publishing

* refactor: streamline skills validation process in workflow and script

* refactor: improve documentation and structure of skills validation script

* implement checks based on jeff's best practices doc

* chore: add pull request template for skill submissions

* chore: update pull request template with additional references and improve automated checks section

* chore: update pull request template to clarify naming convention with a warning for gerund form

* chore: update pull request template to reference skill authoring guide and enhance checklist structure

* chore: enhance GitHub workflows for skills validation and release process

* refactor: improve parsing logic for SKILL.md files and enhance error collection in validation

* chore: update validation checks to enforce character limits for skill names and descriptions

* fix: remove unnecessary whitespace in footer string in validate-skills.ts
2026-03-12 13:41:52 -07:00

7.9 KiB

Async Testing Patterns

Key Principle

Test.stopTest() forces all async operations to execute synchronously, allowing assertions on their results.

Batch Apex Testing

Basic Batch Test

@IsTest
private static void shouldProcessAllRecords_WhenBatchExecutes() {
    // Given: Create test data
    List<Account> accounts = TestDataFactory.createAccounts(200, true);
    
    // When: Execute batch
    Test.startTest();
    MyBatchClass batch = new MyBatchClass();
    Id batchId = Database.executeBatch(batch, 200);
    Test.stopTest(); // Forces batch to complete
    
    // Then: Verify results
    List<Account> updated = [SELECT Id, Status__c FROM Account];
    for (Account acc : updated) {
        System.Assert.areEqual('Processed', acc.Status__c, 
            'Batch should update all account statuses');
    }
}

Testing Batch with Failures

@IsTest
private static void shouldLogErrors_WhenRecordsFail() {
    // Given: Create mix of valid and invalid records
    List<Account> accounts = TestDataFactory.createAccounts(198, true);
    
    // Create 2 accounts that will fail processing
    List<Account> invalidAccounts = new List<Account>();
    for (Integer i = 0; i < 2; i++) {
        invalidAccounts.add(new Account(
            Name = 'Invalid Account ' + i,
            Invalid_Field__c = 'triggers_validation_error'
        ));
    }
    insert invalidAccounts;
    
    // When
    Test.startTest();
    MyBatchClass batch = new MyBatchClass();
    Database.executeBatch(batch, 50);
    Test.stopTest();
    
    // Then
    List<Error_Log__c> errors = [SELECT Id, Message__c FROM Error_Log__c];
    System.Assert.areEqual(2, errors.size(), 'Should log 2 failed records');
}

Testing Batch Scope

@IsTest
private static void shouldRespectBatchSize() {
    // Given
    List<Account> accounts = TestDataFactory.createAccounts(250, true);
    
    Test.startTest();
    MyBatchClass batch = new MyBatchClass();
    Database.executeBatch(batch, 50); // 5 batches of 50
    Test.stopTest();
    
    // Note: In tests, all batches execute but you can verify total processing
    List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
    System.Assert.areEqual(250, processed.size(), 'All records should be processed');
}

Queueable Testing

Basic Queueable Test

@IsTest
private static void shouldCompleteProcessing_WhenQueueableEnqueued() {
    // Given
    Account acc = TestDataFactory.createAccount(true);
    
    // When
    Test.startTest();
    MyQueueableClass queueable = new MyQueueableClass(acc.Id);
    System.enqueueJob(queueable);
    Test.stopTest(); // Forces queueable to complete
    
    // Then
    Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id];
    System.Assert.areEqual('Processed', updated.Status__c, 
        'Queueable should update account status');
}

Testing Queueable Chaining

Chained queueables only execute the first job in tests:

@IsTest
private static void shouldChainNextJob_WhenMoreRecordsExist() {
    // Given: More records than one queueable can process
    List<Account> accounts = TestDataFactory.createAccounts(500, true);
    
    Test.startTest();
    // First queueable processes batch 1 and chains next
    MyChainedQueueable queueable = new MyChainedQueueable(0, 100);
    System.enqueueJob(queueable);
    Test.stopTest();
    
    // Verify first batch processed
    List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
    System.Assert.areEqual(100, processed.size(), 'First batch should process 100 records');
    
    // Verify chain was enqueued (check AsyncApexJob)
    List<AsyncApexJob> jobs = [
        SELECT Id, Status, JobType 
        FROM AsyncApexJob 
        WHERE ApexClass.Name = 'MyChainedQueueable'
    ];
    System.Assert.isTrue(jobs.size() >= 1, 'Chained job should be enqueued');
}

Testing Queueable with Callouts

@IsTest
private static void shouldMakeCallout_WhenQueueableWithCallout() {
    // Given
    Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"status":"ok"}'));
    Account acc = TestDataFactory.createAccount(true);
    
    // When
    Test.startTest();
    MyQueueableWithCallout queueable = new MyQueueableWithCallout(acc.Id);
    System.enqueueJob(queueable);
    Test.stopTest();
    
    // Then
    Account updated = [SELECT Id, External_Status__c FROM Account WHERE Id = :acc.Id];
    System.Assert.areEqual('Synced', updated.External_Status__c, 
        'Should update status after successful callout');
}

Future Method Testing

@IsTest
private static void shouldExecuteFutureMethod() {
    // Given
    Account acc = TestDataFactory.createAccount(true);
    
    // When
    Test.startTest();
    MyClass.processFuture(acc.Id); // @future method
    Test.stopTest(); // Forces future to complete
    
    // Then
    Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id];
    System.Assert.areEqual(true, updated.Processed__c, 'Future should process record');
}

Scheduled Apex Testing

Testing Scheduled Execution

@IsTest
private static void shouldExecuteScheduledJob() {
    // Given
    List<Account> accounts = TestDataFactory.createAccounts(50, true);
    
    // When
    Test.startTest();
    String cronExp = '0 0 0 1 1 ? 2099'; // Arbitrary future time
    String jobId = System.schedule('Test Job', cronExp, new MyScheduledClass());
    
    // Execute the scheduled job immediately
    MyScheduledClass scheduled = new MyScheduledClass();
    scheduled.execute(null); // Pass null SchedulableContext in tests
    Test.stopTest();
    
    // Then
    List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
    System.Assert.areEqual(50, processed.size(), 'Scheduled job should process records');
}

Testing Schedule Registration

@IsTest
private static void shouldScheduleJob() {
    Test.startTest();
    String cronExp = '0 0 6 * * ?'; // Daily at 6 AM
    String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass());
    Test.stopTest();
    
    // Verify job is scheduled
    CronTrigger ct = [
        SELECT Id, CronExpression, State 
        FROM CronTrigger 
        WHERE Id = :jobId
    ];
    System.Assert.areEqual('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
    System.Assert.areEqual('WAITING', ct.State, 'Job should be waiting');
}

Testing Async Limits

@IsTest
private static void shouldNotExceedQueueableLimits() {
    // Given: Setup that might enqueue multiple jobs
    List<Account> accounts = TestDataFactory.createAccounts(100, true);
    
    Test.startTest();
    Integer queueablesBefore = Limits.getQueueableJobs();
    
    MyService.processWithQueueables(accounts);
    
    Integer queueablesUsed = Limits.getQueueableJobs() - queueablesBefore;
    Test.stopTest();
    
    // Verify limit not exceeded (50 in synchronous context, 1 in queueable)
    System.Assert.isTrue(queueablesUsed <= 50, 
        'Should not exceed queueable limit. Used: ' + queueablesUsed);
}

Common Pitfalls

Forgetting Test.stopTest()

// Bad: Async never executes
Test.startTest();
System.enqueueJob(new MyQueueable());
// Missing Test.stopTest()!

List<Account> results = [SELECT Id FROM Account WHERE Processed__c = true];
System.Assert.areEqual(100, results.size()); // FAILS - queueable didn't run

Testing chained jobs without understanding limits

// Only the FIRST chained queueable runs in tests
// Design tests to verify:
// 1. First job completes correctly
// 2. Chain is properly enqueued (check AsyncApexJob)
// 3. Each job works independently

Not mocking callouts in async

// Async with callouts MUST have mock set BEFORE Test.startTest()
Test.setMock(HttpCalloutMock.class, new MockResponse()); // Before startTest!
Test.startTest();
System.enqueueJob(new QueueableWithCallout());
Test.stopTest();