Apex Stub APIを使って管理パッケージ内のglobalクラスを使ったメソッドのテストを書く

## はじめに

AppExchangeアプリケーションではユーザがカスタマイズできるようにApexクラスをglobalで公開していることがあります。

例えば、[ZuoraのOrder Builder](https://knowledgecenter.zuora.com/CA_Commerce/I_Development_Resources/B_Order_Builder/D_Programming_with_Order_Builder)はZuoraのSOAP APIのApex SDKです。
大変便利なのですが、これを使ったクラスのテストはどう書くのでしょうか？

ドキュメントにも`(SeeAllData=true)`つけろとしか書いていないし、コミュニティを検索しても[こんな解決方法](https://community.zuora.com/t5/Zuora-CPQ/I-want-to-know-about-unit-test-of-Apex-class-using-Order-builder/m-p/19467#M1289)しか見つかりません。これはテストしてるって言いません。

## Apex Stub API

Apexでも[Spring'17からスタブが使えるようになった](https://releasenotes.docs.salesforce.com/ja-jp/spring17/release-notes/rn_apex_stub_api.htm)ので、これで解決してみました。

## 例

### テスト対象クラス

こんなZuoraにクエリを投げるだけの処理のテストコードを書いてみましょう。
`ZuoraApiWrapper`クラス（後述）のインスタンスをテスト中は`zApiStub`というstatic変数を使うようにしています。

```java:ZuoraExample.cls
public with sharing class ZuoraExample {
  @TestVisible
  private static ZuoraApiWrapper zApiStub;

  public static String getBankTransferId() {
    // テスト内ではzApiStubを利用する
    ZuoraApiWrapper zApiInstance = Test.isRunningTest() ? zApiStub : new ZuoraApiWrapper();
    zApiInstance.zlogin();
    String zoql = 'SELECT Id FROM PaymentMethod WHERE Type=\'BankTransfer\' AND Active = true';
    List<Zuora.zObject> methods = zApiInstance.zquery(zoql);
    if (methods.isEmpty()) {
      throw new NoDataFoundException('ZuoraにBankTransferのPaymentMethodが見つかりません。');
    }
    return (String) methods.get(0).getValue('Id');
  }
}
```

### スタブが作成できない問題の回避のためのクラス

管理パッケージ内のクラスである`Zuora.zApi`はスタブを作成できないらしく素直に`Zuora.zApi`のスタブを作ろうとするとエラーになります。なので、仕方なくラッパークラスを作ることで回避します。

```java:ZuoraApiWrapper.cls
public class ZuoraApiWrapper {
  private Zuora.zApi api;
  
  public ZuoraApiWrapper() {
    api = new Zuora.zApi();
  }
  
  public Zuora.zApi.LoginResult zlogin() {
    return api.zlogin();
  }
  
  public List<Zuora.zObject> zquery(String query) {
    return api.zquery(query);
  }
}
```

### テストコード

テストコードは`StubProvider`を使ってこんな風に書けます。
もうちょっと頑張れば引数がこれだったらこれを返すとか２回目の呼び出しだったらこれを返すみたいにも書けるようにできると思います。そのレベルのものも標準クラスで提供して欲しいものですが…

```java:ZuoraExampleTest.cls
@isTest
private class ZuoraExampleTest {
  /**
   * 呼ばれたメソッドの情報
   */
  class MethodInformation {
    Object stubbedObject{get;set;}
    String stubbedMethodName{get;set;}
    Type returnType{get;set;}
    List<Type> listOfParamTypes{get;set;}
    List<String> listOfParamNames{get;set;}
    List<Object> listOfArgs{get;set;}
  }

  /**
   * Stub APIの実装
   * https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_testing_stub_api.htm
   */
  class ZApiStubProvider implements System.StubProvider {
    Map<String, MethodInformation> calledMethod = new Map<String, MethodInformation>();
    Map<String, Object> returnMap = new Map<String, Object>();
    Map<String, Exception> exceptionMap = new Map<String, Exception>();
    
    public Object handleMethodCall(
      Object stubbedObject,
      String stubbedMethodName,
      Type returnType, 
      List<Type> listOfParamTypes, 
      List<String> listOfParamNames, 
      List<Object> listOfArgs
    ) {
      MethodInformation method = new MethodInformation();
      method.stubbedObject = stubbedObject;
      method.stubbedMethodName = stubbedMethodName;
      method.returnType = returnType;
      method.listOfParamTypes = listOfParamTypes;
      method.listOfParamNames = listOfParamNames;
      method.listOfArgs = listOfArgs;
      
      calledMethod.put(stubbedMethodName, method);
      
      Object res = returnMap.get(stubbedMethodName);
      if (res != null)
        return res;
      Exception e = exceptionMap.get(stubbedMethodName);
      if (e != null)
        throw e;
      return null;
    }
    
    ZuoraApiWrapper createStub() {
      return (ZuoraApiWrapper) Test.createStub(ZuoraApiWrapper.class, this);
    }
    
    /**
     * メソッドの戻り値を設定
     */
    void setReturnValue(String methodName, Object res) {
      returnMap.put(methodName, res);
    }
    
    /**
     * メソッドが投げるExceptionを設定
     */
    void setException(String methodName, Exception e) {
      exceptionMap.put(methodName, e);
    }
    
    /**
     * 呼ばれたメソッドの情報を取得。アサーションに使用する。
     */
    MethodInformation called(String methodName) {
      return calledMethod.get(methodName);
    }
  }
  
  /**
    * 正常時
    */
  @isTest(SeeAllData=true)
  static void testSuccess() {
    ZApiStubProvider provider = new ZApiStubProvider();
    // zqueryメソッドで正しい戻り値を返すよう設定
    Zuora.zObject paymentMethod = new Zuora.zObject('PaymentMethod');
    paymentMethod.setValue('Id', 'BT0001');
    provider.setReturnValue('zquery', new List<Zuora.zObject>{paymentMethod});
    // スタブを使うよう設定
    ZuoraExample.zApiStub = provider.createStub();
    
    String bankId = ZuoraExample.getBankTransferId();
    
    // zqueryが呼ばれること
    System.assertNotEquals(null, provider.called('zquery'));
    // 戻り値が正しいこと
    System.assertEquals('BT0001', bankId);
  }
  
  /**
    * 0件の場合
    */
  @isTest(SeeAllData=true)
  static void testNoData() {
    ZApiStubProvider provider = new ZApiStubProvider();
    // zqueryメソッドで空のリストを返すよう設定
    provider.setReturnValue('zquery', new List<Zuora.zObject>{});
    // スタブを使うよう設定
    ZuoraExample.zApiStub = provider.createStub();
    
    Exception ex;
    try {
      ZuoraExample.getBankTransferId();
    } catch (NoDataFoundException e) {
      ex = e;
    }
    
    // zqueryが呼ばれること
    System.assertNotEquals(null, provider.called('zquery'));
    // NoDataFoundExceptionが発生すること
    System.assertEquals('ZuoraにBankTransferのPaymentMethodが見つかりません。', ex.getMessage());
  }
}
```

## 問題

- ZuoraApiWrapperのカバレッジが0%になる。
    - ここでは書きませんが、この問題はうまいこと回避してください。

## 終わりに

Apexではテストのしようがないみたいなこともけっこうあったので、そんなときにStub APIはなかなか使えそうです。

## 参照

- [スタブ API を使用したモックフレームワークの作成 | Apex 開発者ガイド | Salesforce Developers](https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_testing_stub_api.htm)