0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HttpCalloutのテストをDIを使ってより便利に、柔軟に、可読性よく使う

Last updated at Posted at 2025-11-28

はじめに

みなさん、ApexでのHttpリクエストを送るコードのテストコードを書く際、公式で推奨されているHttpCalloutMockを使っていますよね?
公式のサポートで、クラスの挙動をそっくり入れ替えることができるのでこれでほぼ十分かと思いますが、今回はDI(Dependency Injection、依存性注入)を使ってこのHttpリクエストのモックをもっと便利に、柔軟に、可読性よく使う方法をご紹介します。

対象読者

Salesforce 認定 Platform デベロッパー以上の知識を有し、InterfaceとDIを理解している方

公式の方法について

こちらの記事が非常にわかりやすく解説されており、テストコードもわかりやすいのでおすすめです。
https://qiita.com/Tatsuhiro_Iwasaki/items/d855218572097727d8e7

公式の方法の辛い点

1. JSON文字列の手動作成

レスポンスボディを定義する際、JSON文字列を手書きする必要があります。

// 辛いポイント:可読性が低く、カンマ一つ忘れたらパースエラー
res.setBody('{"id": 1001, "customFields": [{"id": 11, "value": "A"}]}');

ネストが深くなるとコードが見辛くなりメンテナンス性が低下していきます。

2. URL文字列に依存した「If-Else分岐」

複数のAPIコールが発生する場合、公式の方法ではエンドポイントのURLで分岐させる実装が一般的です。

// 辛いポイント:プロダクションコードのURL生成ロジックと密結合する
if (req.getEndpoint().contains('/parent')) {
    return parentRes;
} else if (req.getEndpoint().contains('/child')) {
    return childRes;
}

これは「振る舞い」ではなく「URLという文字列」に依存しているため、URLパラメータの順序が変わったり、呼び出すAPIのエンドポイントが変更になったりするだけでテストが壊れやすくなります。

ページネーションリクエストを送る例を考えてみましょう。
page=1, page=2, page=3というパラメータによりページネーションが実装されているAPIにリクエスト送るテストを考えてみましょう。

global class LegacyPaginationMock implements HttpCalloutMock {
    global HttpResponse respond(HttpRequest req) {
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        res.setStatusCode(200);
        
        String endpoint = req.getEndpoint();

        // 辛いポイント:URLのパラメータ文字列に依存した分岐処理
        // '&page=1' が '?p=1' や '?pageNumber=1' に変わると壊れる可能性がある
        if (endpoint.contains('page=1')) {
            // 1ページ目:続きがある (hasMore: true)
            res.setBody('{"users": [{"id": 1, "name": "A"}], "hasMore": true}');
        } 
        else if (endpoint.contains('page=2')) {
            // 2ページ目:続きがある (hasMore: true)
            res.setBody('{"users": [{"id": 2, "name": "B"}], "hasMore": true}');
        } 
        else if (endpoint.contains('page=3')) {
            // 3ページ目:ここで終了 (hasMore: false)
            res.setBody('{"users": [{"id": 3, "name": "C"}], "hasMore": false}');
        } 
        else {
            // 想定外のページがリクエストされた場合
            res.setStatusCode(404);
            res.setBody('{"error": "Page not found"}');
        }
        
        return res;
    }
}

実装に強く依存した、ちょっとした変更が入ることで壊れやすいテストになることがわかります。

3. リトライの再現が困難

次のような要件があったとします。

外部API連携時にエラー(ステータスコード500など)が返ってきた場合、最大3回までリトライを行う。 

テストしたいケース: 1回目と2回目は失敗(500)し、3回目のリトライで成功(200)する挙動を確認したい。

これを公式のやり方でやろうとすると、「カウンター付きのMockクラス」を作成し、内部でカウンターを動かして条件分岐をさせる必要があります。「テストのためのロジックを書かないといけない状態」になってしまいます。

public class RetryScenarioMock implements HttpCalloutMock {
        private Integer callCounter = 0; // ← カウンター

        public HttpResponse respond(HttpRequest req) {
            this.callCounter++;
            HttpResponse res = new HttpResponse();
            res.setHeader('Content-Type', 'application/json');
            
            // 辛いポイント:ここにテストシナリオのロジックを書かないといけない
            if (this.callCounter <= 2) {
                // 1回目と2回目はエラーを返す
                res.setStatusCode(500);
                res.setBody('{"error": "Server Busy"}');
            } else {
                // 3回目で成功を返す
                res.setStatusCode(200);
                res.setBody('{"status": "Success"}');
            }
            return res;
        }
    }

解決策:カスタムHTTPハンドラーとDIパターン

