Javaの結合テストとは?Model・DAO・データベースの連携を確認する方法

最終更新日

Schoo Java入門 中級 第7回 データベースと連携したWebアプリケーションの実装

「登録できました」と表示された。本当に、入力した内容がDBに保存されているでしょうか。画面に成功メッセージが出ることと、正しい値が保存されることは、同じ確認ではありません。

結合テストでは、複数の部品をつないで、呼び出し・データの受け渡し・実際の結果が合っているかを確認します。この記事では、画面を経由せずにModelを呼び、DAOが実際にSQLを実行して、テスト専用のDBへ保存するところまでを確かめます。

たとえば、登録件数が1でも、保存されたタスク名が空文字や別の値なら不具合です。戻り値と、DBに残った内容を分けて確認することで、成功したように見えるだけの処理を見逃しにくくなります。

本記事は、Schoo「Java入門 中級」第7回「データベースと連携したWebアプリケーションの実装」に対応する学習記事です。授業の配布ファイルがなくても読めるよう、仕様・DBの状態・テストケース・コードを対応させています。コードは説明する部分の抜粋であり、実行環境の準備を前提とした手順書ではありません。

単体テストと結合テストは、何が違う?

単体テストは、対象にした小さな単位の振る舞いを確かめるテストです。結合テストは、つないだ部品の間が正しく連携しているかに注目します。「メソッドが1個なら単体」「クラスが2個なら結合」という数だけの区別ではありません。単体として扱う範囲には、プロジェクトによる違いもあります。

今回の確認分かること
入力チェックを単独で確認する空白のタスク名を受け付けないなど、決めたルールを確認する。DBへの保存そのものは確認しない。
Model・DAO・実際のDBをつなぐModelから渡した値でDAOがSQLを実行し、DBへ正しく保存されるかを確認する。この記事の対象。
ブラウザから登録して画面を見るフォームの送信、Servletの受け取り、JSPの表示も含め、利用者の操作から結果までを確認する。

JUnitを使っているから単体テスト、とは限りません。JUnitから実物のModel・DAO・DBを動かせば、それらの連携を確認する結合テストを実施できます。この記事ではServletのURLやJSPの表示はテスト範囲に含めません。

イラスト:画面を通さず、ModelからDBまでを確認する

JUnitテストがTaskModelを呼び出す。TaskModelはTaskDaoに登録を依頼し、実際のテスト専用DBに保存する。戻り値はDAOからModelを経てテストへ戻り、保存内容はテスト側から別の接続で読み取る。
呼び出す流れと、戻り値・保存内容を確認する場所

テストコードがServletの代わりにModelを呼びます。ただし、DAOを「1を返すだけの仮の処理」に置き換えていません。本物のDAOとDBを使うため、SQLや接続先を含むつながりを確認できます。戻り値はDAOからModelを経てテストへ返ります。

もう一つの確認は、Modelの処理が終わったあとに別のDB接続で保存内容を読むことです。今回のDAOは接続を開き、INSERTを自動コミットしてから接続を閉じます。そのため、処理後の別接続で、DBに残った行を確認します。

まず、何をもって「正しく登録できた」とするか決める

タスク名を登録するTaskModel.register(String title)を対象にします。Modelは入力のルールを確認するクラス、TaskDaoはSQLを実行するクラス、Taskはタスク名を持ってDAOへ渡すDTOです。次の仕様で考えます。

  • タスク名は1〜50文字。null・空文字・空白だけ・50文字を超える入力は、IllegalArgumentExceptionで通知し、保存しない。文字数はString.length()で数える。
  • 正常な入力なら、tasksテーブルのtitle列に入力文字列をそのまま1行保存し、登録件数の1を返す。IDはDBに割り当てさせる。
  • DBでSQLを実行できない場合は、SQLExceptionを呼び出し元へ通知する。成功したように1を返さない。
  • この例ではSQLiteを使用し、同名のタスクも登録できる。接続先のDBファイルの場所はModelのコンストラクタで受け取る。

