JUnitからSpring Bootへ。Javaの処理をAPIにする。送料計算をHTTPへつなぐ流れを表した配送ラインのイラスト

設計・テスト・レビュー

JUnitで確かめたJavaの処理をSpring BootのAPIにする|単体テストとMockMvcの分け方

この記事でわかること

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

新人SE
新人SE
JUnitの送料テストは通りました。Spring Bootに載せたら、9,999円と10,000円をHTTPでも全部テストするんですか?
ポンコツSE
ポンコツSE
送料を決めるコードはそのまま使えます。HTTPから呼ぶときに増える約束を先に書いて、追加するテストを決めましょう。

購入画面に送料見積もりを表示することになりました。今までは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,000500円と0円ShippingFeeTest.java
許容範囲を外れた小計-1IllegalArgumentExceptionShippingFeeTest.java
APIの正常応答subtotal=8000200、subtotalは8000、shippingFeeは500ShippingQuoteHttpTest.java
必須パラメータと整数変換subtotalなし、またはabcどちらも400ShippingQuoteHttpTest.java
計算側の入力拒否をHTTPに変換subtotal=-1400ShippingQuoteHttpTest.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の操作から復習するなら、同じファイルを解説した入門へ戻れます。

関連記事JUnit 5入門|Mavenで最初のテストを実行し、失敗を直す 今回と同じ送料計算の3件を、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入門」の表紙
商品画像:Amazon.co.jp
PR Java新人・若手向け

作って学ぶ Spring Boot入門

多田 真敏 (著)

Spring Bootを、動くWebアプリを作りながら覚える。

Controller、画面、DB連携などを一つのアプリとして実装できます。Java基礎からSpringの現場へ進む橋渡しに向く一冊です。

  • Spring Bootを手を動かして学びたい
  • Webアプリ開発の流れをつかみたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事Spring Boot初心者におすすめの本3冊|現場で必要な力を身につける Springで画面やデータ保存まで組み立てたいとき、まずどの本で手を動かすかを選べます。 記事を読む 関連記事Spring BootのServiceをJUnit・Mockitoでテストする方法|Repositoryをモックする基本 ServiceにDBの呼び出しが増えたら、何を置き換えて戻り値や保存の有無を確認するかを試せます。 記事を読む

まとめ

JUnitで確認した送料計算にHTTPの入口を足しても、9,999円と10,000円の境界は素のJavaのテストに置けます。MockMvcには、画面が使うURL・パラメータ・JSON項目と、入力エラーが400になる約束を置きます。次の変更でテストを足すときは、まず変わった約束が計算側かHTTP側かを確認してください。

-設計・テスト・レビュー
-, , , ,