MENU

[Java 初心者学習] Spring Boot開発 (実践編) – 0017

この記事は Java 初心者を卒業し、現場で開発作業に向かうことができるためのものです。Java初心者学習シリーズを最初からここまでやってきても、実際の現場ではペーペーレベルだとは思います。ここから先はここまでの基礎的な部分をおさえたうえで実現場で慣れるしかありません。この記事までをしっかり終わらせ、現場でもまれながら成長していきましょう。

ここまで学習してきた主なことは以下の内容です。

  • Eclipse で Maven プロジェクトを作成
  • アノテーション
  • Spring Boot の初期設定
  • データの受け渡しとJSON
  • Thymeleafによる画面表示
  • Spring Data JPAを用いたDB操作
  • CRUDの概念
  • Docker の導入
  • Spring Boot 開発 (導入編)
  • Docker Compose

今回は Spring Boot で作成された Web アプリケーションに機能を追加し、テストを実施します。具体的にこの記事で学ぶことは以下の通りです。

  • 認証・認可
  • データの関連付け
  • API・非同期を意識した設計
  • コードの品質を保証する : 単体テスト (JUnit/Moccito)

ここから先は、以下の記事まで対応済みであることを前提としています。詳しくは以下の別記事をご参照ください。

目次

Spring Boot開発 (実践編)

認証・認可
(Security)
ユーザーごとにログイン機能を持ち、一般ユーザーと管理者でできることを分ける。
データの関連付け
(Relational Mapping)
「誰がそのデータを登録したか」という、ユーザーとデータの紐付け (1対多の結合)
API・非同期を意識した設計 画面のレンダリングだけでなく、バックエンドとして堅牢なデータ構造を持つ

ここまでが実装されていれば「データを登録・表示する」Web アプリになっているはずです。これに上記3つを付け加えると、システムをセキュアに保ち実務で運用するための基本要素がそろいます。

認証・認可 (Security)

これは、いわゆるログイン機能の追加になります。実装の流れは以下の通りです。

  1. データベース設計
  2. pom.xml にセキュリティライブラリを追加
  3. SiteUser.java の作成
  4. UserRepository.java の作成
  5. テスト用初期ユーザー自動登録
  6. UserService.java の作成
  7. SecurityConfig.java の作成
  8. ログイン画面作成
  9. LoginController.java の作成

データベース設計

Table 項目 説明
User (ユーザー情報) id 主キー
User (ユーザー情報) username ログインID
User (ユーザー情報) password 暗号化されたパスワード
User (ユーザー情報) role 権限
(ROLE_USER, ROLE_ADMIN)
Report (日報データ) id 主キー
Report (日報データ) title 日報タイトル
Report (日報データ) contest 業務内容
Report (日報データ) created_at 提出日時
Report (日報データ) user_id 外部キー

今回はPostgreSQL上に、互いに関連を持った2つの上記テーブルを作成します。

pom.xml にセキュリティライブラリを追加

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.thymeleaf.extras</groupId>
        <artifactId>thymeleaf-extras-springsecurity6</artifactId>
    </dependency>

pom.xml の <dependencies> の中に上記を追記し、Mavenの更新してください。これは、ログイン機能とパスワード暗号化を司るライブラリを導入するためのものです。

まずはベースとなるユーザの概念をシステムに組み込み、Spring Bootの標準セキュリティフレームワークである Spring Security を導入して、全画面に鍵をかけるところからスタートします。

SiteUser.java の作成 (ログインするユーザーを表すクラス)

package com.example.demo;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "site_user") // PostgreSQLの予約語「user」を避けるための命名
public class SiteUser {

    @Id
    private String username; // ログインID(重複不可のため主キーにします)
    private String password; // 暗号化されたパスワード
    private String role;     // 権限(ROLE_USER, ROLE_ADMIN)

    public SiteUser() {}

    public SiteUser(String username, String password, String role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

    // ゲッターとセッター
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }
}

com.example.demo パッケージの中に新しく SiteUser.java 作成し、上記のコードを記述してください。これは、ログインするユーザーを表すクラスです。

UserRepository.java の作成 (ユーザーデータをDBから検索するためのリポジトリ)

