Testing an Apex batch method execute() exception incrementing NumberOfErrors in the finish() method

I don't see a way to do it other than directly calling finish() with a mocked Database.BatchableContext and dependency-injecting its query method.

It's not an especially pretty or elegant solution, but it works fine and can cover and validate the error path. Here's how I set it up. Your preferred style of dependency injection; I use a lot of interfaces and added the Stateful interface to preserve the delegate reference across execution. It might be somewhat nicer to place the query in a public utility class with a static delegate, and then do the dependency injection there.

public with sharing class BatchClassThatWillFail implements Database.Batchable<sObject>, Database.Stateful {
    @TestVisible private interface AsyncApexJobDelegate {
        AsyncApexJob getAsyncApexJobById(Id jobId);
    }

    @TestVisible private class AsyncApexJobConcreteDelegate implements AsyncApexJobDelegate {
        public AsyncApexJob getAsyncApexJobById(Id jobId) {
            return [Select Id, NumberOfErrors
            from AsyncApexJob where Id = :jobId];
        }
    }

    @TestVisible private AsyncApexJobDelegate delegate = new AsyncApexJobConcreteDelegate();

    public Database.QueryLocator start(Database.BatchableContext BC) {
        return Database.getQueryLocator('Select Id from Lead LIMIT 2');
    }

    public void execute(Database.BatchableContext BC, List<sObject> scope) {

    }

    public void finish(Database.BatchableContext BC) {
        AsyncApexJob a = delegate.getAsyncApexJobById(BC.getJobId());

        if(a.NumberOfErrors > 0) {
            insert new Account(Name='Error');
            System.debug(LoggingLevel.Error, 'Errors occured');
        }
    }
}

With test class:

@IsTest
public class BatchClassThatWillFail_Test {
    private class MockDelegate implements BatchClassThatWillFail.AsyncApexJobDelegate {
        public AsyncApexJob getAsyncApexJobById(Id jobId) {
            return (AsyncApexJob)JSON.deserialize('{"NumberOfErrors": 1}', AsyncApexJob.class);
        }
    }
    @IsTest
    static void failBatch() {
        BatchClassThatWillFail b = new BatchClassThatWillFail();
        b.delegate = new MockDelegate();

        Database.BatchableContext bc = (Database.BatchableContext)JSON.deserialize('{"jobId": "707000000000001"}', Database.BatchableContextImpl.class);
        b.finish(bc);

        System.assertEquals(1, [SELECT count() FROM Account WHERE Name = 'Error']);
    }
}

The test passes, covers the error path, and can be asserted against (I threw in an sObject insert just for the purpose of making an assertion).

One could drop part of the JSON serialization by having the delegate class directly accept the Database.BatchableContext and simply pass null when calling finish(). Assertions could also be added to the mock class, since it's not being serialized to enqueue the batch.


Here is a slight variation on the same concept using a System.StubProvider to do the mocking.

public class AsyncApexJobSelector {

    // Use this rather than the default public constructor    
    public static AsyncApexJobSelector Instance {
        get {
            if(Instance == null) {
                Instance = new AsyncApexJobSelector();
            }
            return Instance;
        }
        private set;
    }

    @TestVisible
    private static void setMockInstance(AsyncApexJobSelectorMockProvider mockInstance) {
        Instance = (AsyncApexJobSelector)Test.createStub(AsyncApexJobSelector.class, mockInstance);
    }

    public AsyncApexJob getAsyncApexJobById(Id jobId) {
        return [Select Id, Status, NumberOfErrors, JobItemsProcessed,
            TotalJobItems, CreatedBy.Email, ExtendedStatus
            from AsyncApexJob where Id = :jobId];   
    }
}

The corresponding System.StubProvider:

public class AsyncApexJobSelectorMockProvider implements System.StubProvider {

    public Object handleMethodCall(Object stubbedObject, String stubbedMethodName, 
            Type returnType, List<Type> listOfParamTypes, List<String> listOfParamNames, 
            List<Object> listOfArgs) {

        if(stubbedMethodName == 'getAsyncApexJobById') {
            // TODO - allow the required AsyncApexJob to be defined when the mock is created
            // Also, this should populate the same fields as the SOQL query it mocks
            AsyncApexJob mockResponse = (AsyncApexJob)JSON.deserialize('{"NumberOfErrors": 1}', AsyncApexJob.class);
            return mockResponse;
        }

        System.assert(false, 'unmocked stubbedMethodName: ' + stubbedMethodName);

        return null;
    }
}

Then, in the batch classes finish method you use the new selector class to get the AsyncApexJob rather than a direct SOQL query.

public void finish(Database.BatchableContext BC) {
    System.debug('finish');

    AsyncApexJob a = AsyncApexJobSelector.Instance.getAsyncApexJobById(BC.getJobId());
    //AsyncApexJob a = [Select Id, NumberOfErrors from AsyncApexJob where Id = :BC.getJobId()];

    if(a.NumberOfErrors > 0) {
        System.debug(LoggingLevel.Error, 'Errors occured');
    }
}

The test method for handling errors in the finish method can configure the mock and directly call the finish method.

@IsTest
static void finishBatchWithErrors() {
    // Consider modifying the mock provider to configure the response
    AsyncApexJobSelectorMockProvider mockProvider = new AsyncApexJobSelectorMockProvider();
    AsyncApexJobSelector.setMockInstance(mockProvider);

    Database.BatchableContext bc = (Database.BatchableContext)JSON.deserialize('{"jobId": "707000000000001"}', Database.BatchableContextImpl.class);

    BatchClassThatWillFail b = new BatchClassThatWillFail();
    b.finish(bc);

    // Assert that the finish method handled the NumberOfErrors case as expected
}

Any errors which could popup during execute() method, if we can handle with try-catch statement then errors are trappable and it will allow to execute finish() method of that Batch Apex.

I have changed your code to add try-catch statement, then it enters into finish() method, but AsyncApexJob will have 1 record with status = 'Completed'. Here it will not show status = 'Failed'.

public with sharing class BatchClassThatWillFail implements Database.Batchable<sObject>{
    public Database.QueryLocator start(Database.BatchableContext BC) {
        return Database.getQueryLocator('Select Id from Lead LIMIT 2');
    }

    public void execute(Database.BatchableContext BC, List<sObject> scope) {
        try{
            integer divideByZeroExceptionCreator = 1/0;
        } catch(Exception ex)
        {
            System.debug('Error Occurred in execute');
        }
    }

    public void finish(Database.BatchableContext BC) {
        System.debug('finish');
        AsyncApexJob a = [Select Id, NumberOfErrors
            from AsyncApexJob where Id = :BC.getJobId()];    

       if(a.NumberOfErrors > 0) {
            System.debug(LoggingLevel.Error, 'Errors occured');
        }
    }
}

Log shows this:

debug log


The unhandled exception appears to have terminated the test.

Yes, it did.

If your test method is failing with the exception thrown by the batch job, consider adding a try/catch block around the Test.stopTest(); so that the test continues.

try {
    Test.stopTest();
} catch ( Exception e ) {
    // don’t fail test, we want to check
    // the errors reported in AsyncApexJob
}

// TODO query job details

This is same technique I use to test that batch apex platform error events are published when a batch job implements Database.RaisesPlatformEvents. Maybe this will work for you too.