【初心者向け】Spring Bootテスト戦略完全ガイド:テスト戦略を実践するためのコード一式

この記事で解説したテスト戦略を実践するためのソースコードを提供します。
以下の仕様で環境を構築し、テストを実行できます。


目次

  1. プロジェクト技術仕様
  2. プロジェクトのフォルダ構成
  3. pom.xml
  4. アプリケーション設定ファイル
  5. Javaソースコード
  6. Javaテストコード

対象読者

  • 本記事のソースコード全体を参照したい方
  • Spring Bootアプリケーションの具体的な実装例を探している方
  • テストコードを含む完全なプロジェクト構造を理解したい方

関連記事

Spring Bootのテスト戦略や実施方法については、以下の記事で詳細に解説していますので、是非ご覧ください。

https://www.visionnurture.com/spring_web_code_guide_for_beginner_004/

Spring Bootの開発環境の構築については、以下の記事で詳細に解説していますので、是非ご覧ください。


1. プロジェクト技術仕様

Spring Boot 3.x世代のモダンな設計思想に基づき、Testcontainersを活用した信頼性の高いテスト戦略を取り入れた、堅牢かつ保守性の高いJavaバックエンドアプリケーション

  • プログラミング言語
    • Java 25: 最新のLTS(長期サポート)版を見据えた、モダンなJavaバージョンを採用しています。
  • コアフレームワーク
    • Spring Boot 3.5.7: 高速なアプリケーション開発を実現する、業界標準のフレームワークです。内蔵Webサーバ(Tomcatがデフォルト)を持ち、設定の自動化(auto-configuration)によって迅速な立ち上げを可能にします。
  • アーキテクチャ
    • 3層アーキテクチャ: controller(プレゼンテーション層)、service(ビジネスロジック層)、repository(データアクセス層)という典型的な関心事の分離に基づいた、堅牢で保守性の高い構成です。
    • RESTful API: spring-boot-starter-web の利用から、HTTPをベースとしたRESTfulなWeb APIを公開するバックエンドアプリケーションであることが分かります。
  • データ永続化
    • Spring Data JPA: データアクセス層の実装を簡素化するO/Rマッピング(ORM)ソリューションです。UserRepository のようなインターフェースを定義するだけで、実行時に具体的な実装が自動生成されます。
    • PostgreSQL: 本番環境での利用を想定した、信頼性の高いリレーショナルデータベース(RDB)です。
    • H2 Database: 開発時や単体テストで利用される軽量なインメモリデータベースです。環境構築の手間を省き、開発サイクルを高速化します。
    • Spring Data Redis: CacheService の存在とこの依存関係から、パフォーマンス向上のためのキャッシュ基盤としてRedisを利用していることが強く示唆されます。
  • ビルド・依存関係管理
    • Apache Maven: プロジェクトのビルド、パッケージング、依存関係の管理を行うための標準的なツールです。pom.xml にてプロジェクト構成が一元管理されています。
    • Maven Wrapper (mvnw): 開発者間でMavenのバージョンを統一し、環境差異によるビルドエラーを防ぎます。
  • テスト戦略
    • JUnit 5: Javaにおける標準的なテストフレームワークです。
    • Testcontainers: Dockerコンテナをテストコードから直接操作するためのライブラリです。PostgreSQLやRedisといったミドルウェアをコンテナとして起動し、本番環境に近い状態での信頼性の高い統合テストを実現します。
    • spring-boot-testcontainers: Spring Boot 3.1から導入された機能で、@ServiceConnection アノテーションにより、Testcontainersで起動したコンテナへの接続設定を自動化し、テストコードの記述を大幅に簡略化します。
  • 開発支援ツール
    • Lombok: @Data@Getter などのアノテーションを記述するだけで、コンパイル時に定型的なメソッド(getter/setter, toString() など)を自動生成し、コードの記述量を削減します。

2. プロジェクトのフォルダ構成