これらの問題を解決するために、標準の Http クラスをラップするインターフェースと、高機能なMockクラスを作成しましょう。

  • 設計のポイント
    • DI(Dependency Injection): 処理(親タスク取得、更新など)ごとに専用のMockインスタンスを注入する。
    • Map/Listによるデータ定義: JSON文字列を書かず、Apexのコレクションで直感的にレスポンスを定義する。
    • Queue(キュー)構造: 呼び出されるたびにリストから順番にレスポンスを返すことで、リトライやページネーションを表現する。

実装コード

まずインターフェースを定義しましょう。
先頭のIはinterfaceを表します。

public interface IHttpRequestHandler {
    void send(HttpRequest request);
    String getBody();
    Integer getStatusCode();
}

実際にリクエスト送る、interfaceを実装したクラスも作成します。

public with sharing class HttpRequestHandler implements IHttpRequestHandler {
  private HttpResponse currentHttpResponse;

  public HttpRequestHandler() {
    this.currentHttpResponse = null;
  }

  // HTTPリクエストを送る
  public void send(HttpRequest request) {
    Http http = new Http();
    HttpResponse response = http.send(request);

    this.currentHttpResponse = response;
  }

  // レスポンスのbodyを取得 
  public String getBody() {
    if (this.currentHttpResponse == null) {
      return null;
    }

    return this.currentHttpResponse.getBody();
  }

  // レスポンスのステータスコードを取得
  public Integer getStatusCode() {
    if (this.currentHttpResponse == null) {
      return null;
    }
    return this.currentHttpResponse.getStatusCode();
  }
}

そして、これがメインとなるMockクラスです

public with sharing class MockHttpRequestHandler implements IHttpRequestHandler {
  @TestVisible
  private List<HttpRequest> sentRequests = new List<HttpRequest>();
  @TestVisible
  private List<MockResponse> mockResponses = new List<MockResponse>();

  private MockResponse currentMockResponse;

  // 単発レスポンス用コンストラクタ(Mapを渡すだけでOK)
  public MockHttpRequestHandler(Map<String, Object> responseData) {
    if (responseData == null) {
      throw new IllegalArgumentException('Response data cannot be null');
    }
    MockResponse mockResponse = new MockResponse(responseData);
    this.mockResponses.add(mockResponse);
    this.currentMockResponse = null;
  }

  // シーケンシャル(複数回)レスポンス用コンストラクタ
  public MockHttpRequestHandler(List<Map<String, Object>> responseDataList) {
    if (responseDataList == null || responseDataList.isEmpty()) {
      throw new IllegalArgumentException('Response data list cannot be null or empty');
    }
    for (Map<String, Object> responseData : responseDataList) {
      MockResponse mockResponse = new MockResponse(responseData);
      this.mockResponses.add(mockResponse);
    }
    this.currentMockResponse = null;
  }

  // bodyとstatusCodeからも単発レスポンスを作成可能なコンストラクタ
  public MockHttpRequestHandler(String body, Integer statusCode) {
    Map<String, Object> responseData = new Map<String, Object>{ 'body' => body, 'statusCode' => statusCode };
    MockResponse mockResponse = new MockResponse(responseData);
    this.mockResponses.add(mockResponse);
    this.currentMockResponse = null;
  }

  // 実行時にQueueからレスポンスを取り出す
  public void send(HttpRequest request) {
    if (this.sentRequests.size() == this.mockResponses.size()) {
      throw new CalloutException('No more responses configured, but send() was called.');
    }
    this.sentRequests.add(request);
    this.currentMockResponse = this.mockResponses[this.sentRequests.size() - 1];
  }

  public String getBody() {
    if (this.currentMockResponse == null) {
      throw new JSONException('No response available. Ensure send() has been called.');
    }
    return this.currentMockResponse.body;
  }

  public Integer getStatusCode() {
    if (this.currentMockResponse == null) {
      throw new JSONException('No response available. Ensure send() has been called.');
    }
    return this.currentMockResponse.statusCode;
  }

  @TestVisible
  private class MockResponse {
    public String body;
    public Integer statusCode;

    public MockResponse(Map<String, Object> responseData) {
      if (responseData == null) {
        throw new IllegalArgumentException('Response data cannot be null');
      }
      if (responseData.get('body') == null || responseData.get('statusCode') == null) {
        throw new IllegalArgumentException('Response data must contain body and statusCode');
      }

      // ここでJSONシリアライズを自動化 
      Object bodyData = responseData.get('body');
      if (responseData.get('body') instanceof Map<String, Object>) {
        this.body = JSON.serialize((Map<String, Object>) bodyData);
      } else if (responseData.get('body') instanceof List<Object>) {
        this.body = JSON.serialize((List<Object>) bodyData);
      } else if (responseData.get('body') instanceof String) {
        this.body = (String) bodyData;
      } else {
        throw new IllegalArgumentException('Body must be a Map, List, or String');
      }

      Object sc = responseData.get('statusCode');
      if (sc instanceof Integer) {
        this.statusCode = (Integer) sc;
      } else if (sc instanceof String) {
        this.statusCode = Integer.valueOf((String) sc);
      } else {
        throw new IllegalArgumentException('StatusCode must be an Integer or String');
      }
    }
  }
}

