Spring Bootでアプリケーション開発ができるようになったけれど、テストコードはまだ書いたことがない。そんな方は多いのではないでしょうか。

この記事では、JUnitとMockitoを使ったController層とService層の単体テストの書き方を段階的に解説します。テストコードはリファクタリングや機能追加の安全網になり、バグを本番前に見つけるコストを大きく下げてくれます。読み終えた後には、自分のプロジェクトで基本的なテストを独力で書けるようになることを目指します。

Spring Bootのテストに使う主要なツール

単体テストとは、クラスやメソッドといった最小単位を、他の依存から切り離してテストすることです。依存コンポーネントはモックで置き換えることで、テスト対象の振る舞いだけに集中できます。

JUnitとSpring Bootテストサポートの関係

『JUnit vs Spring Boot』という比較で検索される方も多いですが、これらは競合関係ではなく、役割が異なります。

  • JUnit 5: テストの実行エンジン。@Testの認識、アサーション、ライフサイクル管理を担う基盤。
  • Spring Boot Test (spring-boot-starter-test): JUnit 5の上に乗る形で、@SpringBootTest@WebMvcTestなどSpring特有のテストサポートを提供。
  • Mockito: 依存オブジェクトをモック化するライブラリ。JUnitとは独立に動作します。

つまり、Spring BootのテストはJUnit 5を土台に、Spring Boot Test + Mockitoを組み合わせて書きます。Spring Boot 2.4以降はJUnit 5がデフォルトです。JUnit 4から移行する場合は@RunWith@ExtendWithに、@Before@BeforeEachに変わるなど主要アノテーションが一新されているため、新規プロジェクトでは迷わずJUnit 5を選びましょう。

spring-boot-starter-testには何が含まれているのか

spring-boot-starter-testは、テストに必要なライブラリ一式をまとめて導入してくれるスターターです。

ライブラリ役割
JUnit 5 (Jupiter)テストの実行エンジン。@Testの認識・ライフサイクル管理
Spring Test / Spring Boot Test@SpringBootTest@WebMvcTestMockMvcなどSpring統合テストサポート
Mockitoモックの作成(@Mock / @MockBean)
AssertJassertThatによる流れるような記法のアサーション
Hamcrestマッチャーライブラリ
JSONassert / JsonPathJSONレスポンスの検証(jsonPath()の実体)
XMLUnitXMLの検証

この記事に登場するJUnit・Mockito・MockMvcはすべて同梱されており、バージョンの整合性はSpring Boot側で管理されています。個別にバージョン指定して追加する必要はありません。

@WebMvcTestと@MockBeanの使い分け

  • @WebMvcTest: Controller層だけをテストするために、必要最小限のコンポーネントだけを起動します。軽量で高速です。
  • @MockBean: モックのBeanを作成し、DIコンテナに登録します。@WebMvcTestでは、Controllerが依存するServiceを@MockBeanでモック化します。

Mockito単体で動くテストでは@Mock、Springコンテキストを起動するテストでは@MockBeanと使い分けます。

Spring Boot 3.4以降は@MockBeanが非推奨に

Spring Boot 3.4(Spring Framework 6.2)で、@MockBean / @SpyBeanは非推奨になりました。後継はSpring Framework本体が提供する@MockitoBean / @MockitoSpyBeanです。

// Spring Boot 3.3まで
import org.springframework.boot.test.mock.mockito.MockBean;

@MockBean
private UserService userService;

// Spring Boot 3.4以降
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@MockitoBean
private UserService userService;

使い方は基本的に同じで、importとアノテーション名を置き換えるだけで移行できます。本記事のコード例は幅広いバージョンで動く@MockBeanで記載していますが、Spring Boot 3.4以降の新規プロジェクトでは@MockitoBeanを使いましょう。

テスト対象のサンプルアプリケーションを準備する

テスト対象となるシンプルなユーザー管理APIを実装します。spring-boot-starter-testはSpring Initializrで作ったプロジェクトなら最初から含まれていますが、念のためpom.xmlを確認しておきましょう。

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

Userエンティティ

package com.example.demo.model;

public class User {
    private Long id;
    private String name;
    private String email;

    // コンストラクタ
    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    // ゲッター・セッター
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}

UserRepositoryと周辺クラス

実務ではSpring Data JPAを使いますが、ここでは説明を簡潔にするため、save()findById()findAll()を持つMapベースの@Repositoryクラスを用意してください。あわせて、RuntimeExceptionを継承したUserNotFoundExceptionと、nameとemailを持つDTOのUserCreateRequestも作成します。

