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.
- Move the core logic of your
executemethod into a separate Apex service class. Test that service class with standard unit tests, passing in parameters as needed. - 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
SELECTqueries in thestartmethod. - Rather than relying on
try/catchinside 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.executeBatchonly queues the job, which is why yourtry/catchdoes nothing in the test thread.- For unit testing, call the
start(),execute(), andfinish()methods directly. Passingnullas theBatchableContextis 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.
Leave a Comment