Spring Boot|@WebMvcTestと@SpringBootTestの違いと使い分け

Spring Boot|@WebMvcTestと@SpringBootTestの違いと使い分け

Controllerのテストを書こうとすると、@WebMvcTest と @SpringBootTest のどちらを使えばよいのか迷うことがあります。HTTPステータスやJSONだけを確認したい場合もあれば、Serviceの処理までつないで確認したい場合もあるためです。

使い分けるときは、読み込むSpringの構成範囲と、実サーバーを起動するかを分けて考えると整理できます。MockMvcを使うことと、Serviceをモックに置き換えることも別の選択です。

この記事ではSpring Boot 3.5のServlet / Spring MVC構成を対象に、比較表と同じAPIの説明用サンプルで違いを確認します。仕様はSpring Boot 3.5公式リファレンス「Testing Spring Boot Applications」に沿って整理します。

目次

@WebMvcTestと@SpringBootTestの違いを比較

ロード範囲・依存先・HTTP検証方法の比較表

@WebMvcTest はWeb層に対象を絞るためのテスト構成です。一方、@SpringBootTest はアプリケーションの構成を広く読み込みます。ただし、@SpringBootTest を付けるだけで実サーバーが起動するわけではありません。

比較項目@WebMvcTest@SpringBootTestのMOCK+@AutoConfigureMockMvc@SpringBootTestのRANDOM_PORT
コンテキストの範囲対象ControllerとMVC関連の構成を中心に限定アプリケーションの構成を広く読み込むアプリケーションの構成を広く読み込む
Serviceなどの依存先通常は自動で読み込まれず、モックなどを用意する構成に従って実Beanを読み込む。明示的なモックへの置き換えも可能構成に従って実Beanを読み込む。明示的なモックへの置き換えも可能
MockMvc自動構成される@AutoConfigureMockMvcで自動構成する実HTTPクライアントを使う場合は不要
組み込み実サーバー起動しない起動しないランダムポートで起動する
リクエストの経路MockMvcからMVCの処理へ渡すMockMvcからMVCの処理へ渡すHTTPクライアントから実サーバーへ送信する
主な用途入力の受け取り、JSON変換、ステータス、例外応答の確認ControllerとServiceなどの連携を含む応答の確認実サーバーを経由したHTTPのやり取りの確認

ここでいう「アプリケーションの構成」は、テストで指定した設定、プロファイル、条件付きBeanなどに従う範囲です。本番環境のすべてのBeanや外部システムが、そのまま使われることを意味しません。

検証したい範囲から使い分けを決める

まず、テストで見つけたい不具合を具体的にします。「Serviceが返した値をControllerが正しいJSONにするか」を確認するなら、Serviceの戻り値を固定できる @WebMvcTest が候補になります。

「Controllerから実際のServiceを呼び、加工した結果を返せるか」まで確認したいなら、@SpringBootTest と @AutoConfigureMockMvc を組み合わせます。さらに、ポートで待ち受けるサーバーへHTTPリクエストを送りたい場合は RANDOM_PORT を選びます。

判断する順番は、どの処理を実物で動かすか → どこをモックにするか → 実サーバーが必要かです。この3点を決めると、アノテーションを選びやすくなります。

@WebMvcTestでWeb層を検証する

ControllerとMVC関連の構成を読み込む範囲

@WebMvcTest(GreetingController.class) のように指定すると、対象Controllerを絞ってWeb層を検証できます。リクエストのマッピングやJSON変換などを担うMVC基盤と、MockMvcも自動構成されます。

ただし、「指定したControllerだけを生成する」という意味ではありません。@ControllerAdvice など、Web層の動作に関係する構成も対象になります。通常の @Component や @ConfigurationProperties は標準ではスキャン対象にならず、一般的なServiceやRepositoryもそのまま読み込まれる前提にはできません。

独自の設定クラスに定義したBeanが必要なら、その設定がテストへ取り込まれるかも確認します。追加の @Import などで構成を広げると、検証範囲も変わります。

Controllerが依存するServiceをモックで用意する

ControllerがコンストラクタでServiceを受け取る場合、テスト用コンテキストにも注入できるBeanが必要です。Web層だけを確認するなら、Spring Frameworkの @MockitoBean を使って依存Serviceをモックとして登録します。

import org.springframework.test.context.bean.override.mockito.MockitoBean;

// テストクラス内のフィールド
@MockitoBean
GreetingService greetingService;

このモックに「特定の引数ならこの値を返す」と設定すると、Service内部の処理に依存せずControllerの応答を確認できます。通常のMockitoの @Mock を付けただけでは、SpringのコンテキストへBeanとして登録したことにはならない点に注意してください。

@Test やMockitoの基本的な考え方を確認したい場合は、次の記事で補足できます。

MockMvcでリクエストとレスポンスを検証する