早速、従来の方法で問題となっていた点がどのように解消されたかみていきましょう。

JSON文字列の手動作成がどう変わるか

従来のコード

res.setBody('{"id": 1001, "customFields": [{"id": 11, "value": "A"}]}');

新しいモッククラスを使ったコード

ockHttpRequestHandler mock = new MockHttpRequestHandler(
    new Map<String, Object>{
        'body' => new Map<String, Object>{
            'id' => 1001,
            'customFields' => new List<Object>{
                new Map<String, Object>{ 'id' => 11, 'value' => 'A' }
            }
        },
        'statusCode' => 200
    }
);

読者の皆様は「おや?1行だったものが複数行になった上にnew Mapやらで見にくくなった」と思いましたか?
確かにその点についてはその通りだと思います。しかし、次の大きなメリットが手に入ったのです。

  • 構造が見える: インデントが自然につくので、JSONの階層構造が一目でわかる。
  • 型安全(に近い): カンマ忘れなどの構文エラーをIDEやコンパイラが教えてくれる。

ちょっと極端な例を出してみましょう。
下の2つのうち、「テストケースの修正・追加」をするときにパラメータを変更しやすいのはどちらかでしょうか?
圧倒的に後者ではないでしょうか。ここまでのパラメータを使ったテストは少ないかもしれませんが、階層構造は人にもAIにも理解しやすく構築もしやすいので、私はこの階層構造によるレスポンスボディ構築をお勧めします。

res.setBody('{"projectId": 999, "projectName": "新基幹システム開発", "tasks": [{"id": "T-101", "subject": "要件定義", "status": "Done", "assignee": {"id": 55, "name": "田中"}}, {"id": "T-102", "subject": "基本設計", "status": "In Progress", "assignee": {"id": 60, "name": "鈴木"}, "customFields": [{"id": 1, "value": "A"}, {"id": 2, "value": "High"}]}], "meta": {"total": 2, "limit": 20}}');
MockHttpRequestHandler mock = new MockHttpRequestHandler(
    new Map<String, Object>{
        // こんな風に途中にコメントも入れられる
        'projectId' => 999,
        'projectName' => '新基幹システム開発',
        // リスト構造もインデントで表現できるので、パッと見で把握可能
        'tasks' => new List<Object>{
            // 1つ目のタスク
            new Map<String, Object>{
                'id' => 'T-101',
                'subject' => '要件定義',
                'status' => 'Done',
                'assignee' => new Map<String, Object>{ 'id' => 55, 'name' => '田中' }
            },
            // 2つ目のタスク
            new Map<String, Object>{
                'id' => 'T-102',
                'subject' => '基本設計',
                'status' => 'In Progress',
                'assignee' => new Map<String, Object>{ 'id' => 60, 'name' => '鈴木' },
                'customFields' => new List<Object>{
                    new Map<String, Object>{ 'id' => 1, 'value' => 'A' },
                    new Map<String, Object>{ 'id' => 2, 'value' => 'High' }
                }
            }
        },
        'meta' => new Map<String, Object>{ 'total' => 2, 'limit' => 20 }
    }
);

URL文字列に依存した「If-Else分岐」がどう変わるか

従来のやり方

@isTest
public class LegacyMultiRequestMock implements HttpCalloutMock {
    public HttpResponse respond(HttpRequest req) {
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        res.setStatusCode(200);

        String endpoint = req.getEndpoint();

        // 辛いポイント:ここで全ての分岐を管理しないといけない
        if (endpoint.contains('/parent')) {
            res.setBody('{"id": 100, "type": "Parent"}');
        } 
        else if (endpoint.contains('/child')) {
            res.setBody('{"id": 200, "type": "Child"}');
        } 
        else {
            res.setStatusCode(404); // 未定義のエンドポイント
        }
        return res;
    }
}
@isTest
static void testLegacy() {
    // 全てを担うMockを登録
    Test.setMock(HttpCalloutMock.class, new LegacyMultiRequestMock());
    
    // テスト対象実行
    SyncService service = new SyncService();
    service.execute(); 
}

これはエンドポイントURLで判断してレスポンス内容を変える、というやり方でしたね。
さて、これを今回の新しい方法で書いてみましょう。

ステップ1

まず、プロダクションコードで「DI(依存性注入)」を受け入れられる形に修正します。

public class SyncService {
    // インターフェースとして保持する
    private IHttpRequestHandler parentApi;
    private IHttpRequestHandler childApi;

