Apex Continuations: Implementation and Testing in Aura & LWC

In this post we’ll share a testing solution for the Apex Continuations implemented in a static-context (commonly used in Aura and Lightning Web Components).

Note: This approach is not an official Salesforce solution.

Apex Continuation Overview

Apex Continuations are a mechanism provided by the Salesforce platform that allow you to make asynchronous long-running requests to an external Web Service. The service supports callouts to a SOAP or REST Web Service, with a maximum timeout of 120 seconds (versus 10 seconds in a standard synchronous callout).

To give a more visual description of this concept, look at the diagram below:

(1) A user invokes an action that requests some information from a Web Service
(2 – 3) The app server sends the request to the Continuation Server and returns to the client-side, creating then an asynchronous situation / context
(4 – 5) From the Continuation Server, a request is sent to the external Web Service
(6 – 7) The Web Service processes a response that is sent back to the Continuation Server
(8) This response is processed back to the App Server
(9) And finally, sent back to the client-side to be displayed to the user

 

To get into technical details of implementation, this is possible thanks to the definition of two different functions:

  • The first one is responsible of creating, initializing and firing the Continuation. It can receive parameters from the component. We’ll name it startContinuation() in the different examples of this post.
  • The second one is the callback: once the response has been processed and returned, we might want to work with the received data before displaying it to the user or to handle undesired exceptions from the External Service. We’ll name it continuationCallback() in the examples.

Benefits of Apex Continuations

At this point, you may be asking yourself: what’s the point of using these long-running asynchronous callouts, called Continuations, versus other asynchronous alternatives like Queueable jobs? Well, you could use a Queueable job to perform a callout that will be executed asynchronously in the future, but you would lose the context of the execution. Even if you get the job Id, your component won’t have a callback that is executed once the job is finished. Queueable jobs fit better in situations where the user doesn’t need to wait to see the results of the callout.

Implementing and Testing Apex Continuations

If you have already gone through the Salesforce Developer documentation, you might have seen that the implementation of Apex Continuation is not something new. There are different detailed guides, so let’s not reinvent the wheel! You can find below the official documentation for each context:

The main difference between these three points is that the Visualforce implementation uses non-static methods (it relies on a class instance), whereas Aura and LWC use static methods (this is required for the @AuraEnabled annotation). Apex Continuations were initially developed to be used within class instances (Visualforce controllers). This allowed to maintain individual references in an asynchronous context.

When it comes to testing, the test class implements a method invokeContinuationMethod(Object controller, Continuation request) that allows you to synchronously test your Apex Continuation by invoking the callback method for the specified controller. The first parameter of this function expects an instance of the controller class that implements the continuation and the second one is the continuation object that is returned by an action method in the controller class.

When calling an Apex function from a Lightning web component or an Aura Component, the Apex method must be static and annotated with @AuraEnabled. When combined with the previous points… we can see that there is a problem: the invokeContinuationMethod(controller, request) function is expecting an instance of the controller but our class will be static with static methods.

 

⛔ PROBLEM: It is not possible to test the Apex Continuation implemented in a static context.

✅ SOLUTION: You can implement @TestVisible variables to store relevant information for the test methods, and replace the invokeContinuationMethod directly by leveraging your callback function.

Note: Another reason why it is is not possible to test the Apex Continuation in a static context is that while the code is in a testing context, all of the normally asynchronous functionality is executed synchronously (we don’t want to wait an undefined time to finish running our tests) and we don’t store a reference to the Continuation that was fired.

There are multiple ways to work with Apex Continuations: from one to multiple callouts within the same Continuation, to different implementations of the callback function. We will cover a few examples, such as:

  • A simple Continuation with one HttpCallout and a callback(Object state) function.
  • A simple Continuation with one HttpCallout and a callback(List<String> labels, Object state) function.
  • Multiple Continuations with two HttpCallouts and a callback(List<String> labels, Object state) function.

Simple Apex continuation

In this example, we’ll show you how you can work with one HTTP callout and one parameter in the callback function, Object state. There will be no data passed from the function that fires the the continuation back to the callback function.

A highlight of this code sample is the use of a continuationState property annotated with @TestVisible. This lets us store the request state for test purposes.

