この記事でわかること
JUnitで動かした送料計算を、Spring BootのGET APIにつなぎます。送料のルールを変えたときに落ちるテストと、URLやJSONを変えたときに落ちるテストを、同じコードで確かめます。


購入画面に送料見積もりを表示することになりました。今まではJavaのメソッドに小計を渡していましたが、画面チームは小計をURLの文字列で送り、送料をJSONで受け取りたいと言っています。ここで増えた仕事は、整数へ変換し、既存の計算を呼び、成功と失敗をHTTPで伝えることです。
対象は、JavaのクラスとJUnitのassertEqualsを読める人です。SpringのDIやMockMvcを初めて使う場合は、まず配布ZIPの2種類のテストを別々に実行すると、どこまで動いたかを追えます。
実行サンプル
送料の本体・単体テスト、Spring Bootの起動クラス・Controller・MockMvcテスト、pom.xml、READMEを収録。JDK 17とMavenで、展開したディレクトリから mvn clean test を実行できます。
送料の仕様と、APIの仕様を先に分ける
送料の仕様はJUnit入門と同じです。小計は整数の円で、0円から1,000,000円まで。10,000円以上の送料は0円、それ未満は500円です。範囲外を渡されたShippingFeeはIllegalArgumentExceptionを投げます。この計算は、画面・バッチ・単体テストのどこから呼んでも同じです。
購入画面との約束は別に決めます。見積もりはデータを書き換えないのでGET /shipping-quoteを使います。クエリ名はsubtotalで、成功時のJSONはsubtotalとshippingFeeの2項目。小計がない場合、整数にできない場合、整数だが範囲外の場合は、いずれも400を返します。このサンプルではエラー本文の形式を契約に含めません。
| 確かめたいこと | 入力 | 期待結果 | 確かめるファイル |
|---|---|---|---|
| 送料が変わる境界 | 9,999と10,000 | 500円と0円 | ShippingFeeTest.java |
| 許容範囲を外れた小計 | -1 | IllegalArgumentException | ShippingFeeTest.java |
| APIの正常応答 | subtotal=8000 | 200、subtotalは8000、shippingFeeは500 | ShippingQuoteHttpTest.java |
| 必須パラメータと整数変換 | subtotalなし、またはabc | どちらも400 | ShippingQuoteHttpTest.java |
| 計算側の入力拒否をHTTPに変換 | subtotal=-1 | 400 | ShippingQuoteHttpTest.java |
-1は両方に出てきます。Java側のテストで知りたいのは「計算が不正な小計を拒否するか」。HTTP側で知りたいのは「その拒否が画面チームと約束した400になるか」です。同じ入力でも、確かめている結果は違います。
ShippingFeeと既存のJUnitテストを持ってくる
先にShippingFee.javaとShippingFeeTest.javaをコピーし、Spring Bootアプリに合わせてpackage example.shipping;を先頭に追加します。計算の中身と3つのテストは、JUnit入門のコードをそのまま使います。
計算するクラスの全文。src/main/java/example/shipping/ShippingFee.java
package example.shipping;
/** 金額の単位は円。小計は0円から1,000,000円まで。 */
public final class ShippingFee {
public int calculate(int subtotal) {
if (subtotal < 0 || subtotal > 1_000_000) {
throw new IllegalArgumentException("小計は0円以上100万円以下です");
}
return subtotal >= 10_000 ? 0 : 500;
}
}従来の単体テストの全文。src/test/java/example/shipping/ShippingFeeTest.java
package example.shipping;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ShippingFeeTest {
private final ShippingFee shippingFee = new ShippingFee();
@Test
void subtotal9999Costs500() {
assertEquals(500, shippingFee.calculate(9_999));
}
@Test
void subtotal10000IsFree() {
assertEquals(0, shippingFee.calculate(10_000));
}
@Test
void negativeSubtotalIsRejected() {
assertThrows(IllegalArgumentException.class,
() -> shippingFee.calculate(-1));
}
}実行コマンドはmvn -Dtest=ShippingFeeTest testです。テスト自身がnew ShippingFee()で対象を作るので、Springのコンテキストを起動せずに3件を確認できます。MavenやJUnitの操作から復習するなら、同じファイルを解説した入門へ戻れます。
Controllerで小計を受け取り、送料計算に渡す
アプリの起動クラスで@Beanを使い、ShippingFeeをSpringに登録します。Controllerのコンストラクタは、Springが作ったそのインスタンスを受け取ります。この2か所をつなげると、計算クラスにSpring用のアノテーションを足さず、単体テストは引き続き同じ書き方で動きます。
起動クラスの全文。src/main/java/example/shipping/ShippingQuoteApplication.java
package example.shipping;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class ShippingQuoteApplication {
public static void main(String[] args) {
SpringApplication.run(ShippingQuoteApplication.class, args);
}
@Bean
ShippingFee shippingFee() {
return new ShippingFee();
}
}Controllerの全文。src/main/java/example/shipping/ShippingQuoteController.java
package example.shipping;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
class ShippingQuoteController {
private final ShippingFee shippingFee;
ShippingQuoteController(ShippingFee shippingFee) {
this.shippingFee = shippingFee;
}
@GetMapping("/shipping-quote")
ShippingQuote quote(@RequestParam("subtotal") int subtotal) {
int fee;
try {
fee = shippingFee.calculate(subtotal);
} catch (IllegalArgumentException invalidSubtotal) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, invalidSubtotal.getMessage(), invalidSubtotal);
}
return new ShippingQuote(subtotal, fee);
}
record ShippingQuote(int subtotal, int shippingFee) {}
}@GetMappingがGETのURLを決め、@RequestParam("subtotal") int subtotalがクエリの値を受け取ります。未指定やabcは、Springが引数を用意する段階で400にします。-1は整数なのでメソッドに入り、calculateが拒否します。そこで投げられた例外を、ControllerがResponseStatusExceptionで400に変換します。
成功時に返すShippingQuoteは、APIが公開する項目を表すレコードです。送料はshippingFeeに入れ、小計との合計を計算して返す仕様は置いていません。Controllerにsubtotal >= 10_000をもう一度書くと、閾値変更のたびに直す箇所が増えます。送料を変える式はShippingFee.calculateに集めます。
MockMvcでURL・入力変換・JSONを確かめる
HTTPテストの全文。src/test/java/example/shipping/ShippingQuoteHttpTest.java
package example.shipping;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class ShippingQuoteHttpTest {
@Autowired
private MockMvc mvc;
@Test
void returnsTheSubtotalAndFeeAsJson() throws Exception {
mvc.perform(get("/shipping-quote").param("subtotal", "8000"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.subtotal").value(8000))
.andExpect(jsonPath("$.shippingFee").value(500));
}
@Test
void rejectsAMissingSubtotal() throws Exception {
mvc.perform(get("/shipping-quote"))
.andExpect(status().isBadRequest());
}
@Test
void rejectsANonIntegerSubtotal() throws Exception {
mvc.perform(get("/shipping-quote").param("subtotal", "abc"))
.andExpect(status().isBadRequest());
}
@Test
void translatesTheShippingRuleRejectionToBadRequest() throws Exception {
mvc.perform(get("/shipping-quote").param("subtotal", "-1"))
.andExpect(status().isBadRequest());
}
}@SpringBootTestと@AutoConfigureMockMvcで、Springの設定とControllerを読み込みます。mvc.performはURLとパラメータをMVCに渡します。外部ポートを使わずに、URLの割り当て、文字列から整数への変換、レスポンスのJSON化を通ります。今回の例では、計算にも実物のShippingFeeを使います。 Spring FrameworkのMockMvcの説明。
mvn -Dtest=ShippingQuoteHttpTest testで4件です。正常系は、200だけでなくContent-Type、小計、送料も読みます。成功のステータスだけを検証すると、JSONの項目名を間違えても検出できません。異常系は、値がない、整数でない、整数だが範囲外の3つ。別々に送ると、どの入力が通ってしまったかをテスト名から判断できます。
今回のpom.xmlはJava 17とSpring Boot 3.5.16を指定しています。Spring Bootのテスト用スターターからMockitoを外してあります。この練習はモック対象を作らず、テスト対象のJavaクラスとMVCの結線を実物で確認するためです。
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>example</groupId>
<artifactId>shipping-quote</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>ZIPを展開し、テストと実際のHTTPを順に試す
JDK 17とMavenを使います。初回の依存取得にはネット接続が必要です。ZIPを展開したらpom.xmlがあるディレクトリで以下を実行します。全件実行の結果は、単体3件とHTTP4件の合計7件です。
mvn -Dtest=ShippingFeeTest test
mvn -Dtest=ShippingQuoteHttpTest test
mvn clean test続いてmvn spring-boot:runで起動し、別のターミナルでcurl -i 'http://localhost:8080/shipping-quote?subtotal=8000'を実行します。200と{"subtotal":8000,"shippingFee":500}が返れば、ブラウザと同じようにHTTPで到達できます。subtotal=abcとsubtotal=-1は400です。アプリを終了するときは起動したターミナルでCtrl+Cを押します。
送料の境界とJSONの項目名を別々に変えてみる
変更1。送料無料を12,000円以上にする。まずShippingFeeTest.javaの9,999円と10,000円を11,999円と12,000円に変えます。既存実装は11,999円も送料無料にするので、500円を期待するテストが失敗します。次にShippingFee.javaの10_000を12_000へ変更すると、3件が成功します。HTTP側の正常例8,000円はどちらの閾値でも送料500円で、URLやJSONはそのままです。
変更2。画面チームに渡すJSONの項目名を変える。元の状態に戻し、Controller末尾のレコードだけをrecord ShippingQuote(int subtotal, int fee) {}へ変えます。送料の単体テストは3件とも成功します。HTTP側の正常系だけが$.shippingFeeを読めずに失敗します。項目名の変更が正式に決まったら、呼び出し側と変更時期を揃えたうえで、HTTPの期待値を$.feeへ更新します。
レビューで送料条件とControllerに同じ閾値が出てきたら、こう確認できます。「送料無料の条件はShippingFeeTestで11,999円と12,000円を押さえたいです。Controllerでは計算を呼ぶ形にして、既存のshippingFeeが返ることはHTTPテストに残せますか?」
提出前に確認すること
- 送料を決める式は
ShippingFeeの一か所にある。 - 送料無料の前後と範囲外は、単体テストの入力と期待値から読める。
- HTTPテストでURL、クエリ名、JSON項目名、400の扱いを確かめた。
- 実際のブラウザや別システムからの通信が要件なら、その接続も別途確認する。
Spring Bootで小さな機能を一通り作るなら
この記事では、既存のJava処理を1つのURLから呼ぶところまでを作りました。次に画面入力やデータ保存を含む機能を、手を動かしながら組み立てたい場合は『作って学ぶ Spring Boot入門』が候補です。Controllerのコードを読んで仕組みが分かる段階から、自分でアプリを一通り作る練習に進めます。
作って学ぶ Spring Boot入門
Spring Bootを、動くWebアプリを作りながら覚える。
Controller、画面、DB連携などを一つのアプリとして実装できます。Java基礎からSpringの現場へ進む橋渡しに向く一冊です。
- Spring Bootを手を動かして学びたい
- Webアプリ開発の流れをつかみたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
JUnitで確認した送料計算にHTTPの入口を足しても、9,999円と10,000円の境界は素のJavaのテストに置けます。MockMvcには、画面が使うURL・パラメータ・JSON項目と、入力エラーが400になる約束を置きます。次の変更でテストを足すときは、まず変わった約束が計算側かHTTP側かを確認してください。