UserService

Serviceはビジネスロジックを担当します。ユーザーが存在しない場合に例外をスローする処理を含めています。

package com.example.demo.service;

import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(String name, String email) {
        User user = new User(null, name, email);
        return userRepository.save(user);
    }

    public User getUserById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("User not found: " + id));
    }

    public List<User> getAllUsers() {
        return userRepository.findAll();
    }
}

GlobalExceptionHandler

Controller層の異常系テストを動作させるために、@RestControllerAdviceでUserNotFoundExceptionを受け取り、404ステータスとエラーレスポンスを返すように実装してください。実装方法はSpring BootのREST APIで例外をハンドリングする方法で解説しています。

UserController

package com.example.demo.controller;

import com.example.demo.dto.UserCreateRequest;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }

    @GetMapping
    public List<User> getAllUsers() {
        return userService.getAllUsers();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public User createUser(@RequestBody UserCreateRequest request) {
        return userService.createUser(request.getName(), request.getEmail());
    }
}

各層は依存性注入(DI)によって疎結合に保たれており、テストしやすい設計になっています。Controllerは@Componentの特殊化である@RestControllerとしてSpringに管理されています。

Service層の単体テストを書く

まず、Service層のテストから始めましょう。Repositoryをモック化して、Serviceのビジネスロジックだけをテストします。

package com.example.demo.service;

import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void getUserById_shouldReturnUser_whenUserExists() {
        // Given: テストデータの準備
        Long userId = 1L;
        User expectedUser = new User(userId, "太郎", "[email protected]");
        when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));

        // When: テスト対象のメソッド実行
        User actualUser = userService.getUserById(userId);

        // Then: 結果の検証
        assertNotNull(actualUser);
        assertEquals(expectedUser.getId(), actualUser.getId());
        assertEquals(expectedUser.getName(), actualUser.getName());
        assertEquals(expectedUser.getEmail(), actualUser.getEmail());
        verify(userRepository, times(1)).findById(userId);
    }
}

ポイントは3つです。@ExtendWith(MockitoExtension.class) でJUnit 5にMockitoを組み込み、@Mock でUserRepositoryのモックを作り、@InjectMocks でそのモックを注入したテスト対象(UserService)を組み立てています。テスト本体はGiven-When-Thenの3段構成で、準備・実行・検証を分けて書くと読みやすくなりますよ。

異常系のテスト

正常系だけでなく、異常系のテストも重要です。ユーザーが見つからない場合の挙動をテストします。

@Test
void getUserById_shouldThrowException_whenUserNotFound() {
    // Given
    Long userId = 999L;
    when(userRepository.findById(userId)).thenReturn(Optional.empty());

    // When & Then
    UserNotFoundException exception = assertThrows(
        UserNotFoundException.class,
        () -> userService.getUserById(userId)
    );
    
    assertTrue(exception.getMessage().contains("User not found"));
    verify(userRepository, times(1)).findById(userId);
}

assertThrows を使うと、特定の例外がスローされることを検証できます。

createUserのテスト

@Test
void createUser_shouldSaveAndReturnUser() {
    // Given
    String name = "太郎";
    String email = "[email protected]";
    User savedUser = new User(1L, name, email);
    when(userRepository.save(any(User.class))).thenReturn(savedUser);

    // When
    User result = userService.createUser(name, email);

    // Then
    assertNotNull(result);
    assertEquals(1L, result.getId());
    verify(userRepository, times(1)).save(any(User.class));
}

any(User.class) を使うと、任意のUserオブジェクトが渡された場合の振る舞いを定義できます。

Mockitoのwhen/thenReturnの使い方

ここまでのテストでも使ってきたwhen().thenReturn()は、モックの戻り値を定義するMockitoの基本APIです。パターンごとの書き方をまとめて整理します。

// 基本形: 引数に応じた戻り値を定義
when(userRepository.findById(1L)).thenReturn(Optional.of(user));

// 任意の引数にマッチさせる
when(userRepository.findById(anyLong())).thenReturn(Optional.of(user));

// 例外をスローさせる
when(userRepository.findById(999L)).thenThrow(new IllegalStateException("DB error"));

// 呼び出しごとに異なる値を返す
when(userRepository.findAll())
    .thenReturn(List.of(user1))          // 1回目の呼び出し
    .thenReturn(List.of(user1, user2));  // 2回目以降の呼び出し