ApexContinuation.cls

public with sharing class ApexContinuation {
    
    // Test variable used holding the value of the Continuation.state.
    // Because the property is static, there can be only request in the method.
    @TestVisible 
    private static String continuationState;
    
    // The function that fires the continuation
    @AuraEnabled(continuation=true cacheable=true)
    public static Object startContinuation() {
        // Create the callout Request
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setEndpoint('<some URL or callout:NamedCredential here>');

        // Create the continuation with a 40s timeout
        Continuation con = new Continuation(40);
        con.ContinuationMethod = 'continuationCallback';
        
        // Store the HttRequest and make it accessible for a testing-context
        continuationState = con.addHttpRequest(req);
        con.state = continuationState;

        return con;
    }

    // The function that processes the continuation callback
    @AuraEnabled(cacheable=true)
    public static Object continuationCallback(Object state) {
        // Get the response
        HttpResponse response = Continuation.getResponse((String) state);

        Integer statusCode = response.getStatusCode();
        if (statusCode == 200) {
            return response.getBody();
        } else if(statusCode >= 2000) {
            // Handle continutation-specific error codes
            // See https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_class_System_Continuation.htm
            throw new AuraHandledException('Continuation Error: ' + statusCode + ' - ' + response.getBody());
        } else {
            // Handle standard HTTP request errors
            throw new AuraHandledException('HTTP Request Error: ' + statusCode + ' - ' + response.getBody());
        }
    }
}

ApexContinuation_Test.cls

@IsTest
private class ApexContinuation_Test {
    // Case 1: Valid HttpRequest using a Continuation
    @IsTest private static void testApexContinuation() {
        String mockResponseBody = 'TestBodyResponse';

        Test.startTest();
        Continuation con = (Continuation) ApexContinuation.startConinuation();

        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(1, requests.size(), 'The number of requests associated to the Continuation is not correct');

        // Create a mock response
        HttpResponse response = new HttpResponse();
        response.setStatusCode(200);
        response.setBody(mockResponseBody);

        // Assign the mock response to the variable created for testing - for keeping the reference to the correct Continuation
        Test.setContinuationResponse(ApexContinuation.continuationState, response);

        String result = (String) ApexContinuation.continuationCallback(ApexContinuation.continuationState);
        Test.stopTest();
        
        System.assertEquals(mockResponseBody, result, 'Continuation failed: response body not valid');
    }

    // Case 2: Exception caused by a problem with the continuation
    @IsTest private static void testContinuationExceptionCaptured() {
        Test.startTest();
        Continuation con = (Continuation) ApexContinuation.startConinuation();
    
        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(1, requests.size(), 'The number of requests associated to the continuation is not correct');

        // Create a mock response
        HttpResponse response = new HttpResponse();
        response.setStatusCode(2000);
        Test.setContinuationResponse(ApexContinuation.continuationState, response);

        Boolean exceptionThrown = false;
        try {
            Object result = ApexContinuation.continuationCallback(ApexContinuation.continuationState);
        } catch(AuraHandledException e) {
            exceptionThrown = true;
        }
        Test.stopTest();
        
        System.assert(exceptionThrown, 'Failed to catch continuation exception');   
    }

    // Case 3: Exception caused by a bad HTTP response
    @IsTest private static void testHttpRequestExceptionCaptured() {
        Test.startTest();
        Continuation con = (Continuation) ApexContinuation.startConinuation();

        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(1, requests.size(), 'The number of requests associated to the continuation is not correct');

        // Create a mock response
        HttpResponse response = new HttpResponse();
        response.setStatusCode(400);
        Test.setContinuationResponse(ApexContinuation.continuationState, response);

        Boolean exceptionThrown = false;
        try {
            Object result = ApexContinuation.continuationCallback(ApexContinuation.continuationState);
        } catch(AuraHandledException e) {
            exceptionThrown = true;
        }
        Test.stopTest();
    
        System.assert(exceptionThrown, 'Failed to catch HTTP request exception');   
    }
}

Simple Apex continuation, passing data to the callback function