package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<SiteUser, String> {
    // ログイン処理時に主キー(username)で検索するため、標準のfindByIdがそのまま使えます
}

com.example.demo パッケージの中に新しく UserRepository.java を作成し、上記コードを記述してください。これは、ユーザーデータをDBから検索するためのリポジトリです。

テスト用初期ユーザー自動登録 (起動時にテスト用ユーザーを自動でDBに書き込む処理)

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; // 追記

@SpringBootApplication
public class DemoApplication {

    @Autowired
    private UserRepository userRepository;

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

    @Bean
    public CommandLineRunner initUser() {
        return args -> {
            // 安全なパスワード暗号化(ハッシュ化)エンジン
            var encoder = new BCryptPasswordEncoder();
            
            // テストユーザーが未登録の場合のみ、一般ユーザーと管理者を登録
            if (userRepository.count() == 0) {
                // パスワード「password」を暗号化して保存
                userRepository.save(new SiteUser("user1", encoder.encode("password"), "ROLE_USER"));
                userRepository.save(new SiteUser("admin1", encoder.encode("password"), "ROLE_ADMIN"));
                System.out.println("【システム初期化】テストユーザーを登録しました。");
            }
        };
    }
}

現在の DemoApplication.java を開き、中身を以下のように書き換えてください。DBが空っぽだとログインテストができないため、起動時にテスト用ユーザーを自動でDBに書き込む処理をするものです。

UserService.java の作成 (DBのユーザデータを読み込むためのクラス)

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class UserService implements UserDetailsService {

    @Autowired
    private UserRepository userRepository;

    // ログイン画面で入力されたID(username)を元に、DBからユーザーを検索するメソッド
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // DBからユーザーを取得。存在しない場合はエラーを投げる
        SiteUser siteUser = userRepository.findById(username)
            .orElseThrow(() -> new UsernameNotFoundException("ユーザーが見つかりません: " + username));

        // Spring Securityが理解できる形式(UserDetails)に変換して返す
        return User.builder()
            .username(siteUser.getUsername())
            .password(siteUser.getPassword()) // 暗号化済みのパスワード
            .roles(siteUser.getRole().replace("ROLE_", "")) // "ROLE_USER" から "USER" に変換
            .build();
    }
}

com.example.demo パッケージの中に、新しく UserService.java を作成し、上記のコードを記述してください。これは、Spring SecurityがDBのユーザー情報を読み取れるよう、標準のインターフェース (UserDetailsService) を実装したサービスクラスです。

SecurityConfig.java の作成 (最新のセキュリティフィルターチェーンを定義)

package com.example.demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity // Webセキュリティを有効化
public class SecurityConfig {

