dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

[Salesforce]Httpコールアウトを行うバッチのテスト

はじめに

Apexから外部Webサービスを利用するためのHttpコールアウトを実装したクラスがある時、
そのテストにはHttpCalloutMockインターフェースを実装したクラス(いわゆるモックオブジェクト)が必要です。

ところが、このHttpコールアウトがバッチクラス内で実行されていた場合に
同じようにモックオブジェクトを使ってバッチのテストを書くとうまくいかない、という話です。

サンプルコード

順番に、

  1. Httpコールアウトを行うクラス
  2. 1.を呼び出すバッチクラス
  3. モッククラス+バッチのテストクラス

を実装していきます。

Httpコールアウトを行うクラス

まず、Httpコールアウトで外部Webサービスを呼び出すクラスです。
コールアウトの内容は、以前の記事で書いたGitHubと連携してIssueを登録するというものです。

public class CalloutClass {

    private static String USER_NAME = '*****Your GitHub username here*****';
    private static String PASSWORD  = '*****Your GitHub password here*****';
    private static String REPO_NAME = '*****Your GitHub repository name here*****';

    public static HttpResponse executeCallout(Map<String, String> request) {

        HttpRequest req = new HttpRequest();

        req.setMethod('POST');
        // Set Callout timeout
        req.setTimeout(60000);

        // Set HTTPRequest header properties
        req.setHeader('Connection','keep-alive');
        req.setEndpoint('https://api.github.com/repos/' + USER_NAME + '/' + REPO_NAME + '/issues');
        // Basic Authentification
        Blob headerValue = Blob.valueOf(USER_NAME + ':' + PASSWORD);
        String authorizationHeader = 'Basic ' + EncodingUtil.base64Encode(headerValue);
        req.setHeader('Authorization', authorizationHeader);

        // Set HTTPRequest body
        req.setBody(JSON.serialize(request));

        Http http = new Http();
        return http.send(req);
    }
}

バッチクラス

続いて、このコールアウトを行うクラスを呼び出すバッチクラスです。

public class CalloutBatch implements Database.Batchable<SObject>, Database.Stateful, Database.AllowsCallouts {

    public Database.QueryLocator start(Database.BatchableContext bc) {
        // Date.today() はローカルタイムゾーン
        // CreatedDateはUTCで格納されている
        Date today = Datetime.now().dateGMT();
        return Database.getQueryLocator(String.join(new List<String> {
            'SELECT',
                'Subject,',
                'Priority,',
                'Description',
            'FROM',
                'Case',
            'WHERE',
                'CreatedDate >= :today',
            'AND',
                'Status != \'Closed\''
        }, ' '));
    }

    public void execute(Database.BatchableContext bc, List<Case> caseList) {
        for (Case c : caseList) {
            Map<String, String> request = new Map<String, String> {
                'title' => '[' + c.priority + ']' + c.subject,
                'body'  => c.description
            };
            // Httpコールアウトを行うクラスを呼び出す
            HttpResponse res = CalloutClass.executeCallout(request);

            if (res.getStatusCode() == 201) {
                // リクエストが正常終了した場合
                c.status = 'Closed';
                update c;
                System.debug(LoggingLevel.INFO, 'Issue was created successfully.');
                System.debug(LoggingLevel.INFO, 'STATUS:' + res.getStatus());
                System.debug(LoggingLevel.INFO, 'BODY:' + res.getBody());
            }
        }
    }

    public void finish(Database.BatchableContext bc) {
    }
}

ポイント

  • Database.AllowCalloutsインターフェースをimplementsする

これを実装しないと、以下のようにSystem.LimitExceptionが発生します。

System.LimitException: Too many callouts: 1

モッククラス+バッチのテストクラス

ここから上記のバッチのテストクラスを書きます。
まず、Httpコールアウトのテストに必要なモッククラスです。

モッククラスを使ったHttpコールアウトのテストについては
こちらが参考になります。

SFDC:HTTP Calloutsのテスト - tyoshikawa1106のブログ

@isTest
global class CalloutMockImpl implements HttpCalloutMock {

    global HttpResponse respond(HttpRequest req) {

        System.assertEquals('POST', req.getMethod());

        HttpResponse res = new HttpResponse();
        res.setHeader('Content-type', 'application/json');
        res.setStatusCode(201);
        res.setStatus('Created');
        // 本当はボディもそれっぽい値を返すべきですが...
        res.setBody('{"foo": "bar"}');

        return res;
    }
}

ポイント:

  • HttpCalloutMockインターフェースをimplementsする
  • モックにはHttpResponseクラスを返すrespondメソッドを実装する

続いてテストクラス。

@isTest
public class CalloutBatchTest {

    public static testMethod void testExecute() {

        Case c = new Case(
            Subject = 'Sample Case',
            Description = 'This is a sample case to insert',
            Priority = 'Low'
        );
        insert c;

        Map<String, String> request = new Map<String, String> {
            'title' => '[' + c.priority + ']' + c.subject,
            'body'  => c.description
        };
        Test.startTest();
        Test.setMock(HttpCalloutMock.class, new CalloutMockImpl());
        Database.executeBatch(new CalloutBatch(), 1);
        Test.stopTest();

        // assertion
        List<Case> results = [SELECT IsClosed, Status FROM Case];
        System.assertEquals(1, results.size());
        System.assertEquals(true, results[0].isClosed);
    }
}