In this other example, we’ll show you how you can work with one HTTP callout but this time we will have two parameters in the callback function, List<String> labels and Object state. This time, there will be data being passed from the function initiating the Continuation to the callback function.

ApexSimpleContinuationPassingData.cls

public with sharing class ApexSimpleContinuationPassingData {
    
    // Test variable used for holding a reference to the Continuation Request
    @TestVisible
    private static String continuationLabel;
    // Test variable used for holding the value of the Continuation.state attribute
    @TestVisible
    private static String continuationState;
    
    // The function that fires the continuation
    @AuraEnabled(continuation=true cacheable=true)
    public static Object startContinuation() {
        // Create the callout Request
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setEndpoint('<some URL or callout:NamedCredential here>');

        // Create the continuation with a 40s timeout
        Continuation con = new Continuation(40);
        con.ContinuationMethod = 'continuationCallback';
        
        // Store the reference to the HttRequest and make it accessible for a test-context
        continuationLabel = con.addHttpRequest(req);
        
        // Store data to be sent to the callback function
        continuationState = 'Some data here...';
        con.state = continuationState;

        return con;
    }

    // The function that processes the continuation callback
    @AuraEnabled(cacheable=true)
    public static Object continuationCallback(List<String> labels, Object state) {
        HttpResponse response = Continuation.getResponse(labels[0]);
        Integer statusCode = response.getStatusCode();
        // TODO: handle the different status codes
        
        return response.getBody();
    }
}

ApexSimpleContinuationPassingData_Test.cls

@IsTest
private class ApexSimpleContinuationPassingData_Test {
    // Case 1: Valid HttpRequest using a Continuation
    @IsTest private static void testApexSimpleContinuationPassingDataForLWC() {
        String mockResponseBody = 'TestBodyResponse';

        Test.startTest();
        Continuation con = (Continuation) ApexSimpleContinuationPassingData.startConinuation();
        Test.stopTest();
        
        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(1, requests.size(), 'The number of requests associated to the Continuation is not correct');

        // Create a mock response
        HttpResponse response = new HttpResponse();
        response.setStatusCode(200);
        response.setBody(mockResponseBody);

        Test.setContinuationResponse(ApexSimpleContinuationPassingData.continuationLabel, response);

        List<String> labels = new List<String>{ApexSimpleContinuationPassingData.continuationLabel};

        String result = (String)ApexSimpleContinuationPassingData.continuationCallback(labels, ApexSimpleContinuationPassingData.continuationState);
        
        System.assertEquals(true, result.contains(mockResponseBody), 'Continuation failed: response body not valid');
        System.assertEquals(true, result.contains('Some data here...'));
    }

    // Case 2: Exception caused by a problem with the continuation
    @IsTest private static void testContinuationExceptionCaptured() {
        Continuation con = (Continuation) ApexSimpleContinuationPassingData.startConinuation();

        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(1, requests.size(), 'The number of requests associated to the Continuation is not correct');

        // Create a mock response
        HttpResponse response = new HttpResponse();
        response.setStatusCode(2000);
        Test.setContinuationResponse(ApexSimpleContinuationPassingData.continuationLabel, response);

        List<String> labels = new List<String>{ApexSimpleContinuationPassingData.continuationLabel};

        Boolean exceptionThrown = false;
        try {
            String result = (String)ApexSimpleContinuationPassingData.continuationCallback(labels, ApexSimpleContinuationPassingData.continuationState);
        }catch(AuraHandledException e) {
            exceptionThrown = true;
        }
        
        System.assertEquals(true, exceptionThrown, 'Failed to catch Continuation Exception');   
    }

    // Case 3: Exception caused by a bad HTTP response
    @IsTest private static void testHttpRequestExceptionCaptured() {
        Continuation con = (Continuation) ApexSimpleContinuationPassingData.startConinuation();

        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(1, requests.size(), 'The number of requests associated to the Continuation is not correct');

        // Create a mock response
        HttpResponse response = new HttpResponse();
        response.setStatusCode(400);
        Test.setContinuationResponse(ApexSimpleContinuationPassingData.continuationLabel, response);

        List<String> labels = new List<String>{ApexSimpleContinuationPassingData.continuationLabel};

        Boolean exceptionThrown = false;
        try {
            String result = (String)ApexSimpleContinuationPassingData.continuationCallback(labels, ApexSimpleContinuationPassingData.continuationState);
        } catch(AuraHandledException e) {
            exceptionThrown = true;
        }
        
        System.assertEquals(true, exceptionThrown, 'Failed to catch Http Request Exception');   
    }
}