    // パスワードの暗号化方式として「BCrypt」を指定(DemoApplicationと同期)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // セキュリティのルール(フィルターチェーン)を設定(Spring Boot 3.x の最新スタイル)
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 1. 各URLへのアクセス権限(認可)の設定
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/css/**", "/js/**").permitAll() // ログイン画面や静的ファイルは全員許可
                .anyRequest().authenticated() // それ以外の全てのURL(日報画面など)はログイン必須
            )
            // 2. ログイン機能の設定
            .formLogin(form -> form
                .loginPage("/login") // 自作するログイン画面のURLを指定
                .defaultSuccessUrl("/report", true) // ログイン成功時のリダイレクト先(次回作成する日報画面)
                .permitAll()
            )
            // 3. ログアウト機能の設定
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout") // ログアウト成功時はログイン画面に戻す
                .permitAll()
            );

        return http.build();
    }
}

com.example.demo パッケージの中に新しく SecurityConfig.java を作成し、上記のコードを記述してください。これは、Spring Boot 3.5に対応した最新のセキュリティフィルターチェーンを定義するものです。

ログイン画面作成

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ログイン - 日報管理システム</title>
    <style>
        body { font-family: sans-serif; background-color: #f5f5f5; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
        .login-box { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); width: 320px; }
        h2 { text-align: center; margin-bottom: 24px; color: #333; }
        .input-group { margin-bottom: 16px; }
        label { display: block; margin-bottom: 6px; font-size: 0.9em; color: #666; }
        input[type="text"], input[type="password"] { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
        button { width: 100%; padding: 12px; background-color: #007bff; color: white; border: none; border-radius: 4px; font-size: 1em; cursor: pointer; margin-top: 10px; }
        button:hover { background-color: #0056b3; }
        .alert { color: red; font-size: 0.9em; margin-bottom: 15px; text-align: center; }
    </style>
</head>
<body>
    <div class="login-box">
        <h2>日報管理システム</h2>
        
        <div th:if="${param.error}" class="alert">
            IDまたはパスワードが正しくありません。
        </div>
        <div th:if="${param.logout}" style="color: green; text-align: center; margin-bottom: 15px; font-size: 0.9em;">
            ログアウトしました。
        </div>

        <form th:action="@{/login}" method="post">
            <div class="input-group">
                <label for="username">ログインID</label>
                <input type="text" id="username" name="username" required autofocus>
            </div>
            <div class="input-group">
                <label for="password">パスワード</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">ログイン</button>
        </form>
    </div>
</body>
</html>

src/main/resources/templates フォルダの中に新しく login.html を作成し、上記のHTMLコードを記述して保存してください。

LoginController.java の作成 (ログインページ表示用)

package com.example.demo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

    @GetMapping("/login")
    public String loginPage() {
        return "login"; // login.htmlを表示
    }
}

com.example.demo パッケージの中に、新しく LoginController.java を作成し、上記コードを入力してください。これは、ログイン画面 (/login) にアクセスされた際、上記で作った login.html をただ呼び出すだけのシンプルなコントローラーです。

データの関連付け (Relational Mapping) とAPI・非同期を意識した設計

ここでは「データをCRUDしたのは誰か」の管理や、スマホからの通信や JavaScript 側からの非同期通信などの多くの経路からのデータ送信を前提とした設計をし、実装していきます。流れは以下の通りです。

  1. Report.java の作成
  2. ReportRepository.java の作成
  3. ReportForm.java の作成
  4. ReportController.java の作成
  5. report.html の作成

Report.java の作成 (SiteUser との間に多対一の結合を定義)

package com.example.demo;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.LocalDateTime;

@Entity
public class Report {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // PostgreSQLの連番(SERIAL)を自動利用
    private Long id;

    private String title;
    private String content;
    private LocalDateTime createdAt;

    // 🌟ここが最重要:1対多の「多」側から「一」への結合定義
    @ManyToOne
    @JoinColumn(name = "user_id", nullable = false) // 外部キー(FK)のカラム名を定義
    private SiteUser siteUser; // この日報を書いたユーザーの情報が丸ごと紐づく

    public Report() {}

    public Report(String title, String content, LocalDateTime createdAt, SiteUser siteUser) {
        this.title = title;
        this.content = content;
        this.createdAt = createdAt;
        this.siteUser = siteUser;
    }

    // ゲッターとセッター
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public SiteUser getSiteUser() { return siteUser; }
    public void setSiteUser(SiteUser siteUser) { this.siteUser = siteUser; }
}

com.example.demo パッケージの中に新しく Report.java クラスを作成し、上記のコードを記述してください。これは、SiteUser との間に多対一(@ManyToOne) の結合を定義するものです。

ReportRepository.java の作成 (日報データを扱うためのリポジトリ)

package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface ReportRepository extends JpaRepository<Report, Long> {
    
    // 🌟命名規則により「作成日時の降順(新しい順)で全件取得する」SQLが自動生成されます
    List<Report> findAllByOrderByCreatedAtDesc();
}

com.example.demo パッケージの中に新しく ReportRepository.java を作成し、上記コードを記述してください。これは、日報データを扱うためのリポジトリです。今回は実務に則して、「DBから全件取ってくるだけでなく、最新順に並び替えて取得する」 メソッドを1行追記しています。(Spring Data JPAの命名規則による自動クエリ生成を利用)

ReportForm.java の作成 (バリデーションルールを含んだ日報投稿専用クラス)

package com.example.demo;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class ReportForm {

    @NotBlank(message = "タイトルは必須入力です。")
    @Size(max = 50, message = "タイトルは50文字以内で入力してください。")
    private String title;

    @NotBlank(message = "業務内容は必須入力です。")
    private String content;

    // ゲッター・セッター
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
}

com.example.demo パッケージの中に新しく ReportForm.java を作成し、上記コードを入力してください。これは、バリデーションルールを含んだ日報投稿専用のFormクラスです。

ReportController.java の作成 (ログイン中のユーザー情報からCRUD処理するコントローラー)

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.validation.Valid;
import java.security.Principal;
import java.time.LocalDateTime;

@Controller
public class ReportController {

    @Autowired
    private ReportRepository reportRepository;

    @Autowired
    private UserRepository userRepository;

    // 日報一覧画面の表示
    @GetMapping("/report")
    public String index(Model model, @ModelAttribute ReportForm reportForm, Principal principal) {
        // 1. 新しい順に並び替えた日報一覧を取得してモデルに格納
        model.addAttribute("reportList", reportRepository.findAllByOrderByCreatedAtDesc());
        
        // 2. 🌟現在ログイン中のユーザーID(ユーザー名)を画面に渡す
        model.addAttribute("loginUser", principal.getName());
        
        return "report"; // report.htmlを表示
    }

    // 日報の新規登録処理
    @PostMapping("/report/add")
    public String addReport(@Valid @ModelAttribute ReportForm reportForm, BindingResult bindingResult, Model model, Principal principal) {
        
        if (bindingResult.hasErrors()) {
            model.addAttribute("reportList", reportRepository.findAllByOrderByCreatedAtDesc());
            model.addAttribute("loginUser", principal.getName());
            return "report";
        }

        // 1. 🌟ログイン中のユーザーIDから、DBにあるユーザーのエンティティ(親)を丸ごと取得
        SiteUser loginUser = userRepository.findById(principal.getName()).orElseThrow();

        // 2. 日報エンティティ(子)を作成し、親であるユーザー情報をセット(1対多の紐付け)
        Report report = new Report(
            reportForm.getTitle(),
            reportForm.getContent(),
            LocalDateTime.now(), // 現在時刻
            loginUser // 🌟ここで結合!
        );

        // 3. PostgreSQLに保存
        reportRepository.save(report);

        return "redirect:/report";
    }

    // 日報の削除処理
    @GetMapping("/report/delete")
    public String deleteReport(@RequestParam(name = "id") Long id) {
        reportRepository.deleteById(id);
        return "redirect:/report";
    }
}

com.example.demo パッケージの中に新しく ReportController.java を作成し、上記コードを入力してください。これは、ログイン中のユーザー情報 (Principal オブジェクト)からログインIDを抜き出し、それを使ってリレーショナルな登録と削除を行うコントローラーです。

report.html の作成 (日報投稿画面)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>日報一覧 - 日報管理システム</title>
    <style>
        body { font-family: sans-serif; margin: 40px; background-color: #fcfcfc; color: #333; }
        .header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #007bff; padding-bottom: 10px; margin-bottom: 30px; }
        .logout-btn { background-color: #6c757d; color: white; padding: 6px 12px; text-decoration: none; border-radius: 4px; font-size: 0.9em; }
        .form-box { background: white; padding: 20px; border: 1px solid #ddd; border-radius: 6px; width: 600px; margin-bottom: 40px; }
        .form-group { margin-bottom: 15px; }
        label { display: block; margin-bottom: 5px; font-weight: bold; }
        input[type="text"], textarea { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
        textarea { height: 100px; resize: vertical; }
        button { background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 1em; }
        .report-card { background: white; border: 1px solid #e0e0e0; border-radius: 6px; padding: 20px; width: 600px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
        .report-header { display: flex; justify-content: space-between; color: #666; font-size: 0.85em; margin-bottom: 10px; border-bottom: 1px dashed #eee; padding-bottom: 5px; }
        .report-title { font-size: 1.2em; font-weight: bold; margin-bottom: 10px; color: #111; }
        .report-content { white-space: pre-wrap; font-size: 0.95em; line-height: 1.6; }
        .delete-link { color: #d9534f; text-decoration: none; font-size: 0.9em; }
        .error { color: red; font-size: 0.85em; margin-top: 5px; }
    </style>
</head>
<body>

    <div class="header">
        <h2>日報管理システム</h2>
        <div>
          こんにちは、<strong th:text="${loginUser}">ユーザー名</strong> さん
          <form action="/logout" method="post" style="display: inline; margin-left: 15px;">
            <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
            <button type="submit" style="display: inline; width: auto; padding: 6px 12px; background-color: #6c757d; font-size: 0.9em; margin: 0;">ログアウト</button>
          </form>
        </div>
    </div>

    <div class="form-box">
        <h3>📝 新しい日報を提出する</h3>
        <form action="/report/add" method="post" th:object="${reportForm}">
            <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
            <div class="form-group">
                <label for="title">タイトル</label>
                <input type="text" th:field="*{title}" placeholder="例:5月29日の進捗について">
                <div th:if="${#fields.hasErrors('title')}" th:errors="*{title}" class="error"></div>
            </div>
            <div class="form-group">
                <label for="content">業務内容</label>
                <textarea th:field="*{content}" placeholder="本日の作業内容、課題、明日の予定などをご記入ください。"></textarea>
                <div th:if="${#fields.hasErrors('content')}" th:errors="*{content}" class="error"></div>
            </div>
            <button type="submit">日報を提出</button>
        </form>
    </div>

    <h3>📌 日報タイムライン</h3>
    <div th:if="${#lists.isEmpty(reportList)}" style="color: #888;">提出された日報はまだありません。</div>
    
    <div class="report-card" th:each="report : ${reportList}">
        <div class="report-header">
            <div>
                投稿者: <strong th:text="${report.siteUser.username}">user1</strong> | 
                日時: <span th:text="${#temporals.format(report.createdAt, 'yyyy/MM/dd HH:mm:ss')}">2026/05/29</span>
            </div>
            <div th:if="${loginUser == report.siteUser.username}">
                <a th:href="@{/report/delete(id=${report.id})}" class="delete-link" onclick="return confirm('本当に削除しますか?')">❌削除</a>
            </div>
        </div>
        <div class="report-title" th:text="${report.title}">ここにタイトル</div>
        <div class="report-content" th:text="${report.content}">ここに業務内容</div>
    </div>

</body>
</html>

src/main/resources/templates フォルダの中に、新しく report.html を作成し、上記のコードを記述して保存してください。業務システムらしいレイアウトをシンプルな CSS で構築します。また Thymeleaf の「Security拡張タグ (sec:authorize)」を使って「自分が書いた日報にだけ削除ボタンを表示する」 という権限制御の仕組みも仕込みます。

起動と動作確認

Docker を起動して、プロジェクトを右クリックし、実行から Maven ビルドします。

ゴール(goals) に clean package -DskipTests を入力し、実行をクリックします。コンソールに BUILD SUCCESS の文字が出たら、target フォルダに jar ファイルができたことを確認してください。

ターミナルを管理者権限で開き、docker-compose.yml が置いてあるプロジェクトのルートディレクトリに移動します。

ターミナルで docker compose up –build -d を実行してコンテナを起動します。(※JPAの自動更新により、PostgreSQL内に新しく report テーブルと、site_user に対する外部キーが自動構築されます)

ブラウザを開き (http://localhost:8080/report) にアクセスします。このログイン画面からのテスト工程は以下の通りです。

1 ログイン ログイン画面で ID: user1, PW: password でログインできる
2 ログイン ログイン画面で ID: user1, PW: aaaaaaaa でログインできない
3 ログイン ログイン画面で ID: user, PW: password でログインできない
4 日報画面 「こんにちは、user1さん」と右上に表示されることを確認
5 日報画面 日報を1件提出できる
6 日報画面 投稿者に user1 と自動で刻印され、削除ボタンが表示される
7 日報画面 日報1件を削除ボタンで削除できる
8 日報画面 日報を再度1件提出できる
9 日報画面 日報画面からログアウトできる
10 ログイン ログイン画面で ID: admin1, PW: password でログインできる
11 日報画面 user1 の日報が表示されていて、削除ボタンが表示されていない
12 日報画面 日報を1件提出できる
13 日報画面 自分の日報にだけ削除ボタンが出るか確認します。

コードの品質を保証する : 単体テスト (JUnit/Moccito)

プログラムを書き換えたときに、既存の機能が壊れていないかを一瞬で保証するためにテストコードを書きます。ここでは ユーザー認証ロジック (UserService.java) がターゲットです。このクラスは「DBからユーザーを取ってくる」という処理を含んでいます。しかし、テストを実行するたびに本物のPostgreSQLコンテナを起動してデータを準備するのは大変です。そこで、JPAリポジトリの動きを擬似的に身代わりに仕立て上げる Mockito (モキート) というライブラリを使います。

ここまでの記事のように、機能を実装したらそれに対するJUnitのテストコードも書いておくことが実現場では求められるでしょう。

Mockito を使ったDBのデータが空 (もしくは停止状態) でもロジックをテストする方法

実際に UserService.java のロジックに対して、現場では以下の 2つのテストケース(シナリオ) を書いて品質を証明します。

  1. テストA(正常系): 存在するユーザー名を指定したとき、正しくユーザー情報が取得できること
  2. テストB(異常系): 存在しないユーザー名を指定したとき、狙い通り UsernameNotFoundException というエラーが発生すること

テストクラス

package com.example.demo;

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

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;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@ExtendWith(MockitoExtension.class) // ✨Mockitoを有効化するおまじない
class UserServiceTest {

    @Mock
    private UserRepository userRepository; // 👈 本物のDBには繋がず、モック(身代わり)にする

    @InjectMocks
    private UserService userService; // 👈 テスト対象のクラス(上記のリポジトリが自動注入されます)

    // -------------------------------------------------------------
    // テストA:正常系の検証
    // -------------------------------------------------------------
    @Test
    void loadUserByUsername_正常系_ユーザーが存在する場合() {
        // 1. 【お膳立て(Given)】モックの挙動を設定する
        // 「DBに user1(パスワードはハッシュ化された想定のダミー)が存在する」という状況を擬似的に作ります
        SiteUser dummyUser = new SiteUser("user1", "$2a$10$xyz...", "ROLE_USER");
        
        // Mockitoの魔法:「userRepository.findById("user1") が呼ばれたら、dummyUser を返せ」と指示
        when(userRepository.findById("id_user1")).thenReturn(Optional.of(dummyUser));

        // 2. 【実行(When)】テスト対象のメソッドを呼び出す
        UserDetails result = userService.loadUserByUsername("id_user1");

        // 3. 【検証(Then)】JUnitの「Assert(アサーション)」を使って、結果が正しいか検証する
        assertNotNull(result); // 結果がヌル(空)でないこと
        assertEquals("user1", result.getUsername()); // ユーザー名が一致すること
        
        // リポジトリのfindByIdが「確実に1回呼び出されたか」という、内部の挙動まで検証できます
        verify(userRepository, times(1)).findById("id_user1");
    }

    // -------------------------------------------------------------
    // テストB:異常系の検証
    // -------------------------------------------------------------
    @Test
    void loadUserByUsername_異常系_ユーザーが存在しない場合() {
        // 1. 【お膳立て(Given)】
        // 「DBに nobody というユーザーを探しに行ったら、空(Optional.empty())が返る」状況を作ります
        when(userRepository.findById("nobody")).thenReturn(Optional.empty());

        // 2. & 3. 【実行と検証】特定の「エラー(例外)」が発生することを検証する
        // 「userService.loadUserByUsername("nobody") を実行した時、UsernameNotFoundException が発生するはずだ」という検証
        assertThrows(UsernameNotFoundException.class, () -> {
            userService.loadUserByUsername("nobody");
        });
    }
}

プロジェクト内の src/test/java にある com.example.demo を開き UserServiceTest.java というクラスを作成し、

メソッド名に日本語名が使われています。日本人だけの環境でチームの合意があれば、このような記述でも問題ありません。

テストの実行

記述したテストコードを実行します。作成した UserServiceTest.java のソースコード上で右クリックし、実行から Junit テストを選択してください。緑色のバーが伸び、テストにチェックマークがつけば問題なくテストは完了です。

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

この記事を書いた人

コメント

コメントする

CAPTCHA


目次