[SalesForce] @testSetup method and System.CalloutException: You have uncommitted work pending

Apologies ahead of time for the lengthy post, but I'm receiving the following error when attempting to run the test method below. The error occurs when the upsertProject method is called.

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

Test Method

@isTest static void upsertErrorTest() {
    // retrieve project to pass to upsert method
    Project__c p = [SELECT Id, Name FROM Project__c LIMIT 1];
    system.assertEquals('Test Project', p.Name);

    // retrieve replicon project data in apex wrapper
    Replicon.Project project = Replicon.getProjectById(p.Id);

    // instantiate payload to use in mock http request
    String errorPayload = '[insert json payload here]';

    Test.startTest();

    // instantiate the http service mock
    RepliconServiceUpsertMock mock = new RepliconServiceUpsertMock(200, 'Success', errorPayload, null);
    Test.setMock(HttpCalloutMock.class, mock);

    RepliconService service = new RepliconService();
    HttpResponse response = service.upsertProject(project, p.Id);

    // assertions here

    Test.stopTest();
}

Http Callout Method

public HttpResponse upsertProject(Replicon.Project project, String projectId) {
    // serialize the project object for insertion into replicon
    String requestBody = project.toString();

    // instantiate the request to send to replicon
    HttpRequest req = this.getRequest('ImportService1.svc/PutProject4');
    req.setBody(requestBody);

    // send the request to replicon to create a new project
    Http h = new Http();
    HttpResponse response = h.send(req);
    system.debug('[upsertProject] Response:' + response.getBody());

    return response;
}

Test data is created via an @testSetup method in the test class:

@testSetup static void setupData() {
    Account a = new Account();
    a.Name = 'Test Account';
    insert a;

    Opportunity o = new Opportunity();
    o.AccountId = a.Id;
    o.Name = 'Test Opp';
    o.StageName = 'First stage';
    o.CloseDate = system.today();
    o.Amount = 55000.00;
    insert o;

    Project__c p = new Project__c();
    p.Account__c = a.Id;
    p.Opportunity__c = o.Id;
    p.Name = 'Test Project';
    insert p;
}

Mock Class

@isTest
public class RepliconServiceUpsertMock implements HttpCalloutMock {

    protected Integer code;
    protected String status;
    protected String body;
    protected Map<String, String> responseHeaders;

    public RepliconServiceUpsertMock(Integer code, String status, String body, Map<String, String> responseHeaders) {
        this.code = code;
        this.status = status;
        this.body = body;
        this.responseHeaders = responseHeaders;
    }

    public HTTPResponse respond(HTTPRequest req) {

        HttpResponse res = new HttpResponse();
        for(String key : this.responseHeaders.keySet()) {
            res.setHeader(key, this.responseHeaders.get(key));
        }
        res.setBody(this.body);
        res.setStatusCode(this.code);
        res.setStatus(this.status);
        return res;
    }

}

The documentation on using Http callouts with Mocks states:

By default, callouts aren’t allowed after DML operations in the same
transaction because DML operations result in pending uncommitted work
that prevents callouts from executing. Sometimes, you might want to
insert test data in your test method using DML before making a
callout. To enable this, enclose the portion of your code that
performs the callout within Test.startTest and Test.stopTest
statements. The Test.startTest statement must appear before the
Test.setMock statement. Also, the calls to DML operations must not be
part of the Test.startTest/Test.stopTest block.

DML operations that occur after mock callouts are allowed and don’t
require any changes in test methods.

My code is definitely following the order of execution described above. There is also no other processes (e.g. future, scheduled apex, etc) being generated from related triggers. So I'm perplexed as to why I'm receiving this error.

Additionally, I have attempted to move the test data creation into the test method itself and do away with the @testSetup method, but that produced the same results.

Any idea why this is happening?

Best Answer

I ran into something similar to what you're describing here. I had all data setup in a testSetup method and committed no DML's in between Test.startTest() and Test.stopTest(), just querying a record and passing that to the data to a web service.

I also confirmed that there were no fresh DML statements by adding a Limits.getDMLStatements() right before the call out, it returned 0. I have scoured the boards and this is the best example that's similar to the behavior I have run into.

I'm certain it's a bug on the Salesforce side, and unfortunately those can take a while to resolve and I need to have this deployed! I came up with a workaround, and I wanted to share it since it may save you (if you're still having the issue) or someone else a lot of misery!

In my mock class that I was calling during the test, I had my sample JSON responses defined as static strings. So I did a tweaked version of a response built in to the call out:

//This is one of the callout functions that I'm using to hit a REST API
global HttpResponse getAllTemplates() {

    HttpRequest req = setupBaseRequest(null);
    req.setEndpoint(baseEndPoint+'/templates');
    req.setMethod('GET');

    HttpResponse res;
    if (Test.isRunningTest()) {
        res = new HttpResponse();
        res.setBody(MyMockClass.templateExampleJSON);
        res.setStatusCode(200);
        res.setStatus('OK');
    }
    if (!Test.isRunningTest()) res = new Http().send(req);
    return res;
}

Here is my mock class (example only):

@isTest
global class MyMockClass implements HttpCalloutMock {
    global static String templateExampleJSON = '{"templateName":"TestName","TemplateId":"12345"}';

    global HTTPResponse respond(HTTPRequest req) {
    HttpResponse res = new HttpResponse();
    res.setStatusCode(200);
    res.setStatus('OK');

    if (req.getEndPoint().contains('/templates')) {
        res.setBody(templateExampleJSON);
    } else {
        res.setStatusCode(400);
        res.setStatus('Bad Request');
    }

    return res;
}

Doing it this way allows my test class to fire correctly. It also allows the actual response to go out when it's not testing. I feel like it's a little bit of a hack; however, it accomplishes my end goal of testing the functionality.

Related Topic