Multiple Apex continuation

In this other example, we’ll show you how you can work with two or more HTTP callouts with two parameters in the callback function, List<String> labels and Object state. There may or may not be data being passed from the function initiating the Continuation to the callback function.

ApexMultipleContinuation.cls

public with sharing class ApexMultipleContinuation {
    
    // Test variable used for holding the values of the Continuation.state requests
    @TestVisible
    private static String continuationLabel1;
    @TestVisible
    private static String continuationLabel2;

    // The function to be called to fire the continuation
    @AuraEnabled(continuation=true cacheable=true)
    public static Object startConinuation() {
        // Create the callout Request
        HttpRequest req1 = new HttpRequest();
        req1.setMethod('GET');
        req1.setEndpoint('<some URL or callout:NamedCredential here>');

        HttpRequest req2 = new HttpRequest();
        req2.setMethod('GET');
        req2.setEndpoint('<some other URL or callout:NamedCredential here>');

        // Create the continuation with a 40s timeout
        Continuation con = new Continuation(40);
        con.ContinuationMethod = 'continuationCallback';
        
        // Store the references to the requests and make them accessible for a test-context
        continuationLabel1 = con.addHttpRequest(req1);
        continuationLabel2 = con.addHttpRequest(req2);

        return con;
    }

    // The function that will process the callback of the Continuation
    @AuraEnabled(cacheable=true)
    public static Object continuationCallback(List<String> labels, Object state) {
        // Get the response of the first HttpRequest
        HttpResponse response1 = Continuation.getResponse(labels[0]);
        Integer statusCode1 = response1.getStatusCode();
        // TODO: handle the different status codes for statusCode1
        
        // Get the response of the second HttpRequest
        HttpResponse response2 = Continuation.getResponse(labels[1]);
        Integer statusCode2 = response2.getStatusCode();
        // TODO: handle the different status codes for statusCode2
        
        return new List<String>{response1.getBody(), response2.getBody()};
    }
}

ApexMultipleContinuation_Test.cls

@IsTest
private class ApexMultipleContinuation_Test {
     // Case 1: Valid HttpRequest using a Continuation
     @IsTest private static void testApexMultipleContinuationForLWC(){
        String mockResponseBody1 = 'TestBodyResponse1';
        String mockResponseBody2 = 'TestBodyResponse2';

        Continuation con = (Continuation) ApexMultipleContinuation.startConinuation();

        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(2, requests.size(), 'The number of requests associated to the Continuation is not correct');

        // Create a mock response
        HttpResponse response1 = new HttpResponse();
        response1.setStatusCode(200);
        response1.setBody(mockResponseBody1);

        HttpResponse response2 = new HttpResponse();
        response2.setStatusCode(200);
        response2.setBody(mockResponseBody2);

        Test.setContinuationResponse(ApexMultipleContinuation.continuationLabel1, response1);
        Test.setContinuationResponse(ApexMultipleContinuation.continuationLabel2, response2);

        List<String> labels = new List<String>{ApexMultipleContinuation.continuationLabel1, ApexMultipleContinuation.continuationLabel2};

        List<String> result = (List<String>)ApexMultipleContinuation.continuationCallback(labels, null);
        
        System.assertEquals(mockResponseBody1, result.get(0), 'Continuation failed: response body not valid for request 1');
        System.assertEquals(mockResponseBody2, result.get(1), 'Continuation failed: response body not valid for request 2');
    }

