Skip to main content
New tool CRON Expression Builder — preview next run times before you schedule Apex. Open the builder →
A 3D circuit board icon representing technical debugging within an Apex test class structure
Apex

Apex Test Class: Catching Thrown Exceptions in Async Batch Jobs

Why try/catch silently fails inside Batch Apex test classes, and the direct-invocation pattern that makes async exception testing deterministic and fast.

The short answer

Database.executeBatch only queues the job and returns a job ID, so a try/catch in the test thread never sees an exception thrown inside start, execute or finish. Call those methods directly on an instance instead, passing null as the BatchableContext, which keeps execution synchronous and assertable.

The async testing trap

Sooner or later you write a unit test where a try/catch block refuses to intercept an exception you know is being thrown. It usually happens in an Apex Test Class for a Batch Apex job: you wrap the Database.executeBatch call in a try/catch and expect it to handle errors raised inside the start, execute, or finish methods.

Triggering a batch job hands that work to the platform's asynchronous execution engine. Your test method submits the job to the queue and moves on. By the time the code inside the batch class hits an error, your test method's execution context has already finished or moved past the try/catch block. That gap between synchronous and asynchronous execution is what you have to design around.

Why your try/catch block is failing

Database.executeBatch() returns a jobId almost immediately. The ID is a receipt confirming the job has been queued.

Wrap that call in a try/catch and the only exception you might catch is a synchronous one: a configuration issue (executing a batch that doesn't exist, say) or a governor limit blown by the scheduling process itself. Once the job is queued, your start, execute, and finish methods run in a completely separate thread governed by different limits. Your try/catch is bound to the test thread, so it cannot "see" the execution happening in the asynchronous "land" of the background processor.

Test.startTest() and Test.stopTest() do not rescue you either. The asynchronous code waits until the execution pointer hits Test.stopTest(), and even then the platform handles the transition internally. Any exception thrown inside the batch process shows up as a failed job status rather than a caught exception in your test method.

Direct method invocation

Instead of fighting the asynchronous nature of the platform, treat start, execute, and finish as ordinary public (or global) methods and call them directly.

Invoking them by hand keeps the execution synchronous. The code runs in the same call stack as your test, so try/catch blocks and System.Assert statements behave the way you expect when you verify error handling.

Implementation example

Here is the refactor. You can usually pass null as the Database.BatchableContext argument, because the logic rarely relies on the context object itself.

@isTest
private class AccountBatchTest {
    @isTest
    static void testBatchExceptionHandling() {
        // Arrange
        List<Account> testAccs = new List<Account>{new Account(Name = 'Test')};
        insert testAccs;
        
        AccountBatch batch = new AccountBatch();
        
        // Act & Assert
        Test.startTest();
        try {
            // Manually call the start method instead of executing the whole job
            batch.start(null);
            System.assert(false, 'Exception should have been thrown!');
        } catch (Exception e) {
            System.assertEquals('Expected Error Message', e.getMessage());
        }
        Test.stopTest();
    }
}

Calling batch.start(null) removes the dependency on the async queue. Your tests become deterministic, faster and much easier to debug.

Designing for testability

If your batch class needs a complex BatchableContext to function at all, that is usually a design smell. Decouple the business logic from the Database.Batchable interface entirely.

  1. Move the core logic of your execute method into a separate Apex service class. Test that service class with standard unit tests, passing in parameters as needed.
  2. If your batch class performs complex lookups in the constructor, pass the required data (or an interface) into the constructor. Tests can then hand it mock data and skip the SELECT queries in the start method.
  3. Rather than relying on try/catch inside the batch, have the batch log its errors properly, perhaps to a custom object. Your test can query that logging object and assert the error was caught and recorded correctly.

Key takeaways

  • Database.executeBatch only queues the job, which is why your try/catch does nothing in the test thread.
  • For unit testing, call the start(), execute(), and finish() methods directly. Passing null as the BatchableContext is safe for the vast majority of use cases.
  • Extract complex logic into service classes. That makes your code more modular and lets you unit test it without worrying about the batch apex lifecycle.
  • In production code, use a custom logging framework that persists errors to a custom object. Your tests then verify behavior by querying that object instead of trying to intercept exceptions across async boundaries.

FAQ: Apex test classes and Batch Apex

Why doesn't try/catch work in my Batch Apex test class?

Database.executeBatch() returns a job ID and queues the work. It does not run synchronously. By the time the start, execute, or finish methods actually run, your test method's try/catch has already exited. To assert exception handling, call the batch methods directly on an instance: new MyBatch().execute(null, scope).

Can Test.startTest() and Test.stopTest() force a Batch Apex job to run synchronously?

Test.stopTest() does flush the async queue and runs the batch in your test transaction, but exceptions thrown inside the batch are still treated as job failures rather than exceptions in the test thread. You can verify the failure with AsyncApexJob (query Status and ExtendedStatus), but you cannot catch the exception with try/catch around Test.stopTest().

How do I test that a Batch Apex job failed with a specific error?

Two patterns work. (1) Direct invocation: instantiate the batch and call execute(null, records) inside try/catch, the simplest approach. (2) Async-aware: run the batch through Database.executeBatch inside Test.startTest/stopTest, then query SELECT Id, Status, ExtendedStatus FROM AsyncApexJob WHERE JobType = 'BatchApex' AND ApexClassId = ... and assert on ExtendedStatus.

What's the minimum code coverage required for Batch Apex test classes?

Salesforce requires 75% coverage on Apex code in production deployments, and every trigger must have at least 1% coverage. Batch classes count toward the 75%, with no special threshold. In practice, aim for 90%+ on batch classes because their async nature makes production debugging slow and expensive.

Should I use SeeAllData=true in a Batch Apex test class?

Almost never. @isTest(SeeAllData=true) couples your tests to org configuration and breaks reliability across sandboxes. The only legitimate use is for legacy code that calls APIs requiring real org data (e.g., reports, dashboards). For batch tests, create your own test data with @TestSetup.

Can a Batch Apex test class test the @future or Queueable methods inside execute()?

Yes. When you wrap the batch invocation in Test.startTest()/Test.stopTest(), both @future and Queueable jobs queued from within the batch are executed before stopTest returns. You can then assert on side effects. Note: nested async chains (queueable enqueues another queueable) are limited to one level deep in tests.

Frequently asked questions

Why doesn't try/catch work in my Batch Apex test class?

Database.executeBatch() returns a job ID and queues the work; it doesn't run synchronously. By the time the start, execute, or finish methods actually run, your test method's try/catch has already exited. To assert exception handling, call the batch methods directly on an instance: new MyBatch().execute(null, scope).

Can Test.startTest() and Test.stopTest() force a Batch Apex job to run synchronously?

Test.stopTest() does flush the async queue and runs the batch in your test transaction, but exceptions thrown inside the batch are still treated as job failures, not as exceptions in the test thread. You can verify the failure with AsyncApexJob (query Status and ExtendedStatus) but you cannot catch the exception with try/catch around Test.stopTest().

How do I test that a Batch Apex job failed with a specific error?

Two patterns work. (1) Direct invocation: instantiate the batch and call execute(null, records) inside try/catch, which is the simplest approach. (2) Async-aware: run the batch through Database.executeBatch inside Test.startTest/stopTest, then query SELECT Id, Status, ExtendedStatus FROM AsyncApexJob WHERE JobType = 'BatchApex' AND ApexClassId = ... and assert on ExtendedStatus.

What's the minimum code coverage required for Batch Apex test classes?

Salesforce requires 75% coverage on Apex code in production deployments, and every trigger must have at least 1% coverage. Batch classes count toward the 75%; there's no special threshold. In practice, aim for 90%+ on batch classes because their async nature makes production debugging slow and expensive.

Should I use SeeAllData=true in a Batch Apex test class?

Almost never. @isTest(SeeAllData=true) couples your tests to org configuration and breaks reliability across sandboxes. The only legitimate use is for legacy code that calls APIs requiring real org data (e.g., reports, dashboards). For batch tests, create your own test data with @TestSetup.

Can a Batch Apex test class test the @future or Queueable methods inside execute()?

Yes. When you wrap the batch invocation in Test.startTest()/Test.stopTest(), both @future and Queueable jobs queued from within the batch are executed before stopTest returns. You can then assert on side effects. Note: nested async chains (queueable enqueues another queueable) are limited to one level deep in tests.

Newsletter

One email every Tuesday

New guides, tool updates, and the release-note changes that break things.

No spam. Unsubscribe in one click.

Comments

Loading comments...

Leave a Comment