1

I am trying to write unit test for a trigger which calls batch apex performing callouts first then updates the response to those record.

Trigger's psuedo code looks like:

trigger AccountTrigger on Account (after update) {
    if(myCondition) {
        Database.executeBatch(new MyBatchClass(), 1);
    }
}

And batch is:

public class MyBatchClass implements Database.Batchable<SObject>, 
    Database.AllowsCallouts {

    // only relevant execute method
    public void execute(Database.BatchableContext batchableContext, 
    List<Account> accounts) {

        for(Account account: accounts) {

            // make http request callout

            // based on response update account
        }
    }
}

When I run my test test, it gives me error as:

System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out

From the related question, I understand that Database.executeBatch is treated as a DML. When implementing this solution, I get:

System.CalloutException: Callout from triggers are currently not supported.

Please suggest any workaround or point towards any gap in the design. Any insight is highly appreciated.

2
  • 1
    I see you are not passing any parameters to batch. Are you using trigger only to invoke batch? Have you tried future in that case? Commented Jul 29, 2019 at 11:40
  • This is a psuedo code. Extra details were not relevant for the issue, hence I did not post them. Can add if necessary.
    – Raul
    Commented Jul 29, 2019 at 11:43

2 Answers 2

2

Presumably, your test class is using Test.startTest() and Test.stopTest() to force your batch class to run synchronously. You then receive a CalloutException because the callouts that would in normal production use run asynchronously in a separate transaction are forced to run in the context of the current transaction - where you've either

  • already run DML or DML-equivalent operations, such as enqueuing a batch class.
  • or are in a trigger context.

Exactly how this works mechanically isn't very clear, but neither are the exact mechanics of running async Apex synchronously.

You should be able to get around this issue by testing your batch class piecemeal - calling each method start(), execute(), and finish() without actually enqueuing the batch. Provided that you sequester your test setup in an @testSetup method and don't perform any further DML in your test method, you should be able to call execute() directly with mock input and have its callouts successfully directed to your configured HttpCalloutMock.

I think you'll be able to succeed in testing the trigger part of your code by failing to wrap your DML in Test.startTest() and Test.stopTest(). You'll be able to validate by querying AsyncApexJob that your trigger enqueues a batch job, but the job itself won't run within the test context, so you won't be able to do an integration test across the code.

3
  • So I have to basically use Test.isRunningTest() in trigger to skip batch call, then test trigger and batch separately?
    – Raul
    Commented Jul 29, 2019 at 13:09
  • @Raul I don't think you need to use Test.isRunningTest() (although I might be wrong) - I think if you just don't use Test.startTest() while executing the trigger you'll be able to get it through. And yeah, test the trigger and batch separately.
    – David Reed
    Commented Jul 29, 2019 at 13:11
  • Its a shame that this doesn't work, thanks for your answer and better insight. Test.isRunningTest() works but I try to avoid it, unfortunately not in this case. For end to end testing I guess I can call a future method instead of batch from trigger for test context
    – Raul
    Commented Jul 29, 2019 at 14:44
0

End to end test could not be possible with batch class when it's methods are broken for unit test, another workaround is to call the execute's code as future; such that batch runs a future for unit tests.

Still not perfect but for the current scenario atleast I am able to do an end to end test, following is an example-

Test class:

@IsTest
public class TestAccountTrigger {

    @IsTest 
    private static void testBatchFunctionality() {

        // create test data

        Test.startTest();

        // set mock

        // update records

        Test.stopTest();

        // verify records as processed by batch
    }
}

Trigger:

trigger AccountTrigger on Account (after update) {
    if(myCondition) {
        if(Test.isRunningTest()) {
            // call batch's method as future as Database.executeBatch is counted as DML
            MyBatchClass.executeCalloutAsFuture(Trigger.newMap.keySet());
        } else {
            Database.executeBatch(new MyBatchClass(), 1);
        }
    }
}

Batch apex:

public class MyBatchClass implements Database.Batchable<SObject>, 
    Database.AllowsCallouts {

    public Set<Id> accountSet = new Set<Id>();

    public OrderPABXBatch(Set<Id> accountSet) {
        this.accountSet = accountSet;
    }

    // start method
    public Database.QueryLocator start(Database.BatchableContext BC){ 
        return getQueryLocator(this.accountSet);
    }

    private Database.QueryLocator getQueryLocator(Set<Id> accountSet) {
        return Database.getQueryLocator([SELECT Id FROM Account WHERE Id IN :accountSet]);
    }

    // call execute method from test context
    @future(callout=true)
    public static void executeCalloutAsFuture(Set<Id> orderSet) {

        MyBatchClass batchInstance = new MyBatchClass(orderSet);
        // Get an iterator
        Database.QueryLocatorIterator iterator =  batchInstance.getQueryLocator().iterator();

        List<Account> accounts = new List<Account>();
        // Collect the first record
        if(iterator.hasNext()) {
            accounts.add((Account) iterator.next());
        }
        if(!accounts.isEmpty()) {
            batchInstance.execute(null, accounts);
        }
    }


    // only relevant execute method
    public void execute(Database.BatchableContext batchableContext, 
        List<Account> accounts) {

        for(Account account: accounts) {

            // make http request callout

            // based on response update account
        }
    }
}

A private @TestVisible variable could be used instead of Test.isRunningTest(), just a matter of choice.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .