Blog

What Test.startTest and Test.stopTest actually do

Most Apex tests call them because the last test did. They have exactly two jobs — and knowing them tells you precisely where your setup, your action, and your assertions belong.

Short version

Job one: Test.startTest() hands the code between the two calls a fresh set of governor limits, so your data setup doesn't eat the budget of the code under test. Job two: Test.stopTest() executes any async work enqueued in that block — futures, queueables, batches, scheduled jobs — synchronously, right there, so you can assert on the results. Setup goes before startTest, the action between, assertions after stopTest. You get one pair per test.

Job 1: a fresh set of governor limits

Everything a test method does shares one transaction — and normally one budget of governor limits. If your test builds 200 Accounts, 400 Contacts, and a few queries' worth of reference data, that consumption would count against the code you're actually testing. Test.startTest() draws a line: the code after it gets its own, fully replenished set of limits, independent of whatever setup burned before it.

apex
@isTest
static void bulkInsertStaysWithinLimits() {
    // Setup: as expensive as it needs to be — doesn't count
    List<Account> accounts = TestFactory.accountsWithContacts(200);

    Test.startTest();
    insert accounts;                            // fresh limits from here
    Integer used = Limits.getQueries();         // queries by the code under test only
    Test.stopTest();

    System.assert(used < 10, 'Trigger stack used ' + used + ' queries');
}

This makes Limits.getQueries() between the two calls a precise measurement: it reads what the tested code consumed, uncontaminated by setup. It's also the honest answer to "will this survive production bulk load?" — the block gets the same 100-query, 150-DML budget a real synchronous transaction gets. After stopTest, you're back on the original pre-startTest counters.

One thing it is not: a reset button you can press mid-test to buy more limits. You get one startTest/stopTest pair per test method. If a test needs two fresh budgets, it's two tests.

Job 2: stopTest runs your async, synchronously

Async Apex — @future methods, Queueables, Batchables, scheduled jobs — doesn't run when you enqueue it. In production it runs whenever the platform gets to it. In a test, that would leave you nothing to assert against. Test.stopTest() is the fix: any async work enqueued between the two calls executes synchronously, in full, at the moment stopTest runs.

apex
@isTest
static void queueableRecalculatesScores() {
    Account acct = TestFactory.account();

    Test.startTest();
    System.enqueueJob(new ScoreRecalcJob(new Set<Id>{ acct.Id }));
    // At this point the job has NOT run.
    Test.stopTest();
    // Now it has — completely.

    Account result = [SELECT Score__c FROM Account WHERE Id = :acct.Id];
    System.assertEquals(85, result.Score__c);
}

This is why assertions belong after stopTest: before it, the job hasn't run and your query sees stale data. It's also the reason tests of async code that never call startTest/stopTest pass while verifying nothing — the job silently never executes, and an assertion-free test stays green.

For batch jobs, one practical constraint: within a test, keep the records the batch will select within a single scope's worth, so execute runs once. A test is verifying your batch logic, not the platform's chunking.

The shape that falls out: arrange, act, assert

The two jobs dictate the layout of every test that uses the pair:

apex
@isTest
static void theShape() {
    // Arrange — before startTest: build data on the setup budget
    List<Case> cases = TestFactory.openCases(200);

    Test.startTest();
    // Act — the one thing this test verifies, on fresh limits
    CaseEscalationService.escalateStale(cases);
    Test.stopTest();
    // Any async the service enqueued has now completed

    // Assert — after stopTest: observe final state, including async results
    List<Case> escalated = [SELECT Status FROM Case WHERE Status = 'Escalated'];
    System.assertEquals(200, escalated.size());
}

Two mistakes account for most misuse. Putting data setup inside the block makes it eat the fresh limits the tested code was supposed to get — the measurement lies, and big-setup tests hit limit errors that production never would. And asserting async outcomes inside the block checks state before the work has happened.

Do you always need the pair?

No. A test of a pure method — in, out, no DML, no async — gains nothing from it. Reserve the pair for the tests where one of the two jobs matters: the code under test enqueues async work, or you want its limit consumption isolated from setup. Using it everywhere is harmless but noisy; the reader should be able to assume that when the pair appears, one of the two jobs is the reason.

The same semantics, without the wait

Everything above is platform behavior, and it's exactly what Nimbus reproduces locally: startTest opens a fresh limit budget, stopTest drains the async queue synchronously, and each test runs isolated in its own transaction. The difference is the loop around it — the suite runs on your machine in seconds, with no org, with per-test limit consumption in the output, and you can set a breakpoint inside the Queueable and step through what happens at stopTest instead of reading debug logs after the fact.

Watch stopTest run your async

Put a breakpoint in a Queueable's execute, run the test locally, and step through the drain that Test.stopTest() triggers. Async stops being a black box the first time you see it under a debugger.