はじめに
以前、BFF(Backend for Frontend)を開発していた際に、API呼び出し部品のテスト方法について悩んだことがありました。
BFFはフロントエンドとバックエンドの間を取り持つプログラムを指すのですが、外部APIへのリクエストが多く、仕様変更の影響を受けやすいプログラムです。
当初は、Mockito を使ってモック化することで、HTTP通信は行わないテストをしていました。しかしその方法では、API呼び出し部品自体のロジックはテストできません。
そのため
- 実際にどの URL にアクセスしているのか
- クエリパラメータが正しく付与されているか
- 正しいヘッダが設定されているか
といった「HTTPレベルの仕様」を検証できません。
そこで導入したのが、JUnit と WireMock を組み合わせたテストでした。これにより、HTTP 通信を含めた形で API 呼び出し部品を検証できるようになり、安心してリファクタリングや仕様変更に対応できるようになりました。
本記事では、Spring Boot アプリケーションで JUnit5 と WireMock を利用し、API 呼び出し部品を HTTP レベルでテストする方法を紹介します。
Spring Boot で WireMock を使う理由
WireMock は、HTTP サーバとして振る舞うテスト用ライブラリです。
Java で開発されており、スタンドアロンで動作させることもできますし、Java コードから起動することも可能です。今回は Java JUnit のコードから呼び出すので後者の使い方になります。
テストの最初に WireMock サーバを起動します。テスト実行ではリクエストを WireMock に送り、WireMock はあらかじめ定義したレスポンスを返します。HTTPリクエストを行うところまで実行するので、API呼び出し部品のコードを完全に通してテストすることが可能です。
WireMock を Java に組み込む方法はいくつかあります。
- WireMock のAPIを直接扱う方法(@Before などで WireMockServer のインスタンスを生成して起動する方法)
- WireMock 用のJUnit5拡張
@WireMockTestを使う方法 - Spring Cloud Contract の
@AutoConfigureWireMockを使う方法
Spring Boot アプリケーションなら Spring Cloud Contract に含まれる @AutoConfigureWireMock を使うことで、簡単に組み込むことができます。今回はこの方法を説明します。(Spring Boot を使っていない場合は、残りの2つから選択することになります)
なお、 Spring Cloud Contract は、本来は契約駆動開発を行うことができるものですが、この記事では扱いません。今回は spring-cloud-contract-wiremockだけを単品で、WireMock を簡単に利用するための依存関係として使用します。
依存関係の追加(build.gradle)
Gradle(Groovy DSL)の場合の例です。
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock:4.2.3'
}
※ 本記事では説明の簡素化のためバージョンを直接指定していますが、実際のプロジェクトでは Spring Cloud の BOM(spring-cloud-dependencies)を利用してバージョンを管理することを推奨します。
テスト対象クラス (APIクライアント)
今回は、バックエンドの商品APIにアクセスする、以下のような部品を例にします。
ProductsApiClient.java
@Component
public class ProductsApiClient {
private final RestTemplate restTemplate;
private final String productsUrl;
private final String productsByIdUrl;
public ProductsApiClient(RestTemplate restTemplate,
@Value("${products.api.url}") String productsUrl,
@Value("${products.api.by-id.url}") String productsApiUrl) {
this.restTemplate = restTemplate;
this.productsUrl = productsUrl;
this.productsByIdUrl = productsApiUrl;
}
public Product getProductById(String productId) {
ResponseEntity<Product> entity = restTemplate.getForEntity(productsByIdUrl, Product.class, productId);
Product body = entity.getBody();
if (body == null) {
throw new RestClientException("Empty response body");
}
return body;
}
public void postProduct(Product product) {
restTemplate.postForEntity(productsUrl, product, Void.class);
}
}
Spring Boot + @AutoConfigureWireMock を使ったテストクラス
@SpringBootTest, @AutoConfigureWireMock を使った ProductsApiClient のテストクラスです。
ProductApiClientTest.java
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class ProductsApiClientTest {
@Autowired
private ProductsApiClient productsApiClient;
@Test
@DisplayName("GET: 指定IDの商品を取得")
void getProductById_success() {
stubFor(get(urlEqualTo("/products/123"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "123",
"name": "Test Product"
}
""")));
Product product = productsApiClient.getProductById("123");
assertThat(product).isNotNull();
assertThat(product.getId()).isEqualTo("123");
assertThat(product.getName()).isEqualTo("Test Product");
verify(getRequestedFor(urlEqualTo("/products/123")));
}
@Test
@DisplayName("GET: レスポンスボディが空の場合は例外")
void getProductById_emptyBody() {
stubFor(get(urlEqualTo("/products/123"))
.willReturn(aResponse()
.withStatus(200)));
assertThatThrownBy(() -> productsApiClient.getProductById("123"))
.isInstanceOf(RestClientException.class)
.hasMessageContaining("Empty response body");
}
@Test
@DisplayName("POST: 商品登録時にJSONボディが送信される")
void postProduct_verifyRequestBody() {
stubFor(post(urlEqualTo("/products"))
.willReturn(aResponse()
.withStatus(201)));
Product product = new Product();
product.setId("999");
product.setName("New Product");
productsApiClient.postProduct(product);
// JSON の特定プロパティ(name)を検証
verify(postRequestedFor(urlEqualTo("/products"))
.withRequestBody(matchingJsonPath("$.name", equalTo("New Product"))));
}
}
application-test.yml
products.api.url: http://<your-host>:${wiremock.server.port}/products
products.api.by-id.url: http://<your-host>:${wiremock.server.port}/products/{id}
@AutoConfigureWireMock アノテーションとポート番号
テストクラスに @AutoConfigureWireMock アノテーションを付与します。
なお、 port = 0 は0番ポートの意味ではなく、空いているポートを自動的に割り当てるという意味になります。この場合、Spring の Environment プロパティ wiremock.server.port に、実際に割り当てられたポート番号が設定されるため、テスト用の properties で ${wiremock.server.port} を使うと綺麗な構成になります。
ポート番号を固定すると並列実行時に問題が出ることがあるので、この構成がお勧めです。
WireMock スタブ定義
stubFor からメソッドチェインのスタイルで、スタブを定義できます。
GET /products/123 リクエストの動作定義
- HTTPステータス200を返す
- HTTPヘッダ Content-Type: application/json を返す
- レスポンスボディとして(コード記載の)JSONを返す
stubFor(get(urlEqualTo("/products/123"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "123",
"name": "Test Product"
}
""")));
POST /products リクエストの動作定義
- HTTPステータス201を返す
- レスポンスボディは空(書いていないので)
stubFor(post(urlEqualTo("/products"))
.willReturn(aResponse()
.withStatus(201)));
これはシンプルな例ですが、クエリパラメータやリクエストヘッダ、リクエストボディのJSONの中身によって動作を分けたりするなど、様々な機能が用意されています。
呼び出しの検証
verify からメソッドチェインのスタイルで、メソッド呼び出しの検証を行えます。
GET /products/123 リクエストの検証
- GET /products/123 リクエストが行われること
verify(getRequestedFor(urlEqualTo("/products/123")));
POST /products リクエストの検証
- POST /products リクエストが行われること
- リクエストボディのJSONの中の name プロパティが "New Product" であること
verify(postRequestedFor(urlEqualTo("/products"))
.withRequestBody(matchingJsonPath("$.name", equalTo("New Product"))));
おわりに
今回紹介した手法は、BFF開発の現場で非常に有効でした。
ローカルの JUnit や Jenkins の CI で HTTPレベルまで検証できるようになったため、結合テストでバグが検出される頻度がかなり減りました。
Spring Boot 環境では、@AutoConfigureWireMock を利用することで導入ハードルも低くなります。
API呼び出しが多いプログラムを開発されている方は、ぜひ導入を検討してみてください。