コールアウトがあるバッチの単体テストクラス作成に、Apex初心者が挑んだ話 - 実装編

こんにちは、 Takahiroです。先週、ビッフェに行ってきまして、何よりもかぼちゃのプリンがとても美味しくてたくさん食べてしまいました。糖分摂取はバッチリなので、運動もしっかりしたいです。

さて、今回は実装編として、コールアウトがあるバッチの単体テストクラスについて記載します。必要な部分をコピペしてご使用いただければ、大変嬉しいです。また、前回は、Apexの単体テストクラスの基本的な書き方と実行方法について記載しましたので、「単体テストクラスの基礎から知りたい!」という方は、ぜひご覧ください。

5. コールアウトがあるバッチを見てみる

実際の業務上で書いたプログラムは、お見せすることはできませんので、コールアウトがあるサンプルバッチクラスを用意しました。

バッチのそのものの書き方については、私が以前書いた記事が参考になるかと思います。

Apex 知識ゼロの Salesforce 初心者がバッチ開発担当となったが完成できた話

大まかな処理の流れは、以下です。

1. SampleBatch#startでAccountを取得する

2. SampleBatch#executeで各Accountの情報を元にコールアウトを行う

3. レスポンスの結果をAccountに保存する

/** 

 * コールアウトがあるバッチクラスの例 

 */ 