テストするのは、前の記事と同じ登録処理です。次のdbPathはDBファイルの場所を保持するフィールドです。Taskのコンストラクタはtitleをフィールドへセットし、getTitle()はその値を返します。

TaskModel.register:今回のテスト対象

public int register(String title) throws SQLException {
    if (title == null || title.isBlank() || title.length() > 50) {
        throw new IllegalArgumentException("タスク名を1〜50文字で入力してください。");
    }

    Task task = new Task(title);
    TaskDao dao = new TaskDao(dbPath);
    int count = dao.insert(task);
    return count;
}

TaskDao.insertは、INSERT INTO tasks (title) VALUES (?)を使い、setString(1, task.getTitle())で値を設定して、executeUpdate()の件数を返します。正常なタスク名でModelのregisterを呼ぶと、このDAOのSQL実行まで進みます。

テストケースは「実行前・操作・期待結果」を一組にする

今回の基本状態は、tasksテーブルが存在し、行数が0件のテスト専用DBです。テストごとにこの状態から始めます。SQLエラーのケースだけは、そこからテーブルをなくして、失敗条件を意図的に作ります。

ケース操作・条件期待結果
TC01 正常登録空のテーブルに「資料を確認する」を登録戻り値は1。DBには同じタスク名が1行だけ残る。
TC02 入力エラー空のテーブルに半角空白3個を登録しようとするIllegalArgumentException。DBの行数は0のまま。
TC03 SQLエラーテストDBのtasksテーブルを削除してから、正常なタスク名を登録しようとするSQLException。成功件数として1は返らない。

「登録できること」だけでは、どの値を入れて、何を見れば合格なのかが決まりません。TC01なら、仕様書にも入力値、戻り値、保存先、保存された値、行数を残します。

  1. 事前条件:テスト専用DBに、空のtasksテーブルがある。
  2. 操作:model.register(“資料を確認する”)を1回呼ぶ。
  3. 期待する戻り値:1。
  4. 期待するDB:titleが「資料を確認する」の行が1行だけある。
  5. 実施記録:実際の戻り値と検索結果、合否を記入する。未実施の段階で期待結果を実測結果として埋めない。

TC02は「例外が通知された」だけでなく、失敗した入力が保存されていないことも確認します。ただし、最終的に0件だったことだけで「DAOが一度も呼ばれていない」とまでは証明できません。確認した結果と、内部でどこを通ったかは区別します。

テスト用DBは、普段使うDBと分ける

本番DBや、手作業で学習データを入れたDBをそのままテストに使うと、行数が変わったり、データを壊したりします。今回のテストは、JUnitが用意する一時フォルダ内に、そのテストだけのDBファイルを作る方式にします。普段のDBを空にする方式ではありません。

正常登録のテストは一つ目の一時フォルダにある空のDBを使って1行を登録する。空白入力のテストは別の一時フォルダの空のDBを使い、例外通知後も0行のまま。二つのテストはDBを共有しない。
各テストが、自分専用の空のDBから始める

図のA・Bは別々の一時フォルダの目印です。実際のフォルダ名はJUnitが決めます。どちらもファイル名がtasks-test.dbでも、置き場所が違うので別のDBです。TC02はTC01で使ったDBを引き継ぎません。

TaskIntegrationTestの共通準備:各テストの前に空のテーブルを作る

@TempDir
Path tempDir;

String dbPath;
TaskModel model;

@BeforeEach
void prepareDatabase() throws SQLException {
    dbPath = tempDir.resolve("tasks-test.db").toString();
    try (
        Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
        Statement stmt = conn.createStatement()
    ) {
        stmt.executeUpdate(
            "CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"
        );
    }
    model = new TaskModel(dbPath);
}
  • @TempDir:JUnitに一時フォルダを用意してもらう指定。ここではstaticを付けないフィールドで受け取り、JUnitの標準設定でテストメソッドごとに別のフォルダを使う。
  • Path:フォルダやファイルの場所を表す型。resolve(“tasks-test.db”)で、そのフォルダの中のDBファイルの場所を作る。
  • @BeforeEach:各テストメソッドの実行前に呼ばれる準備処理。今回はSQLiteへの接続時にDBファイルが作られ、その中に空のテーブルを作る。
  • new TaskModel(dbPath):テスト用DBの場所を渡す。同じ値がDAOまで渡り、準備したDBに登録する。

以下のテストメソッドは、この共通準備と同じTaskIntegrationTestクラス内に書く抜粋です。JUnit Jupiterの@Test・@BeforeEach・@TempDirとAssertions、Javaのjava.sqlの各クラス・java.nio.file.Pathを使っています。実行する場合にはJUnitとSQLite JDBCドライバ、TaskModel・TaskDao・Taskの実装が必要ですが、ここでは準備処理から各テストの結果までを読み比べます。

DB接続はtry-with-resourcesで閉じ、JUnitの標準設定ではテスト終了後に一時フォルダも削除されます。なお、SQLiteの:memory:を通常の方法で接続する場合、接続ごとに別のDBになります。今回は準備・DAO・確認で接続を開き直すため、同じファイルパスを使っています。

TC01:戻り値と、実際に保存されたタスク名を確認する

準備で作った空のDBへ、Modelから1件登録します。登録処理を直接SQLで代用してはいけません。登録はModelから、結果の確認はテスト側のSELECTから行います。

@Test
void registerSavesTitle() throws SQLException {
    int count = model.register("資料を確認する");

    assertEquals(1, count);

    try (
        Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT title FROM tasks ORDER BY id")
    ) {
        assertTrue(rs.next());
        assertEquals("資料を確認する", rs.getString("title"));
        assertFalse(rs.next());
    }
}
確認行確認していること
assertEquals(1, count)Modelから登録件数1が返った。
assertTrue(rs.next())DBの検索結果に1行目が存在する。
assertEquals("資料を確認する", rs.getString("title"))保存されたタスク名が、入力した値と同じ。
assertFalse(rs.next())2行目は存在しない。空のDBから始めた今回の条件では、登録は1行だけ。

assertTrueはtrue、assertFalseはfalseであることを確認するJUnitのメソッドです。ResultSetは最初から1行目を指していないため、最初のnextで1行目に進みます。2回目のnextがfalseなら、余計な2行目がないことを確認できます。

たとえばDAOが登録せずにreturn 1;としていた場合、件数のチェックは通りますが、1行目の存在確認で失敗します。別のタスク名を保存していた場合は、文字列の比較で失敗します。複数のチェックは、同じことを繰り返しているのではありません。

保存内容を取得する自作DAOのメソッドにも同じ不具合があると、登録と読み取りで間違いを打ち消してしまうことがあります。ここではテスト側に短いSELECTを書き、保存された値を直接照合します。入力値を連結していない固定SQLなので、この確認部分はStatementを使っています。

TC02:例外が通知され、DBが変わらないことを確認する

@Test
void blankTitleDoesNotSave() throws SQLException {
    assertThrows(
        IllegalArgumentException.class,
        () -> model.register("   ")
    );

    try (
        Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery("SELECT COUNT(*) AS total FROM tasks")
    ) {
        assertTrue(rs.next());
        assertEquals(0, rs.getInt("total"));
    }
}

" "は半角空白3個です。assertThrowsに渡した() -> model.register(" ")は、例外を確認するために実行してもらう処理です。IllegalArgumentExceptionが通知されなければテストは失敗します。

続くSELECTのCOUNT(*)はテーブルの行数を数え、AS totalでその列にtotalという名前を付けています。集計結果には0という値を持つ1行が返るため、nextでその行を読み、getInt(“total”)が0であることを確かめます。通常の「0件の検索結果」と、件数を集計した「0という値の1行」は別です。

