Asynchronous Apex is designed to run later, after the current transaction. That’s a problem for tests, which need a deterministic result now. Salesforce solves this with a special rule: anything queued between Test.startTest() and Test.stopTest() runs synchronously when stopTest() is called.
The core pattern
@isTest
static void testQueueable() {
List<Account> accounts = new List<Account>{
new Account(Name = 'A'), new Account(Name = 'B')
};
insert accounts;
Test.startTest();
System.enqueueJob(new SyncToErpQueueable(accounts));
Test.stopTest(); // <-- Queueable runs to completion HERE
// Now assert
List<Account> updated = [SELECT Id, ERP_Synced__c FROM Account];
for (Account a : updated) {
System.assertEquals(true, a.ERP_Synced__c);
}
}
The Queueable would normally execute seconds or minutes later. Inside the startTest/stopTest block, it runs inline.
Per async type
@future methods
Test.startTest();
MyService.doAsync(accountIds); // calls @future method
Test.stopTest();
// @future has now executed
Queueable
Test.startTest();
System.enqueueJob(new MyQueueable(data));
Test.stopTest();
// Queueable.execute() has run
Batch Apex
Test.startTest();
Database.executeBatch(new MyBatch(), 200);
Test.stopTest();
// Batch start, execute (with one chunk only), and finish all ran
Important: in tests, Batch executes only one chunk. If your data exceeds the chunk size (200 by default), only the first 200 records are processed. To test bulk behavior, keep test data within the chunk size.
Scheduled Apex
Test.startTest();
String cron = '0 0 0 1 1 ? 2099';
System.schedule('Test Job', cron, new MyScheduler());
Test.stopTest();
// Scheduler.execute() has run, regardless of the cron expression
The cron expression is ignored in tests — Test.stopTest() forces immediate execution.
Async chaining
If your Queueable enqueues another Queueable, only the first level executes inside the test. The chained job is queued but doesn’t run.
// In production code:
public void execute(QueueableContext ctx) {
// do work
if (moreWork) System.enqueueJob(new MyQueueable(remaining)); // <-- this won't run in tests
}
Workaround: assert against the queued job state, then in a separate test, exercise the chained path directly.
@isTest
static void chainedJob_isEnqueued() {
Test.startTest();
System.enqueueJob(new MyQueueable(initial));
Test.stopTest();
List<AsyncApexJob> jobs = [SELECT Id, Status FROM AsyncApexJob WHERE JobType = 'Queueable'];
System.assertEquals(2, jobs.size(), 'Original + chained job queued');
}
System.runAs — running tests as another user
Distinct from async testing, but commonly confused. System.runAs lets you run a block of code as a specified user, useful for testing sharing rules, profile permissions, and record visibility:
@isTest
static void salesUser_cannotSeeOpsRecords() {
User salesUser = [SELECT Id FROM User WHERE Profile.Name = 'Sales' LIMIT 1];
System.runAs(salesUser) {
List<Account> visible = [SELECT Id FROM Account WHERE Department__c = 'Ops'];
System.assertEquals(0, visible.size());
}
}
System.runAs works in any test context. It’s not async-specific — it’s the right way to test FLS, sharing, and profile-based access.
Things that catch people
- Forgetting
Test.stopTest()— async jobs never execute, assertions fail mysteriously. - Mixing async types — you can only call
startTest/stopTestonce per method. Test each async type separately. - Callouts inside async — still need
Test.setMockbefore the async is queued. - Batch’s chunk size in tests — only the first chunk runs. Don’t put 500 records in if you’re testing 200-record chunking.
Verified against: Apex Developer Guide — Testing Asynchronous Apex. Last reviewed 2026-05-17 for Spring ‘26 release.