つまずきやすい注意点は3つあります。

  • voidメソッドにはwhen()が使えない: 例外をスローさせたい場合はdoThrow(new RuntimeException()).when(mock).deleteById(1L);のようにdo系APIを使います。
  • 引数マッチャーは全引数で統一する: when(service.find(anyLong(), "name"))のようにマッチャーと実値を混在させるとInvalidUseOfMatchersExceptionが発生します。実値側をeq("name")で包みましょう。
  • 使わないスタブを定義しない: MockitoExtensionはデフォルトでstrict stubbingが有効なため、一度も呼ばれないwhen()定義が残っているとUnnecessaryStubbingExceptionでテストが失敗します。

なお、戻り値を定義しなかった場合、モックは参照型でnull、プリミティブで0やfalseを返します。テスト対象が呼ぶメソッドの戻り値を設定し忘れるとNullPointerExceptionにつながるので気をつけてください。

Mockitoのverifyの使い方

verify()は「モックのメソッドが期待通りに呼ばれたか」を検証するAPIです。アサーションがテストの「結果」を見るのに対し、verify()は「過程(モックとの相互作用)」を見ます。

// 1回呼ばれたことを検証(times(1)は省略可能)
verify(userRepository).findById(1L);
verify(userRepository, times(1)).findById(1L);

// 一度も呼ばれていないことを検証
verify(userRepository, never()).deleteById(anyLong());

// 回数の範囲を検証
verify(userRepository, atLeastOnce()).findAll();
verify(userRepository, atMost(2)).findAll();

// モックが一切使われていないことを検証
verifyNoInteractions(mailSender);

モックに渡された引数の中身まで検証したい場合は、ArgumentCaptorを使います。

ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());

User savedUser = captor.getValue();
assertEquals("太郎", savedUser.getName());
assertEquals("[email protected]", savedUser.getEmail());

ひとつ注意したいのは、verify()の使いすぎです。内部実装の呼び出し回数まで細かく縛ると、リファクタリングのたびにテストが壊れる「脆いテスト」になります。検証は「保存された」「通知が送られた」など、外部から観測できる重要な相互作用に絞るのが実務的です。

Controller層の単体テストを書く

Controller層のテストでは、HTTPリクエストとレスポンスが正しく処理されるかを検証します。@WebMvcTestとMockMvcを使います。

package com.example.demo.controller;

import com.example.demo.dto.UserCreateRequest;
import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void getUser_shouldReturnUser_whenUserExists() throws Exception {
        // Given
        Long userId = 1L;
        User user = new User(userId, "太郎", "[email protected]");
        when(userService.getUserById(userId)).thenReturn(user);

        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("太郎"))
            .andExpect(jsonPath("$.email").value("[email protected]"));

        verify(userService, times(1)).getUserById(userId);
    }
}

@WebMvcTest(UserController.class) で対象Controllerだけを起動し、MockMvc でHTTPリクエストをシミュレートして、andExpect()でステータスコードやJSONの内容を検証する流れです。@WebMvcTestはService Beanを起動しないため、Serviceを@AutowiredしようとしてもBeanが見つからずエラーになります。依存するServiceは @MockBean でモック化するのが正しい使い方で、実Serviceを使いたい場合は後述の@SpringBootTest + @AutoConfigureMockMvcを選びましょう。

POSTリクエストのテスト

@Test
void createUser_shouldReturnCreatedUser() throws Exception {
    // Given
    User createdUser = new User(1L, "太郎", "[email protected]");
    when(userService.createUser("太郎", "[email protected]")).thenReturn(createdUser);

    // When & Then
    mockMvc.perform(
            post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"太郎\",\"email\":\"[email protected]\"}")
        )
        .andExpect(status().isCreated())
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.name").value("太郎"))
        .andExpect(jsonPath("$.email").value("[email protected]"));

    verify(userService, times(1)).createUser("太郎", "[email protected]");
}

POSTでは.contentType().content()でリクエストボディを設定し、status().isCreated()で201が返ることを検証しています。バリデーションを含むリクエストのテストは@Valid アノテーションでバリデーションを実装する方法も参考にしてください。

異常系のテスト(404エラー)

@Test
void getUser_shouldReturn404_whenUserNotFound() throws Exception {
    // Given
    Long userId = 999L;
    when(userService.getUserById(userId))
        .thenThrow(new UserNotFoundException("User not found: " + userId));

    // When & Then
    mockMvc.perform(get("/api/users/{id}", userId))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.code").value("USER_NOT_FOUND"))
        .andExpect(jsonPath("$.message").value("User not found: 999"));

    verify(userService, times(1)).getUserById(userId);
}