    // 1. 本番用コンストラクタ(デフォルトの実装を使う)
    public SyncService() {
        this(new HttpRequestHandler(), new HttpRequestHandler());
    }

    // 2. テスト用コンストラクタ(Mockを注入できる!)
    @TestVisible
    private SyncService(IHttpRequestHandler parentApi, IHttpRequestHandler childApi) {
        this.parentApi = parentApi;
        this.childApi = childApi;
    }

    public void execute() {
        // 親タスクの取得(親用のハンドラーを使う)
        HttpRequest parentReq = new HttpRequest();
        parentReq.setEndpoint('https://api.example.com/parent');
        parentReq.setMethod('GET');
        this.parentApi.send(parentReq); // ★ここがポイント
        
        // ... 処理 ...

        // 子タスクの取得(子用のハンドラーを使う)
        HttpRequest childReq = new HttpRequest();
        childReq.setEndpoint('https://api.example.com/child');
        childReq.setMethod('GET');
        this.childApi.send(childReq); // ★ここがポイント
    }
}

ステップ2: テストコードでの実装

ここにはもう、if (req.getEndpoint()...) という分岐は存在しません。 「親にはこのデータを返す」「子にはこのデータを返す」 と、インスタンスを作成して渡すだけです。

@isTest
static void testModernDI() {
    // 1. 親API用のMockを作成(URL分岐なんて考えなくていい!)
    MockHttpRequestHandler parentMock = new MockHttpRequestHandler(
        new Map<String, Object>{
            'body' => new Map<String, Object>{ 'id' => 100, 'type' => 'Parent' },
            'statusCode' => 200
        }
    );

    // 2. 子API用のMockを作成
    MockHttpRequestHandler childMock = new MockHttpRequestHandler(
        new Map<String, Object>{
            'body' => new Map<String, Object>{ 'id' => 200, 'type' => 'Child' },
            'statusCode' => 200
        }
    );

    // 3. コンストラクタ経由で「役割」ごとにMockを渡す
    SyncService service = new SyncService(parentMock, childMock);

    Test.startTest();
    service.execute();
    Test.stopTest();
    
    // 検証:それぞれのMockが正しく呼ばれたか(Spy機能)
    System.assertEquals(1, parentMock.sentRequests.size(), '親APIが1回呼ばれたはず');
    System.assertEquals(1, childMock.sentRequests.size(), '子APIが1回呼ばれたはず');
}

if-elseで行っていた分岐を、それぞれ単独のモッククラスの責務として定義しサービスクラスで利用するようになっています。これにより次のようなメリットが得られます。

  • if文の消滅: 「親用のMock変数」「子用のMock変数」を定義した時点で、それはそれぞれのAPIのレスポンスになることが確定しているためif文による分岐が消えます。
  • 可読性: new SyncService(parentMock, childMock) という行を見るだけで、「あ、このサービスクラスは2種類の外部接続を行うんだな」と構造が理解できます。
  • 独立性: もし「親APIだけエラーになるケース」をテストしたければ、parentMock の定義だけを statusCode => 500 に変えればよく、childMock に影響を与えません。従来の if/else 型では、全体のロジックに手を入れるリスクがありました。「親に該当する部分だけを変更すれば子の部分は影響ないはずだ」という考え方もありますが、そもそもクラスレベルで分離していれば親部分を変えても子部分に影響を与えないと自信を持ってコードを変えることができます。

ページネーションリクエストがどう変わるか

ページネーションリクエストのモックは従来では次のようにリクエストに含まれるパラメータで判断する必要がありました。

global class LegacyPaginationMock implements HttpCalloutMock {
    global HttpResponse respond(HttpRequest req) {
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        res.setStatusCode(200);
        
        String endpoint = req.getEndpoint();

        // 辛いポイント:URLのパラメータ文字列に依存した分岐処理
        // '&page=1' が '?p=1' や '?pageNumber=1' に変わると壊れる可能性がある
        if (endpoint.contains('page=1')) {
            // 1ページ目:続きがある (hasMore: true)
            res.setBody('{"users": [{"id": 1, "name": "A"}], "hasMore": true}');
        } 
        else if (endpoint.contains('page=2')) {
            // 2ページ目:続きがある (hasMore: true)
            res.setBody('{"users": [{"id": 2, "name": "B"}], "hasMore": true}');
        } 
        else if (endpoint.contains('page=3')) {
            // 3ページ目:ここで終了 (hasMore: false)
            res.setBody('{"users": [{"id": 3, "name": "C"}], "hasMore": false}');
        } 
        else {
            // 想定外のページがリクエストされた場合
            res.setStatusCode(404);
            res.setBody('{"error": "Page not found"}');
        }
        
        return res;
    }
}

これを新しい方法で書き換えると、こうなります