MockMvcは、実サーバーを起動せずにSpring MVCのリクエスト処理を検証する仕組みです。Controllerのメソッドを直接呼ぶ場合と異なり、URLのマッピング、リクエスト本文の変換、バリデーション、例外処理、レスポンスの生成を含めて確認できます。

たとえば、JSONをPOSTし、HTTPステータスが200で、レスポンスの message が期待値と一致するかを検証します。実際の書き方は後半の共通サンプルで示します。

MockMvcを使っても、ControllerやServiceが自動的にモックになるわけではありません。 MockMvcはリクエストを処理させるための仕組み、@MockitoBean は依存Beanをモックにするための仕組みです。

@SpringBootTestでアプリケーションの連携を検証する

アプリケーション全体の構成と依存先を読み込む

@SpringBootTest は、SpringApplicationを使ってApplicationContextを作成します。Web層に限定する @WebMvcTest と比べ、アプリケーションの設定に従ってServiceやRepositoryなども含めた構成を読み込みます。

そのため、ControllerからServiceへ処理を渡す流れや、Bean同士の接続を確認できます。一方、DBや外部APIへ接続するBeanが含まれる場合は、テスト用の接続先や置き換え方も決める必要があります。@SpringBootTest 自体が外部接続を遮断するわけではありません。

MOCKと@AutoConfigureMockMvcで検証する構成

@SpringBootTest の webEnvironment の既定値は MOCK です。この記事のServlet構成ではモックのWeb環境を用意し、組み込み実サーバーは起動しません。MockMvcも使いたい場合は @AutoConfigureMockMvc を併用します。

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class GreetingApplicationTest {
    // MockMvcを注入して検証する
}

この構成なら、実際のServiceを使いながら、MockMvcでHTTPステータスやJSONを検証できます。「Serviceまで含めて確認するには実サーバーが必要」と考える必要はありません。

RANDOM_PORTで実サーバーを起動する構成

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) を指定すると、組み込み実サーバーをランダムポートで起動します。HTTPクライアントからそのサーバーにリクエストを送り、応答を確認する構成です。

後半では TestRestTemplate を使った例を示します。この場合、検証用のリクエストはMockMvcではなく、実際のHTTP通信を経由します。サーバーを起動したうえでMockMvcだけを呼んでも、そのリクエストが実サーバーを通ることにはなりません。

ほかに、設定されたポートで起動する DEFINED_PORT と、Web環境を作らない NONE もあります。まずは、実サーバーを使わない MOCK と、実サーバーへHTTP通信する RANDOM_PORT の違いを押さえると選びやすくなります。

同じAPIの説明用サンプルで使い分けを確認する

サンプルの構成と検証対象をそろえる

名前をJSONで受け取り、挨拶文を返す POST /greetings を共通の検証対象にします。正常時は200と message を返し、空白だけの名前は400、予約名の admin は409にする仕様です。DBへの保存や外部API通信は行いません。

以下は、Spring Boot 3.5とJava 17、Mavenを使う構成例です。Spring Securityは依存関係に含めていません。既存プロジェクトへ組み込む場合は、セキュリティ設定や追加Beanの影響を受けます。

Mavenでテストする場合の依存関係の例です。Spring Bootの親POMまたはBOMでバージョンを管理している既存プロジェクト内の設定断片として示します。

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

本番コードは src/main/java/com/example/greeting/、テストコードは src/test/java/com/example/greeting/ に置きます。各クラスは記載したクラス名と同名のJavaファイルに分けてください。この最小構成では、追加の application.properties は使用しません。

GreetingApplication.java はアプリケーション構成の起点です。ほかのクラスとテストを同じパッケージに置くことで、テストからこの構成を検出できる配置にします。

package com.example.greeting;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

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

GreetingService.java は挨拶文を組み立てます。名前の前後の空白を除去し、予約名の場合は独自例外を投げます。例外クラスは、このサンプルではService内に定義しています。

package com.example.greeting;

import org.springframework.stereotype.Service;

@Service
public class GreetingService {
    public String greet(String name) {
        String normalized = name.strip();
        if ("admin".equalsIgnoreCase(normalized)) {
            throw new RejectedNameException();
        }
        return "Hello, " + normalized + "!";
    }

    public static class RejectedNameException extends RuntimeException {
    }
}

GreetingController.java はJSONを受け取り、Serviceの結果をJSONへ変換して返します。リクエストとレスポンスのDTOは、Controller内のrecordとして定義しています。

package com.example.greeting;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {
    private final GreetingService greetingService;

    public GreetingController(GreetingService greetingService) {
        this.greetingService = greetingService;
    }

    @PostMapping("/greetings")
    public GreetingResponse greet(@Valid @RequestBody GreetingRequest request) {
        return new GreetingResponse(greetingService.greet(request.name()));
    }

    public record GreetingRequest(@NotBlank String name) {
    }

    public record GreetingResponse(String message) {
    }
}

GreetingExceptionHandler.java では、入力エラーと予約名のエラーにそれぞれ固定のコードを返します。後のテストは、この独自のエラーレスポンス仕様を検証します。

package com.example.greeting;

import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GreetingExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException exception) {
        return ResponseEntity.badRequest()
                .body(Map.of("code", "INVALID_NAME"));
    }

    @ExceptionHandler(GreetingService.RejectedNameException.class)
    public ResponseEntity<Map<String, String>> handleRejectedName(
            GreetingService.RejectedNameException exception) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(Map.of("code", "NAME_REJECTED"));
    }
}

@WebMvcTestでServiceをモック化して応答を確認する

GreetingControllerWebMvcTest.java では、Serviceが返した値がレスポンスの message に入るかを確認します。モックの戻り値には、実Serviceが組み立てる挨拶文とは別の文字列を指定し、何を検証しているのかを明確にします。

package com.example.greeting;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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;

@WebMvcTest(GreetingController.class)
class GreetingControllerWebMvcTest {
    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    GreetingService greetingService;

    @Test
    void returnsServiceResultAsJson() throws Exception {
        when(greetingService.greet("mikan")).thenReturn("from-service");

        mockMvc.perform(post("/greetings")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name":"mikan"}
                                """))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.message").value("from-service"));

        verify(greetingService).greet("mikan");
    }
}

このテストで確認するのは、JSONの name がServiceへ渡され、その戻り値がJSONで返ることです。GreetingService の strip() や挨拶文の組み立ては実行しないため、その処理の正しさは確認できません。

@SpringBootTestで実際のServiceとの連携を確認する

GreetingApplicationTest.java では、Serviceをモックに置き換えずに読み込みます。前後に空白を付けた名前を送り、Serviceの加工を経て Hello, mikan! が返ることを期待するテストです。

package com.example.greeting;

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.post;
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(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
class GreetingApplicationTest {
    @Autowired
    MockMvc mockMvc;

    @Test
    void returnsGreetingFromRealService() throws Exception {
        mockMvc.perform(post("/greetings")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name":" mikan "}
                                """))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.message").value("Hello, mikan!"));
    }
}

MockMvcの使い方は先ほどとほぼ同じですが、今回はControllerから実際のServiceまで処理が進みます。検証範囲を変えているのは、主にコンテキストの構成とServiceをモック化するかどうかです。

同じAPIを実サーバー経由で確認する場合は、別ファイルの GreetingHttpTest.java を次のようにします。TestRestTemplate の相対URLは、テストで起動したサーバーを対象にします。

package com.example.greeting;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GreetingHttpTest {
    @Autowired
    TestRestTemplate restTemplate;

    @Test
    void returnsGreetingOverHttp() {
        ResponseEntity<GreetingController.GreetingResponse> response =
                restTemplate.postForEntity(
                        "/greetings",
                        new GreetingController.GreetingRequest(" mikan "),
                        GreetingController.GreetingResponse.class);

        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertNotNull(response.getBody());
        assertEquals("Hello, mikan!", response.getBody().message());
    }
}

こちらは実際のHTTP通信を含みますが、DBや外部サービスはサンプルに存在しません。実サーバーを起動したことだけを根拠に、システム全体の連携を確認できたとは判断しないようにします。

バリデーションと例外処理の応答を確認する

正常系に加え、空白だけの名前が400になることも確認します。次のメソッドを先ほどの GreetingControllerWebMvcTest のクラス内に追加してください。

@Test
void rejectsBlankNameBeforeCallingService() throws Exception {
    mockMvc.perform(post("/greetings")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"name":"   "}
                            """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("INVALID_NAME"));

    org.mockito.Mockito.verifyNoInteractions(greetingService);
}

この例は、DTOの @NotBlank とControllerの @Valid によって入力が拒否され、Serviceが呼ばれないことを検証します。INVALID_NAME はSpring Bootが標準で返すコードではなく、掲載したAdviceが定義する値です。

DTOの制約や @Valid の実装方法を詳しく確認したい場合は、次の記事を参照してください。

Serviceからの例外を409へ変換する処理も、Web層のテストで確認できます。同じ GreetingControllerWebMvcTest に、次のメソッドを追加します。

@Test
void convertsServiceExceptionToConflict() throws Exception {
    when(greetingService.greet("admin"))
            .thenThrow(new GreetingService.RejectedNameException());

    mockMvc.perform(post("/greetings")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"name":"admin"}
                            """))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.code").value("NAME_REJECTED"));
}

このテストは「その例外が発生したときの応答」を確認します。実Serviceが予約名を判定して例外を投げることまで確認する場合は、次のメソッドを GreetingApplicationTest のクラス内へ追加します。

