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

「登録できました」と表示された。本当に、入力した内容が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までを確認する

テストコードが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なら、仕様書にも入力値、戻り値、保存先、保存された値、行数を残します。
- 事前条件:テスト専用DBに、空のtasksテーブルがある。
- 操作:model.register(“資料を確認する”)を1回呼ぶ。
- 期待する戻り値:1。
- 期待するDB:titleが「資料を確認する」の行が1行だけある。
- 実施記録:実際の戻り値と検索結果、合否を記入する。未実施の段階で期待結果を実測結果として埋めない。
TC02は「例外が通知された」だけでなく、失敗した入力が保存されていないことも確認します。ただし、最終的に0件だったことだけで「DAOが一度も呼ばれていない」とまでは証明できません。確認した結果と、内部でどこを通ったかは区別します。
テスト用DBは、普段使うDBと分ける
本番DBや、手作業で学習データを入れたDBをそのままテストに使うと、行数が変わったり、データを壊したりします。今回のテストは、JUnitが用意する一時フォルダ内に、そのテストだけの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は対象外」と記載すると、確認範囲が伝わります。
確認問題
- 空のtasksテーブルに「資料を確認する」を1件登録します。assertEquals(1, count)だけで合格としてよいですか。追加すべきDBの確認を具体的に答えてください。
- TC01の登録後のDBをそのままTC02でも使うと、空白入力が正しく拒否されてもCOUNT(*)は1になります。テストケースを独立させるには、DBの用意をどう変えますか。
- 入力エラーのテストで、IllegalArgumentExceptionは通知されましたが、実行後のDBに1行追加されていました。仕様に合っていますか。例外の確認に加えて、何を確認するべきですか。
- Model・DAO・テスト専用DBの3件のテストがすべて通りました。それでもフォームからの登録が失敗します。今回のテスト範囲に含まれていない箇所を二つ挙げ、次に何を確認するか答えてください。
演習後の確認:こちらのページを使って、自分で考えた内容を見直せます。
まとめ
- 結合テストは、つないだ部品の呼び出しやデータの受け渡し、実際の結果を確認する。
- DBへの登録では、戻り値だけでなく、保存された値と行数を確認する。
- 入力エラーでは、例外通知と、保存されていないことの両方を確認する。
- テストごとに専用DBを用意し、事前条件を揃える。
- JUnitを使うか、画面を使うかだけでテストの種類や確認範囲は決まらない。