はじめに
Apexから外部Webサービスを利用するためのHttpコールアウトを実装したクラスがある時、
そのテストにはHttpCalloutMock
インターフェースを実装したクラス(いわゆるモックオブジェクト)が必要です。
ところが、このHttpコールアウトがバッチクラス内で実行されていた場合に
同じようにモックオブジェクトを使ってバッチのテストを書くとうまくいかない、という話です。
サンプルコード
順番に、
- Httpコールアウトを行うクラス
- 1.を呼び出すバッチクラス
- モッククラス+バッチのテストクラス
を実装していきます。
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.setMock
はTest.startTest
の後に記述する
2つ目のポイントの理由は、コールアウトの前にDML処理は実行できないからです。
Test.startTest
によってトランザクションがリセットされ(?)、テストが失敗しなくなります。
テスト結果を見てみる
この状態で、テストクラスCalloutBatchTest
を実行してみましょう。
すると、次のようなエラーが出てテストが失敗していると思います。
System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out
(もしかしたら、テスト結果には表示されないけどデバッグログを見ると上記のエラーが確認できる、
という状態になっているかもしれません)
何が原因か?
テストが失敗する原因については、以下のサイトに面白いことが書いてありました。
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
apex - Testing a combination of webservice callouts and inserts - Salesforce Stack Exchange