23

I'm dealing with an odd situation where I need to call Webservice A to authenticate to Webservice B and Webservice C, all with different endpoints. I know how to setup mocks to test B and C individually, but I can't do that for my real tests. The workflow is:

  1. User loads a VF page
  2. On load, VF page calls B.someMethod()
  3. B.someMethod needs to first call A.getAuthToken()
  4. Use authToken to complete call to B.someMethod()

I know that I could create one mock class to handle all of the web services (or at least to fake the authentication), but that could be very cumbersome to maintain given that B and C both have dozens of methods.

Is there a better way to handle unit tests where each method needs to provide a mock for multiple endpoints?

Note: These are JSON-based web services, and not SOAP.

  • How about using instanceof on the 'stub' parameter passed into the doInvoke? e.g. if stub instanceof A then delegate to a mock function for A else if stub instanceof B then delegate to mock function for B – Andrew Fawcett Nov 5 '13 at 22:16
  • I'm not sure what you mean; could you elaborate in an answer? – Mike Chale Nov 5 '13 at 22:22
  • Sure, have added an answer. – Andrew Fawcett Nov 5 '13 at 23:40
  • 1
    Wouldn't MultiStaticResourceCalloutMock handle this scenario? salesforce.com/us/developer/docs/apexcode/Content/… – Phil B Oct 2 '14 at 14:48
23

The doInvoke method on the mock interface passes in the stub and request parameters. You can use these to condition to dispatch the mock logic to various other mock implementations, while only registering a single mock implementation.

Test.setMock(WebServiceMock.class, new MockDispatcher());

public class MockDispatcher implements WebServiceMock 
{
    public void doInvoke(
        Object stub, Object request, Map<String, Object> response,
        String endpoint, String soapAction, String requestName,
        String responseNS, String responseName, String responseType) 
    {
        if(stub instanceof A)
            new AMock().doInvoke(
                stub, request, response, 
                endpoint, soapAction, requestName, 
                responseNS, responseName, responseType);
        else if(stub instanceof B)
            new BMock().doInvoke(
                stub, request, response, 
                endpoint, soapAction, requestName, 
                responseNS, responseName, responseType);
        return;
    }
}    

public class AMock 
{
    public void doInvoke(
        Object stub, Object request, Map<String, Object> response,
        String endpoint, String soapAction, String requestName,
        String responseNS, String responseName, String responseType) 
    {
        if(request instanceof A.GetAuthTokenRequest_element)
            response.put('response_x', new A.GetAuthTokenResponse_element());
        return;
    }
}    

public class BMock 
{
    public void doInvoke(
        Object stub, Object request, Map<String, Object> response,
        String endpoint, String soapAction, String requestName,
        String responseNS, String responseName, String responseType) 
    {
        if(request instanceof B.SomeMethodRequest_element)
            response.put('response_x', new B.SomeMethodResponse_element());
        return;
    }
}    
  • I like this approach and will look into it in more detail; I hadn't seen doInvoke in Apex before. – Mike Chale Nov 7 '13 at 13:24
  • I forgot to specify that I'm working with JSON web services, but I think I can adapt the dispatcher pattern by examining the request object. – Mike Chale Nov 7 '13 at 17:23
  • Thank you for this! It worked great for me. I had some weird generated code that I had to work through but once I got through it, this fit the bill perfectly! Thanks @AndrewFawcett – lifewithryan Nov 17 '15 at 21:18
6

I've found I've had to add something like this in my actual Class methods to switch my testMock if I'm running a test.

public String getActivityFile() {
    if(test.isRunningTest) {
        Test.setMock(WebServiceMock.clas, new WebserviceNumber2Mock());
    }
    return new WebserviceNumber2().getActivityFile();
}

Update 2018-05-22

Extending this idea, it's a pain to deal with circular references when referencing a Test class in a Production class. However, you can references a Test Class Type without causing a direct reference to another class. As of today (API 42.0), Types are not compiled when saving Apex. So you can do something like the following:

public abstract class SoapApi {

    public interface Request {
        Type getType();
        Type getResponseType();
        Type getMockResponseType();
        String getSoapAction();
        String getRequestNamespace();
        String getResponseNamespace();
        String getResponseName();
    }

    public abstract class MockResponse implements WebServiceMock {

        // Abstract
        public abstract Object getResponse(Request request);

        protected Object response;

