2017.11.29

Apex スタブ API を使用してテストクラスを作成する

  • このエントリーをはてなブックマークに追加
  • follow us in feedly
みなさん、おはようございます。
エンジニアの高橋です。2回目の投稿になります。

今回は、spring' 17 でリリースされました「Apex スタブ API」を使用してテストクラスを作成する についてお話します。
「Apex スタブ API」「テストクラス」とそれぞれ記事はありますが、両方を組み合わせて且つサンプルを踏まえた記事がありませんでしたので書いてみた次第です。

対象は、業務で Force.com 開発に関わり始め、テストクラスの作成にこなれてきた人向けになります。

そもそもテストでスタブが必要な場面とは?

スタブが必要な場面を考えてみます。
筆者の経験になりますが、Force.com の開発をすると、作成される Apexクラスが下記のような階層を持っていたりすることが結構あります。また、持っていた場合は階層ごとに役割分担があったりします。

画面イベントのシーケンス図

画面からデータベースまでアクセスする一例
階層役割実装難易度
controllerクラス画面遷移を制御簡単に終わる
サービスクラスビジネスロジックを記述結構かかる
DAOクラスデータ取得Apex なら簡単に終わる
スタブが必要とされる場面は、メソッドの処理に依存先のクラスが存在し、依存先クラスから呼び出すメソッドが複雑な処理をしている、または、依存先クラスから呼び出すメソッドが未完成で開発に時間がかかる場合になります。
上のシーケンス図では controllerクラスのメソッド1 の部分が該当します。(赤枠の部分)
また、テスト対象クラスは決まっているはずなのに、現在のテスト対象ではないクラスのテストまでを実装するのは変な話です。
依存先のクラスのテストは依存先のテストクラスで実装すべきです。切り分けが大事です。
以上の観点からスタブの利用は有用と言えます。
実際にサンプルを示します。

サンプルコード

サンプルとして、記事の公開日が11/29 と「いい肉の日」ということもあり、Web画面から牛の識別番号を入力して「検索」ボタンを押したら、その牛の焼肉にした時にお勧めの部位を返すアプリケーションを考えてみます。

牛の部位の検索画面

画面上の「検索」ボタンのイベントを受けるコントローラクラスの実装が下記になります。
/** 牛検索画面のコントローラクラス. */
public with Sharing class CattleSearchController {
    
    /** 個体識別番号 */
    public String cattleIDNo {get; set;}
    
    /** 牛の情報を検索するサービスクラス(非常に複雑な処理をする) */
    private CattleSearchService service;
    
    public CattleSearchController() {
        service = new CattleSearchService();
    }
    
    /**
     * 入力された個体識別番号から牛情報を検索し、牛肉の部位の紹介ページを返します.
     * @return ページリファレンスを返します.
     */
    public PageReference search() {
        System.debug('###個体識別番号 : ' + cattleIDNo);
        try {
            /*
             * 個体識別番号をもとに、給餌飼料や肥育農家名などの農家情報と
             * 牛の出生地、肥育場所、肥育日数や独自の調査結果と計算式を合わせ、
             * いろいろ処理した結果、最終的に焼肉をするに評価が高かった部位
             * 「カルビ」「ロース」「ハラミ」のいずれかを返すメソッドを呼び出す.
             */
            CattleSearchService.BeefPart part = service.getRecommandation(cattleIDNo);
            if(part == CattleSearchService.BeefPart.Ribs) {
                // 焼肉:カルビの特集ページ
                return Page.BeefRecommendRibs;
            } else if (part == CattleSearchService.BeefPart.Loin) {
                // 焼肉:ロースの特集ページ
                return Page.BeefRecommendLoin;
            } else if (part == CattleSearchService.BeefPart.SkirtSteak) {
                // 焼肉:ハラミの特集ページ
                return Page.BeefRecommendSkirtSteak;
            } else {
                ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.INFO, '該当の牛は見つかりませんでした.'));
                return null;
            }
        } catch (Exception ex) {
            // システムエラー
            ApexPages.addMessage(new ApexPages.Message(ApexPages.Severity.ERROR, ex.getMessage()));
            return Page.SystemError;
        }
    }
}
CattleSearchController
上記の CattleSearchControllerクラス search メソッドに対して、テストクラスを用意します。
/** CattleSearchController のテストクラス(Apex スタブ API 未使用) */
@isTest
private class CattleSearchControllerTest {

