Skip to main content

SF-0392 · Coding · Medium

How can we test a batch apex?

✓ Verified by Vikas Singhal · Last reviewed 5/17/2026 · Updated for Spring '26

Test a batch by submitting it between Test.startTest() and Test.stopTest(). The async batch runs synchronously inside stopTest, so by the time stopTest returns, all chunks have completed and you can assert on the final state.

The basic pattern

@isTest
private class StaleCaseCloserBatchTest {

    @testSetup
    static void setup() {
        List<Case> cases = new List<Case>();
        for (Integer i = 0; i < 5; i++) {
            cases.add(new Case(
                Subject = 'Test ' + i,
                Status = 'New',
                Description = 'Original'
            ));
        }
        insert cases;

        // Backdate to make them "stale"
        Test.setCreatedDate(cases[0].Id, Datetime.now().addDays(-60));
        Test.setCreatedDate(cases[1].Id, Datetime.now().addDays(-45));
    }

    @isTest
    static void cleanup_closesStaleCases() {
        Test.startTest();
        Database.executeBatch(new StaleCaseCloserBatch(), 200);
        Test.stopTest(); // batch runs to completion here

        List<Case> closed = [SELECT Id, Status FROM Case WHERE Status = 'Auto-Closed'];
        System.assertEquals(2, closed.size(), 'Both backdated cases should be closed');
    }
}

Three things to know:

  1. Test.startTest() and Test.stopTest() form an async boundary. Async work submitted between them runs synchronously inside stopTest.
  2. Only one chunk runs in test. The batch’s start returns up to 200 records (or 50,000 for QueryLocator), and execute is called once with all of them. The chunking you’d see in production doesn’t happen in test.
  3. Both execute and finish are called. Full lifecycle.

What “only one chunk” means

This is the big test-vs-production difference. In test mode, the batch runs in a single transaction with all the records. So:

  • Per-chunk governor limits apply to the full dataset, not per real chunk.
  • Database.Stateful is not exercised the same way — there’s only one execute, so cross-chunk persistence isn’t really tested.
  • Tests don’t catch chunk-order bugs.

If you absolutely need to test stateful behavior across chunks, you have to mock the chunks yourself by calling execute multiple times.

Testing with multiple chunks (manual)

@isTest
static void execute_accumulatesStateAcrossChunks() {
    StaleCaseCloserBatch b = new StaleCaseCloserBatch();
    Database.BatchableContext ctx = null; // tests run sync, ctx not needed

    // First "chunk"
    b.execute(ctx, [SELECT Id FROM Case WHERE Status = 'New' LIMIT 2]);
    // Second "chunk"
    b.execute(ctx, [SELECT Id FROM Case WHERE Status = 'New' LIMIT 2 OFFSET 2]);

    System.assertEquals(4, b.totalProcessed);
}

Not a true simulation (real chunks would be fresh instances without Stateful), but enough to assert that your execute accumulates correctly.

Testing callouts

If the batch implements Database.AllowsCallouts, mock the HTTP:

@isTest
static void syncBatch_callsExternalApi() {
    Test.setMock(HttpCalloutMock.class, new MyMockResponse());

    Test.startTest();
    Database.executeBatch(new SyncBatch(), 5);
    Test.stopTest();

    // Assert records updated based on mocked response
}

Asserting on AsyncApexJob

You can also assert that the job ran cleanly:

Test.stopTest();
AsyncApexJob job = [
    SELECT Status, NumberOfErrors FROM AsyncApexJob
    WHERE JobType = 'BatchApex' AND ApexClass.Name = 'StaleCaseCloserBatch'
];
System.assertEquals('Completed', job.Status);
System.assertEquals(0, job.NumberOfErrors);

Common test coverage targets

ScenarioTest method
Happy pathNormal data, assert final state
Empty startstart returns no records → finish still runs
Errored chunksCause an exception in execute → assert NumberOfErrors > 0
Stateful aggregationCall execute twice manually, assert cumulative state
CalloutsMock with Test.setMock
finish side effectsAssert email sent, next batch chained, etc.

Common interview follow-ups

  • Why does only one chunk run in test? — Test framework constraint. Async work runs synchronously and once inside Test.stopTest().
  • Does @testSetup data persist for the batch? — Yes — the batch’s start query sees @testSetup data normally.
  • Can I exceed 50K records in test? — No. Even though QueryLocator allows 50M in production, test data is constrained by what you can insert in setup (50K total DML rows).

Verified against: Apex Developer Guide — Testing Batch Apex. Last reviewed 2026-05-17.