.
├── pom.xml
├── src/
   ├── main/
      ├── java/
         └── com/
             └── example/
                 └── demo/
                     ├── DemoApplication.java
                     ├── exception/
                        └── UserNotFoundException.java
                     ├── model/
                        ├── Product.java
                        └── User.java
                     ├── repository/
                        ├── ProductRepository.java
                        └── UserRepository.java
                     ├── service/
                        ├── CacheService.java
                        └── UserService.java
                     └── controller/
                         └── UserController.java
      └── resources/
          └── application.properties
   └── test/
       └── java/
           └── com/
               └── example/
                   └── demo/
                       ├── controller/
                          ├── UserControllerIntegrationTest.java
                          └── UserControllerWebMvcTest.java
                       ├── repository/
                          ├── ProductRepositoryTest.java
                          └── UserRepositoryTest.java
                       └── service/
                           ├── CacheServiceTest.java
                           └── UserServiceTest.java

3. pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<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.7</version>
        <relativePath/>
    </parent>

    <!-- =============================================================== -->
    <!-- プロジェクト基本情報                                            -->
    <!-- =============================================================== -->
    <groupId>com.example</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>

    <!-- =============================================================== -->
    <!-- プロパティ定義                                                  -->
    <!-- =============================================================== -->
    <properties>
        <java.version>25</java.version>
        <testcontainers.version>2.0.1</testcontainers.version>
    </properties>

    <!-- =============================================================== -->
    <!-- 依存関係の定義                                                  -->
    <!-- =============================================================== -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

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

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

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

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

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-postgresql</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>com.redis</groupId>
            <artifactId>testcontainers-redis</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    </dependencies>

    <!-- =============================================================== -->
    <!-- 依存関係バージョンの集中管理                                    -->
    <!-- =============================================================== -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.testcontainers</groupId>
                <artifactId>testcontainers-bom</artifactId>
                <version>${testcontainers.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <!-- =============================================================== -->
    <!-- ビルド設定                                                      -->
    <!-- =============================================================== -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

4. アプリケーション設定ファイル

src/main/resources/application.properties

# ===============================================================
# DATABASE CONNECTION (データベース接続設定)
# ===============================================================
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password

# ===============================================================
# JPA & HIBERNATE CONFIGURATION (JPAおよびHibernate設定)
# ===============================================================
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update

# ===============================================================
# REDIS CONNECTION (Redis接続設定)
# ===============================================================
spring.redis.host=localhost
spring.redis.port=6379

5. Javaソースコード

src/main/java/com/example/demo/DemoApplication.java

package com.example.demo;

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

/**
 * このアプリケーションのエントリーポイント(起動クラス)です。
 * Javaアプリケーションとして実行されると、ここからSpring Bootが起動します。
 */
@SpringBootApplication
public class DemoApplication {

    /**
     * このアプリケーションのメインメソッド。
     * Javaプログラムは、このmainメソッドから実行が開始されます。
     *
     * @param args コマンドライン引数。
     */
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

src/main/java/com/example/demo/exception/UserNotFoundException.java

package com.example.demo.exception;

/**
 * ユーザーが見つからない場合にスローされる、アプリケーション独自のカスタム例外クラスです。
 */
public class UserNotFoundException extends RuntimeException {

    /**
     * エラーメッセージを受け取るコンストラクタ。
     *
     * @param message 例外の詳細メッセージ。
     */
    public UserNotFoundException(String message) {
        // 親クラスであるRuntimeExceptionのコンストラクタを呼び出し、メッセージを渡します。
        super(message);
    }
}

src/main/java/com/example.demo.model/User.java

package com.example.demo.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Objects;

/**
 * ユーザー情報を表すエンティティクラス。
 * このクラスのオブジェクトは、データベースの "users" テーブルの1レコードに対応します。
 *
 * @see jakarta.persistence.Entity
 */
@Entity
@Table(name = "users")
public class User {

    /**
     * ユーザーID。データベースの主キー(Primary Key)です。
     */
    // @Id: このフィールドがエンティティの主キーであることを示します。
    @Id
    // @GeneratedValue: 主キーの値をデータベースの自動採番機能に任せることを指定します。
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * ユーザー名。
     */
    private String username;

    /**
     * デフォルトコンストラクタ。
     * JPAがデータベースから取得したデータを使ってこのクラスのインスタンスを生成する際に必要となります。
     */
    public User() {}

    /**
     * 全てのフィールドを初期化するコンストラクタ。
     *
     * @param id       ユーザーID
     * @param username ユーザー名
     */
    public User(Long id, String username) {
        this.id = id;
        this.username = username;
    }

    // --- Getters and Setters ---
    // 各フィールドの値を取得(get)したり、設定(set)したりするためのメソッド群です。

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    // --- Object-related methods ---
    // オブジェクト同士が等しいかを判断するための equals() と、
    // オブジェクトのハッシュ値を返す hashCode() メソッド。
    // これらを適切に実装することで、SetやMapなどのコレクションでこのオブジェクトを正しく扱えます。

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return (
            Objects.equals(id, user.id) &&
            Objects.equals(username, user.username)
        );
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, username);
    }
}