    @testSetup
    static void setup() {
        //
        // テストデータ:牛さんのデータをいろいろ登録. たぶん大変.
        //
    }

    /**
     * ケース:検索した結果、カルビの紹介ページを返す.
     */
    @isTest
    static void testSearch01() {
        PageReference pageRef = Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000001'; // カルビをお勧めする牛の識別番号をセット.

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals('/apex/beefrecommendribs', pr.getUrl());
    }

    /**
     * ケース:検索した結果、ロースの紹介ページを返す.
     */
    @isTest
    static void testSearch02() {
        PageReference pageRef = null;// Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000002'; // ロースをお勧めする牛の識別番号をセット.

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals('/apex/beefrecommendloin', pr.getUrl());
    }

    /**
     * ケース:検索した結果、ハラミの紹介ページを返す.
     */
    @isTest
    static void testSearch03() {
        PageReference pageRef = null;// Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000003'; // ハラミをお勧めする牛の識別番号をセット.

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals('/apex/beefrecommendskirtsteak', pr.getUrl());
    }

    /**
     * ケース:検索した結果、牛が未登録のページを返す.
     */
    @isTest
    static void testSearch04() {
        PageReference pageRef =  null;// Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000004'; // テストデータとして登録していない番号をセット.

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals(null, pr);
    }
}
CattleSearchControllerTest.cls
サービスクラスのメソッドの実装の進捗も聞かずに、テストクラスを実行してみると・・・。

テスト実行結果(Apex スタブ API 未使用版)

まさかの全てのテストメソッドで Fail です。テストを実行したユーザが「へ?」と思う場面です。

ここで、 testSearch01 の詳細を確認してみます。

テスト実行結果 エラー詳細

Apex スタブ API 未使用版
実際の値に 例外発生時のシステムエラーのページが返されています。テストを実行したユーザが頭をひねる場面です。

訝しげに CattleSearchServiceクラスをのぞいてみると・・・
/** 牛の検索サービスクラス. */
public with Sharing class CattleSearchService {

    /** 牛肉の部位:カルビ、ロース、ハラミ */
    public enum BeefPart {Ribs, Loin, SkirtSteak}

    /**
     * 入力された個体識別番号から牛情報を検索し、食肉としてお勧めの部位を返します.
     * @param cattleIdNo 牛の個体識別番号(10桁)
     * @return 牛肉の部位を返します. 列挙型で定義した以外の値は返しません.
     */
    public BeefPart getRecommandation(String cattleIdNo) {
        throw new NotImplementedException('実装完了は 2017/12/24');
    }

    /** 未実装の例外クラス. */
    public class NotImplementedException extends Exception {}
}
CattleSearchService.cls
ご丁寧に 完成日が 12/24 とあります。
今日が 11/29 であるならば 約 3週間待たされることになります。(完成するのは日曜日かい!ってツッコミは置いておき)

たいていのマネージャーなら別作業の指示が来るでしょうが、
終わりかけのタスクが3週間も残っているのはちょっと気持ち悪いですね。

また、CattleSearchControllerクラスの search メソッドのテストクラスが興味があるのは、依存先クラスである CattleSearchService の getRecommandation メソッドの戻り値だけであり、中の処理は見る必要がありません。
ここで Apex スタブ API の出番になります。
CattleSearchControllerクラスで宣言されているメンバー変数 service を、想定した値が返ってくるようなスタブに置き換えます。

Apex にてスタブクラスを定義するには StubProvider インターフェース を継承します。
また、handleMethodCall メソッドでスタブとしての処理を定義します。

StubProvider インターフェース、および、handleMethodCall メソッドについては、Apex 開発者ガイドの StubProvider インターフェース を参照して下さい。

Apex スタブ APIを使用した CattleSearchServiceクラスのスタブクラスの定義が下記になります。
/** CattleSearchServiceクラスのスタブを定義 */
@isTest
public class CattleSearchServiceStubProvider implements System.StubProvider {
    
