0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チャットbotお試し用アプリ開発_33日目_システムテスト

0
Last updated at Posted at 2026-03-14

目次

・概要
・本日の成果・考え
・最後に

概要

本日は、システムテストの計画および実行をしていきたいと思います。

方針

  • ユーザー操作を想定し、手動で再現する。
  • 異常系については、モックサーバを立て、それに対して画面から通信することで再現させる

目的

  • ユーザーの操作によって画面が期待通りに動作すること
  • 通信が正常・異常の時に画面が破綻しないこと
  • ログが想定した内容を出力すること
     (1.エラー原因が追及できる 2.PII(個人情報)が出力されない)

本日の成果

問題点

テストするために、アプリ起動用クラスを追加しました。
しかし、起動クラスで失敗した時ユーザーに一切通知する機能がないことに気がつきました。

考え
設計時は、起動できない時はアプリが落ちるので通知をしなくて良いかと考えていました。←本当にどうしてそう考えたのかわかりません。携帯のアプリみたいに一瞬表示されて落ちるかなと考えていたと思いますが、画面にすら一切表示されないまま落ちるためユーザー視点だと何もわからないです。

とはいえ、既存のJavaFXを利用して改修しようとすると複数のクラスに波及することが判明しましたので、システムテストとしてはSwingのダイアログ通知で簡易に実装し、後ほど改修したいと思います。
Swingを採用した理由は外部ライブラリを追加しなくても、利用できる点と簡易に実装できる点です。

Mainクラスのソース

Main.java
package app;

import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import app.windowView.window.WindowLogic;

/**
 * デスクトップアプリ起動クラス
 *
 * このクラスは、
 *  - 起動前のエラーをユーザーに通知する
 *  - JavaFXや別スレッドでのエラーも拾って通知できるようにする
 *  - SwingのJOptionPane を使ってポップアップ通知を行う
 */
public class Main {

    private static final Logger logger = LoggerFactory.getLogger(Main.class);

    public static void main(String[] args) {

        // 未捕捉例外ハンドラをセット
        // JavaFXのApplication Threadや別スレッドの例外を拾う
        Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
            logger.error("未捕捉例外が発生しました。thread={}", thread.getName(), throwable);
            notifyUser("予期しない内部エラーが発生しました。", throwable);
        });
        
        try {
            // 起動処理
            WindowLogic logic = new WindowLogic();
            logic.execute();

        } catch (Throwable t) {
            // execute内の例外をキャッチ
            logger.error("起動中に例外が発生しました。", t);
            notifyUser("アプリケーションの起動に失敗しました。", t);
        }
    }

    /**
     * 起動前エラーをユーザーに通知する
     *
     * @param message ユーザー向けのタイトルや説明
     * @param t       発生した例外
     */
    private static void notifyUser(String message, Throwable t) {
        // まず標準エラー出力に出す(ログとして残す)
        System.err.println("[" + message + "] " + (t == null ? "" : t.getMessage()));
        if (t != null) {
            t.printStackTrace(System.err);
        }

        // SwingのEDTを使ってダイアログを出す
        try {
            SwingUtilities.invokeLater(() -> {
                JOptionPane.showMessageDialog(
                        null,
                        buildDialogText(message, t),
                        "エラー",
                        JOptionPane.ERROR_MESSAGE
                );
            });
        } catch (Throwable ignored) {
            // Swing 上でさらに例外が出る可能性があるため、安全に握りつぶし
        }
    }

    /**
     * ユーザーに出すテキストを整形する
     *
     * @param message   表示するメインメッセージ
     * @param throwable エラー詳細
     * @return 表示用テキスト
     */
    private static String buildDialogText(String message, Throwable throwable) {
        StringBuilder sb = new StringBuilder();
        sb.append(message);
        if (throwable != null) {
            String detail = throwable.getMessage();
            if (detail != null && !detail.isBlank()) {
                sb.append("\n\n詳細: ").append(detail);
            }
        }
        return sb.toString();
    }
}

Mainクラスでアプリ起動を実行してからウィンドウが表示するまでの間でエラーが発生した時に通知するようにしています。

