apex test starttest stoptest queueable assertion
Use `Test.startTest()` / `Test.stopTest()` to flush queued async jobs synchronously in unit tests — pattern for Queueable + Future assertions plus the governor-limit reset side effect.
Test.startTest and Test.stopTest in Apex: Flushing Queued Async Jobs in Unit Tests
The first time I wrote an Apex test for a Queueable, I spent an afternoon convinced the queueable itself was broken. The assertion at the bottom of my test method kept failing — the Rating field I expected to see populated was stubbornly null. I rewrote the queueable twice before a teammate glanced at my screen and pointed at the two lines I'd left out: Test.startTest() and Test.stopTest(). Adding them fixed everything in seconds. I felt silly, but I also realized I had no idea what those two calls actually did. Most tutorials I'd read just wrapped them around the line under test like decoration.
This lesson is the version of the explanation I wish I'd had that afternoon. The pair does two specific things that nothing else in the Apex test framework gives you: it forces async jobs queued during the test to run synchronously when stopTest() is called, and it resets the per-transaction governor limits between setup and assertion. Both behaviors matter the moment you write a test for a Queueable, a future method, or a batch class. Without Test.stopTest() your async job sits queued forever and your assertion fails because the records the job was supposed to write never exist. With it, the job runs inline, you assert against the result, and the test stays fast and deterministic.
What the pair actually does
What does Test.startTest() actually flip the moment you call it, before a single line of the code under test runs? Anything before it counts as setup. After the call, the governor limits reset to fresh values, which means you get a clean budget of SOQL queries, DML rows, CPU time, and heap. The 50 query limit, the 150 DML statement limit, the 10,000-row DML limit, and the 10MB heap allocation all reset to zero usage at this boundary.
Test.stopTest() does two jobs in sequence:
- It forces any async job queued during the
startTest/stopTestwindow to execute synchronously, on the same thread, before the method returns. - It resets governor limits again, so post-test assertions and cleanup run against a fresh budget.
Async jobs covered by this flush include Queueable Apex enqueued via System.enqueueJob(), future methods invoked via @future, Batchable jobs executed via Database.executeBatch(), and Schedulable jobs scheduled via System.schedule() if their fire time falls inside the test window. Platform events published with EventBus.publish() also fire their triggers during Test.stopTest().
The Salesforce Apex Developer Guide describes this as "all asynchronous calls made between the startTest and stopTest methods are processed synchronously after the call to stopTest." See the official reference at developer.salesforce.com.
A working example: asserting a Queueable
Here's a test that compiles, runs without errors, and still fails its assertion every single time — until two lines are added. Consider a Queueable that updates account ratings based on some external signal. In production it runs async because the calling code does not want to block. In tests you want the rating set before the assertion runs.
public class AccountRatingQueueable implements Queueable {
private final Set<Id> accountIds;
public AccountRatingQueueable(Set<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [
SELECT Id, AnnualRevenue
FROM Account
WHERE Id IN :accountIds
];
for (Account a : accounts) {
a.Rating = a.AnnualRevenue > 1000000 ? 'Hot' : 'Warm';
}
update accounts;
}
}
@IsTest
private class AccountRatingQueueableTest {
@IsTest
static void itSetsRatingFromRevenue() {
Account a = new Account(Name = 'Acme', AnnualRevenue = 5000000);
insert a;
Test.startTest();
System.enqueueJob(new AccountRatingQueueable(new Set<Id>{ a.Id }));
Test.stopTest();
Account updated = [SELECT Rating FROM Account WHERE Id = :a.Id];
System.assertEquals('Hot', updated.Rating);
}
}
Trace what actually happens line by line. The insert runs in setup, outside the test window. Test.startTest() resets limits and opens the window. System.enqueueJob would normally return a job ID and exit, leaving the queueable to run later on the Apex async queue. Inside a test, the job is held until Test.stopTest() fires, then executes inline on the test thread. By the time the SOQL on the next line runs, the queueable has already updated the account.
Remove the startTest/stopTest pair and the test fails — that was my afternoon. The queueable never runs during the test transaction and the assertion sees null for Rating. This is the single most common Apex testing bug filed by developers new to async, and it almost never gets explained as crisply as it deserves.
The governor-limit reset side effect
Most developers think Test.startTest() is about async jobs — but the limit reset is the feature that quietly saves more tests from breaking. Imagine a setup block that loads 500 accounts, 2000 contacts, and 5000 opportunities. That setup alone consumes a measurable share of the 150 DML statements and 10000-row DML limit. If your test method then asserts against a service that itself does heavy DML, you risk hitting limits during the assertion phase instead of during the production path you actually want to validate.
Test.startTest() solves this cleanly. The setup runs against the original budget, Test.startTest() zeros usage, the code under test runs against its own fresh budget, and Test.stopTest() zeros usage again so the assertions have headroom.
There is a numeric consequence: in a worst case you effectively get 3x the per-transaction limits across a single test method, one budget for each of the setup, body, and assertion phases. That triples your headroom for DML rows from 10000 to 30000 across the method, which matters when you are testing services that touch large object graphs.
You cannot abuse this. Each phase still has its own hard cap, so a single SOQL inside the body that returns 50001 rows will still throw System.LimitException. The reset adds phases, not capacity within a phase.
When to use startTest/stopTest
Use the pair when at least one of these is true:
- The code under test enqueues a Queueable, calls a future method, runs a batch, schedules a job, or publishes a platform event whose handler you want to assert against.
- Your setup is heavy enough that you want a fresh limit budget for the action under test.
- You want a clean visual marker in the test that separates setup, action, and assertion.
Skip it for simple synchronous tests where the action is one insert followed by one assert. The pair costs nothing at runtime, but adding it where no async work happens muddles intent. I've seen teams paste the calls into every test on autopilot, and that habit is exactly the trap this article aims to break.
Comparison: startTest/stopTest vs Test.isRunningTest patterns
A common alternative pattern is to use Test.isRunningTest() inside production code to branch around async calls and run them synchronously in tests. The pattern looks like:
public class AccountService {
public static void rateAccounts(Set<Id> accountIds) {
if (Test.isRunningTest()) {
new AccountRatingQueueable(accountIds).execute(null);
} else {
System.enqueueJob(new AccountRatingQueueable(accountIds));
}
}
}
This works, but it's strictly worse than startTest/stopTest. It leaks test infrastructure into production code, it skips the actual enqueueJob path so you never validate that the queueable serializes correctly, and it bypasses the governor-limit reset so the calling test runs everything against a single shared budget.
The startTest/stopTest approach validates the real production path. The queueable instance is serialized to the async queue and deserialized before execution, exactly as it would be in production. If your queueable holds a non-serializable member field, the test catches it. The Test.isRunningTest branch would not.
Reach for Test.isRunningTest() only when you have a legitimate need to short-circuit code paths that touch external HTTP callouts, and even then, the modern answer is Test.setMock(HttpCalloutMock.class, mockImpl), which is cleaner and does not require production-side branching.
Pitfalls and edge cases
One pair per test method. You can call Test.startTest() and Test.stopTest() only once per test method. A System.LimitException fires if you call either twice. If your test has two distinct phases of async work to validate, split them into two test methods.
Chained Queueables run only one level deep. If your queueable enqueues another queueable from inside its execute method, the chained job does NOT run inside the same Test.stopTest() flush. Salesforce documents this explicitly in the Queueable Apex testing guide. To test the second level you have to assert on the chain initiation, not the chain completion, or restructure the queueable to take a depth parameter.
Batchable jobs run only one execute scope. When you call Database.executeBatch() between startTest and stopTest, only the first batch scope runs. If your batch processes 10000 records in scopes of 200, the test sees the first 200 records processed, not all 10000. Write your batch logic so that one scope is meaningfully testable on its own.
@future methods cannot call other @future methods. This is true outside tests too, but it surfaces inside Test.stopTest() as a fresh error if your future method enqueues another future call. The flush forces you to discover this earlier than you would in production — which, honestly, is one of the underrated gifts of the framework.
Platform event triggers count their own DML. If your test publishes a platform event and the trigger inserts records, those inserts count against the post-stopTest DML budget, not the pre-startTest budget. This is rarely a problem but worth knowing when you debug a limit error in an event-heavy test.
Practical patterns that work
Pattern 1, the canonical Queueable test:
@IsTest
static void itProcessesQueueable() {
// setup: build records, mock external dependencies
insertTestData();
Test.startTest();
callTheProductionMethodThatEnqueues();
Test.stopTest();
// assertions: query the records the queueable should have written
assertExpectedState();
}
Pattern 2, the future-method test where you do not control the enqueue call:
@IsTest
static void itTriggersFutureFromTrigger() {
Account a = new Account(Name = 'TriggerTest');
Test.startTest();
insert a; // the AccountTrigger calls a @future method
Test.stopTest();
// by here the @future has run; assert its side effect
Account refreshed = [SELECT Status__c FROM Account WHERE Id = :a.Id];
System.assertEquals('Processed', refreshed.Status__c);
}
Pattern 3, the batch test with Database.executeBatch():
@IsTest
static void itProcessesBatchScope() {
createTestRecords(50);
Test.startTest();
Database.executeBatch(new MyBatchable(), 200);
Test.stopTest();
// batch finished its single scope; assert on results
System.assertEquals(50, countProcessedRecords());
}
In all three, the Test.startTest() / Test.stopTest() pair is the boundary between "set up the world" and "assert against the world after the async work has run."
What changes in 2026
The Apex test framework hasn't meaningfully changed the startTest/stopTest contract since the API was introduced, and the Spring '26 release notes did not announce any modification. Recent Salesforce work has focused on Test.runAs() permissions, mock provider improvements, and Apex Replay Debugger integration, none of which alter the async flush behavior.
If you're migrating tests from an older codebase, the pattern is forward-compatible. Tests written against API version 25 still pass on API version 60+ as long as the production code under test still compiles. The one thing to watch is that System.enqueueJob with the AsyncOptions overload added in recent releases respects startTest/stopTest the same way the original single-argument version does.
References
- Salesforce Developer Guide: Test.startTest and Test.stopTest reference. https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_methods_system_test.htm
- Salesforce Apex Developer Guide: Testing Queueable Apex. https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_queueing_jobs.htm
- Trailhead: Apex Testing module. https://trailhead.salesforce.com/content/learn/modules/apex_testing
Repository
Full source at https://github.com/vytharion/apex-test-starttest-stoptest-async-flush.
Walk the lessons by stepping through the git commits in the repo — each major step has its own commit you can git checkout and rerun.