ポイント

  • コールアウトを行うクラスの前に、Test.setMockメソッドを記述する
  • Test.setMockTest.startTestの後に記述する

2つ目のポイントの理由は、コールアウトの前にDML処理は実行できないからです。
Test.startTestによってトランザクションがリセットされ(?)、テストが失敗しなくなります。

テスト結果を見てみる

この状態で、テストクラスCalloutBatchTestを実行してみましょう。
すると、次のようなエラーが出てテストが失敗していると思います。

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

(もしかしたら、テスト結果には表示されないけどデバッグログを見ると上記のエラーが確認できる、
という状態になっているかもしれません)

何が原因か?

テストが失敗する原因については、以下のサイトに面白いことが書いてありました。

IdeaExchange

The reason is that Salesforce performs inner insert operation to AsyncApexJob object which prevents from execution of callout mock with error:

訳)バッチ処理を実行した時、Salesforceは内部的にAsyncApexJobをinsertする。
そのため、コミットしてないDML処理の後にコールアウトを行ったとみなされて、You have uncommitted...が発生する。

といった感じでしょうか。
ちょっと説得力があります。

(ただ、もしそうだとテストじゃなくてもバッチはエラーになる気がしますが、実際にはそうではありません。。。)

コードを修正する

きちんとした理由は不明のままですが、
とりあえずテストがこけないようにコードを修正します。

【修正版】Httpコールアウトを行うクラス

public class CalloutClass {

    private static String USER_NAME = '*****Your GitHub username here*****';
    private static String PASSWORD  = '*****Your GitHub password here*****';
    private static String REPO_NAME = '*****Your GitHub repository name here*****';

    // ********モッククラス***********
    public static HttpCalloutMock mock = null;

    public static HttpResponse executeCallout(Map<String, String> request) {

        System.debug(LoggingLevel.INFO, mock);
        HttpRequest req = new HttpRequest();

        req.setMethod('POST');
        // Set Callout timeout
        req.setTimeout(60000);

        // Set HTTPRequest header properties
        req.setHeader('Connection','keep-alive');
        req.setEndpoint('https://api.github.com/repos/' + USER_NAME + '/' + REPO_NAME + '/issues');
        // Basic Authentification
        Blob headerValue = Blob.valueOf(USER_NAME + ':' + PASSWORD);
        String authorizationHeader = 'Basic ' + EncodingUtil.base64Encode(headerValue);
        req.setHeader('Authorization', authorizationHeader);

        // Set HTTPRequest body
        req.setBody(JSON.serialize(request));

        Http http = new Http();
        HttpResponse res;

        if (Test.isRunningTest() && (mock != null)) {
            // ********テスト時はこちらが実行される*********
            res = mock.respond(req);
        } else {
            res = http.send(req);
        }
        return res;
    }
}

staticフィールドにモックを用意しつつ、テスト実行時はモッククラスを利用するように切り替えるという方法です。

【修正版】テストクラス

@isTest
public class CalloutBatchTest {

    public static testMethod void testExecute() {

        Case c = new Case(
            Subject = 'Sample Case',
            Description = 'This is a sample case to insert',
            Priority = 'Low'
        );
        insert c;

        Map<String, String> request = new Map<String, String> {
            'title' => '[' + c.priority + ']' + c.subject,
            'body'  => c.description
        };
        Test.startTest();
        // *********Test.setMockのかわりにこれを使う***********
        CalloutClass.mock = new CalloutMockImpl();
        Database.executeBatch(new CalloutBatch(), 1);
        Test.stopTest();

        // assertion
        List<Case> results = [SELECT IsClosed, Status FROM Case];
        System.assertEquals(1, results.size());
        System.assertEquals(true, results[0].isClosed);
    }
}

バッチ実行前にモックをセットするわけですね。

他にもいくつか方法はあると思いますが、個人的にこの書き方が好きなのは
テスト内容によってモックを切り替えるという作業がテストクラス側からだけで行えるという点です。

さらなる落とし穴に気づく

テスト内でトリガが実行されちゃう場合も要注意です。
あまり詳しく調査できてませんが、非同期で走る処理が実装されたトリガなどが
テストデータのinsert時などに実行されてしまうと
修正後のコードでもYou have uncommitted...が発生してしまうかもしれません。

おわりに

というわけで、Httpコールアウトを行うクラスのテストを書く場合、
普通は公式リファレンスなどに載ってるような方法でモックを利用して問題ないですが、
コールアウトがバッチから実行されている場合は以下のような点に少し気をつける必要がありますね。

  • コールアウト呼び出しクラスはテスト実行時と通常実行時とで処理を分岐させる
  • テストクラスではTest.setMockは使わない

リファレンス

ここの真ん中あたりにでてきた方法で実装した unit test - Testing HttpCallout with HttpCalloutMock and UnitTest Created Data - Salesforce Stack Exchange

Testing Batch Job with HTTP Callouts - Salesforce Stack Exchange

理由らしきことが書かれていたのはこちら IdeaExchange

Testing Webservice Callouts with Winter '13 Test.setMock fails because of Test Data creation. - Salesforce Developer Community

IdeaExchange

apex - Testing a combination of webservice callouts and inserts - Salesforce Stack Exchange