src/main/java/com/example/demo/model/Product.java

package com.example.demo.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Objects;

/**
 * 商品情報を表すエンティティクラス。
 * このクラスのオブジェクトは、データベースの "products" テーブルの1レコードに対応します。
 *
 * @see jakarta.persistence.Entity
 */
@Entity
@Table(name = "products")
public class Product {

    /**
     * 商品ID。データベースの主キー(Primary Key)に対応します。
     */
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /**
     * 商品名。
     */
    private String name;

    /**
     * 商品価格。
     */
    private Double price;

    /**
     * デフォルトコンストラクタ。
     * JPAがエンティティのインスタンスを生成するために必要です。
     */
    public Product() {}

    /**
     * 全てのフィールドを初期化するコンストラクタ。
     * 主にテストコードや、オブジェクトを新規作成する際に便利です。
     *
     * @param id    商品ID
     * @param name  商品名
     * @param price 商品価格
     */
    public Product(Long id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    // --- Getters and Setters ---
    // 各フィールドの値を取得(get)したり、設定(set)したりするためのメソッド群です。
    // このような定型的なコードは、Lombokというライブラリを使うと自動生成でき、コードを簡潔に保てます。

    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 Double getPrice() {
        return price;
    }

    public void setPrice(Double price) {
        this.price = price;
    }

    // --- Object-related methods ---
    // オブジェクトの等価性を比較するための equals() と、ハッシュコードを生成する hashCode() メソッドです。
    // これらを正しくオーバーライドすることで、コレクション(SetやMapなど)でオブジェクトを正しく扱うことができます。

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Product product = (Product) o;
        return (
            Objects.equals(id, product.id) &&
            Objects.equals(name, product.name) &&
            Objects.equals(price, product.price)
        );
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name, price);
    }
}

src/main/java/com/example/demo/repository/UserRepository.java

package com.example.demo.repository;

import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * ユーザー(User)エンティティのデータベース操作を担当するリポジトリインターフェースです。
 * ProductRepositoryと同様に、Spring Data JPAの機能により、基本的なCRUD操作は自動的に実装されます。
 *
 * @see com.example.demo.model.User
 * @see org.springframework.data.jpa.repository.JpaRepository
 */
@Repository
public interface UserRepository extends JpaRepository<User, Long> {}

src/main/java/com/example/demo/repository/ProductRepository.java

package com.example.demo.repository;

import com.example.demo.model.Product;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
 * 商品(Product)エンティティのデータベース操作を担当するリポジトリインターフェースです。
 * Spring Data JPAの魔法により、このインターフェースを定義するだけで、
 * 実行時にSpringが自動的に実装クラスを作成してくれます。
 *
 * @see org.springframework.data.jpa.repository.JpaRepository
 */
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    /**
     * 商品名(name)を元に商品を検索します。
     *
     * @param name 検索したい商品の名前。
     * @return 検索結果の商品をOptionalでラップして返します。商品が見つからない場合は空のOptionalを返します。
     *         Optionalを使うことで、結果がnullになる可能性を明示的に示し、呼び出し側でNullPointerExceptionを防ぎやすくなります。
     */
    Optional<Product> findByName(String name);
}

src/main/java/com/example/demo/service/UserService.java

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;

/**
 * ユーザー情報に関するビジネスロジックを担当するサービスクラス。
 * データベースとのやり取りはUserRepositoryを介して行います。
 */
@Service
public class UserService {

    // ユーザー情報をデータベースから操作するためのリポジトリ。
    private final UserRepository userRepository;

