The trick: wrap the future-triggering code in Test.startTest() / Test.stopTest(). Calls made between those two execute asynchronously in the live org, but in a test they complete before stopTest() returns — synchronously, on demand — so your assertions run after the future has actually executed.
The class under test
public class AccountStatusUpdater {
@future
public static void markActive(List<Id> accountIds) {
List<Account> accs = [SELECT Id, Status__c FROM Account WHERE Id IN :accountIds];
for (Account a : accs) a.Status__c = 'Active';
update accs;
}
}
The test
@isTest
private class AccountStatusUpdaterTest {
@isTest
static void markActive_setsStatusOnAllAccounts() {
// Arrange: create test data
List<Account> accs = new List<Account>();
for (Integer i = 0; i < 5; i++) {
accs.add(new Account(Name = 'Test ' + i, Status__c = 'Prospect'));
}
insert accs;
List<Id> ids = new List<Id>();
for (Account a : accs) ids.add(a.Id);
// Act: invoke the future inside startTest/stopTest
Test.startTest();
AccountStatusUpdater.markActive(ids);
Test.stopTest(); // future runs here, before this returns
// Assert
List<Account> after = [SELECT Status__c FROM Account WHERE Id IN :ids];
for (Account a : after) {
System.assertEquals('Active', a.Status__c);
}
}
}
That’s the whole pattern. The key line is Test.stopTest() — it blocks until all async jobs queued between startTest and stopTest finish.
Callout in a future method
If the future makes a callout, you also need a mock:
@isTest
static void markActive_callsExternalApi() {
Test.setMock(HttpCalloutMock.class, new MyMockResponse());
List<Id> ids = createTestAccounts();
Test.startTest();
AccountStatusUpdater.syncToErp(ids);
Test.stopTest();
// Mock recorded the call — assert request body, status, etc.
}
private class MyMockResponse implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"ok":true}');
return res;
}
}
Test.setMock intercepts any HTTP callout during the test transaction — including those inside the future.
What Test.startTest()/Test.stopTest() actually do
| Method | Effect |
|---|---|
Test.startTest() | Resets governor limits, opens a “test context” |
| Async work between them | Buffered, not actually executed yet |
Test.stopTest() | Runs all buffered async work synchronously, then closes the test context |
Without startTest/stopTest, the future method does not run in the test — and AsyncApexJob stays empty.
Things that catch people out
- Only one
Test.stopTest()per test method. Calling future-then-stopTest-then-future-then-stopTest doesn’t work as you’d hope. - DML must happen before
startTestif you want it to count against the parent test’s limits, not the future’s. - Callouts inside the future still need
Test.setMock— there’s no “auto-pass.” - Chained jobs — if your future enqueues a Queueable,
stopTestonly runs the first level. You may need to assert on the second level separately or refactor.
Verifying the future actually ran
You can also assert via AsyncApexJob:
Test.stopTest();
List<AsyncApexJob> jobs = [
SELECT Status, MethodName FROM AsyncApexJob
WHERE JobType = 'Future' AND ApexClass.Name = 'AccountStatusUpdater'
];
System.assertEquals(1, jobs.size());
System.assertEquals('Completed', jobs[0].Status);
Verified against: Apex Developer Guide — Testing Future Methods. Last reviewed 2026-05-17.