        // WebServiceMock
        public void doInvoke(
            Object stub,
            Object request,
            Map<String, Object> response,
            String endpoint,
            String soapAction,
            String requestName,
            String responseNamespace,
            String responseName,
            String responseType
        ) {
            if(!(request instanceof Request)) {
                new InvalidMockRequestException(System.Label.SoapApi_InvalidMockRequestException); // 'Requestable must implement SoapApi.Requestable'
            }
            this.response = this.getResponse((Request) request);
            response.put('response_x', this.response);
        }
    }

    // Abstract
    public abstract String getEndpoint();

    // Instance
    public Object send(SoapApi.Request request) {
        if(request == null) {
            throw new NullRequestException(System.Label.SoapApi_NullRequestException); // 'Request cannot be null'
        }
        if(request.getType() == null) {
            throw new NullRequestTypeException(System.Label.SoapApi_NullRequestTypeException); // 'Request\'s Type cannot be null'
        }

        if(Test.isRunningTest()) {
            Object response = request.getMockResponseType() == null ? null : request.getMockResponseType().newInstance();
            if(!(response instanceof MockResponse)) {
                throw new InvalidMockResponseException(System.Label.SoapApi_InvalidMockResponseException); // 'Request\'s Mock Response Type must extend SoapApi.MockResponse'
            }
            Test.setMock(WebServiceMock.class, response);
        }

        // Invoke WebServiceCallout
        Map<String, Object> responses = new Map<String, Object> {
            'response_x' => null
        };

        try {
            WebServiceCallout.invoke(
                this,
                request,
                responses,
                new String[] {
                    this.getEndpoint(),
                    request.getSoapAction(),
                    request.getRequestNamespace(),
                    request.getRequestName(),
                    request.getResponseNamespace(),
                    request.getResponseName(),
                    request.getResponseType().getName()
                }
            );
        } catch(Exception e) {
            // Can do debugging here else remove the Try/Catach
            throw e;
        }

        return responses.get('response_x');
    }
}

public with sharing class ThirdPartyData {

    public with sharing class LocationRequest implements SoapApi.Request {
        public String address;
        @TestVisible String[] address_type_info = new String[] {/* ... */};

        @TestVisible String[] apex_schema_type_info = new String[] {/*...*/};
        @TestVisible String[] field_order_type_info = new String[] {
            'address'
        };

        // SoapApi.Request
        public Type getType() {
            return ThirdPartyData.LocationRequest.class;
        }

        public Type getResponseType() {
            return return ThirdPartyData.LocationResponse.class;
        }

        public Type getMockResponseType() {
            return return ThirdPartyDataMock.LocationResponse.class;
        }

        public String getSoapAction() {
            return String.join(
                new String[] {
                    'https://api.example.com', 
                    'ThirdPartData', 
                    'getLocation'
                }, 
                '/'
            );
        }

        public String getRequestNamespace() {
            return 'ThirdPartyData';
        }

        public String getResponseNamespace() {
            return 'ThirdPartyData';
        }

        public String getResponseName() {
            return 'LocationResponse';
        }
    }

    public with sharing class LocationResponse {
        public String address, city, state, postal;
        @TestVisible String[] address_type_info = new String[] {/* ... */};
        @TestVisible String[] city_type_info = new String[] {/* ... */};
        @TestVisible String[] state_type_info = new String[] {/* ... */};
        @TestVisible String[] postal_type_info = new String[] {/* ... */};

        @TestVisible String[] apex_schema_type_info = new String[] { /*...*/};
        @TestVisible String[] field_order_type_info = new String[] {
            'address', 
            'city', 
            'state', 
            'postal'
        };
    }

    // Instance
    public with sharing class Api extends SoapApi {

        public Integer timeout_x = 60000; // Milliseconds
        public String[] ns_map_type_info = new String[] {
            'https://api.example.com', 'ThirdPartData',
        };
        public Map<String, String> inputHttpHeaders_x;

        public Api() {
            super();
        }

        // SoapApi
        public override String getEndpoint() {
            return 'callout:packagenamepace__ThirdPartData/Api.svc';
        }

        public LocationResponse getLocation(String address) {
            LocationRequest request = new LocationRequest();
            {
                request.address = address;
            }
            return (LocationResponse) this.send(request);
        }
    }
}