    /**
     * コンストラクタ・インジェクション。
     * SpringがUserServiceのインスタンスを生成する際に、UserRepositoryのインスタンスを自動的に注入します。
     *
     * @param userRepository Springによって注入されるUserRepositoryのインスタンス。
     */
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /**
     * 指定されたIDのユーザー情報を取得します。
     *
     * @param id 取得したいユーザーのID。
     * @return ユーザー情報。
     * @throws UserNotFoundException 指定されたIDのユーザーが見つからない場合にスローされます。
     */
    public User getUserById(Long id) {
        return userRepository
            .findById(id)
            .orElseThrow(() ->
                new UserNotFoundException("User not found with id: " + id)
            );
    }

    /**
     * 新しいユーザーをデータベースに保存します。
     *
     * @param user 保存するユーザーオブジェクト。
     * @return 保存され、IDが採番された後のユーザーオブジェクト。
     */
    public User createUser(User user) {
        // JpaRepositoryが提供するsaveメソッドを使って、ユーザー情報をデータベースに保存します。
        return userRepository.save(user);
    }
}

src/main/java/com/example/demo/service/CacheService.java

package com.example.demo.service;

import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

/**
 * Redisを利用したキャッシュ機能を提供するサービスクラス。
 * 文字列データのキャッシュへの保存、および取得を行います。
 */
@Service
public class CacheService {

    // Redisを操作するためのテンプレートクラス。文字列のキーと値に特化しています。
    private final StringRedisTemplate stringRedisTemplate;

    /**
     * コンストラクタ・インジェクション(Constructor Injection)。
     * SpringがこのCacheServiceのインスタンスを生成する際に、自動的にStringRedisTemplateのインスタンスを
     * 引数として渡し、フィールドに設定します。
     *
     * @Autowiredをフィールドに直接付ける(フィールドインジェクション)よりも、
     * 依存関係が不変(final)であることを保証でき、テストもしやすくなるため、現在推奨されているDIの方法です。
     *
     * @param stringRedisTemplate Springによって注入されるStringRedisTemplateのインスタンス。
     */
    public CacheService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 指定されたキーと値のペアをキャッシュ(Redis)に保存します。
     * このキャッシュは10分後に自動的に期限切れになります。
     *
     * @param key   キャッシュのキー(識別子)。
     * @param value 保存する文字列の値。
     */
    public void cacheValue(String key, String value) {
        stringRedisTemplate.opsForValue().set(key, value, 10, TimeUnit.MINUTES);
    }

    /**
     * 指定されたキーに対応する値をキャッシュ(Redis)から取得します。
     *
     * @param key 取得したい値のキー。
     * @return キーに対応する文字列の値。キーが存在しない場合はnullを返します。
     */
    public String getValue(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
}

src/main/java/com/example/demo/controller/UserController.java

package com.example.demo.controller;

import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

/**
 * ユーザー情報に関するHTTPリクエストを処理するコントローラクラス。
 * APIエンドポイントとして、ユーザーの取得、作成などの機能を提供します。
 *
 * @see com.example.demo.service.UserService
 */
@RestController
@RequestMapping("/api/users")
public class UserController {

    // ユーザー関連のビジネスロジックを担当するサービスクラス。
    private final UserService userService;

    /**
     * コンストラクタ・インジェクション。
     * SpringがUserControllerのインスタンスを生成する際に、UserServiceのインスタンスを自動的に注入します。
     *
     * @param userService Springによって注入されるUserServiceのインスタンス。
     */
    public UserController(UserService userService) {
        this.userService = userService;
    }

    /**
     * 指定されたIDのユーザー情報を取得します。
     * HTTP GETリクエストを "/api/users/{id}" の形式で受け付けます。
     *
     * @param id 取得したいユーザーのID。URLのパスから受け取ります。
     * @return ResponseEntity<User> ユーザー情報を含むHTTPレスポンス。
     */
    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Long id) {
        User user = userService.getUserById(id);
        return ResponseEntity.ok(user);
    }