@isTest
static void testPaginationWithNewMock() {
    // 1. レスポンスデータの定義
    // 文字列解析やIf文は不要。「何回目のコールで何を返すか」を定義するだけ。
    
    // 1ページ目 (hasMore: true)
    Map<String, Object> page1 = new Map<String, Object>{
        'body' => new Map<String, Object>{
            'users' => new List<Object>{ new Map<String, Object>{ 'id' => 1, 'name' => 'A' } },
            'hasMore' => true
        },
        'statusCode' => 200
    };

    // 2ページ目 (hasMore: true)
    Map<String, Object> page2 = new Map<String, Object>{
        'body' => new Map<String, Object>{
            'users' => new List<Object>{ new Map<String, Object>{ 'id' => 2, 'name' => 'B' } },
            'hasMore' => true
        },
        'statusCode' => 200
    };

    // 3ページ目 (hasMore: false -> ここでループ終了)
    Map<String, Object> page3 = new Map<String, Object>{
        'body' => new Map<String, Object>{
            'users' => new List<Object>{ new Map<String, Object>{ 'id' => 3, 'name' => 'C' } },
            'hasMore' => false
        },
        'statusCode' => 200
    };

    // 2. モックの作成
    // リストで渡すことで「1回目→2回目→3回目」という順序付きの振る舞い(Queue)になる
    MockHttpRequestHandler paginationMock = new MockHttpRequestHandler(
        new List<Map<String, Object>>{
            page1,
            page2,
            page3
        }
    );

    // 3. 実行 (DI注入)
    // 内部でループ処理が走るが、Mockは渡された順にレスポンスを返すだけ
    UserSyncService service = new UserSyncService(paginationMock);
    
    Test.startTest();
    service.syncAllUsers(); 
    Test.stopTest();

    // 4. 検証
    // 「本当に3回呼ばれたか?」を確認
    System.assertEquals(3, paginationMock.sentRequests.size(), '3ページ分リクエストされたはず');
}

条件分岐というロジックが消え、単なる「こういうデータを返すという定義(宣言)」になりました。
一つ一つのリクエストが冗長だという場合は、テストクラス内にリクエスト生成を行うプライベートメソッドなどを用意しても良いでしょう。
これにより次のメリットが得られます。

  • URL変更への耐性: プロダクションコードが ?page=1?p=1 に変更しても、このテストコードは1行も修正する必要がありません。 「1回目の呼び出しには1ページ目を返す」という振る舞いは変わらないからです。
  • ロジックの排除: 従来はMockクラスの中に if/else というロジックがあり、「テストコード自体のバグ」 に悩まされるリスクがありました。新しい方法では、データをリストに詰めているだけなのでバグの入り込む余地がありません。
  • 可読性: 「このテストは3ページ分取得して終了するシナリオだ」ということが、page1, page2, page3 の定義を見るだけで一目瞭然です。

リトライの再現がどう変わるか

リトライの再現は従来の方法では次のようにモッククラスの中にカウンターを作って管理する必要がありました。

public class RetryScenarioMock implements HttpCalloutMock {
        private Integer callCounter = 0; // ← カウンター

        public HttpResponse respond(HttpRequest req) {
            this.callCounter++;
            HttpResponse res = new HttpResponse();
            res.setHeader('Content-Type', 'application/json');
            
            // 辛いポイント:ここにテストシナリオのロジックを書かないといけない
            if (this.callCounter <= 2) {
                // 1回目と2回目はエラーを返す
                res.setStatusCode(500);
                res.setBody('{"error": "Server Busy"}');
            } else {
                // 3回目で成功を返す
                res.setStatusCode(200);
                res.setBody('{"status": "Success"}');
            }
            return res;
        }
    }

これを新しい方法で書くと、こうなります

@isTest
static void testRetryWithNewMock() {
    // 1. レスポンスデータの定義
    // 使い回すエラーレスポンスを1つ定義しておけばOK
    Map<String, Object> errorRes = new Map<String, Object>{
        'body' => new Map<String, Object>{ 'error' => 'Server Busy' },
        'statusCode' => 500
    };

    // 成功レスポンス
    Map<String, Object> successRes = new Map<String, Object>{
        'body' => new Map<String, Object>{ 'status' => 'Success' },
        'statusCode' => 200
    };

    // 2. モックの作成
    // 「1回目失敗、2回目失敗、3回目で成功」というシナリオを
    // リストの並び順だけで直感的に表現できる
    MockHttpRequestHandler retryMock = new MockHttpRequestHandler(
        new List<Map<String, Object>>{
            errorRes,   // 1回目:500エラー
            errorRes,   // 2回目:500エラー
            successRes  // 3回目:200 OK
        }
    );

    // 3. 実行 (DI注入)
    MyService service = new MyService(retryMock);
    
    Test.startTest();
    service.executeWithRetry(); // 内部でリトライ処理が走る
    Test.stopTest();

    // 4. 検証
    // リトライを含めて合計3回リクエストが飛んだことを確認
    System.assertEquals(3, retryMock.sentRequests.size(), '3回目で成功したのでリクエスト数は3になるはず');
}