このテストは、前述のGlobalExceptionHandlerが実装されていることで正しく動作します。

テストを実行する

IDEならテストクラスを右クリックして実行するだけです(IntelliJ IDEAは「Run」、Eclipseは「Run As」から「JUnit Test」)。コマンドラインからは以下で全テストを実行できます。

./mvnw test

特定のテストクラスやメソッドだけ実行したい場合は-Dtestで絞り込みます。

./mvnw test -Dtest=UserServiceTest
./mvnw test -Dtest=UserServiceTest#getUserById_shouldReturnUser_whenUserExists

実行後はTests run: 6, Failures: 0, Errors: 0, Skipped: 0のようなサマリが表示され、失敗時にはどのアサーションで落ちたかが詳細に出力されます。

@WebMvcTestと@SpringBootTestの使い分け

Spring Bootのテストアノテーションは、起動するコンテキストの範囲が異なります。目的に応じて使い分けましょう。

アノテーション起動範囲主な用途速度
@WebMvcTestController層のみ(MVC関連Beanのみ)Controller単体テスト高速
@DataJpaTestJPA関連Beanのみ(Repository + H2など)Repository単体テスト高速
@SpringBootTestアプリ全体のApplicationContext統合テスト・E2E低速
@SpringBootTest
@AutoConfigureMockMvc
class UserIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void createAndGetUser_endToEnd() throws Exception {
        // Controllerから実DBまで通しでテスト
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"花子\",\"email\":\"[email protected]\"}"))
            .andExpect(status().isCreated());
    }
}

@SpringBootTestは本物のApplicationContextを起動するため結合を検証できますが、起動に数秒かかります。普段は@WebMvcTest/@DataJpaTestで書き、結合の確認だけ@SpringBootTestを使う のがバランスの良い構成です。テストスイート全体が遅いと感じたら、@SpringBootTestのスライス化に加えて、@MockBeanの組み合わせをクラス間で揃えてコンテキストキャッシュを効かせると大幅に改善します。

@DataJpaTestでRepository層をテストする

Repository層は@DataJpaTestを使うと、JPA関連のBeanとインメモリDB(H2)だけが起動され、高速に動作を検証できます。

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmail_shouldReturnUser_whenEmailExists() {
        User saved = userRepository.save(new User(null, "太郎", "[email protected]"));

        Optional<User> found = userRepository.findByEmail("[email protected]");

        assertTrue(found.isPresent());
        assertEquals(saved.getId(), found.get().getId());
    }
}

@DataJpaTestはデフォルトでテストごとにトランザクションをロールバックするため、テスト間の独立性が保たれます。

テストカバレッジをJaCoCoで計測する

テストを書いたら、どれだけコードがカバーされているかをJaCoCoで計測しましょう。

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

./mvnw testを実行するとtarget/site/jacoco/index.htmlにHTMLレポートが出力されます。実務では行カバレッジ70-80%を目標にしつつ、重要なビジネスロジックは分岐カバレッジも意識するのが現実的です。

テスト実装時のポイント

例外ハンドラのテストでは、ProblemDetailのカスタムフィールドやMDCによるトレースID付与の検証が必要になることもあります。本番品質の例外ハンドラ設計はSpring BootのGlobalExceptionHandlerを本番運用向けに実装するで詳しく解説しています。

また、リクエストDTOにカスタムバリデーションを掛けるケースでは、テスト戦略も変わります。独自バリデーションの実装とテスト方法はSpring Bootでカスタムバリデーションアノテーションを作る方法、イベント駆動な処理のテストはSpring BootのApplicationEventでモジュール間を疎結合にする方法も合わせてご確認ください。

まとめ

この記事では、JUnitとMockitoを使った単体テストの基本を学びました。

  • Service層のテスト: @MockでRepositoryをモック化し、ビジネスロジックを独立してテストする
  • Controller層のテスト: @WebMvcTestとMockMvcでHTTPリクエスト・レスポンスを検証する
  • モックの使い分け: Springコンテキストの有無で@MockBeanと@Mockを使い分ける
  • 正常系・異常系の両方をテスト: エラーケースも忘れずに検証する

テスト名を説明的にし、Given-When-Thenパターンで読みやすく書くことを意識して、まずはService層の1本から始めてみましょう。