    /**
     * 新しいユーザーを作成します。
     * HTTP POSTリクエストを "/api/users" の形式で受け付けます。
     *
     * @param user HTTPリクエストのボディに含まれるJSONから生成されたUserオブジェクト。
     * @return ResponseEntity<User> 作成されたユーザー情報と、そのリソースの場所を示すURLを含むHTTPレス_レスポンス。
     */
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User user) {
        User createdUser = userService.createUser(user);
        URI location = URI.create("/api/users/" + createdUser.getId());
        return ResponseEntity.created(location).body(createdUser);
    }

    /**
     * このコントローラ内で UserNotFoundException が発生した場合に呼び出される例外ハンドラメソッド。
     * try-catchブロックを各メソッドに記述する代わりに、例外処理を一元化できます。
     *
     * @param ex 発生した UserNotFoundException オブジェクト。
     * @return エラーメッセージを含むHTTPレスポンス。
     */
    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ResponseEntity<String> handleUserNotFoundException(
        UserNotFoundException ex
    ) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
            ex.getMessage()
        );
    }
}

6. Javaテストコード

src/test/java/com/example/demo/service/UserServiceTest.java

package com.example.demo.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.example.demo.exception.UserNotFoundException;
import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
import java.util.Optional;
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;

/**
 * UserService のユニットテストクラス。
 * このテストは、Springの機能を一切使わずに、Mockitoというライブラリだけを使って
 * UserServiceクラスのロジックが単体で正しく動作するかを検証します。
 * データベースなどの外部依存から完全に切り離されているため、非常に高速に実行できます。
 */
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    // @Mock: モックオブジェクト(偽物のオブジェクト)を作成します。
    // ここでは、データベースと通信するUserRepositoryをモック化し、
    // DBに実際にアクセスすることなく、テストで都合の良い振る舞いをさせることができます。
    @Mock
    private UserRepository userRepository;

    // @InjectMocks: テスト対象のクラス(System Under Test)のインスタンスを生成し、
    // @Mockアノテーションが付いたモックオブジェクトを自動的にそのインスタンスに注入します。
    // ここでは、`new UserService(userRepository)` を実行するのと同じ効果があります。
    @InjectMocks
    private UserService userService;

    /**
     * ユーザーが存在する場合に、getUserByIdメソッドが正しくユーザー情報を返すことをテストします。
     */
    @Test
    void getUserById_shouldReturnUser_whenUserExists() {
        // --- 準備 (Arrange) ---
        // 1. テスト用のUserオブジェクトを作成します。
        User mockUser = new User(1L, "testuser");
        // 2. モックの振る舞いを定義します。
        // 「userRepositoryのfindByIdメソッドが1Lという引数で呼ばれたら、mockUserを含むOptionalを返す」ように設定します。
        when(userRepository.findById(1L)).thenReturn(Optional.of(mockUser));

        // --- 実行 (Act) ---
        // テスト対象のメソッドを呼び出します。
        User foundUser = userService.getUserById(1L);

        // --- 検証 (Assert) ---
        // 1. 戻り値の検証: 取得したユーザーが、準備したモックユーザーと等しいことを確認します。
        assertThat(foundUser).isEqualTo(mockUser);
        // 2. 相互作用の検証: userRepositoryのfindByIdメソッドが、1Lという引数で、ちょうど1回だけ呼び出されたことを確認します。
        verify(userRepository, times(1)).findById(1L);
    }

    /**
     * ユーザーが存在しない場合に、getUserByIdメソッドがUserNotFoundExceptionをスローすることをテストします。
     */
    @Test
    void getUserById_shouldThrowException_whenUserNotFound() {
        // --- 準備 (Arrange) ---
        // モックの振る舞いを定義します。
        // 「userRepositoryのfindByIdメソッドがどんなLong型の引数で呼ばれても、空のOptionalを返す」ように設定します。
        // anyLong() はMockitoの引数マッチャーで、「任意のlong値」を意味します。
        when(userRepository.findById(anyLong())).thenReturn(Optional.empty());

        // --- 実行 & 検証 (Act & Assert) ---
        // userService.getUserById(1L) を実行した結果、例外がスローされることを検証します。
        assertThatThrownBy(() -> userService.getUserById(1L))
            // スローされた例外が UserNotFoundException のインスタンスであることを確認します。
            .isInstanceOf(UserNotFoundException.class);

        // --- (任意) 相互作用の検証 ---
        // userRepositoryのfindByIdメソッドが、1Lという引数で、ちょうど1回だけ呼び出されたことを確認します。
        verify(userRepository, times(1)).findById(1L);
    }
}