これにより、次のようなメリットが得られます!

  • シナリオの可視化: new List>{ errorRes, errorRes, successRes } という行を見るだけで、「あ、これは2回失敗して3回目に成功するテストだな」と誰でも分かります。
  • カウンター変数の撤廃: 「いま何回目か?」を管理する変数が不要になり、カウントロジックのミスによる不具合のリスクが消滅します。
  • 変更の容易さ: 「5回リトライするテスト」に変えたければ、リストに errorRes を増やすだけです。従来のコードのように if (this.callCounter <= 2) の数字を修正するという「テストのためのロジック修正」が必要ありません。

最後に

いかがでしたでしょうか。

今回ご紹介した手法は、一見すると「準備が大変」「そこまでする必要があるのか?」と、ややオーバーエンジニアリングに感じる部分もあるかもしれません。しかし、私はSalesforce標準の Test.setMock よりも、Web開発の歴史の中で練り上げられてきた「Interface + DI」を使ったこの手法の方が、圧倒的に理解しやすく、応用が利き、何よりテストコードを書く苦痛から解放されました。

Salesforceの認定Platform Developer試験などでは、Interfaceは「構文としての宣言方法」しか触れられず、「なぜ使うのか」「どう設計に活かすのか」までは深く問われません。そのため、Salesforceからキャリアをスタートさせた方の中には、今回のようなコードを見て「なぜわざわざこんな回り道をするのか?」と戸惑う方もいらっしゃるかと思います。

しかし、InterfaceやDIは、先人たちが数多のプロジェクトで試行錯誤し、現代まで生き残ってきた、いわばソフトウェア開発の 王道パターン です。これらを学ぶことは、単に「その場のテストを通す」だけでなく、変更に強く、長く愛されるコードを書くための第一歩になります。

「Salesforce公式がこう言っているから」という思考停止に陥らず(公式のドキュメントには、手軽さをアピールするためのポジショントークが含まれていることもあります!)、ぜひ一歩外の世界の技術やアーキテクチャにも目を向け、ご自身の武器として取り入れてみてください。

付録

もし今回のHTTPRequestHandlerを使いたい場合、テストクラスが必須になるのでそちらも載せておきます。
コピペで動くはずなのでよろしければお使いください

/**
 * @description HttpRequestHandler のテストクラス
 */
@isTest
public with sharing class HttpRequestHandlerTest {
  @isTest
  static void testHttpRequestHandler_WhenSend_ThenCanGetBodyAndStatusCode() {
    // Arrange
    HttpResponse mockResponse = new HttpResponse();
    mockResponse.setBody('{"status":"success"}');
    mockResponse.setStatusCode(200);
    Test.setMock(HttpCalloutMock.class, new MockHttpResponseGenerator(mockResponse));

    // Act
    HttpRequestHandler requestHandler = new HttpRequestHandler();
    HttpRequest request = new HttpRequest();
    request.setEndpoint('https://api.example.com/test');
    request.setMethod('GET');
    requestHandler.send(request);
    String responseBody = requestHandler.getBody();
    Integer statusCode = requestHandler.getStatusCode();

    // Assert
    Assert.areEqual('{"status":"success"}', responseBody);
    Assert.areEqual(200, statusCode);
  }

  @isTest
  static void testHttpRequestHandler_WhenNoRequest_ThenGetBodyAndStatusCodeReturnNull() {
    // Arrange
    HttpRequestHandler requestHandler = new HttpRequestHandler();

    // Act
    String responseBody = requestHandler.getBody();
    Integer statusCode = requestHandler.getStatusCode();

    // Assert
    Assert.isNull(responseBody);
    Assert.isNull(statusCode);
  }

  private class MockHttpResponseGenerator implements HttpCalloutMock {
    private HttpResponse response;

    public MockHttpResponseGenerator(HttpResponse response) {
      this.response = response;
    }

    public HttpResponse respond(HttpRequest req) {
      return this.response;
    }
  }
}
/**
 * @description MockHttpRequestHandler のテストクラス
 */
