Test schedulable class with future callout method

calloutfuturescheduled-apexunit-test

I'm trying to run this test and it keeps failing. The error I'm getting is "System.QueryException: unexpected token: 'null'" so it's related to my lstContacts SOQL query, but I can't figure out what's going. Works fine when I'm running the callout in the execute anonymous window and elsewhere.

Callout:

public class FilastCallout implements Schedulable {
    
    public void execute(SchedulableContext ctx) {
        makePostCallout();
    }
    
    @future(callout=true) public static void makePostCallout() {
        
        
        // Get custom settings
        Filast_Settings__c fu = Filast_Settings__c.getOrgDefaults();
        
        List<Contact> lstContacts = Database.query('SELECT '+fu.Contact_First_Name__c+','+fu.Contact_Last_Name__c+','+fu.Contact_Email__c+',AccountId,Account.'+fu.Account_Name__c+' FROM Contact WHERE '+fu.Contact_Email__c+' !=null AND '+fu.Contact_First_Name__c+' !=null');
        
        // Transform list ot json and add advanced settings
        String jsonS = JSON.serialize(new Map<String, Object> {
            'min_contacts' => fu.Minimum_Contacts__c,
            'pct_syntax' => fu.Minimum_Pct_Same_Syntax__c,
            'acct_fuzzy' => fu.Account_Fuzzy_Match__c,
            'contacts' => lstContacts
        });
        
        // Create api request and send jsonified data
        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setEndpoint('https://j9cf5hum55.execute-api.us-east-1.amazonaws.com/default/Filast');
        request.setMethod('POST');
        request.setHeader('Content-Type', 'application/json');
        // Set the body as a JSON object
        request.setBody(jsonS);
        HttpResponse response = http.send(request);
        
        // Convert json response back to list of accounts
        JSONParser parser = JSON.createParser(response.getBody());
        
        List<Account> lstAccounts = new List<Account>();
        
        while (parser.nextToken() != null) {
            if (parser.getCurrentToken() == JSONToken.START_OBJECT) {
                    Account a = (Account)parser.readValueAs(Account.class);
                    lstAccounts.add(a);
                }
        }
        
        //Update accounts
        update lstAccounts;
        
    }
}

Mock:

@isTest
public class CalloutMock implements HttpCalloutMock {
    public HttpResponse respond(HttpRequest request) {
        
            // Create a fake response
            HttpResponse response = new HttpResponse();
            response.setHeader('Content-Type', 'application/json');
            response.setBody('[{"Id": "0015f000004htPSAAY", "Filast_Email_Syntax__c": "[email protected]"}, {"Id": "0015f000004htPTAAY", "Filast_Email_Syntax__c": "[email protected]"}]');
            response.setStatusCode(200);
            return response;
    }
}

Test Class:

@isTest
public class FilastCalloutTest {

    @isTest
    public static void makePostCalloutTest() {
        Test.setMock(HttpCalloutMock.class, new CalloutMock());
        Test.startTest();
          FilastCallout.makePostCallout();
        Test.stopTest();
    }
}

Best Answer

tl;dr:

  • You need to explicitly create data for your test (Sobjects like Account, and Custom Settings)
  • You shouldn't hard-code Ids anywhere in a test (so you need to adjust your HttpCalloutMock class)
  • You should use JSON.deserialize() instead of using JSONParser

The longer version

In unit testing, we can generally cause one (and only one) level of asynchronous code to be executed.

When I say "asynchronous code", I mean one of "Schedulable", "Queueable", "@future", or "batchable".

In unit testing, we shouldn't be concerned about how the asynchronous code is run. It's something we don't have control over. We should trust that Salesforce is handling it appropriately (Salesforce should be the ones writing the tests for that feature).So in general, we should be calling the asynchronous code ourselves, directly, when running unit tests.

In that regard, it looks to me like you're doing that appropriately. We can't call @future methods directly (and cause them to run synchronously without using Test.startTest() and Test.stopTest()), but you are calling the method as directly as you can. I might suggest breaking out the actual work into a different static method (which your @future method, or any other async code, would call), but that's not usually a big issue until you have multiple levels of async code (async code which calls other async code).

The issue that I see here is that you aren't doing any data setup in your test. That, and you're hard-coding an Id in your callout mock.

Tests generally have 3 phases:

  1. Set up test data (set up the "test environment"), a.k.a. "Arrange"
  2. Cause the code you want to test to be executed, a.k.a. "Act"
  3. Gather the results, and compare them to expected values, a.k.a. "Assert"

In tests, you generally don't have access to data (tests are isolated from "real" data, and real data is isolated from tests), except for "setup objects" like User, Profile, Custom Metadata Type records, and a handful of other things. If you need data inside of a test, you need to create that data in the test class.

Notably, Custom Settings (both List and Hierarchy types) are not setup objects. You'll need to create and insert that data in your test, along with at least one Account and one Contact (presumably related to your test Account) so that your query has some data to return, and the Contact has some data for your later DML statement to update the Account(s).

You'll want to modify your HttpCalloutMock implementation to be able to take (and only use) Account Ids from the Account(s) that you create as part of the test.

There is a way to use "real" data in tests, but it's generally not advised (unless you absolutely need it, which you don't in this case). Ids are effectively guaranteed to be different between each of your sandboxes, and between any given sandbox and your "production" org (the exception being full and partial copy sandboxes). Hard-coding Ids and using "real" data makes your tests brittle and likely to fail when you try to run them in other orgs.

As a final note, you generally don't want to be using JSONParser. It's very verbose, prone to (programmer) errors, and we have a better way to deal with JSON. You should either create an Apex class (or classes) to mimic the structure of the JSON you're expecting (which, by the look of it, is entirely possible in your case) so you can use JSON.deserialize(jsonString, apexClassType), or use JSON.deserializeUntyped() to get something that you can cast to a Map<String, Object> or a List<Object>.

Related Topic