src/test/java/com/example/demo/controller/UserControllerIntegrationTest.java

package com.example.demo.controller;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.example.demo.model.User;
import com.example.demo.repository.UserRepository;
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.transaction.annotation.Transactional;

/**
 * UserController のインテグレーションテストクラス。
 * このテストは、アプリケーション全体のコンテキストを起動し、HTTPリクエストの受付から
 * データベースアクセスまで、一連の流れを通してテストします。
 */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserControllerIntegrationTest {

    // HTTPリクエストをシミュレートするためのMockMvc。
    @Autowired
    private MockMvc mockMvc;

    // 実際のデータベース(このテストではH2インメモリDB)と連携する本物のリポジトリ。
    // テストデータの準備のために使用します。
    @Autowired
    private UserRepository userRepository;

    /**
     * ユーザー取得API(GET /api/users/{id})が、データベースに存在するユーザーを
     * 正しく取得できるかをテストします。
     */
    @Test
    void getUserById_shouldReturnUser_whenUserExists() throws Exception {
        // --- 準備 (Arrange) ---
        // モックではなく、実際のリポジトリを使ってテストデータをデータベースに保存します。
        User savedUser = userRepository.save(
            new User(null, "integration-test-user")
        );

        // --- 実行 & 検証 (Act & Assert) ---
        // MockMvcを使ってAPIエンドポイントにリクエストを送信し、レスポンスを検証します。
        mockMvc
            .perform(get("/api/users/" + savedUser.getId()))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(savedUser.getId()))
            .andExpect(jsonPath("$.username").value("integration-test-user"));
    }

    /**
     * ユーザー取得API(GET /api/users/{id})が、存在しないユーザーIDに対して
     * HTTP 404 (Not Found) を返すことをテストします。
     */
    @Test
    void getUserById_shouldReturnNotFound_whenUserDoesNotExist()
        throws Exception {
        // --- 実行 & 検証 (Act & Assert) ---
        // 存在しないID(例: 999L)でAPIを呼び出し、ステータスが404になることを検証します。
        mockMvc.perform(get("/api/users/999")).andExpect(status().isNotFound());
    }
}

src/test/java/com/example/demo/controller/UserControllerWebMvcTest.java

package com.example.demo.controller;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

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;

/**
 * UserController のWeb層に特化したテストクラス(スライステスト)。
 * 実際にHTTPリクエストを送信し、コントローラが期待通りのレスポンス(HTTPステータスやJSON)を返すかを検証します。
 * サービス層より下はモック化するため、データベースへのアクセスは発生しません。
 */
@WebMvcTest(UserController.class)
class UserControllerWebMvcTest {

    // @Autowired: SpringのテストコンテキストからMockMvcのインスタンスを注入します。
    // MockMvcは、HTTPリクエストをシミュレートし、レスポンスを検証するためのメインとなるクラスです。
    @Autowired
    private MockMvc mockMvc;

    // @MockBean: Springのアプリケーションコンテキストに存在するBeanをMockitoのモックに置き換えます。
    // ここでは、UserControllerが依存しているUserServiceをモック化しています。
    // これにより、DBアクセスを伴う実際のビジネスロジックは実行されず、テストで定義した通りの振る舞いをします。
    // ※ @MockBeanはSpring Boot 3.4.0で非推奨となりましたが、@WebMvcTestのようなスライステストでは依然として有効な選択肢です。
    //   ここではその意図を明確にし、警告を抑制するために @SuppressWarnings を使用します。
    @SuppressWarnings("deprecation")
    @MockBean
    private UserService userService;

    /**
     * ユーザー取得API(GET /api/users/{id})が、ユーザーが存在する場合に
     * HTTP 200 (OK) とユーザー情報をJSONで返すことをテストします。
     */
    @Test
    void getUserById_shouldReturnUser_whenUserExists() throws Exception {
        // --- 準備 (Arrange) ---
        // 1. モックが返すダミーのユーザーオブジェクトを作成します。
        User mockUser = new User(1L, "testuser");
        // 2. モックの振る舞いを定義します。
        // 「userServiceのgetUserByIdメソッドが1Lという引数で呼ばれたら、mockUserを返す」ように設定します。
        when(userService.getUserById(1L)).thenReturn(mockUser);

        // --- 実行 & 検証 (Act & Assert) ---
        // MockMvcを使って、GETリクエストを "/api/users/1" に擬似的に送信します。
        mockMvc
            .perform(get("/api/users/1"))
            // andExpect() でレスポンスの内容を検証します。
            // HTTPステータスが200 (OK) であることを期待します。
            .andExpect(status().isOk())
            // レスポンスのJSONボディの内容を検証します。
            // jsonPath("$.id") はJSONのルートにあるidフィールドを指します。
            .andExpect(jsonPath("$.id").value(1L))
            .andExpect(jsonPath("$.username").value("testuser"));
    }