@IsTest
private class MockHttpRequestHandlerTest {
  @IsTest
  static void testSend_WhenMapConstructorUsed_ThenReturnsCorrectResponse() {
    // Arrange
    Map<String, Object> bodyMap = new Map<String, Object>{ 'id' => 'map123', 'success' => true };
    Map<String, Object> responseData = new Map<String, Object>{ 'body' => bodyMap, 'statusCode' => 200 };

    MockHttpRequestHandler mock = new MockHttpRequestHandler(responseData);
    HttpRequest req = new HttpRequest();

    // Act
    mock.send(req);
    String actualBody = mock.getBody();
    Integer actualStatusCode = mock.getStatusCode();

    // Assert
    Map<String, Object> expectedBodyMap = new Map<String, Object>{ 'id' => 'map123', 'success' => true };
    Map<String, Object> actualBodyMap = (Map<String, Object>) JSON.deserializeUntyped(actualBody);
    Assert.areEqual(200, actualStatusCode, 'StatusCode should be 200');
    Assert.areEqual(expectedBodyMap, actualBodyMap, 'Body should match the expected map');
    Assert.areEqual(1, mock.sentRequests.size(), 'Should store one request');
    Assert.areEqual(req, mock.sentRequests[0], 'Stored request should match');
  }

  @IsTest
  static void testSend_WhenListConstructorUsed_ThenReturnsMultipleResponsesInOrder() {
    // Arrange
    String expectedBody1 = '{"status":"pending"}';
    String expectedBody2 = '{"status":"completed"}';

    List<Map<String, Object>> responseDataList = new List<Map<String, Object>>{
      new Map<String, Object>{ 'body' => new Map<String, Object>{ 'status' => 'pending' }, 'statusCode' => 202 },
      new Map<String, Object>{ 'body' => new Map<String, Object>{ 'status' => 'completed' }, 'statusCode' => 200 }
    };

    MockHttpRequestHandler mock = new MockHttpRequestHandler(responseDataList);
    HttpRequest req1 = new HttpRequest();
    HttpRequest req2 = new HttpRequest();

    // Act (1回目)
    mock.send(req1);
    String actualBody1 = mock.getBody();
    Integer actualStatusCode1 = mock.getStatusCode();

    // Act (2回目)
    mock.send(req2);
    String actualBody2 = mock.getBody();
    Integer actualStatusCode2 = mock.getStatusCode();

    // Assert (1回目)
    Assert.areEqual(202, actualStatusCode1, 'First status code should be 202');
    Assert.areEqual(expectedBody1, actualBody1, 'First body should be pending');

    // Assert (2回目)
    Assert.areEqual(200, actualStatusCode2, 'Second status code should be 200');
    Assert.areEqual(expectedBody2, actualBody2, 'Second body should be completed');

    // Assert (リクエスト保存の確認)
    Assert.areEqual(2, mock.sentRequests.size(), 'Should store two requests');
    Assert.areEqual(req1, mock.sentRequests[0], 'First stored request');
    Assert.areEqual(req2, mock.sentRequests[1], 'Second stored request');
  }

  @IsTest
  static void testSend_WhenStringConstructorUsed_ThenReturnsCorrectResponse() {
    // Arrange
    String expectedBody = '{"message":"Created"}'; // String (JSON)
    Integer expectedStatusCode = 201;

    MockHttpRequestHandler mock = new MockHttpRequestHandler(expectedBody, expectedStatusCode);
    HttpRequest req = new HttpRequest();

    // Act
    mock.send(req);
    String actualBody = mock.getBody();
    Integer actualStatusCode = mock.getStatusCode();

    // Assert
    Assert.areEqual(expectedStatusCode, actualStatusCode, 'StatusCode should be 201');
    Assert.areEqual(expectedBody, actualBody, 'Body should be the raw string');
    Assert.areEqual(1, mock.sentRequests.size(), 'Should store one request');
  }

  @IsTest
  static void testConstructor_WhenBodyIsList_ThenSerializesListCorrectly() {
    // Arrange
    String expectedBody = '[{"id":1},{"id":2}]'; // List
    List<Object> bodyList = new List<Object>{ new Map<String, Object>{ 'id' => 1 }, new Map<String, Object>{ 'id' => 2 } };
    Map<String, Object> responseData = new Map<String, Object>{ 'body' => bodyList, 'statusCode' => 200 };

    MockHttpRequestHandler mock = new MockHttpRequestHandler(responseData);
    HttpRequest req = new HttpRequest();

    // Act
    mock.send(req);
    String actualBody = mock.getBody();

    // Assert
    Assert.areEqual(expectedBody, actualBody, 'Body should be serialized list');
  }

  @IsTest
  static void testConstructor_WhenStatusCodeIsString_ThenInitializesSuccessfully() {
    // Arrange
    Map<String, Object> responseData = new Map<String, Object>{
      'body' => 'OK',
      'statusCode' => '200' // ★ String 型のステータスコード
    };

    MockHttpRequestHandler mock = new MockHttpRequestHandler(responseData);
    HttpRequest req = new HttpRequest();

    // Act
    mock.send(req);
    Integer actualStatusCode = mock.getStatusCode();

    // Assert
    Assert.areEqual(200, actualStatusCode, 'String StatusCode should be converted to Integer');
  }