もし「保存してから入力チェックする」という間違いがあれば、例外の確認だけは通っても、DBの行数チェックが失敗します。これが、エラー時にもDBの状態を確認する理由です。

TC03:DBの問題を、成功したように返していないか確認する

このケースでは、共通準備で作った今回のテスト専用DBだけからtasksテーブルを削除します。普段使うDBや本番DBで実行するSQLではありません。テーブルが存在しない状態を作り、DAOのINSERTが失敗する条件にします。

@Test
void missingTableNotifiesSqlException() throws SQLException {
    try (
        Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
        Statement stmt = conn.createStatement()
    ) {
        stmt.executeUpdate("DROP TABLE tasks");
    }

    assertThrows(
        SQLException.class,
        () -> model.register("資料を確認する")
    );
}

正しい実装では、SQLExceptionがDAOからModelを経てテストまで通知されます。ModelやDAOで例外を握りつぶして1を返すと、assertThrowsは「期待した例外が通知されなかった」と判定して失敗します。

テストメソッドのthrows SQLExceptionは、準備や確認のSQLで予期しない例外が出た場合にJUnitへ伝える指定です。その指定だけでは、例外が出ることを確認したことにはなりません。期待する例外はassertThrowsの中で確認しています。

SQLiteでは、指定先のDBファイルがない場合、新しい空のDBが作られることがあります。「存在しないファイル名にすれば必ず接続エラー」という条件にはしません。今回は接続できるテストDBに対して、テーブルがない状態を明示的に作っています。

3件とも通れば、画面も含めて完成と言える?

この3件で確認したのは、指定した入力とDB状態におけるModel・DAO・SQLiteの連携です。フォームのnameが間違っている、Servletが別のModelを呼んでいる、JSPが違う属性名を読む、といった画面側の不具合は、このテストでは見つかりません。

  • 画面からの確認:送信したタスク名をServletが受け取り、登録後の結果を正しく表示するか。
  • 入力ルールの追加確認:null、空文字、50文字、51文字など。この3件で全入力を網羅したわけではない。
  • DBの追加確認:既存行がある場合や制約違反など、実際の仕様に必要な条件。
  • 実際の接続条件の確認:本番と異なるDB製品を使うなら、SQLや型・制約の違いはSQLiteで通っただけでは保証できない。

単体テスト、部品をつないだテスト、ブラウザからの確認は、置き換え合うものではありません。どこまで通して、何が正しいと分かったかを明確にして組み合わせます。テスト仕様書にも「Model・DAO・テスト用DBが対象。Servlet・JSPは対象外」と記載すると、確認範囲が伝わります。

確認問題

  1. 空のtasksテーブルに「資料を確認する」を1件登録します。assertEquals(1, count)だけで合格としてよいですか。追加すべきDBの確認を具体的に答えてください。
  2. TC01の登録後のDBをそのままTC02でも使うと、空白入力が正しく拒否されてもCOUNT(*)は1になります。テストケースを独立させるには、DBの用意をどう変えますか。
  3. 入力エラーのテストで、IllegalArgumentExceptionは通知されましたが、実行後のDBに1行追加されていました。仕様に合っていますか。例外の確認に加えて、何を確認するべきですか。
  4. Model・DAO・テスト専用DBの3件のテストがすべて通りました。それでもフォームからの登録が失敗します。今回のテスト範囲に含まれていない箇所を二つ挙げ、次に何を確認するか答えてください。

演習後の確認:こちらのページを使って、自分で考えた内容を見直せます。

まとめ

  • 結合テストは、つないだ部品の呼び出しやデータの受け渡し、実際の結果を確認する。
  • DBへの登録では、戻り値だけでなく、保存された値と行数を確認する。
  • 入力エラーでは、例外通知と、保存されていないことの両方を確認する。
  • テストごとに専用DBを用意し、事前条件を揃える。
  • JUnitを使うか、画面を使うかだけでテストの種類や確認範囲は決まらない。

関連記事

シェアする