Mastering Test.startTest() and Test.stopTest() in Apex
Learn how to properly use Test.startTest() and Test.stopTest() in your Apex test classes, including their critical role in testing asynchronous code like future methods, batch jobs, and scheduled Apex.
TL;DR
Test.startTest() and Test.stopTest() are essential Apex testing methods that reset governor limits and force asynchronous code to execute synchronously during tests. All async operations (future methods, batch jobs, queueable jobs, scheduled Apex) enqueued between these methods run immediately when stopTest() is called.
What Are Test.startTest() and Test.stopTest()?
These two methods create a special testing context in Apex that serves two critical purposes:
- Resetting Governor Limits: Gives you a fresh set of governor limits for the code between
startTest()andstopTest() - Forcing Async Execution: Makes all asynchronous code run synchronously so you can test the results immediately
Why Are They Important?
Without these methods:
- You can't test async code because it won't complete during test execution
- Complex tests might hit governor limits before testing the actual code
- You can't verify the outcomes of future methods, batch jobs, or scheduled Apex
Basic Syntax and Usage
@isTest
static void testMyMethod() {
// Setup test data (uses original governor limits)
Account acc = new Account(Name = 'Test Account');
insert acc;
// Start test context - governor limits reset here
Test.startTest();
// Code to test (gets fresh governor limits)
MyClass.myFutureMethod(acc.Id);
// Stop test context - async code runs synchronously here
Test.stopTest();
// Verify results (back to original context)
Account updatedAcc = [SELECT Status__c FROM Account WHERE Id = :acc.Id];
System.assertEquals('Processed', updatedAcc.Status__c);
}How Governor Limits Work
The Three Execution Contexts
- Before startTest(): Original governor limits
- Between startTest() and stopTest(): Fresh governor limits (reset to zero)
- After stopTest(): Back to original context
Example
@isTest
static void demonstrateGovernorLimits() {
// Context 1: Original limits
List<Account> accounts1 = new List<Account>();
for (Integer i = 0; i < 150; i++) {
accounts1.add(new Account(Name = 'Test ' + i));
}
insert accounts1; // Uses 150 DML rows
System.debug('DML rows used: ' + Limits.getDmlRows()); // Shows 150
Test.startTest();
// Context 2: Fresh limits (reset)
System.debug('DML rows after startTest: ' + Limits.getDmlRows()); // Shows 0
List<Account> accounts2 = new List<Account>();
for (Integer i = 0; i < 150; i++) {
accounts2.add(new Account(Name = 'Test2 ' + i));
}
insert accounts2; // Uses 150 DML rows from fresh limit
Test.stopTest();
// Context 3: Back to original (150 from context 1)
System.debug('DML rows after stopTest: ' + Limits.getDmlRows()); // Shows 150
}Testing Asynchronous Apex
Testing Future Methods
Future methods marked with @future run asynchronously in production but must run synchronously in tests.
public class AccountProcessor {
@future
public static void updateAccountStatus(Set<Id> accountIds) {
List<Account> accounts = [SELECT Id FROM Account WHERE Id IN :accountIds];
for (Account acc : accounts) {
acc.Status__c = 'Processed';
}
update accounts;
}
}
@isTest
static void testFutureMethod() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
AccountProcessor.updateAccountStatus(new Set<Id>{acc.Id});
Test.stopTest(); // Future method executes HERE
// Now we can verify the results
Account result = [SELECT Status__c FROM Account WHERE Id = :acc.Id];
System.assertEquals('Processed', result.Status__c);
}Testing Batch Apex
Batch Apex processes large data sets in chunks. In tests, the entire batch runs when stopTest() is called.
public class AccountBatch implements Database.Batchable<sObject> {
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator('SELECT Id, AnnualRevenue FROM Account');
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
acc.Rating = acc.AnnualRevenue > 1000000 ? 'Hot' : 'Warm';
}
update scope;
}
public void finish(Database.BatchableContext bc) {
// Batch complete
}
}
@isTest
static void testBatchApex() {
// Create test data
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(
Name = 'Test ' + i,
AnnualRevenue = i * 10000
));
}
insert accounts;
Test.startTest();
Database.executeBatch(new AccountBatch(), 100); // Scope size 100
Test.stopTest(); // Entire batch runs HERE synchronously
// Verify results
List<Account> hotAccounts = [SELECT Id FROM Account WHERE Rating = 'Hot'];
System.assert(hotAccounts.size() > 0, 'Should have hot accounts');
}Testing Queueable Apex
Queueable jobs provide more advanced async processing with job chaining capabilities.
public class AccountQueueable implements Queueable {
private List<Id> accountIds;
public AccountQueueable(List<Id> accountIds) {
this.accountIds = accountIds;
}
public void execute(QueueableContext context) {
List<Account> accounts = [SELECT Id FROM Account WHERE Id IN :accountIds];
for (Account acc : accounts) {
acc.Description = 'Processed by Queueable';
}
update accounts;
}
}
@isTest
static void testQueueable() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
System.enqueueJob(new AccountQueueable(new List<Id>{acc.Id}));
Test.stopTest(); // Queueable executes HERE
Account result = [SELECT Description FROM Account WHERE Id = :acc.Id];
System.assertEquals('Processed by Queueable', result.Description);
}Testing Scheduled Apex
Scheduled Apex runs at specified times. In tests, scheduled jobs execute immediately after stopTest().
public class AccountScheduler implements Schedulable {
public void execute(SchedulableContext sc) {
List<Account> accounts = [SELECT Id FROM Account WHERE Rating = null LIMIT 100];
for (Account acc : accounts) {
acc.Rating = 'Cold';
}
update accounts;
}
}
@isTest
static void testScheduledApex() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
// Schedule the job
String cronExp = '0 0 0 15 3 ? 2042'; // March 15, 2042 at midnight
String jobId = System.schedule('Test Job', cronExp, new AccountScheduler());
// Verify job was scheduled
CronTrigger ct = [SELECT Id, CronExpression, TimesTriggered, NextFireTime
FROM CronTrigger WHERE Id = :jobId];
System.assertEquals(cronExp, ct.CronExpression);
System.assertEquals(0, ct.TimesTriggered); // Not run yet
Test.stopTest(); // Scheduled job executes HERE immediately
// Verify job executed
Account result = [SELECT Rating FROM Account WHERE Id = :acc.Id];
System.assertEquals('Cold', result.Rating);
}Critical Rules and Limitations
Rule #1: Use Only Once Per Test Method
You cannot call startTest() and stopTest() more than once in a single test method.
// ❌ WRONG - This will throw an exception
@isTest
static void badExample() {
Test.startTest();
// some code
Test.stopTest();
Test.startTest(); // ERROR: Cannot call startTest() again
// more code
Test.stopTest();
}Rule #2: All Async Code Runs at stopTest()
All asynchronous operations enqueued after startTest() are collected and executed synchronously when stopTest() is called.
@isTest
static void demonstrateAsyncExecution() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
// These three async operations are queued but not executed yet
MyClass.futureMethod1(acc.Id);
MyClass.futureMethod2(acc.Id);
System.enqueueJob(new MyQueueable(acc.Id));
// At this point, NONE of the async code has run yet
Test.stopTest(); // ALL three async operations execute HERE in order
// Now all results are available for assertions
}Rule #3: Watch for Chained Jobs
Important: Only the first level of chained async jobs executes in tests.
public class ChainedQueueable implements Queueable {
public void execute(QueueableContext context) {
// Do some work
// Chain another job
System.enqueueJob(new AnotherQueueable()); // Won't run in test!
}
}
@isTest
static void testChainedJobs() {
Test.startTest();
System.enqueueJob(new ChainedQueueable()); // This runs
Test.stopTest();
// ChainedQueueable executes, but AnotherQueueable does NOT
// You cannot test chained jobs in a single test method
}How Async Code Executes During Tests
The Execution Sequence
When you call Test.stopTest():
- Collection Phase: All async jobs enqueued after
startTest()are collected - Synchronous Execution: Jobs execute one by one in the order they were enqueued
- Completion: All jobs complete before
stopTest()returns - Test Continues: Your test code after
stopTest()can now verify results
Visual Example
@isTest
static void visualizeAsyncFlow() {
System.debug('1. Before startTest');
Test.startTest();
System.debug('2. After startTest - fresh limits');
futureMethod();
System.debug('3. After enqueueing future - NOT executed yet');
System.enqueueJob(new MyQueueable());
System.debug('4. After enqueueing queueable - NOT executed yet');
Database.executeBatch(new MyBatch());
System.debug('5. After starting batch - NOT executed yet');
Test.stopTest();
System.debug('6. After stopTest - ALL async code has now executed');
// Verify all results
System.debug('7. Verifying results');
}Debug Output Order:
1. Before startTest
2. After startTest - fresh limits
3. After enqueueing future - NOT executed yet
4. After enqueueing queueable - NOT executed yet
5. After starting batch - NOT executed yet
[Future method executes]
[Queueable executes]
[Batch executes]
6. After stopTest - ALL async code has now executed
7. Verifying results
Best Practices
1. Always Use for Async Code
If you're testing future methods, batch, queueable, or scheduled Apex, you must use startTest() and stopTest().
2. Setup Data Before startTest()
Put data setup before startTest() to avoid using up your fresh governor limits.
@isTest
static void goodPattern() {
// ✅ GOOD: Setup before startTest
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test ' + i));
}
insert accounts; // Uses original limits
Test.startTest();
// Fresh limits available for actual test
MyClass.processAccounts();
Test.stopTest();
}3. Use Even When Not Testing Async
Many developers use startTest() and stopTest() in every test as a best practice for:
- Visual clarity (marks where the test actually begins)
- Fresh governor limits
- Future-proofing (in case async code is added later)
4. Test Mixed DML with System.runAs()
When testing code that performs mixed DML (setup vs. non-setup objects), combine with System.runAs().
@isTest
static void testMixedDML() {
User testUser = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId()];
// runAs allows mixed DML in test context
System.runAs(testUser) {
Test.startTest();
MyClass.createUserAndAccount(); // Creates User and Account
Test.stopTest();
}
// Verify both records exist
}Common Mistakes to Avoid
Mistake #1: Forgetting to Call stopTest()
// ❌ WRONG - Async code never executes
@isTest
static void badTest() {
Test.startTest();
myFutureMethod();
// Missing Test.stopTest() - future method never runs!
}Mistake #2: Asserting Before stopTest()
// ❌ WRONG - Assertions before async code completes
@isTest
static void badTiming() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
myFutureMethod(acc.Id);
// This will fail because future method hasn't run yet
Account result = [SELECT Status__c FROM Account WHERE Id = :acc.Id];
System.assertEquals('Processed', result.Status__c); // FAILS
Test.stopTest();
}
// ✅ CORRECT - Assert after stopTest()
@isTest
static void goodTiming() {
Account acc = new Account(Name = 'Test');
insert acc;
Test.startTest();
myFutureMethod(acc.Id);
Test.stopTest(); // Future method runs HERE
// Now the assertion will pass
Account result = [SELECT Status__c FROM Account WHERE Id = :acc.Id];
System.assertEquals('Processed', result.Status__c); // PASSES
}Mistake #3: Expecting Chained Jobs to Run
// ❌ MISUNDERSTANDING - Only first job runs
@isTest
static void testChaining() {
Test.startTest();
System.enqueueJob(new JobThatChainsAnotherJob());
Test.stopTest();
// First job runs, but chained job does NOT run in test
// Test will fail if it expects chained job results
}Mistake #4: Hitting Limits in Test Context
// ❌ WRONG - Wastes fresh limits on setup
@isTest
static void wastedLimits() {
Test.startTest();
// This wastes your fresh SOQL queries limit
List<Account> accounts = [SELECT Id FROM Account LIMIT 100];
// Now you have fewer queries available for actual test
MyClass.methodThatUsesLotsOfQueries();
Test.stopTest();
}
// ✅ CORRECT - Setup before startTest
@isTest
static void efficientLimits() {
// Use original limits for setup
List<Account> accounts = [SELECT Id FROM Account LIMIT 100];
Test.startTest();
// Fresh limits available for the actual code being tested
MyClass.methodThatUsesLotsOfQueries();
Test.stopTest();
}Real-World Example: Complete Test Class
Here's a comprehensive example testing multiple async patterns:
@isTest
private class OpportunityProcessorTest {
@TestSetup
static void setup() {
// Test setup runs before each test method
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 50; i++) {
accounts.add(new Account(Name = 'Test Account ' + i));
}
insert accounts;
List<Opportunity> opps = new List<Opportunity>();
for (Account acc : accounts) {
opps.add(new Opportunity(
Name = 'Test Opp',
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
));
}
insert opps;
}
@isTest
static void testFutureMethodUpdatesDeal() {
Opportunity opp = [SELECT Id FROM Opportunity LIMIT 1];
Test.startTest();
OpportunityProcessor.updateOpportunityAsync(opp.Id);
Test.stopTest();
Opportunity result = [SELECT StageName FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals('Qualification', result.StageName);
}
@isTest
static void testBatchProcessesAllOpportunities() {
Test.startTest();
Database.executeBatch(new OpportunityBatch(), 25);
Test.stopTest();
List<Opportunity> processed = [SELECT Id FROM Opportunity WHERE IsProcessed__c = true];
System.assertEquals(50, processed.size(), 'All opportunities should be processed');
}
@isTest
static void testScheduledJobRunsSuccessfully() {
Test.startTest();
String jobId = System.schedule(
'Test Opp Scheduler',
'0 0 0 1 1 ? 2030',
new OpportunityScheduler()
);
Test.stopTest();
// Verify scheduled job executed
List<Opportunity> updated = [SELECT Id FROM Opportunity WHERE LastProcessedDate__c = TODAY];
System.assert(updated.size() > 0, 'Scheduled job should process opportunities');
// Clean up
System.abortJob(jobId);
}
@isTest
static void testQueueableProcessesChain() {
List<Opportunity> opps = [SELECT Id FROM Opportunity LIMIT 10];
Test.startTest();
System.enqueueJob(new OpportunityQueueable(opps));
Test.stopTest();
List<Opportunity> results = [SELECT Status__c FROM Opportunity WHERE Id IN :opps];
for (Opportunity opp : results) {
System.assertEquals('Queued Processing Complete', opp.Status__c);
}
}
}Quick Reference
| Method | Purpose | When to Use |
|---|---|---|
Test.startTest() | Resets governor limits and starts collecting async jobs | Before the code you want to test |
Test.stopTest() | Executes all async jobs synchronously | After enqueueing async jobs, before assertions |
Async Methods and startTest/stopTest
| Async Type | Requires startTest/stopTest | Runs When |
|---|---|---|
@future | Yes | At stopTest() |
Database.executeBatch() | Yes | At stopTest() |
System.enqueueJob() | Yes | At stopTest() |
System.schedule() | Yes | At stopTest() |
| Chained Queueable | Partially (only first level) | At stopTest() |
| Platform Events | Yes (use Test.getEventBus().deliver()) | After deliver() call |
Conclusion
Test.startTest() and Test.stopTest() are essential tools in your Apex testing arsenal. They enable you to:
- Test asynchronous code synchronously
- Reset governor limits for complex tests
- Ensure your async operations work correctly
Remember:
- Use them for all async code testing
- Setup data before
startTest() - Assert results after
stopTest() - Only call them once per test method
Master these methods, and you'll be able to write comprehensive, reliable tests for any Apex code—synchronous or asynchronous!
About Warren Walters
Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.
Visit Profile →