intermediate
Testing

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.

11 min read
Warren Walters
apex
testing
asynchronous
best-practices
unit-tests

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:

  1. Resetting Governor Limits: Gives you a fresh set of governor limits for the code between startTest() and stopTest()
  2. 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

  1. Before startTest(): Original governor limits
  2. Between startTest() and stopTest(): Fresh governor limits (reset to zero)
  3. 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():

  1. Collection Phase: All async jobs enqueued after startTest() are collected
  2. Synchronous Execution: Jobs execute one by one in the order they were enqueued
  3. Completion: All jobs complete before stopTest() returns
  4. 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

MethodPurposeWhen to Use
Test.startTest()Resets governor limits and starts collecting async jobsBefore the code you want to test
Test.stopTest()Executes all async jobs synchronouslyAfter enqueueing async jobs, before assertions

Async Methods and startTest/stopTest

Async TypeRequires startTest/stopTestRuns When
@futureYesAt stopTest()
Database.executeBatch()YesAt stopTest()
System.enqueueJob()YesAt stopTest()
System.schedule()YesAt stopTest()
Chained QueueablePartially (only first level)At stopTest()
Platform EventsYes (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:

  1. Use them for all async code testing
  2. Setup data before startTest()
  3. Assert results after stopTest()
  4. 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!

WW

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 →
Share:

Related Posts