2026-03-26 02:07:54 +08:00
# 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
2026-03-28 01:02:47 +08:00
**Critical:** In test context only one `execute()` invocation runs, so always set `batchSize >= testRecordCount` (e.g., `Database.executeBatch(batch, 200)` with 200 records). Never create more records than the batch size.
2026-03-26 02:07:54 +08:00
```apex
@isTest
static void shouldProcessAllRecords_WhenBatchExecutes() {
List< Account > accounts = TestDataFactory.createAccounts(200, true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
MyBatchClass batch = new MyBatchClass();
Id batchId = Database.executeBatch(batch, 200);
2026-03-28 01:02:47 +08:00
Test.stopTest();
2026-03-26 02:07:54 +08:00
List< Account > updated = [SELECT Id, Status__c FROM Account];
for (Account acc : updated) {
2026-03-28 01:02:47 +08:00
Assert.areEqual('Processed', acc.Status__c,
2026-03-26 02:07:54 +08:00
'Batch should update all account statuses');
}
}
```
2026-03-28 01:02:47 +08:00
### Batch with Failures
2026-03-26 02:07:54 +08:00
```apex
@isTest
static void shouldLogErrors_WhenRecordsFail() {
List< Account > accounts = TestDataFactory.createAccounts(198, true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
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;
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
MyBatchClass batch = new MyBatchClass();
2026-03-28 01:02:47 +08:00
Database.executeBatch(batch, 200);
2026-03-26 02:07:54 +08:00
Test.stopTest();
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
List< Error_Log__c > errors = [SELECT Id, Message__c FROM Error_Log__c];
2026-03-28 01:02:47 +08:00
Assert.areEqual(2, errors.size(), 'Should log 2 failed records');
2026-03-26 02:07:54 +08:00
}
```
2026-03-28 01:02:47 +08:00
### Batch Chaining
2026-03-26 02:07:54 +08:00
2026-03-28 01:02:47 +08:00
Test `finish()` chaining in a **separate test method** — calling `Database.executeBatch()` inside `finish()` during a test can throw `UnexpectedException` . Verify the first batch independently, then test that `finish()` enqueues the next batch.
2026-03-26 02:07:54 +08:00
## Queueable Testing
### Basic Queueable Test
```apex
@isTest
static void shouldCompleteProcessing_WhenQueueableEnqueued() {
Account acc = TestDataFactory.createAccount(true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
MyQueueableClass queueable = new MyQueueableClass(acc.Id);
System.enqueueJob(queueable);
2026-03-28 01:02:47 +08:00
Test.stopTest();
2026-03-26 02:07:54 +08:00
Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id];
2026-03-28 01:02:47 +08:00
Assert.areEqual('Processed', updated.Status__c,
2026-03-26 02:07:54 +08:00
'Queueable should update account status');
}
```
2026-03-28 01:02:47 +08:00
### Queueable Chaining
2026-03-26 02:07:54 +08:00
2026-03-28 01:02:47 +08:00
Only the **first** chained queueable executes in tests. Design tests to verify:
1. First job completes correctly
2. Chain is properly enqueued (query `AsyncApexJob` )
3. Each job works independently in its own test method
2026-03-26 02:07:54 +08:00
```apex
@isTest
static void shouldChainNextJob_WhenMoreRecordsExist() {
List< Account > accounts = TestDataFactory.createAccounts(500, true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
MyChainedQueueable queueable = new MyChainedQueueable(0, 100);
System.enqueueJob(queueable);
Test.stopTest();
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
List< Account > processed = [SELECT Id FROM Account WHERE Processed__c = true];
2026-03-28 01:02:47 +08:00
Assert.areEqual(100, processed.size(), 'First batch should process 100 records');
2026-03-26 02:07:54 +08:00
List< AsyncApexJob > jobs = [
2026-03-28 01:02:47 +08:00
SELECT Id, Status, JobType
FROM AsyncApexJob
2026-03-26 02:07:54 +08:00
WHERE ApexClass.Name = 'MyChainedQueueable'
];
2026-03-28 01:02:47 +08:00
Assert.isTrue(jobs.size() >= 1, 'Chained job should be enqueued');
2026-03-26 02:07:54 +08:00
}
```
2026-03-28 01:02:47 +08:00
### Queueable with Callouts
Set the callout mock **before** `Test.startTest()` :
2026-03-26 02:07:54 +08:00
```apex
@isTest
static void shouldMakeCallout_WhenQueueableWithCallout() {
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"status":"ok"}'));
Account acc = TestDataFactory.createAccount(true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
MyQueueableWithCallout queueable = new MyQueueableWithCallout(acc.Id);
System.enqueueJob(queueable);
Test.stopTest();
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Account updated = [SELECT Id, External_Status__c FROM Account WHERE Id = :acc.Id];
2026-03-28 01:02:47 +08:00
Assert.areEqual('Synced', updated.External_Status__c,
2026-03-26 02:07:54 +08:00
'Should update status after successful callout');
}
```
## Future Method Testing
```apex
@isTest
static void shouldExecuteFutureMethod() {
Account acc = TestDataFactory.createAccount(true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
2026-03-28 01:02:47 +08:00
MyClass.processFuture(acc.Id);
Test.stopTest();
2026-03-26 02:07:54 +08:00
Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id];
2026-03-28 01:02:47 +08:00
Assert.areEqual(true, updated.Processed__c, 'Future should process record');
2026-03-26 02:07:54 +08:00
}
```
## Scheduled Apex Testing
2026-03-28 01:02:47 +08:00
### Direct Execution
2026-03-26 02:07:54 +08:00
```apex
@isTest
static void shouldExecuteScheduledJob() {
List< Account > accounts = TestDataFactory.createAccounts(50, true);
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
Test.startTest();
MyScheduledClass scheduled = new MyScheduledClass();
2026-03-28 01:02:47 +08:00
scheduled.execute(null);
2026-03-26 02:07:54 +08:00
Test.stopTest();
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
List< Account > processed = [SELECT Id FROM Account WHERE Processed__c = true];
2026-03-28 01:02:47 +08:00
Assert.areEqual(50, processed.size(), 'Scheduled job should process records');
2026-03-26 02:07:54 +08:00
}
```
2026-03-28 01:02:47 +08:00
### CRON Registration
2026-03-26 02:07:54 +08:00
```apex
@isTest
static void shouldScheduleJob() {
Test.startTest();
2026-03-28 01:02:47 +08:00
String cronExp = '0 0 6 * * ?';
2026-03-26 02:07:54 +08:00
String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass());
Test.stopTest();
2026-03-28 01:02:47 +08:00
2026-03-26 02:07:54 +08:00
CronTrigger ct = [
2026-03-28 01:02:47 +08:00
SELECT Id, CronExpression, State
FROM CronTrigger
2026-03-26 02:07:54 +08:00
WHERE Id = :jobId
];
2026-03-28 01:02:47 +08:00
Assert.areEqual('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
Assert.areEqual('WAITING', ct.State, 'Job should be waiting');
2026-03-26 02:07:54 +08:00
}
```
## Common Pitfalls
2026-03-28 01:02:47 +08:00
| Pitfall | Impact |
|---|---|
| Missing `Test.stopTest()` | Async never executes, assertions fail silently |
| Expecting all chained queueables to run | Only the first runs; test each independently |
| Mock set after `Test.startTest()` | Callout mock must be set **before** `Test.startTest()` |
| Batch size < record count in tests | Only `batchSize` records processed ; set `batchSize >= recordCount` |