Thread.setDefaultUncaughtExceptionHandler((thread, throwable)については、JavaFXのApplication Threadで発生した例外をキャッチしてもらう役割で実装しています。
ただ、少し厄介でstart()メソッドでウィンドウ起動中に例外が発生した際、JavaFXのランタイム内部で捕捉されるみたいで、このメソッドでキャッチできない可能性が高いです。
Application Threadでの例外を通知させるならJavaFX Alertが良いみたいなのですが、現時点では工数が高いため、後日の改修にしたいと思います。
あくまでその他の例外を捕捉するための防御程度にしておきました。

Swingの通知機能を使う時は、EDT(イベントディスパッチスレッド)を呼び出して使用する必要があるみたいなので、SwingUtilities.invokeLater()を挟んで呼び出しています。
※Swing専用のスレッドみたいで、これを経由することで画面処理の順番を整理できるみたいです。

参考

JavaFXとThread.setDefaultUncaughtExceptionHandlerの挙動

テストパターン

  1. 正常起動
    通常の起動動作を行った時、アプリが正常に起動しウィンドウでチャット画面が表示されることを確認する。
  2. 起動失敗(設定ファイル不備)のユーザー通知
    以下のパターンで起動失敗し、Swingダイアログでの通知およびログへ出力されることを確認する。加えて、プロセスが残留しないことを確認する。
    ①指定の位置に設定ファイルを配置しない。
    ※起動時のエラー通知が目的のため、パターンは一つで良いと考えています。
  3. 正常終了
    アプリを通常動作で終了させた時、Javaプロセスが残留しないことを確認する。
  4. 正常動作(動作の安定性確認を含む)
    ユーザー操作を想定し、チャット機能が正しく実行することを確認する。
    ①モックサーバに通信し、応答が表示できる。
    ②通信応答が複数回実行しても正常に動作する。
    ③レスポンスがエラーとなっても、エラーメッセージを出して、再度リクエストできる。
    ④ 通信中にウィンドウを閉じた時に、Javaプロセスが残留しない。

システムテストでは、テスト用のモックサーバを別のJavaプログラムで起動しつづけ、それに対してHttp通信を行なってDifyとの通信を再現します。

モックサーバ起動コード

MockDifyChatServer.java
package mock;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;

import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;

/**
 * DifyチャットAPI再現用モックサーバ
 * システムテスト用にDifyAPIの挙動を簡易的に再現する。
 *
 * URLパスに応じてレスポンスを切り替える。
 *
 * /normal       : 正常SSEレスポンス
 * /http500      : HTTP500エラー
 * /sse-no-end   : SSE異常(message_end無し)
 * /slow-normal  : 遅延付き正常SSEレスポンス
 *
 * ポートは固定で 18081 を使用する。
 *
 * @author
 * @version 1.2
 * 修正:
 * 受信リクエスト内容の標準出力ログを追加
 * 遅延付き正常レスポンス(/slow-normal)を追加
 */
public class MockDifyChatServer {

    /** モックサーバの待受ポート */
    private static final int SERVER_PORT = 18081;

    /** ログ出力用日時フォーマット */
    private static final DateTimeFormatter DATE_TIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    /**
     * モックサーバ起動メソッド
     *
     * Dispatcherを利用して、リクエストURLに応じて
     * レスポンスを切り替える。
     *
     * @param args 起動引数(未使用)
     */
    public static void main(String[] args) {

        MockWebServer server = new MockWebServer();

        try {
            Dispatcher dispatcher = new Dispatcher() {

                @Override
                public MockResponse dispatch(RecordedRequest request) {

                    outputRequestLog(request);

                    String path = request.getPath();

                    if (path != null && path.contains("/slow-normal")) {
                        return slowNormalResponse();
                    }

                    if (path != null && path.contains("/normal")) {
                        return normalResponse();
                    }

                    if (path != null && path.contains("/http500")) {
                        return http500Response();
                    }

                    if (path != null && path.contains("/sse-no-end")) {
                        return sseErrorResponse();
                    }

                    return new MockResponse()
                            .setResponseCode(404)
                            .setBody("Not Found");
                }
            };

            server.setDispatcher(dispatcher);
            server.start(SERVER_PORT);

            System.out.println("Mock Dify Chat Server started");
            System.out.println("url=http://localhost:" + SERVER_PORT);

            // 停止指示があるまで待機
            Thread.currentThread().join();

        } catch (Exception e) {
            System.err.println("Mock Server 起動エラー");
            e.printStackTrace();

        } finally {
            try {
                server.shutdown();
            } catch (Exception e) {
                System.err.println("Mock Server 終了エラー");
                e.printStackTrace();
            }
        }
    }

    /**
     * 受信したリクエスト内容を標準出力する。
     *
     * @param request 受信リクエスト
     */
    private static void outputRequestLog(RecordedRequest request) {

        String now = LocalDateTime.now().format(DATE_TIME_FORMATTER);
        String method = request.getMethod();
        String path = request.getPath();
        String body = request.getBody().readUtf8();

        System.out.println("===== Mock request received =====");
        System.out.println("time   : " + now);
        System.out.println("method : " + method);
        System.out.println("path   : " + path);
        System.out.println("body   : " + body);
        System.out.println("================================");
    }

    /**
     * 正常SSEレスポンスを返す。
     *
     * @return 正常系のMockResponse
     */
    private static MockResponse normalResponse() {

        String body =
                "data: {\"event\":\"message\",\"answer\":\"これは\"}\n\n" +
                "data: {\"event\":\"message\",\"answer\":\"テスト\"}\n\n" +
                "data: {\"event\":\"message\",\"answer\":\"です\"}\n\n" +
                "data: {\"event\":\"message_end\"}\n\n";

        return new MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "text/event-stream")
                .setBody(body);
    }

    /**
     * 遅延付き正常SSEレスポンスを返す。
     * 通信中にウィンドウを閉じるテスト用。
     *
     * @return 遅延付き正常系のMockResponse
     */
    private static MockResponse slowNormalResponse() {

        String body =
                "data: {\"event\":\"message\",\"answer\":\"これは\"}\n\n" +
                "data: {\"event\":\"message\",\"answer\":\"少し\"}\n\n" +
                "data: {\"event\":\"message\",\"answer\":\"遅い\"}\n\n" +
                "data: {\"event\":\"message_end\"}\n\n";

        return new MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "text/event-stream")
                .setBody(body)
                .setBodyDelay(3, TimeUnit.SECONDS);
    }

    /**
     * HTTP500レスポンスを返す。
     *
     * @return HTTP500系のMockResponse
     */
    private static MockResponse http500Response() {

        return new MockResponse()
                .setResponseCode(500)
                .setBody("Internal Server Error");
    }

    /**
     * SSE異常レスポンス(message_end無し)を返す。
     *
     * @return SSE異常系のMockResponse
     */
    private static MockResponse sseErrorResponse() {

        String body =
                "data: {\"event\":\"message\",\"answer\":\"これは\"}\n\n" +
                "data: {\"event\":\"message\",\"answer\":\"途中\"}\n\n";

        return new MockResponse()
                .setResponseCode(200)
                .setHeader("Content-Type", "text/event-stream")
                .setBody(body);
    }
}

