3
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?

More than 1 year has passed since last update.

よりそうAdvent Calendar 2023

Day 6

外部サービスをモックする際はアプリケーション終端のクラスをモックする

Last updated at Posted at 2023-12-05

テストに関する書籍を読んでいて自分のモックの使い方を考えた

テストのベストプラクティスを体系的に学び整理したい、と思い読んでいた下記の書籍。
学ぶことは多々ありましたが、モックの使い方について自分の中でモヤモヤしていた部分をすとんと解決してくれた箇所について、自分のこれまでのお悩みポイントとともに言語化してみたいと思います。

単体テストの考え方/使い方

シナリオ

あなたは葬儀プランの予約サービスを開発しています。
あるユーザーがプランを予約しようとした際、プランが在庫切れであればSlackのチャンネルに通知をしたいと仮定しましょう。

この予約を行うAPIに対してテストを行うシーンが今回のサンプルシナリオです。

final class BookingService
{
    public function __construct(private readonly SlackService $slackService)
    {
    }

    /**
     * 指定されたプランを予約する
     */
    public function book(Plan $plan, User $user): bool
    {
        // プランが在庫切れであれば
        // 当該プランと予約しようとしたユーザーの情報を
        // Slackに通知する
        if ($plan->outOfStock()) {
            $this->slackService->notify([
                'planId' => $plan->id,
                'userId' => $user->id,
            ]);

            return false;
        }

        return $plan->book($user);
    }
}
final class SlackService
{
    /**
     * Slackに通知する
     */
    public function notify(array $data): bool
    {
        $response = Http::withHeaders([
            'Content-type' => 'application/json',
            'Authorization' => 'Bearer ' . config('const.slack.secret'),
        ])
            ->post(
                config('const.slack.url'),
                $data
            );

        return $response->successful();
    }
}

これまで私がやっていたこと

「プランが在庫切れであればSlackのチャンネルに通知をする」が実現されているかを確かめるために、Slack APIリクエストを司るSlackServiceをモックしていました。

SlackServiceのモックに対してonce()を呼び出すことで、Slackメッセージ送信メソッドが必ず一度呼ばれることを検証しようとしていました。

/**
 * プランが在庫切れの場合はSlackメッセージが送信されることをテストする
 */
public function testIfSlackMessageIsSentWhenPlanIsOutOfStock(): void
{
    // SlackServiceのnotify()が一度呼ばれることを確認する
    $slackService = Mockery::mock(
        SlackService::class,
        function (MockInterface $mock) {
            $mock->shouldReceive('notify')
                ->once()
                ->andReturn(true);
        }
    );

    // SlackServiceに対してモックをバインド
    $this->instance(
        SlackService::class,
        $slackService
    );

    $user = User::factory()->create();
    // プランは在庫切れ
    $plan = Plan::factory()->create([
        'stock' => 0,
    ]);

    $response = $this->post(
        '/api/book',
        [
            'user_id' => $user->id,
            'plan_id' => $plan->id,
        ]
    );

    $response->assertBadRequest();
}

この本が指摘していること

この本では外部サービスをモックする際はアプリケーションの終端のクラスをモックしようと提唱しています。
つまり、Slackのメッセージを送信するためにあなたが明示的に呼び出した終端のクラスをモックするのです。

すると、本来はSlackServiceではなくIlluminate\Support\Facades\Httpをモックしなければならなかったことになります。

image.png
※BookingRequestをリクエスト側の終端とするかは議論の余地がありそうです・・・

これまで何故そうしていなかったか

とはいえ、私とて理由もなくSlackServiceをモックしていたわけではありません。
自分がSlackのメッセージを送信するためにIlluminate\Support\Facades\Httpを呼び出していたことも認識していました。

それでもSlackServiceをモックすることを選んだのは、Illuminate\Support\Facades\Httpをモックしようとすると、

  • withHeaders()によるヘッダーのセット
  • post()によるHTTPリクエストの送信

の二つに対してエクスペクテーションを設定する必要があり、それがSlackServiceの内部実装を不当にのぞき込む事になっているのではないかと考えていたためでした。
すなわち、テストとて各クラスのカプセル化を破るべからずという別のベストプラクティスに則るため、泣く泣くSlackServiceをモックしていたというのが感覚としては近いと思います。

本書を読んだあとの納得ポイント

これに対し、本書で紹介されていた下記のポイントが響き、自分の考えを改めることになります。

モックに置き換えるべき依存は管理下にない依存、つまり、外部アプリケーションから観察可能な依存だけになります。

引用 : 単体テストの考え方/使い方 kindle版 p307

この「外部アプリケーションから観察可能な依存」というキーワードが肝だと考えています。
つまり、この葬儀プラン予約システムを外から見たときに

  • SlackのAPIをたたく(HTTPリクエストを発行する)
  • その際にSlack APIで求められているcontent-typeや認証の情報をヘッダーに含める

というのはどちらも観察可能な振る舞いとして当てはまります。
特に2番目の点をモックで扱うことに関してカプセル化を壊すとして恐れていましたが、これは観察可能なふるまいなのです。

修正するとどうなるか

結果として下記のようにコードを修正しました。
これによって、SlackのAPIに対してHTTPリクエストが発行されているだけでなく、Slack APIのI/F上必ず必要なヘッダーが備わっているかについても検証でき、より退行に対する保護を備えたコードとなりました。

/**
 * プランが在庫切れの場合はSlackメッセージが送信されることをテストする
 */
public function testIfSlackMessageIsSentWhenPlanIsOutOfStock(): void
{
    $user = User::factory()->create();
    // プランは在庫切れ
    $plan = Plan::factory()->create([
        'stock' => 0,
    ]);

    $header = [
        'Content-type' => 'application/json',
        'Authorization' => 'Bearer xxxxxxxxxxxxxxxx',
    ];

    $data = [
        'planId' => $plan->id,
        'userId' => $user->id,
    ];

    Http::fake();
    // PendingRequest::post()から返すダミーのレスポンスを作っておく
    $dummyResponse = Http::post('http://someurl', []);

    // Http::withHeaderの結果帰ってくるPendingRequestクラスをモック
    $pendingRequest = Mockery::mock(
        PendingRequest::class,
        function (MockInterface $mock) use ($dummyResponse, $data) {
            // post()が必ず一度呼ばれることを検証
            $mock->shouldReceive('post')
                ->once()
                ->with(config('const.slack.url', $data))
                ->andReturn($dummyResponse);
        }
    );

    // post前にwithHeadersを通して、content-typeと認証トークンの設定が行われることを検証
    Http::shouldReceive('withHeaders')
        // 必ず一度呼ばれる
        ->once()
        ->with($header)
        ->andReturn($pendingRequest);

    $response = $this->post(
        '/api/book',
        [
            'user_id' => $user->id,
            'plan_id' => $plan->id,
        ]
    );

    $response->assertBadRequest();
}

余談 (本のおすすめ)

今回はモックのお話に集中するため、細部を大分端折ってきました。
実際には豊富なサンプルコード含め、さまざまなベストプラクティスとその理由が紹介されている書籍ですので、ぜひ皆さんも手に取ってみてください!

参考文献

単体テストの考え方/使い方

3
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
3
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?