    public Object handleMethodCall(Object stubbedObject, String stubbedMethodName, Type returnType, List<Type> listOfParamTypes, List<String> listOfParamNames,  List<Object> listOfArgs) {
        
        if(stubbedMethodName == 'getRecommandation') {
            
            // 渡ってきた個体識別番号に対応して下記を返す.
            // 0000000001 ⇒ カルビ
            // 0000000002 ⇒ ロース
            // 0000000003 ⇒ ハラミ
            // 0000000004 ⇒ null(牛が未登録)
            // それ以外 ⇒ 例外をスロー
            String cattleIdNo = (String)listOfArgs[0];
            if(cattleIdNo == '0000000001') {
                return CattleSearchService.BeefPart.Ribs;                
            } else if (cattleIdNo == '0000000002') {
                return CattleSearchService.BeefPart.Loin;
            } else if (cattleIdNo == '0000000003') {
                return CattleSearchService.BeefPart.SkirtSteak;
            } else if (cattleIdNo == '0000000004') {
                return null;
            }
        }
        // 想定していないパラーメータ.
        throw new IllegalArgumentException();
    }
    
    /** 引数が不正の例外クラス. */
    public class IllegalArgumentException extends Exception {}
}
CattleSearchServiceStubProvider.cls
メソッド getRecommandation の定義を下記のように設定しています。
引数の値戻り値
0000000001カルビ
0000000002ロース
0000000003ハラミ
0000000004null(牛が未登録)
上記以外例外をスロー
作成した スタブクラスをテストクラスで利用してみます。

初めに、CattleSearchController のメンバー変数である serviceに、スタブクラスのインスタンスをセットできるようにするため、TestVisible アノテーションを付与します。
public with Sharing class CattleSearchController {

    /******** 省略 ********/

    /** 牛の情報を検索するサービスクラス */
    @TestVisible
    private CattleSearchService service;

    /******** 省略 ********/
}
CattleSearchController.cls TestVisible アノテーション付与の部分を抜粋
続いて、テストクラスです。

スタブクラスを使用することで例外が故意に起こせるようになりましたので、前に上げたテストクラス(CattleSearchControllerTest)に例外発生時のテストケースを追加します。
/** CattleSearchController のテストクラス(Apex スタブ API 使用) */
@isTest
private class CattleSearchControllerTest2 {

    @testSetup
    static void setup() {
        //
        // 牛さんのデータ登録はしない.
        //
    }

    /**
     * ケース:検索した結果、カルビの紹介ページを返す.
     */
    @isTest
    static void testSearch01() {
        PageReference pageRef = Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000001';
        controller.service = (CattleSearchService)Test.createStub(CattleSearchService.class, new CattleSearchServiceStubProvider());

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals('/apex/beefrecommendribs', pr.getUrl());
    }

    /**
     * ケース:検索した結果、ロースの紹介ページを返す.
     */
    @isTest
    static void testSearch02() {
        PageReference pageRef = Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000002';
        controller.service = (CattleSearchService)Test.createStub(CattleSearchService.class, new CattleSearchServiceStubProvider());

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals('/apex/beefrecommendloin', pr.getUrl());
    }

    /**
     * ケース:検索した結果、ハラミの紹介ページを返す.
     */
    @isTest
    static void testSearch03() {
        PageReference pageRef = Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000003';
        controller.service = (CattleSearchService)Test.createStub(CattleSearchService.class, new CattleSearchServiceStubProvider());

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals('/apex/beefrecommendskirtsteak', pr.getUrl());
    }

    /**
     * ケース:検索した結果、牛が未登録のページを返す.
     */
    @isTest
    static void testSearch04() {
        PageReference pageRef = Page.CattleSearch;
        Test.setCurrentPage(pageRef);

        CattleSearchController controller = new CattleSearchController();
        controller.cattleIDNo = '0000000004';
        controller.service = (CattleSearchService)Test.createStub(CattleSearchService.class, new CattleSearchServiceStubProvider());

        Test.startTest();
        PageReference pr = controller.search();
        Test.stopTest();

        System.assertEquals(null, pr);
    }
}
CattleSearchControllerTest2.cls
テストクラスCattleSearchControllerTest2 を実行します。

テスト実行結果(Apex スタブ API 使用)

全てのテストメソッドで Pass という結果が得られました。
カバレッジも見てみます。

テスト実行結果 カバレッジ(Apex スタブ API 使用)

例外発生時のコードも通っていることが分かります。

まとめ

Force.com 開発のユニットテストにて、テストクラスの作成にこなれてきた人向けに書きましたが
いかがでしたでしょうか?

スタブを使用したテストの注意点として、どこでも言われている内容になりますが、
スタブを使用したテストが通ったからと言って即本番環境へリリースするのではなく、
実際のプログラムで一通り流れるテストを行いましょう。

本記事が Force.com 開発の手助けとなれば幸いです。
それでは良い Force.com 開発生活を~♪
39 件

関連する記事