立ち上げたモックサーバでは、テストパターンに合わせたレスポンスを用意しています。
テストパターン毎にプロパティファイルを差し替えることで、リクエストするapiURLを切り替えてレスポンスを選択したいと思います。

テストNo.1 正常起動

スクリーンショット 2026-03-15 7.35.31.png

Mainクラスを右クリックして、「Javaアプリケーション」を実行します。

実行結果

スクリーンショット 2026-03-15 7.54.44.png

エラー出現
初っ端からエラーで出鼻挫かれました。
内容を見るとプロパティファイルのキーがnullみたいです。テストで使用したプロパティファイルと比較すると
LOGPATH=XXX LOGFILEEX=XXXの2つのキーが存在していませんでした。
軽く調べた限り、最初の設計でログに関する設定をここで指定する予定だったみたいですが、log4j2で実装したため死んでいる設定値みたいです。
とはいえ、設定ファイル読み込み関連の処理では、パラメータなどで渡しているので影響範囲は少なくなさそうです。

調査結果
AppSettingFactory.java
class AppSettingFactory {
	/*
	 属性:設定ファイルDto
	*/
	private AppSettingDto appSettingDto;

	/************
	* メソッド名:インスタンス取得処理
	* 処理内容:インスタンスが生成済かチェックして、返す。
	* @return AppSettingDto
	* @throws IllegalStateException すでに生成済みの場合
	/************/
	public AppSettingDto getInstance() {
		if (appSettingDto == null) {
			throw new IllegalStateException("生成処理未実施エラー:インスタンスが存在しません。");
		}
		return appSettingDto;
	}

