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

この記事では、Spring Bootアプリケーションで初めてテストコードを書く方に向けて、JUnitとMockitoを使ったController層とService層の単体テストの書き方を段階的に解説します。記事を読み終えた後には、自分のプロジェクトで基本的なテストコードを独力で書けるようになることを目指します。

なぜSpring Bootアプリケーションにテストが必要なのか

実務でテストコードを書く理由は主に3つです。

まず、アプリケーションが成長すると手動テストでは限界があります。自動テストなら、コマンド一つで数秒〜数分ですべての機能を検証できますよね。

次に、リファクタリングや機能追加の際の安全網になります。テストコードがあれば、変更によって既存機能が壊れていないかを即座に確認できます。

最後に、バグの早期発見でコスト削減につながります。開発中にバグを見つけられれば、本番環境にリリースされてから発見されるよりもはるかに低コストで対処できます。

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

単体テストとは

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

JUnit 5とMockito

JUnit 5 は、Javaのテストを実行するためのフレームワークです。@Testアノテーションを付けたメソッドがテストとして認識されます。

Mockito は、依存するオブジェクトの「モック(偽物)」を作成するライブラリです。例えば、Service層をテストする際に、実際のRepositoryではなくモックを使うことで、データベースなしでロジックだけをテストできます。

これらはspring-boot-starter-testに含まれているので、特別な設定は不要です。

@WebMvcTestと@MockBeanの使い分け

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

単体テストでは、テスト対象の層だけを起動する@WebMvcTestを使うことで、テストの実行速度を大幅に改善できます。

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

テストを書く前に、テスト対象となるシンプルなユーザー管理APIを実装します。

pom.xmlの依存関係設定

Spring Boot 3.2以降のプロジェクトなら、spring-boot-starter-testが自動的に含まれています。念のため、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のRepositoryインターフェースを使用しますが、ここではテストの説明を簡潔にするためMapベースのシンプルな実装を使用します。save()findById()findAll()の3つのメソッドを持つ@Repositoryクラスとして実装してください。

UserNotFoundException

カスタム例外クラスとして、RuntimeExceptionを継承したUserNotFoundExceptionを作成します。

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を@ExceptionHandlerで受け取り、404ステータスとエラーレスポンスを返すように実装してください。詳しい例外ハンドリングの実装方法については、Spring BootのREST APIで例外をハンドリングする方法の記事で解説しています。

UserController

ControllerはHTTPリクエストを受け取り、Serviceを呼び出して結果を返します。

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());
    }
}

UserCreateRequest

POSTリクエストのボディを受け取るDTOクラスです。nameとemailフィールドを持つシンプルなPOJOとして実装してください。

このアプリケーションでは、各層が明確に責務を分担しています。

  • Controller: HTTPリクエストのハンドリング
  • Service: ビジネスロジック
  • Repository: データの永続化

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

Service層の単体テストを書く

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

基本的な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);
    }
}

コードの解説

  • @ExtendWith(MockitoExtension.class): JUnit 5でMockitoを使うための設定です。
  • @Mock: モックオブジェクトを作成します。ここではUserRepositoryのモックを作成しています。
  • @InjectMocks: モックを注入したテスト対象のオブジェクトを作成します。UserServiceに@MockのUserRepositoryが自動的に注入されます。
  • when().thenReturn(): モックの振る舞いを定義します。userRepository.findById(userId)が呼ばれたときに、指定したUserオブジェクトを返すように設定しています。
  • verify(): メソッドが期待通りに呼ばれたかを検証します。ここではfindByIdが1回だけ呼ばれたことを確認しています。

異常系のテスト

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

@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());
    assertEquals(name, result.getName());
    assertEquals(email, result.getEmail());
    verify(userRepository, times(1)).save(any(User.class));
}

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

Controller層の単体テストを書く

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

基本的なControllerテストの構造

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): UserControllerだけをテストするために、必要最小限のコンポーネントだけを起動します。
  • @Autowired MockMvc: HTTPリクエストをシミュレートするためのツールです。
  • @MockBean UserService: UserServiceをモック化し、Spring ApplicationContextに登録します。
  • mockMvc.perform(): HTTPリクエストをシミュレートします。
  • andExpect(): レスポンスの検証を行います。ステータスコード、Content-Type、JSONの内容などを検証できます。
  • jsonPath(): JSONレスポンスの特定のフィールドを検証します。

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-Typeを指定し、.content()でリクエストボディを設定します。また、.andExpect(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が実装されていることで正しく動作します。@RestControllerAdviceによって例外が適切にハンドリングされ、404ステータスとエラーレスポンスが返されます。

テストを実行する

テストコードを書いたら、実際に実行して結果を確認しましょう。

IDEでテストを実行する

IntelliJ IDEA

  1. テストクラスまたはテストメソッドを右クリック
  2. 「Run ‘テスト名’」を選択
  3. 画面下部にテスト結果が表示されます

Eclipse

  1. テストクラスまたはテストメソッドを右クリック
  2. 「Run As」→「JUnit Test」を選択
  3. JUnitビューにテスト結果が表示されます

Mavenコマンドでテストを実行する

コマンドラインからすべてのテストを実行するには、プロジェクトのルートディレクトリで以下のコマンドを実行します。

./mvnw test

Windowsの場合は、mvnw.cmd testを実行します。

特定のテストだけを実行する

特定のテストクラスだけを実行したい場合は、以下のようにします。

./mvnw test -Dtest=UserServiceTest

特定のテストメソッドだけを実行する場合は、以下のようにします。

./mvnw test -Dtest=UserServiceTest#getUserById_shouldReturnUser_whenUserExists

テスト結果の読み方

テストが成功すると、以下のような出力が表示されます。

[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
  • Tests run: 実行されたテストの総数
  • Failures: アサーションが失敗したテストの数
  • Errors: 予期しないエラーが発生したテストの数
  • Skipped: スキップされたテストの数

テストが失敗すると、どのテストが失敗したか、どのアサーションが失敗したかが詳細に表示されます。

テスト実装時のポイント

テストを書いていてつまずきやすいのは、モックの扱いです。@WebMvcTestを使う場合、Controllerが依存するServiceは@MockBeanでモック化する必要があります。また、モックの振る舞いをwhen().thenReturn()で定義しないと、デフォルトでnullが返されてNullPointerExceptionが発生します。

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

まとめ

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

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

実務では、テスト名を説明的にし、Given-When-Thenパターンで読みやすく書くことが大切です。Repository層のテストや統合テストについては、今後の記事で詳しく解説する予定です。