HTTPコールアウトのテストを書いてみよう

はじめに

みなさん、こんにちは。
走れるシステムエンジニア、溝口です。


3月13日の横浜マラソンに向けて少しずつ準備をしています。今週末には千葉マリンハーフマラソンがあったり、来月には浦安シティハーフマラソン、再来月の頭には三浦国際ハーフマラソンがあったりと、中々ハードなスケジュールとなっております。

どこかのハーフで70分は切って、本番のフルマラソンでは2時間30分を切りたいですね。

コールアウトのテストって

さてさて、今回の本題に参りましょう。

最近はSalesforceの中だけで完結せず、Apexで外部のAPIとデータ連携をするような機会が増えて来ているかと思います。(AmazonRDSのデータを取得したり、Twitterのタイムラインの情報を取得したり・・・)

この場合、通常ではApexでHTTPコールアウトの処理を実装し、外部からデータを取得することになると思います。

では、実際にこのような処理を実装した後のテストクラスってどうやって書くのでしょうか?

みなさんご存知の通り、ApexのテストクラスからはHTTPコールアウトが実行出来ないので、外部のAPIをテストとして使うことは出来ません。
かと言って、コールアウトのテストをすっ飛ばすことも出来ません(出来たとしてもコードをカバー出来ませんし、品質の観点からも推奨出来ません)。

しかーし!
安心してください、用意されてますよ!

Apexでは標準でコールアウトのテスト用にHttpCalloutMockという便利なインターフェイスが用意されています。
今回はこれを使って簡単なアプリのテストを書いてみましょう。

コールアウトを実行するアプリの準備

コールアウトのテストをする前に、まずその元となるアプリケーションを構築しましょう。

