How to fix: "You have uncommitted work pending. Please commit or rollback before callout out" exception in unit tests

Reason for the error

Some Apex operations such as DML, Asynchronous Apex (Future Methods, Batch Jobs, Scheduled Apex), and sending emails are not allowed before making an HTTP callout in Apex. Doing so will result in an "uncommitted work pending" exception being thrown.

In Apex unit tests, this issue most commonly arises when attempting to insert test data for a test which later makes an HTTP callout.

// Setting up test data...
// ... but DML operations not allowed before HTTP Callouts.
Account acc = new Account(Name='Account created in unit test');
insert acc;

// Mocking the HTTP Callout
Test.setMock(HttpCalloutMock.class, new ExampleCalloutMock());
// "Uncommitted work pending" exception thrown here
HttpResponse res = CalloutClass.MakeAnHTTPCallout(acc.Id);
DML operation before an HTTP callout throws an exception

The same exception is also thrown when executing Asynchronous Apex before an HTTP callout.

// Asynchronous operations not allowed before HTTP Callouts.
FutureMethodClass.FutureMethodThatCreatesAnAccount();

// Mocking the HTTP Callout
Test.setMock(HttpCalloutMock.class, new ExampleCalloutMock());
// "Uncommitted work pending" exception thrown here
HttpResponse res = CalloutClass.MakeAnHTTPCallout();
Asynchronous Apex before an HTTP callout results in an exception

Note: DML and Asynchronous Apex is allowed after HTTP callouts, just not before.

The fixes

How to insert test data before an HTTP callout

  1. Use DML to insert test data at the beginning of your unit test (or in a @testSetup method).
  2. Wrap the HTTP callout (along with the mocking configuration) in a Test.startTest() - Test.stopTest() block. This ensures that the HTTP callout executes in a different context than the preceding DML, avoiding the exception.
@isTest
private static void DMLBeforeCallout_Fix_WrapMockSetupAndCalloutInTestStartStop() {

    // 1. Use DML to insert test data
    Account acc = new Account(Name='Account created in unit test');
    insert acc;

    // 2. Wrapping the HTTP callout in Test.startTest() - Test.stopTest()
    // ensures it executes in a different context than the preceding DML
    Test.startTest();
        Test.setMock(HttpCalloutMock.class, new ExampleCalloutMock());
        HttpResponse res = CalloutClass.MakeAnHTTPCallout(acc.Id);
    Test.stopTest();

    // Assert
    Assert.areEqual(200, res.getStatusCode());
    Assert.areEqual('Example Callout Test Response', res.getBody());
}
Wrapping the HTTP callout in a Test block creates a separate execution context

Allowing async operations before HTTP callouts in unit tests

Asynchronous Apex includes Future Methods, Batch Jobs, and Scheduled Apex.

Issues

There are 2 potential issues when it comes to mixing async operations, and HTTP callouts in unit tests:

  1. We need to avoid the "uncommitted work pending" exception.
  2. We probably want the async operation, which would normally run at some unspecified time in the future, to run immediately.

Solution

The solution is to put the async operation in a Test.startTest() - Test.stopTest() block and place the HTTP callout after it outside the block.

  1. Wrapping the async operation in a Test.startTest() - Test.stopTest() block ensures it is a different execution context than the following HTTP callout, so we avoid the "uncommitted work pending exception".
  2. The future method, batch job, or scheduled job is forced to run immediately when the code gets to Test.stopTest(), so you can be sure that it is complete before the rest of your test code runs.
@isTest
private static void FutureMethodBeforeCallout_Fix_WrapTheAsynchronousApexInTestStartStop() {

    // Test
    Test.startTest();
        // Wrapping the future method in a Test.startTest() - Test.stopTest()
        // block ensures it executes in a separate context than
        // the HTTP callout AND forces it to run immediately.
        FutureMethodClass.FutureMethodThatCreatesAnAccount();
    Test.stopTest();

    // The future method has run and created an Account
    Account acc = [Select Name From Account];
    Assert.areEqual('Account created in future method', acc.Name);

    // The HTTP callout is *outside* the Test.startTest() - Test.stopTest()
    // block *after* the future method.
    Test.setMock(HttpCalloutMock.class, new ExampleCalloutMock());
    HttpResponse res = CalloutClass.MakeAnHTTPCallout();

    // Assert
    Assert.areEqual(200, res.getStatusCode());
    Assert.areEqual('Example Callout Test Response', res.getBody());
}
Mixing a future method and an HTTP callout without an exception
@isTest
private static void BatchJobBeforeCallout_Fix_WrapTheAsynchronousApexInTestStartStop() {

    Account acc = new Account(Name='Test Account');
    insert acc;
    
    Test.startTest();
        // Wrapping the batch job in a Test.startTest() - Test.stopTest()
        // block ensures it executes in a separate context than
        // the HTTP callout AND forces it to run immediately.
        Database.executeBatch(new UpdateAccountsBatch());            
    Test.stopTest();

    // The batch job has run and updated the Account
    acc = [Select Name From Account];
    Assert.areEqual('Account updated in batch job', acc.Name);

    // The HTTP callout is *outside* the Test.startTest() - Test.stopTest()
    // block *after* the batch job.
    Test.setMock(HttpCalloutMock.class, new ExampleCalloutMock());
    HttpResponse res = CalloutClass.MakeAnHTTPCallout();

    // Assert
    Assert.areEqual(200, res.getStatusCode());
    Assert.areEqual('Example Callout Test Response', res.getBody());
}
Mixing a batch job and an HTTP callout without an exception

Github repo with examples

This GitHub repo has full examples for fixing the "uncommitted work pending" exceptions triggered by DML, future methods, batch jobs, scheduled Apex, and emails.