Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

Briefly: Test.startTest() gives the code between it and Test.stopTest() a fresh set of governor limits, so the records you created during setup do not spend the budget you want to measure. It does not start a new transaction. Statics survive, the database state carries over, and a savepoint taken before startTest is still valid. Understanding that distinction is the difference between a test that proves something about scale and one that proves only that 200 inserts fit in a test method.

What the reset actually does

A test method has the same limits as any synchronous transaction: 100 SOQL queries, 50,000 query rows, 150 DML statements, 10,000 DML rows, 6 MB of heap and 10,000 ms of CPU. Building realistic test data eats into all of them before your code runs.

Test.startTest() resets the counters to zero. The code under test gets the full allocation, exactly as it would in production. Test.stopTest() restores the original counters, so anything after it is measured against the setup context again.

That is a counter reset, not an isolation boundary. Everything else persists:

  • Records inserted before startTest are queryable inside it.
  • Static variables keep their values, including recursion guards you tripped during setup.
  • Uncommitted work is still uncommitted, and a rollback spans the whole method.
  • The running user is the same unless you wrapped something in System.runAs.

The static point causes real failures. A trigger handler guard set during data creation will suppress the very logic you are trying to test. Either expose a reset method for tests or key the guard so setup and the test do not collide.

The other thing stopTest does

Test.stopTest() forces queued asynchronous work to run: future methods, queueables, batches and scheduled jobs launched inside the block execute before the call returns. That is usually why the block is there at all. Only one startTest and stopTest pair is allowed per test method.

Getting your data creation out of the way

@TestSetup runs in its own transaction before each test method, and its limit consumption is not charged to the test. It is also rolled back and re created for each method, so tests stay independent.

@TestSetup
static void makeData() {
    List<Account> accounts = new List<Account>();
    for (Integer i = 0; i < 200; i++) {
        accounts.add(new Account(Name = 'Bulk ' + i));
    }
    insert accounts;
}

With setup out of the picture, Test.startTest() becomes about measuring rather than about rescuing a method that ran out of DML rows.

Testing at the boundary

The useful test is not “does this work”, it is “does this work at 200”. Triggers receive up to 200 records per chunk, so that is the shape to write.

@IsTest
static void handlesAFullTriggerChunk() {
    List<Account> accounts = [SELECT Id FROM Account LIMIT 200];
    for (Account a : accounts) { a.Industry = 'Banking'; }

    Test.startTest();
    Integer queriesBefore = Limits.getQueries();
    update accounts;
    Integer queriesUsed = Limits.getQueries() - queriesBefore;
    Test.stopTest();

    Assert.isTrue(queriesUsed <= 5,
        'Query count should not scale with record count, used ' + queriesUsed);
}

That assertion is worth more than a coverage number. It fails the moment somebody adds a query inside a loop, and the failure message says what happened. Give it a ceiling with a little headroom rather than an exact figure, or every legitimate change breaks the test.

The Limits class exposes a matching pair for each limit: getQueries() and getLimitQueries(), getDmlStatements() and getLimitDmlStatements(), getHeapSize(), getCpuTime() and the rest. Assert on the consumed value, not on the limit, which is a constant.

Avoid asserting on CPU time. It varies between runs and between org configurations, and a CPU assertion will fail on a Friday for no reason anybody can reproduce.

The limit a test cannot show you

A test with 200 records will not reveal a batch job that falls over at 50,000 query rows, because you are never going to insert 50,000 records in a test. The query row limit and the heap limit are the two that bite in production and stay invisible in tests.

For those, reason about the code rather than measuring it. Count the rows a query can return at full production volume. Work out what a Map<Id, Account> of that size costs against 6 MB synchronous or 12 MB asynchronous. Then decide whether the operation belongs in a batch, where the chunk size caps both.

Frequently asked

Do tests get higher limits than production? No. The numbers are the same, which is the point. A test that passes only because it ran 40 records is not evidence.

Can I nest startTest and stopTest? No, and you cannot call the pair twice in one method. Split into two test methods.

Does System.runAs reset limits? No. It changes the user context for sharing and permission purposes and nothing else. It does not create a new transaction either, though it does let you exceed the normal restriction on mixed DML.

Does @TestSetup work with SeeAllData=true? No, the two are incompatible, and SeeAllData is a bad idea in almost every case anyway. Create what you need.