今回はlivedoor天気情報(
http://weather.livedoor.com/weather_hacks/webservice)のAPIを使用して、
明日の天気の
JSONデータを取得し、Visualforceで表示するだけの簡単なアプリを作成します。

/**
 * WeatherReportHTTPRequest
 *
 * @author Daichi Mizoguchi
 * @description livedoor天気予報と通信を行うクラス
 *
 */
public with sharing class WeatherReportHTTPRequest {
   /**
    * @author Daichi Mizoguchi
    * @description livedoor天気予報からJSONデータを取得する
    * @param cityCode 都市コード
    * @return 天気予報データ
    */
    public static WeatherReport getWeatherReport(String cityCode) {
        HttpRequest req = new HttpRequest();
        req.setMethod('GET');
        req.setEndpoint('http://weather.livedoor.com/forecast/webservice/json/v1?city=' + cityCode);

        try {
            Http http = new Http();
            HTTPResponse res = http.send(req);

            System.debug(LoggingLevel.INFO, 'STATUS:' + res.getStatus());
            System.debug(LoggingLevel.INFO, 'STATUS_CODE:' + res.getStatusCode());
            System.debug(LoggingLevel.INFO, 'BODY:' + res.getBody());

            WeatherReport result = new WeatherReport(res.getBody());
            return result;
        } catch(System.CalloutException e) {
            System.debug(LoggingLevel.INFO, 'ERROR:' + e.getMessage());
            return null;
        }
    }
}
livedoor天気情報と通信を行う為のクラスです。
後述する天気情報データクラスに取得した天気予報のJSONデータを渡します。

/**
 * WeatherReport
 *
 * @author Daichi Mizoguchi
 * @description 天気予報データクラス
 *
 */
public with sharing class WeatherReport {
    // タイトル
    public String title { get; set; }
    // 天気
    public String weather { get; set; }
    // 天気の詳細
    public String description { get; set; }
    // 最高気温
    public Decimal maxTemperature { get; set; }
    // 最低気温
    public Decimal minTemperature { get; set; }

    /**
     * @description コンストラクタ
     * @param jsonData 天気予報のJSONデータ
     */
	public WeatherReport(String jsonData) {
        // タイトルをパース
        Map<String, Object> responseMap = (Map<String, Object>)JSON.deserializeUntyped(jsonData);
		this.title = responseMap.get('title') != null ? (String)responseMap.get('title') : '';

        // 天気をパース
        List<Object> weatherDataList = (List<Object>)responseMap.get('forecasts');
        // リストの1番目が今日の天気、2番目が明日の天気
        Map<String, Object> weatherTommorowMap = (Map<String, Object>)weatherDataList[1];
        this.weather = weatherTommorowMap.get('telop') != null ? (String)weatherTommorowMap.get('telop') : '';

        // 天気の詳細をパース
        Map<String, Object> descriptionMap = (Map<String, Object>)responseMap.get('description');
        this.description = descriptionMap.get('text') != null ? (String)descriptionMap.get('text') : '';

        // 最高気温、最低気温をパース
        Map<String, Object> temperatureDataMap = (Map<String, Object>)weatherTommorowMap.get('temperature');
        Map<String, Object> temperatureMaxMap = (Map<String, Object>)temperatureDataMap.get('max');
        Map<String, Object> temperatureMinMap = (Map<String, Object>)temperatureDataMap.get('min');
        String maxTemperatureString = temperatureMaxMap.get('celsius') != null ? (String)temperatureMaxMap.get('celsius') : '0.0';
        String minTemperatureString = temperatureMinMap.get('celsius') != null ? (String)temperatureMinMap.get('celsius') : '0.0';
        this.maxTemperature = Decimal.valueOf(maxTemperatureString);
        this.minTemperature = Decimal.valueOf(minTemperatureString);
	}
}
WeatherReportHTTPRequestで取得したJSONデータを受け取ってパースし、各プロパティにセットします。

/**
 * WeatherReportController
 *
 * @author Daichi Mizoguchi
 * @description 天気予報表示画面コントローラクラス
 *
 */
public with sharing class WeatherReportController {
    // 天気予報データ
    public WeatherReport weatherData { get; set; }

    /**
     * @description コンストラクタ
     */
	public WeatherReportController() {
        // 130010=東京 でデータを取得し、セット
		this.weatherData = WeatherReportHTTPRequest.getWeatherReport('130010');
	}
}
天気予報画面のコントローラです。livedoor天気情報から取得した天気情報データをセットします。
今回は東京の天気予報のみを取得するようにしています。

<apex:page showHeader="false" sidebar="false" controller="WeatherReportController">
    <head>
        <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <div class="container" id="main">
        <div class="table-responsive">
            <table class="table table-striped">
                <thead>
                    <tr>
                        <th><apex:outputText value="タイトル" /></th>
                        <th><apex:outputText value="明日の天気" /></th>
                        <th><apex:outputText value="詳細" /></th>
                        <th><apex:outputText value="最高気温" /></th>
                        <th><apex:outputText value="最低気温" /></th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td><apex:outputText value="{!weatherData.title}" /></td>
                        <td><apex:outputText value="{!weatherData.weather}" /></td>
                        <td><apex:outputText value="{!weatherData.description}" /></td>
                        <td><apex:outputText value="{!weatherData.maxTemperature}" /></td>
                        <td><apex:outputText value="{!weatherData.minTemperature}" /></td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</apex:page>
天気予報表示用のVisualforceページです。


では、このアプリを実際にうごかしてみましょう




わぁ・・・。

なんとも味気ない画面が登場しましたが、一応は動いています。


実際に返されるJSONデータに関しては、
http://weather.livedoor.com/forecast/webservice/json/v1?city=130010
にアクセスすることで確認出来ます。
では、このアプリを使ってHTTPコールアウトのテストを書いていきましょう。

コールアウトのテストを書いてみよう

まず用意しなければならないのが、HttpCalloutMockを実装したテストクラスです。
基本的な実装方法はリファレンス(https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_classes_restful_http_testing_httpcalloutmock.htm)に載っていますので、参照してみて下さい。
実際に今回作成したアプリでテストを書くと、このようになります。

/**
 * WeatherReportHTTPRequestMock
 *
 * @author Daichi Mizoguchi
 * @description livedoor天気予報と通信を行うクラスのモッククラス
 *
 */
@isTest
global class WeatherReportHTTPRequestMock implements HttpCalloutMock {
    // エラー発生フラグ
    public Boolean errorFlg { get; set; }

    /**
     * @author Daichi Mizoguchi
     * @description コンストラクタ
     * @param errFlg エラー発生フラグ
     *
     */
    public WeatherReportHTTPRequestMock(Boolean errFlg) {
        this.errorFlg = errFlg;
    }

   /**
    * @author Daichi Mizoguchi
    * @description テストで返すレスポンスを設定する
    * @param req HTTPリクエストが渡される
    * @return 天気予報データのテストデータ
    */
    global HTTPResponse respond(HTTPRequest req) {
        // エラーフラグがtrueだった場合は通信エラーを投げる
        if (this.errorFlg) {
            throw new System.CalloutException('通信エラー');
        }

        // 天気予報データ取得APIの呼び出しである
        System.assert(req.getEndpoint().contains('http://weather.livedoor.com/forecast/webservice/json/v1?city='));
        System.assertEquals('GET', req.getMethod());

        // レスポンスデータ
        Map<String, Object> responseMap = new Map<String, Object>();

        // タイトルデータ
        responseMap.put('title', '東京');

        // 天気データ
        List<Object> weatherDataList = new List<Object>();
        Map<String, Object> weatherTodayMap = new Map<String, Object>();
        Map<String, Object> weatherTommorowMap = new Map<String, Object>();
        weatherTommorowMap.put('telop', '晴れ');

        // 気温のテストデータ
        Map<String, Object> temperatureDataMap = new Map<String, Object>();
        Map<String, String> temperatureMaxMap = new Map<String, String>();
        Map<String, String> temperatureMinMap = new Map<String, String>();
        temperatureMaxMap.put('celsius', '10');
        temperatureMinMap.put('celsius', '1');
        temperatureDataMap.put('max', temperatureMaxMap);
        temperatureDataMap.put('min', temperatureMinMap);
        weatherTommorowMap.put('temperature', temperatureDataMap);

        weatherDataList.add(weatherTodayMap);
        weatherDataList.add(weatherTommorowMap);
        responseMap.put('forecasts', weatherDataList);

        // 詳細のテストデータ
        Map<String, String> descriptionMap = new Map<String, String>();
        descriptionMap.put('text', '詳細のテストデータ');
        responseMap.put('description', descriptionMap);


        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        // JSONデータとしてBodyにセット
        res.setBody(JSON.serialize(responseMap));
        res.setStatusCode(200);
        return res;
    }

}
HttpCalloutMock、という名前の通り、本来呼ぶべきAPIをそのまま肩代わりするような動きを実装します。
HTTPリクエストに対し、実際に返される天気予報のデータと同じようなJSONデータを返すように実装しています。

また、通信エラー時のテストの為に、本クラスのプロパティにエラーを強制的に発生させるフラグも設けています。


次にこのモッククラスをテストクラスで読み込みましょう。
/**
 * WeatherReportControllerTest
 *
 * @author Daichi Mizoguchi
 * @description 天気予報表示画面テストクラス
 *
 */
@isTest
public class WeatherReportControllerTest {
   /**
    * @author Daichi Mizoguchi
    * @description 表示テスト
    */
    @isTest static void initViewLoadTest() {
        // コールアウトクラスのモッククラスをセット
        Test.setMock(HttpCalloutMock.class, new WeatherReportHTTPRequestMock(false));
        Test.startTest();
        WeatherReportController cntl = new WeatherReportController();
        Test.stopTest();
        // モックでセットしたデータが取得出来ることを確認
        System.assertEquals(cntl.weatherData.title, '東京');
        System.assertEquals(cntl.weatherData.weather, '晴れ');
        System.assertEquals(cntl.weatherData.description, '詳細のテストデータ');
        System.assertEquals(cntl.weatherData.maxTemperature, 10);
        System.assertEquals(cntl.weatherData.minTemperature, 1);
    }

   /**
    * @author Daichi Mizoguchi
    * @description 表示テスト(エラー)
    */
    @isTest static void initViewLoadErrorTest() {
        // コールアウトクラスのモッククラスをセット
        Test.setMock(HttpCalloutMock.class, new WeatherReportHTTPRequestMock(true));
        Test.startTest();
        WeatherReportController cntl = new WeatherReportController();
        Test.stopTest();
        // 通信エラーにより、天気予報データが取得出来ないことを確認
        System.assertEquals(cntl.weatherData, null);
    }
}
Test.setMock()メソッドの第二引数に、先ほど作成したモッククラスのインスタンスを引き渡します。
これにより、今後のテストで実行されるコールアウトの処理はモッククラス側で実行されます。


ポイントとしては、
  • 本番データと同じようなデータを返すHttpCalloutMockを実装したテストクラスを作成
  • テストクラスでTest.setMock()を実装

の2点だけなので、非常に簡単ですね!

まとめ

AWSの隆盛や、最近ではIoTも流行り始めていて、外部とのデータ連携は今後もどんどん案件が増えて来る思います。

「外部APIとのデータ連携のテスト作成」と聞くとちょっと尻込みしてしまう気もしますが、実際に作ってみるとSalesforceには簡単にテスト出来る仕組みが揃っているので、みなさんも是非活用していきましょう!