    // Case 2: Exception caused by a problem with the continuation
    @IsTest private static void testContinuationExceptionCapturedInFirstRequest() {
        Continuation con = (Continuation) ApexMultipleContinuation.startConinuation();
        
        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(2, requests.size(), 'The number of requests associated to the Continuation is not correct');
        
        // Create a mock response
        HttpResponse response1 = new HttpResponse();
        response1.setStatusCode(2000);
        
        HttpResponse response2 = new HttpResponse();
        response2.setStatusCode(200);
        
        Test.setContinuationResponse(ApexMultipleContinuation.continuationLabel1, response1);
        Test.setContinuationResponse(ApexMultipleContinuation.continuationLabel2, response2);
        List<String> labels = new List<String>{ApexMultipleContinuation.continuationLabel1, ApexMultipleContinuation.continuationLabel2};
        
        Boolean exceptionThrown = false;
        try {
            List<String> result = (List<String>) ApexMultipleContinuation.continuationCallback(labels, null);
        } catch (AuraHandledException e) {
            exceptionThrown = true;
        }
        
        System.assertEquals(true, exceptionThrown, 'Failed to catch Continuation Exception');
    }

    // Case 3: Exception caused by a bad Http Response
    @IsTest private static void testHttpRequestExceptionCapturedInFirstRequest() {
        Continuation con = (Continuation) ApexMultipleContinuation.startConinuation();
    
        // Verify that the continuation has the correct number of requests
        Map<String, HttpRequest> requests = con.getRequests();
        System.assertEquals(2, requests.size(), 'The number of requests associated to the Continuation is not correct');
    
        // Create a mock response
        HttpResponse response1 = new HttpResponse();
        response1.setStatusCode(400);
    
        HttpResponse response2 = new HttpResponse();
        response2.setStatusCode(200);
    
        Test.setContinuationResponse(ApexMultipleContinuation.continuationLabel1, response1);
        Test.setContinuationResponse(ApexMultipleContinuation.continuationLabel2, response2);
        List<String> labels = new List<String>{ApexMultipleContinuation.continuationLabel1, ApexMultipleContinuation.continuationLabel2};
    
        Boolean exceptionThrown = false;
        try {
            List<String> result = (List<String>) ApexMultipleContinuation.continuationCallback(labels, null);
        } catch (AuraHandledException e) {
            exceptionThrown = true;
        }
    
        System.assertEquals(true, exceptionThrown, 'Failed to catch Http Request Exception');
    }
}

Running the examples above in a Lightning web component

If you want to use the same principles in a Lightning web component, you can implement it with the following code. In this example the Continuation is called imperatively when the component renders but you can call it any time.

apexContinuation.js

import { LightningElement } from 'lwc';
import startContinuation from '@salesforce/apexContinuation/<Your Continuation Controller class>.<Your startContinuation method>';

export default class ApexContinuation extends LightningElement {
    result;
    isLoading = true;

    connectedCallback() {
        startContinuation()
            .then(result => {
                this.result = result;
                this.isLoading = false;
            }).catch(error => {
                // TODO: handle error
                this.isLoading = false;
            });
    }
}

apexContinuation.html

<template>
    <div class="slds-card slds-p-around_large">
        <lightning-spinner if:true={isLoading} size="small"></lightning-spinner>

        <h1 class="slds-text-heading_medium slds-p-bottom_x-small">APEX Continuation Callback</h1>
        <code>{result}</code>
    </div>
</template>

Recommendations

Finally, here are some tips and advice from what I have learned by implementing Apex Continuations:

  1. Always use the callback function with two parameters (List<String> labels, Object state). This will help you have a standard development style in your code (and it is easier to work with test variables).
  2. When implementing Apex Continuations with multiple callouts, create multiple @TestVisible variables (continuationLabelX) instead of a list in the Controller class. This will help you to easily identify which label is referencing which request in the test context.

You can find the source code of the examples in this repository. Don’t hesitate to contribute or leave suggestions there.

About the author

Víctor García Zarco is a Technical Consultant at Salesforce France. He has an IT Engineering and Business background, helps customers to implement Salesforce adapted to their needs and is an innovation and technology enthusiast. Check his GitHub projects @victorgz

References

Examples of the implementation and testing of Apex Continuations in LWC and Aura repository
Apex Developer Guide: Continuation Class
Apex Developer Guide: Make Long-Running Callouts from a Visualforce Page
Apex Developer Guide: Execution Governors and Limits
Lightning Aura Components Developer Guide: @AuraEnabled Annotations for Continuations
Lightning Web Components Developer Guide: Continuations