Async Apex — @future, Queueable, Batchable, Scheduled — doesn't run at the call site in a test. It's enqueued, and Test.stopTest() is what flushes the queue and runs it synchronously. Enqueue between startTest and stopTest, assert after. Each flavor adds one wrinkle: chained Queueables need a Test.isRunningTest() guard, Batchable tests need to fit inside one batch scope, and Scheduled Apex is usually best tested by calling execute() directly rather than relying on the cron trigger.
The problem: enqueued is not executed
Every async mechanism in Apex — @future, System.enqueueJob, Database.executeBatch, System.schedule — does the same thing when you call it: it hands the platform a unit of work to run later, and returns. In production, "later" might be seconds or minutes away, on a different transaction, with its own governor limits. In a test, "later" means it sits on a queue until something flushes it. If your test asserts right after the call, nothing has run yet — the assertion is checking pre-async state and either passes by accident or fails for a reason that has nothing to do with your code.
What Test.startTest and Test.stopTest actually do covers the mechanism behind this: stopTest resets governor limits and runs all pending asynchronous work synchronously before returning. This post is the practical playbook — what to do for each flavor of async, and where each one bites.
The universal pattern
Every async test follows the same shape: enqueue the work inside Test.startTest() / Test.stopTest(), and assert after stopTest() returns. Here it is end to end with a Queueable:
@isTest
static void enqueuesAndProcessesAccount() {
Account acct = new Account(Name = 'Acme', AnnualRevenue = null);
insert acct;
Test.startTest();
System.enqueueJob(new AccountEnrichmentQueueable(new Set<Id>{ acct.Id }));
Test.stopTest(); // the queueable's execute() runs here, synchronously
Account updated = [SELECT AnnualRevenue FROM Account WHERE Id = :acct.Id];
System.assertNotEquals(null, updated.AnnualRevenue);
}Nothing about the queueable's own code needs to know it's under test. The only rule is positional: enqueue inside the start/stop block, assert outside and after it.
@future
@future methods are fire-and-forget — they return void, so the only way to observe what one did is through its side effects: records it updated, rows it inserted, an external call it made (which you'd mock). stopTest() runs it synchronously; assert on the data afterward, same as any other async test.
@isTest
static void futureMethodUpdatesRecord() {
Account acct = new Account(Name = 'Acme');
insert acct;
Test.startTest();
AccountService.refreshRatingAsync(acct.Id);
Test.stopTest();
Account updated = [SELECT Rating FROM Account WHERE Id = :acct.Id];
System.assertEquals('Hot', updated.Rating);
}One restriction to know before you hit it in a test: you cannot call a @future method from another @future method, and you cannot call one from inside a batch's execute(). Both throw an AsyncException at runtime, and a test is the first place this usually shows up, because it's the first time the two run in the same transaction.
Queueable
System.enqueueJob returns the new job's Id, which is an AsyncApexJob ID. If you want to assert that something was enqueued without triggering execution — useful when you're testing the enqueueing logic in isolation — query AsyncApexJob before calling stopTest(). Otherwise, the same start/stop/assert pattern applies.
The real gotcha is chained Queueables. Calling System.enqueueJob from inside a currently-running Queueable's execute() is how chaining works in production — but in a test context, that inner call throws, because a test can only run one queueable synchronously per stopTest(). The conventional fix is to guard the chain:
public class ImportBatchQueueable implements Queueable {
private List<Id> remainingIds;
public ImportBatchQueueable(List<Id> remainingIds) {
this.remainingIds = remainingIds;
}
public void execute(QueueableContext ctx) {
processNext(remainingIds);
if (!remainingIds.isEmpty() && !Test.isRunningTest()) {
System.enqueueJob(new ImportBatchQueueable(remainingIds));
}
}
}It's not elegant — production code branching on whether it's under test is a code smell everywhere else — but it's the standard, accepted pattern for chained Queueables, because there's no other way to unit-test each link without letting the chain run unbounded in test context. Test each link separately: one test enqueues the first job and asserts it processed its slice and left the chain-continuation call unexercised (guarded by theTest.isRunningTest() check); a separate test calls execute() directly, or constructs the next queueable directly, to verify the next link's behavior. You're testing units, not the chain as a whole.
Batchable
Database.executeBatch called between startTest and stopTest runs the entire batch lifecycle synchronously at stopTest(): start(), exactly one batch's worth of execute(), then finish(). The consequence is easy to miss: in test context, execute() only runs once, over the first scope. If your test data is larger than the batch size (default 200, or whatever you passed to executeBatch), the records past the first scope are never processed — finish() still runs, but on a partial job.
@isTest
static void batchProcessesAllTestRecords() {
// Keep this at or under the batch's scope size — one batch, one execute() call.
List<Account> accounts = TestFactory.accounts(150);
insert accounts;
Test.startTest();
Database.executeBatch(new StaleAccountCleanupBatch(), 200);
Test.stopTest(); // start() -> execute() (one scope) -> finish(), all synchronous
Integer flagged = [SELECT COUNT() FROM Account WHERE Stale__c = true];
System.assertEquals(150, flagged);
}Keep test volume within a single scope and the whole lifecycle — start() through finish() — is genuinely covered by one test. If the batch implements Database.Stateful, that instance state also carries through correctly across the single execute call, so a stateful counter or accumulator is fair to assert on after stopTest().
Scheduled
System.schedule takes a job name, a cron string, and a Schedulable instance. Called between startTest and stopTest, the scheduled job's execute() fires synchronously at stopTest(), same as the others.
Where it gets murky is the common pattern of a scheduled job that just turns around and launches a batch. Don't build a mental model of exactly which nested async does or doesn't get flushed as part of that single stopTest() call — it's not worth the fragility. The conventional, reliable approach is to decompose: test the batch on its own (as above), and test the schedulable's execute() as a plain method call, passing a null or fake SchedulableContext — you don't need a real one, since most implementations never touch it.
@isTest
static void scheduledJobLaunchesCleanupBatch() {
List<Account> accounts = TestFactory.accounts(3);
insert accounts;
Test.startTest();
new NightlyCleanupSchedulable().execute(null); // no cron trigger involved
Test.stopTest(); // flushes the batch the schedulable enqueued
System.assertEquals(3, [SELECT COUNT() FROM Account WHERE Stale__c = true]);
}
@isTest
static void cronExpressionIsValid() {
Test.startTest();
String jobId = System.schedule('Nightly Cleanup', '0 0 2 * * ?', new NightlyCleanupSchedulable());
Test.stopTest();
CronTrigger ct = [SELECT CronExpression, State FROM CronTrigger WHERE Id = :jobId];
System.assertEquals('0 0 2 * * ?', ct.CronExpression);
}Use System.schedule in a test mainly to validate the cron wiring — that the expression parses and the job registers — and get your actual coverage of what the job does from the direct execute() call and the batch's own tests.
Asserting on the queue itself
Sometimes you want to verify that something was enqueued without triggering it — confirming a trigger correctly kicks off async work, without paying for that work to run in every test. Query AsyncApexJob before stopTest():
@isTest
static void triggerEnqueuesEnrichmentJob() {
Test.startTest();
insert new Account(Name = 'Acme');
List<AsyncApexJob> jobs = [
SELECT Id, JobType, Status, ApexClass.Name
FROM AsyncApexJob
WHERE ApexClass.Name = 'AccountEnrichmentQueueable'
];
System.assertEquals(1, jobs.size());
System.assertEquals('Queued', jobs[0].Status);
// Deliberately not calling Test.stopTest() with an assertion after —
// this test only cares that the job was queued.
Test.stopTest();
}JobType tells you which flavor of async it is (Future, Queueable, BatchApex, ScheduledApex), Status tracks its lifecycle (Queued, Processing, Completed, Failed), and ApexClass.Name confirms which class picked it up — useful when more than one type of job could plausibly have been enqueued.
Checklist
- Async runs at
stopTest(), not at the call site. Enqueue inside start/stop, assert after. - @future has no return value. Assert on the side effects it produced, and remember it can't call another @future or run inside a batch's execute().
- Batchable tests need one scope of data. Keep test volume at or under the batch size so a single
execute()call in test context covers everything. - Guard Queueable chains with
Test.isRunningTest()and test each link as its own unit — don't try to run the whole chain in one test. - Test a Schedulable's
execute()directly. UseSystem.scheduleonly to confirm the cron expression is valid and the job registers. AsyncApexJoblets you assert "enqueued" without asserting "ran." Query it beforestopTest()when that's the behavior under test.
Step through async Apex like any other code
Nimbus implements @future, Queueable, Batchable, and Schedulable locally, running them synchronously at Test.stopTest() exactly like the platform does — so this entire playbook runs unchanged on your machine, no deploy, no waiting on job status. A misbehaving batch that normally means deploy, run, wait, and read logs becomes: set a breakpoint inside execute(), run the test, and step through with real variable inspection.