Spring Bootで性能テスト用のスタブAPIを作る

外部APIと連携するアプリケーションの性能試験をしていると、接続先APIの応答が遅いときの挙動を見たくなることがあります。

本物の外部APIで都合よく遅延を起こすのは難しいので、性能テスト用に遅延レスポンスを返すスタブAPIを用意しておくと便利です。

この記事では、Spring Bootで小さなスタブAPIを作り、固定遅延ではなく「たまに遅いレスポンスが混ざる」状態を作る考え方を整理します。

作りたいもの

今回作りたいのは、次のようなスタブAPIです。

  • HTTPリクエストを受け取る
  • 設定に応じて少し待つ
  • 決まったHTTPステータスとレスポンス本文を返す
  • 遅延時間は毎回同じではなく、重み付きで変わる

性能試験用のスタブなので、本物の外部サービスを完全に再現することは目指しません。

まずは「接続先が普段は速いが、たまに遅くなる」という条件を作れることを優先します。

flowchart LR
    Client[性能試験クライアント] --> App[自システム]
    App --> Stub[Spring BootスタブAPI]
    Stub --> Delay[重み付き遅延]
    Delay --> Response[固定レスポンス]
    Response --> App

固定遅延だけだと足りない理由

単純なスタブなら、毎回 500ms 待ってからレスポンスを返せば作れます。

ただ、実際に見たいのは平均値だけではないことが多いです。

たとえば、次の2つはどちらも平均にすると近い値になるかもしれません。

  • すべてのリクエストがだいたい同じ時間で返る
  • ほとんどは速いが、たまにかなり遅いレスポンスが混ざる

後者のような状態を見たい場合、固定遅延だけでは足りません。

そこで、厳密な統計分布を再現するのではなく、まずは重み付きの遅延パターンを使います。

stub:
  responses:
    status: 200
    body: '{"result":"ok"}'
  delays:
    - weight: 90
      millis: 100
    - weight: 9
      millis: 500
    - weight: 1
      millis: 2000

この例では、だいたい次のようなイメージになります。

  • 90%くらいは 100ms
  • 9%くらいは 500ms
  • 1%くらいは 2000ms

厳密にp99を再現するというより、「遅いレスポンスが少し混ざる状態」を作るための設定です。

pie showData
    title 遅延パターンの例
    "100ms" : 90
    "500ms" : 9
    "2000ms" : 1

Spring Bootで実装する

まずはControllerで遅延してからレスポンスを返します。

package com.github.massakai.snippets.performancestub;

import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class StubController {

    private final StubProperties properties;

    public StubController(StubProperties properties) {
        this.properties = properties;
    }

    @GetMapping("/stub")
    public ResponseEntity<String> stub() throws InterruptedException {
        long delayMillis = chooseDelayMillis(properties.delays());
        Thread.sleep(delayMillis);

        return ResponseEntity
                .status(properties.responses().status())
                .body(properties.responses().body());
    }

    long chooseDelayMillis(List<DelayPattern> delays) {
        if (delays.isEmpty()) {
            throw new IllegalArgumentException("At least one delay pattern is required");
        }

        int totalWeight = delays.stream()
                .mapToInt(DelayPattern::weight)
                .sum();

        if (totalWeight <= 0) {
            throw new IllegalArgumentException("Total delay weight must be positive");
        }

        int value = ThreadLocalRandom.current().nextInt(totalWeight);
        int current = 0;

        for (DelayPattern delay : delays) {
            current += delay.weight();
            if (value < current) {
                return delay.millis();
            }
        }

        return delays.get(delays.size() - 1).millis();
    }
}

設定値は @ConfigurationProperties で受け取る想定です。

package com.github.massakai.snippets.performancestub;

import java.util.List;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "stub")
public record StubProperties(
        StubResponse responses,
        List<DelayPattern> delays
) {
}

レスポンス設定と遅延パターンは、それぞれ別のrecordとして定義しています。

package com.github.massakai.snippets.performancestub;

public record StubResponse(
        int status,
        String body
) {
}
package com.github.massakai.snippets.performancestub;

public record DelayPattern(
        int weight,
        long millis
) {
}

実際に使うときは、@ConfigurationPropertiesScan を付けるか、設定クラスで有効化します。

package com.github.massakai.snippets.performancestub;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@SpringBootApplication
@ConfigurationPropertiesScan
public class PerformanceStubApplication {

    public static void main(String[] args) {
        SpringApplication.run(PerformanceStubApplication.class, args);
    }
}

本文では主要なクラスだけ載せています。テストコードやビルドファイルを含む完全なサンプルは、massakai/java-snippetsspring-boot-performance-stub に置いています。

Thread.sleep を使うときの注意点

この実装は分かりやすいですが、Thread.sleep は現在のスレッドを指定時間止めます。

Spring MVCのリクエスト処理で使うと、リクエストを処理しているサーバー側のスレッドをその間ブロックします。

小規模な検証や、接続先APIの遅延をざっくり再現したいだけなら、これで十分なこともあります。

一方で、同時アクセス数が多い性能試験では注意が必要です。スタブ側のスレッドが詰まってしまうと、本当に見たい自システムの挙動ではなく、スタブサーバーの限界を見ているだけになる可能性があります。

遅延時間が長い、同時アクセスが多い、スタブ側をなるべく軽くしたい、といった場合はSpring MVCの非同期リクエスト処理も検討対象になります。

まずは小さく作る

性能テスト用のスタブは、作り込み始めるといくらでも本物に近づけたくなります。

ただ、最初から複雑にしすぎると、スタブ自体の保守が大変になります。

まずは次の3つを制御できれば十分です。

  • 遅延時間
  • HTTPステータス
  • レスポンス本文

必要になったら、エラー率、レスポンスサイズ、エンドポイントごとの設定、実測ログからの再生などを追加していけばよいと思います。

まとめ

Spring Bootで性能テスト用のスタブAPIを作るなら、まずは小さく始めるのが扱いやすいです。

固定遅延だけではなく、重み付きの遅延パターンを使うと、「ほとんどは速いが、たまに遅い」という状態を簡単に作れます。

ただし、Thread.sleep はリクエスト処理スレッドを止めるため、大きな負荷をかける試験ではスタブ側がボトルネックになっていないか確認が必要です。

まずは小さなスタブで試験条件を作り、必要になったところだけ少しずつ足していくのがよさそうです。

参考

コメントする

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)