  @IsTest
  static void testGetBody_WhenSendNotCalled_ThenThrowsException() {
    // Arrange
    MockHttpRequestHandler mock = new MockHttpRequestHandler('Body', 200);
    String expectedMessage = 'No response available. Ensure send() has been called.';
    JSONException caughtException = null;

    // Act
    Test.startTest();
    try {
      mock.getBody();
    } catch (JSONException e) {
      caughtException = e;
    }
    Test.stopTest();

    // Assert
    Assert.isNotNull(caughtException, 'A JSONException should have been thrown');
    Assert.areEqual(expectedMessage, caughtException.getMessage(), 'Exception message should match');
  }

  @IsTest
  static void testGetStatusCode_WhenSendNotCalled_ThenThrowsException() {
    // Arrange
    MockHttpRequestHandler mock = new MockHttpRequestHandler('Body', 200);
    String expectedMessage = 'No response available. Ensure send() has been called.';
    JSONException caughtException = null;

    // Act
    Test.startTest();
    try {
      mock.getStatusCode();
    } catch (JSONException e) {
      caughtException = e;
    }
    Test.stopTest();

    // Assert
    Assert.isNotNull(caughtException, 'A JSONException should have been thrown');
    Assert.areEqual(expectedMessage, caughtException.getMessage(), 'Exception message should match');
  }

  @IsTest
  static void testSend_WhenCalledMoreThanConfigured_ThenThrowsException() {
    // Arrange
    // (1回分のレスポンスしか設定しない)
    MockHttpRequestHandler mock = new MockHttpRequestHandler('Body', 200);
    HttpRequest req1 = new HttpRequest();
    HttpRequest req2 = new HttpRequest();
    String expectedMessage = 'No more responses configured, but send() was called.';
    CalloutException caughtException = null;

    // Act
    Test.startTest();
    mock.send(req1); // 1回目は成功
    try {
      mock.send(req2); // 2回目で失敗するはず
    } catch (CalloutException e) {
      caughtException = e;
    }
    Test.stopTest();

    // Assert
    Assert.isNotNull(caughtException, 'A CalloutException should have been thrown');
    Assert.areEqual(expectedMessage, caughtException.getMessage(), 'Exception message should match');
  }

  // --- MockResponse (Inner Class) Constructor Exception Tests ---

  @IsTest
  static void testConstructor_WhenMapIsNull_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Test.startTest();
    try {
      new MockHttpRequestHandler((Map<String, Object>) null);
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('Response data cannot be null', caughtException.getMessage());
  }

  @IsTest
  static void testConstructor_WhenListIsNull_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Test.startTest();
    try {
      new MockHttpRequestHandler((List<Map<String, Object>>) null);
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('Response data list cannot be null or empty', caughtException.getMessage());
  }

  @IsTest
  static void testConstructor_WhenListIsEmpty_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Test.startTest();
    try {
      new MockHttpRequestHandler(new List<Map<String, Object>>());
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('Response data list cannot be null or empty', caughtException.getMessage());
  }

  @IsTest
  static void testConstructor_WhenBodyIsMissing_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Map<String, Object> responseData = new Map<String, Object>{
      'statusCode' => 200 // body が欠けている
    };
    Test.startTest();
    try {
      new MockHttpRequestHandler(responseData);
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('Response data must contain body and statusCode', caughtException.getMessage());
  }

  @IsTest
  static void testConstructor_WhenStatusCodeIsMissing_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Map<String, Object> responseData = new Map<String, Object>{
      'body' => 'OK' // statusCode が欠けている
    };
    Test.startTest();
    try {
      new MockHttpRequestHandler(responseData);
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('Response data must contain body and statusCode', caughtException.getMessage());
  }

  @IsTest
  static void testConstructor_WhenBodyIsInvalidType_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Map<String, Object> responseData = new Map<String, Object>{
      'body' => 12345, // Invalid type (Integer)
      'statusCode' => 200
    };
    Test.startTest();
    try {
      new MockHttpRequestHandler(responseData);
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('Body must be a Map, List, or String', caughtException.getMessage());
  }

  @IsTest
  static void testConstructor_WhenStatusCodeIsInvalidType_ThenThrowsException() {
    IllegalArgumentException caughtException = null;
    Map<String, Object> responseData = new Map<String, Object>{
      'body' => 'OK',
      'statusCode' => new List<Integer>{ 200 } // Invalid type (List)
    };
    Test.startTest();
    try {
      new MockHttpRequestHandler(responseData);
    } catch (IllegalArgumentException e) {
      caughtException = e;
    }
    Test.stopTest();
    Assert.isNotNull(caughtException, 'Exception should be thrown');
    Assert.areEqual('StatusCode must be an Integer or String', caughtException.getMessage());
  }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?