6

I have a batch job that runs over a SOQL query locator. Occasionally there will be an unhandled exception in the execute() method.

Later on in the finish() method I check the corresponding AsyncApexJob.NumberOfErrors based on the BatchableContext jobId. If it is greater than zero I'll perform some extra steps.

I want to create a test method that verifies those extra steps are being performed as expected. However, I've found that deliberately throwing an exception in the execute method won't cause the finish method to run.

It's probably easiest to demonstrate with an example.

Sample batch class

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) {
        integer divideByZeroExceptionCreator = 1/0;
    }

    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');
        }
    }
}

Sample test class

@IsTest
public class BatchClassThatWillFail_Test {
    @IsTest
    static void failBatch() {
        Lead acc = new Lead(LastName='Test', Company='C');
        insert acc;

        BatchClassThatWillFail b = new BatchClassThatWillFail();
        integer scopeSize = 1;
        Test.startTest();
        ID batchprocessid = Database.executeBatch(b, scopeSize);
        Test.stopTest();
    }
}

When the failBatch() method runs it fails due to an unhandled System.MathException from line 7 in BatchClassThatWillFail. The debug log shows the FATAL_ERROR occurring immediately after the METHOD_EXIT for System.Test.stopTest()

There is no sign in the debug log that the finish() method in the batch job ran after Test.stopTest() was called. The unhandled exception appears to have terminated the test.

I can't directly construct a Database.BatchableContext to directly call finish(). Even if I could, the corresponding AsyncApexJob can't be updated via DML.

I guess I could move the error handling out into a separate @TestVisible method and pass in an in memory AsyncApexJob.

Please note - I'm not after a mechanism to catch and handle the exceptions in the execute method. The standard AsyncApexJob.NumberOfErrors functionality does work in a non-test batch job. Note that it also handles uncatchable exceptions like those that come out of hitting limits.

If I just wanted to handle the errors individually I'd use something like Database.RaisesPlatformEvents. But that doesn't really address how to test the finish method when exceptions occur in the batch processing.

4

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
}
  • 3
    Nice. Or if one is using Selector layers, create a Selector for AsyncApexJob and then apexMocks + Json.deserialize to return whatever you need – cropredy Jul 4 at 19:15
  • This works well to test how the finish method handles the reported errors. I've gone with a dedicated class to get the AsyncApexJob records so it can be mocked with a corresponding System.StubProvider. That way there is minimal change to the batch job itself and I can use it in all my batch jobs. I could merge my variation onto the end or your answer if you don't mind. – Daniel Ballinger Jul 4 at 20:12
  • 1
    @DanielBallinger Go right ahead! – David Reed Jul 4 at 20:13
2

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

  • I Was about to post the same answer!! :) – rahul gawale Jul 4 at 7:11
  • But your job has NumberOfErrors = 0, which is not working for the questioner. – Mariia Illarionova Jul 4 at 7:17
  • 2
    If we catch the exception then AsyncApexJob will not fail and number of errors willbe 0. So I belive thiswont work – Pranay Jaiswal Jul 4 at 7:17
  • 1
    The goal isn't to catch and handle the exceptions in the execute method. I want to use the standard batch processing functionality to detect and report on the errors in the finish method. That currently works, I just can't easily automate the tests for it. – Daniel Ballinger Jul 4 at 8:12
1

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.

  • Thanks Doug. I just tried this out. It does prevent the exception from terminating the test case, but the batches finish method is not called. Also, if I do a SOQL query on AsyncApexJob using the ID returned from Database.executeBatch the NumberOfErrors is 0. In fact, the Status is still set to 'Processing'. – Daniel Ballinger Jul 4 at 19:39
  • 1
    That's unfortunate that the job isn't finishing =/ – Doug Ayers Jul 4 at 21:27

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.