@IsTest
public with sharing class ThirdPartyDataMock {

    public with sharing class InvalidMockRequestException extends Exception {}

    public with sharing class LocationResponse extends SoapApi.MockResponse {
        public override Object getResponse(SoapiApi.Request request) {
            if(!(request instanceof ThirdPartyData.LocationRequest)) {
                throw new InvalidMockRequestException(String.format(
                    System.Label.ThirdPartyDataMock_InvalidMockRequestException, // '{0} only supports {1}'
                    new String[] {
                        ThirdPartyDataMock.LocationResponse.class.getName(), 
                        ThirdPartyData.LocationRequest.class.getName()
                    }
                ));
            }

            ThirdPartyData.LocationRequest locationRequest = (ThirdPartyData.LocationRequest) request;

            ThirdPartyData.LocationResponse response = new ThirdPartyData.LocationResponse();
            {
                response.address = address;
                response.city = 'Anytown';
                response.state = 'MI';
                response.postal = '49000';
            }
            return response;
        }
    }
}
  • 2
    I hadn't even thought of that approach. It'd allow me to have separate mocks, which is good. Nice idea. – Mike Chale Nov 5 '13 at 22:00
4

I saw this technique from Andrew a little while back, and it has been a lifesaver for testing complex WebServices. One addition to his answer is that I set the response directly in the mock class e.g.

if(request instanceof MyWebService.GetService_element) {
    MyWebService.GetServiceResponse_element testresponse_x = new MyWebService.GetServiceResponse_element();

    testresponse_x.GetServiceResult = MyMockTestResponses_TESTS.getTestValues();
    response.put('response_x', testresponse_x);
}

Where GetService is the method in the WebService and MyMockTestResponses.getTestValues() is a returning a static JSON/XML/String that has been set previously in my test classes.

1

I have written a generic mock generator class to handle all my mock generation.

Its not perfect, but it has handled every scenario I've come across, including this one.

//Author: Charlie Jonas 
// Description:  Class is use for creating mock http callouts in test methods. 
// See http://www.salesforce.com/us/developer/docs/apexcode/Content/apex_classes_restful_http_testing_httpcalloutmock.htm
// for info on how to use class.

@isTest
global class MockHttpResponseGenerator implements HttpCalloutMock {
    private static final String DEFAULT_CONTENT_TYPE = 'text/xml';

    //require properites
    private Map<String,String> responseMap = new Map<String,String>();  

    //optional
    public String method { get; set;}

    //optional
    public String contentType { get; set;}
    {contentType = DEFAULT_CONTENT_TYPE;}

    //optional 
    public Integer statusCode {get; set;}
    //defaults to success
    {statusCode = 200;}

    //optional 
    public Boolean ignoreQuery {get; set;}
    //defaults to success
    {ignoreQuery = true;}


    public MockHttpResponseGenerator(){}

    public MockHttpResponseGenerator(String endpoint, String response){
        addResp(endpoint,response);
    }

    //add an additional endpoint response pairs
    public void addResponse(String endpoint, String response){
        addResp(endpoint,response);
    }

    // Implement this interface method
    global HTTPResponse respond(HTTPRequest req) {
        // Optionally, only send a mock response for a specific endpoint
        // and method.
        String endpoint = req.getEndpoint();
        if(ignoreQuery){
            endpoint = getBaseUrl(endpoint);
        }
        String response = responseMap.get(endpoint);
        System.assertNotEquals(response, null);

        if(method != null){
            System.assertEquals(method, req.getMethod());
        }

        // Create a fake response
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', contentType);
        res.setBody(response);
        res.setStatusCode(statusCode);
        return res;
    }

    private void addResp(String endpoint, String response){
        if(ignoreQuery){
            endpoint = getBaseUrl(endpoint);
        }
        responseMap.put(endpoint, response);
    }

    private String getBaseUrl(String endpoint){
        Url baseUrl = new URL(endpoint);
        return baseUrl.getHost() + baseUrl.getPath();
    }

}

Testing multiple endpoints with 1 mock:

MockHttpResponseGenerator mockResponse = new MockHttpResponseGenerator();
mockResponse.addResponse(testEndpoint1, testResponseString1);
mockResponse.addResponse(testEndpoint2, testResponseString2);

There are some limitations. All endpoints must currently require the same "options" (Method, Content Type, etc). I have plans to support setting these in each "response", but I haven't come across the need for it yet. Will update this post if I ever do.

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.