	/************
	* メソッド名:インスタンス生成メソッド
	* 処理内容:引数つきコンストラクタを呼び出す。
	* AppSettingDtoインスタンスを生成
	* @param logPath
	* @param logFileExtension
	* @param title
	* @param windUrl
	* @param windWidth
	* @param windHeight
	* @return void
	************/
	void createInstance(String logPath,
			String logFileExtension,
			String title,
			String apiUrl,
			String apiKey,
			int windWidth,
			int windHeight) {

		//引数付きコンストラクタ呼び出し。
		appSettingDto = new AppSettingDto(logPath, logFileExtension, title, apiUrl,apiKey, windWidth, windHeight);
	}
}
Factory呼び出し元クラス.java
public class SettingLoader {

	/**
	 * 読み込んだプロパティからDTOを生成し、Factoryに登録する
	 * @param props 呼び出し元で生成したPropertiesオブジェクト
	 * @return void
	 */
	public static AppSettingDto startSettings(Properties props) {
		int windWidth;
		int windHeight;
		// Factoryメソッド呼び出しのためのインスタンス準備
		AppSettingFactory fact = new AppSettingFactory();
		
		// 引数のプロパティオブジェクトのnullチェック
		if (props == null) {
			throw new IllegalStateException("設定ファイルが読み込まれていません。");
		}
		String logPath = props.getProperty("LOGPATH").trim();
		String logFileExtension = props.getProperty("LOGFILEEX").trim();
		String title = props.getProperty("WINDOW_TITLE").trim();
		String apiUrl = props.getProperty("API_URL").trim();
		String apiKey = props.getProperty("API_KEY").trim();
		
		//int型の引数の整数チェック。整数以外はNG。設定値が取得できなかった場合も例外。
		try {
			String windWidthStr = props.getProperty("WINDOW_WIDTH").trim();
			String windHeightStr = props.getProperty("WINDOW_HEIGHT").trim();
			windWidth = Integer.parseInt(windWidthStr);
			windHeight = Integer.parseInt(windHeightStr);
		} catch (NullPointerException | NumberFormatException e) {
			throw new IllegalStateException("設定ファイルのint型の項目に問題があります。:" + e);
		}

		//引数のNULLおよび空白チェック。int型は呼び出し元のLoaderクラスのメソッドでint型と制限しているのでnull混入はコンパイルで弾ける。
		//例外キャッチは呼び出し元。
		CommonFunction.checkNullBlank(logPath, logFileExtension, title, apiUrl,apiKey);

		fact.createInstance(logPath, logFileExtension, title, apiUrl,apiKey, windWidth, windHeight);
		return fact.getInstance();
	}
}

こちらのクラスですが、呼び出し元からAppSettingDtoのコンストラクタの引数を受け取って渡しているだけです。
このような無意味なクラスを設計していることに自分で驚きました。
推測ですが、AppSettingDtoの生成責任をFactoryが持ち、呼び出し元が引数の値の責任を持つようにしているみたいです。
また、呼び出し元であるAppSettingLoaderでは、プロパティファイルのKeyの値を取り出し処理でハードコーディングされています。

$\color{red}{\huge \textsf{???!!!}}$

どうなってるんだよ!

最後に

この記事でシステムテストを完遂までしてデプロイに備えようと考えていました。
しかし、初っ端から流石に「今後改修したら良いよね〜」といえないようなものを発見したので、先にそちらを片付けてから再開したいと思います。

このプロジェクトは自身の設計訓練も兼ねているため、このようなゴミ**のようなクラスを許容するのは企画崩壊となってしまいます。

以上、ここまでお付き合いありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?