public with sharing class SampleBatch implements Database.Batchable<sObject>, Database.AllowsCallouts { 

 

    public Database.QueryLocator start(Database.BatchableContext bc) { 

        return Database.getQueryLocator('SELECT id, naicsCode FROM account WHERE naicsCode != \'\' ORDER BY name ASC'); 

    } 

 

    public void execute(Database.BatchableContext bcList<Accountaccounts) { 

        // リクエストを実行し、各accountにレスポンス内容をセットする 

        Http http = new Http(); 

        HttpRequest request = new HttpRequest(); 

        request.setMethod('GET'); 

 

        for (Account acc : accounts) { 

            // リクエストを実行し、レスポンスを取得 (実際には存在しないURLです) 

            request.setEndpoint('https://hogehoge.hogehoge/company/' + acc.naicsCode); 

 

            HttpResponse response; 

            try { 

                response = http.send(request); 

                // サンプルとして、ステータスコードは200のみを対象とします 

                if (response.getStatusCode() == 200) { 

                    // レスポンス内容をCompanyInfoにdeserializeする 

                    CompanyInfo info = (CompanyInfoJSON.deserialize(response.getBody(), CompanyInfo.class); 

                    acc.description = createDescription(info); 

                } 

            } catch (Exception e) { 

                // 本来であれば、ログ出力などを行う 

            } 

        } 

 

        update accounts; 

    } 

 

    public void finish(Database.BatchableContext bc) { 

        // 本来であれば、実行結果をオブジェクトに保存するなどの処理をする 

    } 

 

    /** 

     * companyInfoの内容からdescriptionを作成する 

     * @param CompanyInfo 

     * @return String description 

     */ 

    @testVisible 

    private String createDescription(CompanyInfo info) { 

        String description = 'Name: ' + info.name ; 

 

        if (!info.offices.isEmpty()) { 

            description += ' Offices: ' + String.join(info.offices', '); 

        } 

 

        return description; 

    } 

 

    /** 

     * 以下のJsonをdeserializeする時に使用するCompanyInfoクラス 

     * { 

     *   "name" : "test company" 

     *   "offices" : ["office1", "office2"] 

     * } 

     */ 

    private class CompanyInfo { 

        public String name {getprivate set;} 

        public List<Stringoffices {getprivate set;} 

    } 

} 

 
6. 単体テストクラスで実施するテストケースを考える

単体テストクラスを書き始まる前に、どういったテストケースが必要であるかを考えてみましょう。私の考えでは、以下のテストケースが必要であると考えました。

  • #1: Accountが1件の場合、処理できること
  • #2: Accountが複数件の場合、処理できること
    • SampleBatch#startのSOQLで処理対象とならないAccountも初期データとして準備する
    • レスポンス内容によって条件分岐の処理が行われることもテスト内容に含める
  • #3: ステータスコードが200以外の場合、そのAccountは処理されないこと
  • #4: コールアウトした際にExceptionが発生した場合、そのAccountは処理されないこと
  • #5: バッチサイズ(execute内で一度に処理する件数)に指定した件数分のAccountレコードが存在する場合でも、ガバナ制限にあたらずに処理できること
    • 想定されるガバナ制限は、DMLステートメントの合計数(150回)、トランザクション内のコールアウト合計数(100回)、ヒープの合計サイズ(6MB)、CPU時間(10秒)です

ちなみに、これらのテストケースが上げられた理由は、以下です。

  • Accountが単体と複数のどちらのパターンでも期待通りに動作すること(#1と#2)
  • Batch#startのSOQLの条件やレスポンス内容により条件分岐(SampleBatch#createDescriptionの処理)が期待通りに動作すること(#2)
  • レスポンスのステータスコードの条件分岐が期待通りに動作すること(#3)
  • コールアウト時にExceptionが発生した場合、try/catchの処理が期待通りに動作すること(#4)
  • Salesforceのガバナ制限にあたらずに動作すること (#5)
7. テストを効率よく進めるためにTestUtilクラスを作る

上記に書いたテストケースを進める場合、テストを効率よく進めるためにTestUtilクラスが必要になりました。今回は、複数のコールアウトに対して、それぞれ異なるレスポンスを返したり、Exceptionを発生させたりする必要があります。

正直なところ、今までの私のJava開発経験から察すると、レスポンスをモック化する必要があるのは分かるのですが、Apexではどうやったら実現できるのだろう?からのTestUtilクラス作成でした。

ドキュメントとにらめっこして色々と試行錯誤の末、以下のTestUtilクラスを作成しました。詳しい使い方については、次のセクションに書いています。

/** 

 * テスト用のUtilクラス 

 */ 

@isTest 

public class TestUtil { 

 

    /** 

     * HttpCalloutをMockするためのクラス 

     * 以下のように使用する 

     *  TestUtil.HttpCalloutMockImpl httpCalloutMockImpl = new TestUtil.HttpCalloutMockImpl(); 

     *  Map<String, Object> responseBody = new Map<String, Object>(); 

     *  responseBody.put('attribute', 'value'); 

     *  httpCalloutMockImpl.add(TestUtil.getJsonResponseMock(200, responseBody)); // 通常 

     *  httpCalloutMockImpl.add(new TestUtil.HttpResponseMock(new MyException())); // 例外 

     *  Test.setMock(HttpCalloutMock.class, httpCalloutMockImpl); 

     */ 

    public class HttpCalloutMockImpl implements HttpCalloutMock { 

        public List<HttpRequestactualRequests {getprivate set;} 

        public List<HttpResponseMockhttpResponseMocks {getprivate set;} 

 

        /** 呼び出し回数 */ 

        public Integer calloutCount {getprivate set;} 

 

        public HttpCalloutMockImpl() { 

            calloutCount = 0; 

            actualRequests = new List<HttpRequest>(); 

            httpResponseMocks = new List<HttpResponseMock>(); 

        } 

 

        /** 

         * HttpResponseのMockクラスを追加する 

         */ 

        public void add(HttpResponseMock httpResponseMock) { 

            httpResponseMocks.add(httpResponseMock); 

        } 

 

        /** 

         * response内容を設定する 

         * @override 

         */ 

        public HttpResponse respond(HttpRequest actualRequest) { 

            // requestのコピーを記録(必要に応じて追加) 

            HttpRequest copyActualRqeust = new HttpRequest(); 

            copyActualRqeust.setMethod(actualRequest.getMethod()); 

            copyActualRqeust.setEndpoint(actualRequest.getEndpoint()); 

            copyActualRqeust.setBody(actualRequest.getBody()); 

            actualRequests.add(copyActualRqeust); 

 

            HttpResponseMock mockHttpResponse = httpResponseMocks.get(calloutCount); 

            calloutCount += 1; 

            return mockHttpResponse.getHttpResponse(); 

        } 

    } 

 

    /** 

     * HttpResponseをwrapしたクラス 

     */ 

    public class HttpResponseMock { 

        private HttpResponse httpResponse; 

        private Exception e; 

 

        public HttpResponseMock(HttpResponse httpResponse) { 

            this.httpResponse = httpResponse; 

        } 

 

        public HttpResponseMock(Exception e) { 

            this.e = e; 

        } 

 

        public HttpResponse getHttpResponse() { 

            if (e != null) { 

                throw e; 

            } 

            return httpResponse; 

        } 

    } 

 

    /** 

     * HttpResponseMockを取得する 

     */ 

    public static HttpResponseMock getJsonResponseMock(Integer statusCodeMap<StringObjectbody) { 

        HttpResponse httpResponse = new HttpResponse(); 

        httpResponse.setHeader('Content-Type''application/json'); 

        httpResponse.setBody(Json.serialize(body)); 

        httpResponse.setStatusCode(statusCode); 

        return new HttpResponseMock(httpResponse); 

    } 

} 

HttpCalloutMock インターフェースの実装による HTTP コールアウトのテスト | Apex 開発者ガイド | Salesforce Developers

8. 各テストケースを書いてみる

それでは、いよいよテストケースを書いてみましょう。

各テストケースの大まかな処理の流れは、以下です。

1. Accountの初期データを用意する

2. レスポンス内容をモック化する

3. バッチを実行する

4. リクエスト内容が期待値であることをテストする

5. Accountが期待値に更新されていることをテストする

テストクラス内には、各テストケース分のテストメソッドを用意しました。複数のテストメソッドに分ける理由は、バグが発生した場合に、どのテストケースで発生したのかが分かり、バグの特定が容易になるからです。

  • #1: Accountが1件の場合、処理できること

Accountをテストデータとして1件準備して、期待通りになることをテストします。

/** 

 * SampleBatchのテストクラス 

 */ 

@isTest 

private with sharing class SampleBatchTest { 

    // executeメソッド内で一度に処理する件数 

    private static final Integer BATCH_SIZE = 30; 

 

    @TestSetup 

    static void makeData() { 

        // ユーザ作成 

        UserRole testRole = new UserRole(Name = 'testRole'); 

        insert testRole; 

 

        Profile p = [SELECT Id FROM Profile WHERE Name = 'システム管理者' LIMIT 1]; 

        User testUser = new User( 

                        alias = 'test', 

                        email = 'test@hogehoge.hogehoge', 

                        emailEncodingKey = 'UTF-8', 

                        lastName = 'test', 

                        languageLocaleKey = 'ja', 

                        localeSidKey = 'ja_JP', 

                        profileId = p.Id, 

                        userRoleId = testRole.id, 

                        timeZoneSidKey = 'Asia/Tokyo', 

                        userName = 'test@hogehoge.hogehoge'); 

        insert testUser; 

    } 

 

    /** 

     * Accountが1件処理できること 

     */ 

    @isTest 

    private static void testCase1() { 

        // Accountの初期データを用意する 

        List<Accountaccounts = new List<Account>(); 

        accounts.add(new Account(name = 'test000'naicsCode = 'code000')); 

        insert accounts; 

 

        // レスポンス内容を作成する 

        TestUtil.HttpCalloutMockImpl httpCalloutMockImpl new TestUtil.HttpCalloutMockImpl(); 

        Map<StringObjectresponseBody = new Map<StringObject>(); 

        responseBody.put('name''test company000'); 

        responseBody.put('offices'new List<String>{'office001''office002'}); 

        httpCalloutMockImpl.add(TestUtil.getJsonResponseMock(200responseBody)); 

 

        // 実行ユーザ指定 

        User testUser 

            = [SELECT id FROM User WHERE userName = 'test@hogehoge.hogehoge' LIMIT 1]; 

        System.runAs(testUser) { 

            Test.startTest(); 

 

            // レスポンス内容をmock化する 

            Test.setMock(HttpCalloutMock.classhttpCalloutMockImpl); 

 

            // バッチの実行 

            Database.executeBatch(new SampleBatch(), BATCH_SIZE); 

 

            Test.stopTest(); 

        } 

 

        // 各値をテストする 

        // リクエスト 

        System.assertEquals(1httpCalloutMockImpl.calloutCount); 

        HttpRequest actualRequest = httpCalloutMockImpl.actualRequests[0]; 

        System.assertEquals('GET'actualRequest.getMethod()); 

        System.assertEquals( 

            'https://hogehoge.hogehoge/company/code000', 

            actualRequest.getEndpoint()); 

 

        // Account 

        Account actualAccount 

            = [SELECT description FROM Account WHERE id = :accounts[0].id LIMIT 1]; 

        System.assertEquals( 

            'Name: test company000 Offices: office001, office002', 

            actualAccount.description); 

    } 

} 

  • Test.setMock(HttpCalloutMock.class, httpCalloutMockImpl)
    • コールアウトをモック化する場合、HttpCalloutMockを実装したクラスのインスタンスを第2引数に渡します。第1引数は、HttpCalloutMock.classを渡します。
  • #2: Accountが複数件の場合、処理できること

Accountの初期データを複数用意し、naicsCodeに値を設定しないレコードも用意します。また、レスポンス内容も条件分岐の処理も行われるように設定します。以降、テストメソッド部分だけを記載します。

    /** 

     * Accountが複数件処理できること 

     * naicsCodeに値が設定されていないものは、処理対象とならないこと 

     */ 

    @isTest 

    private static void testCase2() { 

        // Accountの初期データを用意する 

        List<Accountaccounts = new List<Account>(); 

        accounts.add(new Account(name = 'test000'naicsCode = 'code000')); 

        accounts.add(new Account(name = 'test001'naicsCode = 'code001')); 

        accounts.add(new Account(name = 'test002'naicsCode =