    /**
     * ユーザー取得API(GET /api/users/{id})が、ユーザーが存在しない場合に
     * HTTP 404 (Not Found) を返すことをテストします。
     */
    @Test
    void getUserById_shouldReturnNotFound_whenUserDoesNotExist()
        throws Exception {
        // --- 準備 (Arrange) ---
        // 「userServiceのgetUserByIdメソッドが1Lで呼ばれたら、UserNotFoundExceptionをスローする」ように設定します。
        when(userService.getUserById(1L)).thenThrow(
            new UserNotFoundException("User not found")
        );

        // --- 実行 & 検証 (Act & Assert) ---
        // GETリクエストを "/api/users/1" に送信し、HTTPステータスが404 (Not Found) であることを期待します。
        // UserControllerに定義した@ExceptionHandlerが正しく機能しているかを検証することになります。
        mockMvc.perform(get("/api/users/1")).andExpect(status().isNotFound());
    }

    /**
     * ユーザー作成API(POST /api/users)が、正しくユーザーを作成し、
     * HTTP 201 (Created) と作成されたユーザー情報を返すことをテストします。
     */
    @Test
    void createUser_shouldCreateUser() throws Exception {
        // --- 準備 (Arrange) ---
        User savedUser = new User(1L, "newuser");
        // 「userServiceのcreateUserメソッドがどんなUserオブジェクトを引数に取っても、savedUserを返す」ように設定します。
        when(userService.createUser(any(User.class))).thenReturn(savedUser);

        // --- 実行 & 検証 (Act & Assert) ---
        mockMvc
            .perform(
                post("/api/users") // POSTリクエストを送信
                    .contentType(MediaType.APPLICATION_JSON) // リクエストボディのコンテントタイプをJSONに指定
                    .content("{\"username\":\"newuser\"}")
            ) // リクエストボディに含めるJSON文字列
            .andExpect(status().isCreated()) // HTTPステータスが201 (Created) であることを期待
            .andExpect(jsonPath("$.id").value(1L)) // レスポンスJSONのidを検証
            .andExpect(jsonPath("$.username").value("newuser")) // レスポンスJSONのusernameを検証
            .andExpect(header().string("Location", "/api/users/1")); // Locationヘッダーが正しいことを検証
    }
}

src/test/java/com/example/demo/repository/UserRepositoryTest.java

package com.example.demo.repository;

import static org.assertj.core.api.Assertions.assertThat;

import com.example.demo.model.User;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

/**
 * UserRepository のインテグレーションテストクラス。
 * このテストは、Spring Data JPAのテスト支援機能を利用し、
 * 実際のDBではなく、テスト用のインメモリデータベース(H2)を使って実行されます。
 */
@DataJpaTest
class UserRepositoryTest {

    /**
     * TestEntityManagerは、JPAテスト専用の便利なヘルパークラスです。
     * 通常のEntityManagerと似ていますが、テストのコンテキストでのみ利用できます。
     * データの準備や、永続化層の状態を直接確認したい場合に役立ちます。
     * 今回のテストでは使用していませんが、@DataJpaTestでよく使われるため参考として記述しています。
     *
     * 使用例:
     * User user = new User(null, "test");
     * Long id = testEntityManager.persistAndGetId(user, Long.class);
     */
    @Autowired
    private UserRepository userRepository;