@Test
void rejectsReservedNameThroughRealService() throws Exception {
    mockMvc.perform(post("/greetings")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"name":"admin"}
                            """))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.code").value("NAME_REJECTED"));
}

今回の配置では、GreetingExceptionHandler を同じパッケージに置き、Web層のテストでも読み込む前提です。別の配置や構成でAdviceが含まれない場合は、対象の確認や明示的な取り込みが必要です。例外処理そのものの書き方は、次の記事で補足できます。

テストは mvn test で実行します。実行結果を記録する場合は、Spring Bootのパッチバージョン、JDK、Maven、Spring Securityの有無も併記すると、前提を追いやすくなります。

使い分けで迷いやすいポイント

Bean不足ではロード範囲と依存先の用意を確認する

@WebMvcTest で依存Beanが見つからない場合は、まずControllerやAdvice、フィルターが何に依存しているかを確認します。通常のServiceを読み込まない構成なら、そのServiceをモックとして用意する必要があります。

実際の処理を確認したい依存先なら、必要な構成だけを取り込むか、アプリケーションの連携を確認する @SpringBootTest にするかを判断します。エラーを解消するためだけに読み込み範囲を広げると、テストの目的が曖昧になります。

また、同じ型のBeanが複数ある場合は注入先の識別、構成クラスが見つからない場合はパッケージ配置や classes の指定も確認します。「Beanがない」と「アプリケーション構成を検出できない」は切り分けて考えてください。

認証・CSRF・フィルターが検証に与える影響

Spring Securityを導入したプロジェクトでは、Controllerへ到達する前に認証や認可、CSRFのチェックが行われる場合があります。@WebMvcTest だからセキュリティが無効になる、という前提にはできません。

たとえば、Controllerでは200を返す想定でも、セキュリティ設定によって401、403、ログイン画面へのリダイレクトなどになる可能性があります。POSTのテストでは、認証済みであることに加え、CSRFトークンが必要な構成かも確認します。

MockMvcで認証情報やCSRFトークンを付けるテストには、spring-security-test の支援機能を利用できます。ただし、模擬ユーザーを与えることと、実際のログイン処理を検証することは別です。独自のSecurity設定をWeb層のテストへ取り込めているかも合わせて確認してください。

@AutoConfigureMockMvc(addFilters = false) でフィルターを外すと、そのテストでは認証や独自フィルターを経由した動作を確認できません。無効化する場合は、外した範囲をどのテストで確認するかも決めておきましょう。

モック化した範囲と検証できない連携を整理する

@SpringBootTest でも、Serviceを @MockitoBean に置き換えれば、そのServiceの実装は検証対象から外れます。コンテキストを広く読み込んだことと、実際の処理をどこまで通したかは別です。

今回のサンプルでは、Web層のテストが「Serviceの結果をレスポンスへ変換する処理」、実Serviceを使うテストが「名前の加工からレスポンス生成まで」、RANDOM_PORTのテストが「実サーバーへのHTTP通信を含む流れ」を確認する構成です。

DBや外部APIが加わるプロジェクトでも、実物、テスト用の代替環境、モックのどれを使ったかを明らかにします。たとえば外部APIクライアントをモックにしたテストでは、相手のAPIとの接続やレスポンス仕様の一致までは確認できません。

アノテーション名とテストの分類を分けて考える

@WebMvcTest はMVC基盤を含み、@SpringBootTest は構成によってモックも利用できます。そのため、アノテーション名だけを見て、単体テスト・結合テスト・E2Eテストを一律に決めることはできません。

特に RANDOM_PORT は実サーバーを起動する設定であり、それだけでE2Eテストになるわけではありません。画面操作、データの保存、外部システムとの連携など、利用シナリオのどこまでを対象にしたかを見て分類します。

チームで説明するときは、「Controllerと実ServiceをMockMvcで確認する」のように、実際の範囲を添えると認識をそろえやすくなります。テスト分類の基本を整理したい場合は、次の記事を参照してください。

まとめ

@WebMvcTest と @SpringBootTest は、検証したい処理の範囲から選びます。Web層の応答に集中するのか、Serviceなどの実装まで組み合わせるのかを先に決めることがポイントです。

  • @WebMvcTest:対象ControllerとMVC関連の構成を読み込み、依存Serviceはモックなどで用意する。
  • @SpringBootTestのMOCK+@AutoConfigureMockMvc:実サーバーを起動せず、実Serviceを含むアプリケーションの連携をMockMvcで確認する。
  • @SpringBootTestのRANDOM_PORT:組み込み実サーバーを起動し、HTTPクライアントから応答を確認する。

MockMvcの利用と依存先のモック化は別の選択です。読み込む構成、モックへ置き換える範囲、実サーバーの必要性を分けて整理すれば、テストで確認できることと、別のテストで補うことが明確になります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次