mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-05 23:41:31 +08:00
11 KiB
11 KiB
Async Testing Patterns
Key Principle
Test.stopTest() forces all async operations to execute synchronously, allowing assertions on their results.
Batch Apex Testing
Critical Constraint — Single Execute in Tests
In test context, Salesforce calls the batch execute() method only once, processing at most batchSize records. The start() and finish() methods still run normally, but only one chunk of data passes through execute(). This has three consequences:
- Match record count to batch size — create
Ntest records and callDatabase.executeBatch(batch, N)so all records are processed in the single invocation. If you create more records than the batch size, the excess records are silently skipped. - One
executeBatchcall per test method — callingDatabase.executeBatch()more than once betweenTest.startTest()andTest.stopTest()throwsSystem.UnexpectedException. This includes chained batches triggered from thefinish()method. Database.Statefulreflects one chunk — stateful accumulators only capture values from the singleexecute()invocation.
Basic Batch Test
@IsTest
private static void shouldProcessAllRecords_WhenBatchExecutes() {
// Given — record count MUST equal batch size
List<Account> accounts = TestDataFactory.createAccounts(200, true);
// When — batch size matches record count so all records are processed
Test.startTest();
MyBatchClass batch = new MyBatchClass();
Database.executeBatch(batch, 200);
Test.stopTest();
// Then
List<Account> updated = [SELECT Id, Status__c FROM Account];
Assert.areEqual(200, updated.size(), 'All 200 accounts should be processed');
for (Account acc : updated) {
Assert.areEqual('Processed', acc.Status__c,
'Batch should update all account statuses');
}
}
Testing Batch with Failures
@IsTest
private static void shouldLogErrors_WhenRecordsFail() {
// Given — total records (valid + invalid) must fit within batch size
List<Account> accounts = TestDataFactory.createAccounts(198, true);
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 — batch size = 200 to cover all 200 records in single execute()
Test.startTest();
MyBatchClass batch = new MyBatchClass();
Database.executeBatch(batch, 200);
Test.stopTest();
// Then
List<Error_Log__c> errors = [SELECT Id, Message__c FROM Error_Log__c];
Assert.areEqual(2, errors.size(), 'Should log 2 failed records');
}
Testing Database.Stateful Tracking
@IsTest
private static void shouldTrackProcessedCount_WhenStatefulBatch() {
// Given — use a count that fits in one execute()
List<Account> accounts = TestDataFactory.createAccounts(150, true);
// When
Test.startTest();
MyStatefulBatch batch = new MyStatefulBatch();
Database.executeBatch(batch, 150);
Test.stopTest();
// Then — query finish-method output or assert on batch instance
// (stateful values reflect only the single execute() invocation)
List<Batch_Log__c> logs = [SELECT Records_Processed__c FROM Batch_Log__c];
Assert.areEqual(1, logs.size(), 'Finish should create a log record');
Assert.areEqual(150, logs[0].Records_Processed__c,
'Should track all 150 processed records');
}
Testing Batch Chaining
A batch whose finish() method calls Database.executeBatch() will throw System.UnexpectedException in tests. Test each batch in the chain independently in separate test methods.
@IsTest
private static void shouldCompleteFirstBatch_WhenChainedBatch() {
// Given
List<Account> accounts = TestDataFactory.createAccounts(100, true);
// When — test only the first batch; suppress chaining in test context
Test.startTest();
MyChainedBatch batch = new MyChainedBatch();
Database.executeBatch(batch, 100);
Test.stopTest();
// Then — verify first batch's work completed
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
Assert.areEqual(100, processed.size(), 'First batch should process all records');
}
@IsTest
private static void shouldCompleteSecondBatch_WhenRunIndependently() {
// Given — setup data as if first batch already ran
List<Account> accounts = TestDataFactory.createAccounts(100, true);
for (Account acc : accounts) {
acc.Processed__c = true;
acc.NeedsFollowUp__c = true;
}
update accounts;
// When — test second batch in isolation
Test.startTest();
MyFollowUpBatch batch = new MyFollowUpBatch();
Database.executeBatch(batch, 100);
Test.stopTest();
// Then
List<Account> followedUp = [SELECT Id FROM Account WHERE FollowUpDone__c = true];
Assert.areEqual(100, followedUp.size(), 'Second batch should process all records');
}
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];
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];
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'
];
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];
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];
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];
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
];
Assert.areEqual('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
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)
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];
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();
❌ Duplicate Rule violations on bulk insert
// Bad: identical or near-identical field values trigger DUPLICATES_DETECTED at row 200+
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 251; i++) {
accounts.add(new Account(Name = 'Test Account')); // same Name on all records
}
insert accounts; // FAILS: DUPLICATES_DETECTED
// Good: unique values per record via TestDataFactory
List<Account> accounts = TestDataFactory.createAccounts(251);
insert accounts;
// Good: bypass duplicate rules when fuzzy matching is active
Database.DMLOptions dml = new Database.DMLOptions();
dml.DuplicateRuleHeader.allowSave = true;
Database.insert(accounts, dml);