    /**
     * findByIdメソッドが、保存したユーザーを正しく取得できるかをテストします。
     */
    @Test
    void findById_shouldReturnUser() {
        // --- 準備 (Arrange) ---
        // テスト用のユーザーオブジェクトを作成します。IDはDBで自動採番されるためnullにしておきます。
        User user = new User(null, "newuser");
        // 作成したユーザーをリポジトリのsaveメソッドでDBに保存します。
        // saveメソッドは、保存後の(IDが採番された)エンティティを返します。
        user = userRepository.save(user);

        // --- 実行 (Act) ---
        // テスト対象のメソッド(findById)を、保存されたユーザーのIDを使って呼び出します。
        Optional<User> foundUser = userRepository.findById(user.getId());

        // --- 検証 (Assert) ---
        // 実行結果が期待通りか検証します。
        assertThat(foundUser).isPresent(); // ユーザーが見つかったことを確認
        assertThat(foundUser.get().getUsername()).isEqualTo("newuser"); // 見つかったユーザーの名前が正しいことを確認
    }
}

src/test/java/com/example/demo/repository/ProductRepositoryTest.java

package com.example.demo.repository;

import static org.assertj.core.api.Assertions.assertThat;

import com.example.demo.model.Product;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

/**
 * ProductRepository のインテグレーションテストクラス。
 * 実際のデータベース(この場合はTestcontainersで起動したPostgreSQL)と連携して、
 * リポジトリのメソッドが正しく動作するかを検証します。
 */
@Testcontainers
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {

    /**
     * テスト用のPostgreSQLコンテナを定義します。
     */
    @Container
    @ServiceConnection
    @SuppressWarnings({ "deprecation", "rawtypes" })
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        "postgres:18-alpine"
    );

    // テスト対象のリポジトリ。Springが自動的にインスタンスを注入(DI)します。
    @Autowired
    private ProductRepository productRepository;

    /**
     * findByNameメソッドが正しく商品を検索できるかをテストします。
     */
    @Test
    void findByName_shouldReturnProduct() {
        // --- 準備 (Arrange) ---
        // テスト用のデータを作成し、データベースに保存します。
        Product product = new Product(null, "Laptop", 1200.0);
        productRepository.save(product);

        // --- 実行 (Act) ---
        // テスト対象のメソッド(findByName)を呼び出します。
        Optional<Product> foundProduct = productRepository.findByName("Laptop");

        // --- 検証 (Assert) ---
        // 実行結果が期待通りであるかを検証します。
        assertThat(foundProduct).isPresent(); // 結果が空(empty)でないことを確認
        assertThat(foundProduct.get().getPrice()).isEqualTo(1200.0); // 取得した商品の価格が正しいことを確認
    }
}

src/test/java/com/example/demo/service/CacheServiceTest.java

package com.example.demo.service;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

/**
 * CacheService のインテグレーションテストクラス。
 * Testcontainersで起動したRedisコンテナと実際に連携して、
 * キャッシュの保存と取得が正しく機能するかを検証します。
 */
@Testcontainers
@SpringBootTest
class CacheServiceTest {

    /**
     * テスト用のRedisコンテナを定義します。
     */
    @Container
    @ServiceConnection
    static GenericContainer<?> redis = new GenericContainer<>(
        DockerImageName.parse("redis:8-alpine")
    ).withExposedPorts(6379);

    // テスト対象のサービス。@SpringBootTestにより、本物のインスタンスがDI(依存性注入)されます。
    @Autowired
    private CacheService cacheService;

    /**
     * 値をキャッシュに保存し、正しく取得できるかをテストします。
     */
    @Test
    void cacheValue_shouldStoreAndRetrieve() {
        // --- 準備 (Arrange) ---
        String key = "myKey";
        String value = "myValue";

        // --- 実行 (Act) ---
        // 1. サービスメソッドを呼び出して、値をキャッシュに保存します。
        cacheService.cacheValue(key, value);
        // 2. サービスメソッドを呼び出して、同じキーで値を取得します。
        String retrievedValue = cacheService.getValue(key);

        // --- 検証 (Assert) ---
        // 取得した値が、最初に保存した値と等しいことを確認します。
        assertThat(retrievedValue).isEqualTo(value);
    }
}

免責事項

本付録のソースコードは、記事の解説を補完するためのサンプルです。
記載されているコードは、特定の目的への適合性や完全性を保証するものではありません。
利用者の責任において、適切に修正・利用してください。


SNSでもご購読できます。

コメントを残す

*