3
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DBテストの実践知 — 信頼できる統合テスト基盤の作り方

3
Posted at

はじめに

「DBを含むテストは面倒だから、モックで済ませよう」
——そんな経験はありませんか?

自分もそう思っていた時期がありました。
しかし、DBのモックで検証できるのは「アプリケーションがDBに何を渡すか」までです。
「渡したデータがDBの制約を通るか」「SQLが正しい結果を返すか」は、
実際のDBに接続しない限りわかりません。

自アプリケーションだけがアクセスするDB
(自チームが完全に管理・制御できる依存であり、managed依存と呼ばれます)は、
統合テストで実物を使うべき対象です。

ところが、いざ実DBでテストを書こうとすると、とにかく準備の壁が高い。

その「面倒さ」の正体は何かというと、
本番環境とテスト環境の間に生じるギャップです。

  • スキーマの管理方法がチームで統一されていない
  • トランザクションの扱いが本番コードとテストで違う
  • テスト間でデータが残留して結果が不安定になる

この記事では、このギャップを体系的に埋める方法を整理します。
扱う内容は、DBスキーマの管理と配信アプローチ、テスト内のトランザクション管理、
テストデータのライフサイクルと分離戦略、テストコードの整理術と判断基準です。

TL;DR

  • DBスキーマはソース管理に入れ、migration-basedで配信し、
    開発者ごとに個別のDBインスタンスを持つ
  • テストでは本番と同じDBMSを使い、
    arrange/act/assertごとに別トランザクションで分離する
  • テストデータのクリーンアップは「テスト開始時」に行い、
    インメモリDB(SQLite等)は使わない

DBスキーマの管理 — 土台なくして信頼なし

DBテストを書く前に、
そもそもDBスキーマが正しく管理されていなければ話が始まりません。
「テスト環境のDBが本番と違う構造だった」という
事故はスキーマ管理の不備から生まれます。

スキーマをソース管理に入れる

開発チームで1つの専用DBインスタンスを「参照用DB」として維持し、
本番デプロイのときに比較ツールでアップグレードスクリプトを自動生成する
——という運用を見たことがある方もいるかもしれません。

この参照用DBインスタンスは「モデルデータベース」と呼ばれますが、
これはアンチパターンです。

モデルデータベースの問題

┌────────────────────────────────────────────────────────┐
│  情報源が 2つに分裂する                                   │
│                                                        │
│  Git(コード) ←──?──→ モデルDB(スキーマ)                │
│                                                        │
│  どちらが正しい? → 判断できなくなる                        │
├────────────────────────────────────────────────────────┤
│  変更履歴がない                                          │
│                                                        │
│  過去のある時点のスキーマを再現できない                      │
│  → 本番バグの再現が困難                                   │
└────────────────────────────────────────────────────────┘

正しいアプローチは、テーブル、ビュー、インデックス、ストアドプロシージャなど、
DBを構成するすべての要素をSQLスクリプトとしてソース管理(Git等)に入れることです。
DBへの変更はソース管理を経由してのみ行う。
こうすれば、Gitが「唯一の信頼できる情報源(single source of truth)」として機能します。

リファレンスデータもスキーマの一部

スキーマ管理と聞くと、テーブルやインデックスの定義を思い浮かべがちですが、
もう一つ忘れてはいけないものがあります。
それがリファレンスデータです。

リファレンスデータとは

アプリケーションが正常に動作するために、
事前に投入しておく必要があるデータのことです。
たとえば、ユーザーの種別マスタ(「一般ユーザー」「管理者」など)が該当します。

通常データとの見分け方: アプリケーションが変更できるかどうか。
変更できるなら通常データ、変更できないならリファレンスデータです。

リファレンスデータはINSERT文としてスキーマと一緒にソース管理に入れます。
テーブル定義だけ管理してリファレンスデータを管理しないのは片手落ちです。

なお、通常データとリファレンスデータが同じテーブルに共存するケースもあります。
その場合の対処は以下のとおりです。

  • フラグ列で区別する
  • アプリケーション側でリファレンスデータの変更を禁止するルールを設ける

DB配信アプローチ — state-based vs. migration-based

スキーマをソース管理に入れたとして、
そのスキーマ変更を本番環境にどう届けるか?
ここには大きく2つのアプローチがあります。

state-basedアプローチは、
DBの「あるべき状態」を定義しておき、
比較ツールが本番DBとの差分を
自動検出してマイグレーションスクリプトを生成する方式です。
つまり、「状態」が明示的で「マイグレーション」は暗黙的になります。

migration-basedアプローチは、
DBの「状態遷移」を明示的なマイグレーションスクリプトとして管理する方式です。
各マイグレーションにバージョン番号が付き、
forward(適用)とbackward(ロールバック)の両方向を記述します。

つまり、「マイグレーション」が明示的で「状態」は暗黙的です。
Flyway、Liquibaseなどのツールがこのアプローチを支援します。

state-based                     migration-based
────────────                    ───────────────
明示的: DBの状態                  明示的: 状態間の遷移
暗黙的: マイグレーション            暗黙的: DBの状態

  あるべき状態                      V1 → V2 → V3 → V4
       ↓                          (各遷移を明示的に記述)
  比較ツールが差分を自動生成
比較軸 state-based migration-based
明示的に管理するもの DBの状態 状態間の遷移
マージコンフリクト 解決しやすい 解決しにくい
データモーション 自動化困難 明示的に制御可能
初期導入の手間 小さい 大きい
本番リリース後の運用 困難 適切

結論として、migration-basedアプローチを推奨します。
その最大の理由はデータモーションです。

データモーション(data motion)とは

既存データの形状を新しいスキーマに合わせて変換するプロセスのことです。

たとえば、Name列をFirstNameLastNameに分割する変更を考えてみてください。
列の追加・削除だけでなく、
既存の名前データを2つに分割するスクリプトが必要になります。
これはビジネスの文脈を知らないとできない作業であり、
比較ツールでは自動化できません。

state-basedアプローチはリリース前の段階なら使えますが、
本番にデータが入った瞬間から「既存データの移行」が避けられなくなります。
データモーションを確実に管理するには、migration-basedが必要です。

マイグレーション運用のルール: 一度コミットしたマイグレーションは修正しない。
間違いがあれば新しいマイグレーションで修正する。
例外はデータ損失に繋がる場合のみです。

開発者ごとに個別のDBインスタンスを持つ

DBスキーマの管理ができても、
チーム全員で1つのDBインスタンスを共有していると別の問題が起きます。

  • テストの干渉: 他の開発者が実行したテストのデータが残り、
    自分のテスト結果が変わってしまう
  • 後方互換性のない変更がブロッカーになる:
    あるスキーマ変更が他の開発者のブランチを壊す

解決策はシンプルで、開発者ごとに独立したDBインスタンスを持つことです。
可能であれば開発者のローカルマシンに配置すると、テスト実行速度も最大化できます。

トランザクション管理 — 本番と同じ境界をテストで再現する

スキーマの管理基盤が整ったら、次はテスト実行時の挙動に目を向けます。
トランザクション管理が本番と異なると「テストは通るが本番では落ちる」事態を招きます。

本番コードのトランザクション設計

まず本番コード側のトランザクション設計を確認しましょう。

素朴な実装では、
DBアクセスのたびに独立したコネクション(=独立したトランザクション)を開きます。
この方式の問題は、
ビジネス操作の途中で障害が発生したときにデータ不整合が起きることです。

たとえば、ユーザーのメール変更操作を想像してください。
会社テーブルの従業員数を更新するトランザクションは成功したけれど、
ユーザーテーブルの更新はネットワーク障害で失敗した。
結果、従業員数と実際のユーザー数が食い違ってしまいます。

[素朴な実装: 各DB呼び出しが独立したトランザクション]

Controller
  ├─ Tx1: ユーザー取得       → 成功
  ├─ Tx2: 会社情報取得       → 成功
  ├─ Tx3: 会社の従業員数更新  → 成功 ✓
  └─ Tx4: ユーザー情報更新   → 失敗 ✗ ← ここで障害!
                                        Tx3はコミット済み...

この問題を防ぐには、ビジネス操作全体を1つのトランザクションで包みます。
具体的には、リポジトリとトランザクションの責務を分離します。

  • リポジトリ: データの読み書きを担当。短命で、使い終わったら破棄する
  • トランザクション: コミットまたはロールバックの判断を担当。
    ビジネス操作全体と同じ寿命を持つ

リポジトリはトランザクションの上で動作し、
単独ではDB呼び出しを行いません(トランザクションへの参加が前提です)。
操作全体が成功して初めてコミットし、
途中で失敗すればすべてがロールバックされます。

アトミックな更新(atomic updates)とは

「全てが成功するか、全てが無効になるか」の
二択で実行されるデータ更新のことです。
半端な状態でデータが残ることを防ぎます。

Unit of Workパターン — トランザクションの進化形

トランザクションでアトミック性は確保できますが、
さらに一歩進んだパターンがあります。
それがUnit of Work(ユニットオブワーク)です。

Unit of Workとは

ビジネス操作で影響を受けるオブジェクトのリストを管理し、
操作が完了したタイミングでまとめてDB更新を実行するパターンです。

トランザクションとの違いは、更新のタイミングにあります。
素のトランザクションではリポジトリの呼び出しごとにDBにSQLが飛びますが、
Unit of Workはすべての更新を操作の最後まで遅延させます。

[トランザクション]                [Unit of Work]
操作開始                          操作開始
 ├─ SQL発行(会社更新)            ├─ メモリ上で変更を蓄積
 ├─ SQL発行(ユーザー更新)        ├─ メモリ上で変更を蓄積
 └─ COMMIT                       └─ まとめてSQL発行 → COMMIT

 DBトランザクション: 長い            DBトランザクション: 短い

DBトランザクションの存続時間が短くなるため、
データの競合(ロック待ち等)が軽減されます。
実装は自力で書く必要はなく、ORMが提供してくれることがほとんどです。
たとえば、Entity FrameworkのDbContext、NHibernateのISessionなどがこれにあたります。

NoSQLの場合はどうする?

多くのNoSQLデータベースは、
複数のドキュメントにまたがるトランザクションをサポートしていません。
この場合は、1つのビジネス操作が1つのドキュメントだけを変更するように
ドキュメント設計で対処します。
DDDでいう
「1ビジネス操作で1アグリゲート(整合性を保つべきデータの塊)のみ変更する」
という考え方と同じです
(このガイドラインは、複数ドキュメントにまたがるトランザクションが使えないNoSQLの文脈で特に重要になります)。

テストではarrange/act/assertごとにトランザクションを分ける

ここからがテスト特有の話です。

やってしまいがちなアンチパターンは、
テスト全体で1つのUnit of Work(たとえばDBコンテキスト)を使い回すことです。

なぜダメなのかというと、本番ではビジネス操作ごとに
独立したUnit of Workが生成・破棄されるからです。
テストでUnit of Workを共有すると、本番にはない挙動が発生します。

特に厄介なのがORMのキャッシュです。
arrangeセクションで作成したオブジェクトがキャッシュに残り、
assertセクションでDBから読み直したつもりが
キャッシュから返ってきてしまう——これでは検証になりません。

[アンチパターン: 1つのUnit of Workを共有]

 Unit of Work (1つだけ)
  │
  ├── arrange: ユーザーを作成・保存
  │     └── キャッシュにユーザーが残る
  ├── act:     メール変更APIを実行
  └── assert:  ユーザーを取得して検証
        └── キャッシュから返る(DBを読んでいない!)

正しいアプローチは、arrange、act、assertそれぞれに
独立したトランザクション(またはUnit of Work)を使うことです。
最低3つのトランザクションが必要になります。

[正しいアプローチ: 各セクションで独立したUnit of Work]

 Unit of Work 1 (arrange用)
  └── テストデータを作成・保存 → COMMIT → 破棄

 Unit of Work 2 (act用)
  └── コントローラを実行 → COMMIT → 破棄

 Unit of Work 3 (assert用)
  └── DBから取得して検証 → 破棄

この原則の根拠は「本番環境の再現」です。
本番では操作ごとにUnit of Workが独立しているのだから、
テストでも同じように分離する。
そうしないと、テストの意味がなくなります。

テストデータのライフサイクル — 干渉のない実行環境を作る

トランザクションを正しく分離しても、
テスト間でデータが残留すると、テストの成否が実行順序に依存してしまいます。

  • 「ローカルでは通るがCIで落ちる」
  • 「テストを単体実行すると通るがスイート全体だと落ちる」

この種の不安定さは、テストデータのライフサイクル管理の不備から生まれます。

統合テストは逐次実行を原則とする

統合テストを並列実行しようとすると、追加のコストが一気に増えます。

  • テストデータの一意性保証が必要になる
  • DB制約の回避策を個別に用意しなければならない
  • クリーンアップが複雑化する

コンテナ(Docker)で並列化するアプローチもあります。
Testcontainersのようなライブラリを使えば、
テストごとに独立したDBコンテナを立ち上げることも可能です。

ただし、コンテナのイメージ管理やインスタンスのライフサイクル管理は
追加の運用負荷になるため、すべてのチームに適しているわけではありません。

推奨は、まず開発者ごとに1つのDBインスタンスを持ち、
統合テストは逐次実行することです。
実行時間が問題になった段階で並列化を検討するのでも遅くありません。

テスト開始時のクリーンアップが最善策

テストデータのクリーンアップには4つの選択肢があります。

クリーンアップ方法 速度 信頼性 本番との一致
DBバックアップの復元 遅い 高い 高い
テスト終了時のクリーンアップ 速い 低い 高い
テスト全体をトランザクションで包んでロールバック 速い 高い 低い
テスト開始時のクリーンアップ 速い 高い 高い

1つずつ見ていきましょう。

DBバックアップの復元は確実ですが、毎回の復元に時間がかかります。
コンテナを使ったとしても、インスタンスの破棄と再作成に数秒かかり、
テストスイート全体の実行時間が膨らみます。

テスト終了時のクリーンアップは高速ですが、
ビルドサーバーのクラッシュやデバッガでのテスト中断時に
クリーンアップがスキップされるリスクがあります。
残ったデータが以降のテスト実行に影響を与えてしまいます。

テスト全体をトランザクションで包んでロールバックする方法は、
一見きれいに見えますが、
テスト全体を覆うトランザクションが本番には存在しない構造を作ってしまいます。

Unit of Workの共有問題と同じで、本番とテストの間にギャップが生まれます。

テスト開始時のクリーンアップが最善策です。
速く、確実で、本番との乖離を生みません。

前回のテストが中断してデータが残っていても、
次のテスト実行時に自動的にクリーンアップされるため、
クリーンアップのスキップ問題も解消されます。

実装のポイントは3つです。

  • 削除スクリプトは外部キー制約を考慮した順序で手書きする。
    自動生成は不要で、手書きの方がシンプルで制御しやすい
  • 基底クラスに削除スクリプトを配置し、全統合テストで自動実行する
  • 削除するのは通常データのみ。
    リファレンスデータはマイグレーションで管理するため、
    クリーンアップスクリプトでは触らない

インメモリDBを使わない理由

テストデータの管理が面倒なら、
SQLiteのようなインメモリDBに差し替えてしまえばいいのでは?
——と考えたくなる気持ちはわかります。

インメモリDBには確かにメリットがあります。
データ削除が不要で、高速で、テストごとにインスタンスを生成できます。

しかし、致命的な問題が1つあります。
本番のDBMSと機能的に一致しないのです。

SQL方言の違い、データ型の扱い、制約の挙動など、
DBMSが違えば挙動も変わります。
この差異が、2種類のテスト結果の信頼性低下を引き起こします。

  • 偽陰性: テストは通るが本番では落ちる(バグを見逃す)
  • 偽陽性: 本番では動くがテストが落ちる(無駄な調査が発生する)

テストで使うDBMSは本番と同じベンダーにする。
バージョンやエディションが多少異なるのは許容範囲ですが、
ベンダーは揃えるべきです。

インメモリDBへの差し替えは、テストの「速度」と「手軽さ」を得る代わりに、
「本番との一致」という統合テストの存在意義そのものを犠牲にします。

テストコードの整理術 — 保守性を確保する

ここまでで信頼性の高いDBテストの基盤が整いました。
しかし統合テストは構造上長くなりがちです。
技術的な詳細を整理して、検証に集中できるようにしましょう。

arrangeセクションの整理 — Object Motherパターン

arrangeセクションでは、テストに必要なデータを準備しますが、
このデータ生成コードは冗長になりがちです。

Object Motherパターンを使うと、これをすっきり整理できます。
Object Motherとは、
テストで使うオブジェクトを生成するファクトリメソッドのパターンです。

ポイントは、デフォルト引数を活用して、
テストシナリオに関係のある引数だけを明示的に指定することです。

// Before: 毎回すべてのパラメータを指定
var user = new User(0, "user@mycorp.com", UserType.Employee, false);

// After: Object Motherパターン + デフォルト引数
// テストに関係のあるパラメータだけ明示する
var user = CreateUser(email: "user@mycorp.com");

テストの意図が「このテストはemailに関心がある」と一目でわかるようになります。

Object Mother vs. Test Data Builder

Test Data Builderはnew UserBuilder().WithEmail("...").Build()のように
fluent interface(メソッドチェーン)でオブジェクトを組み立てるパターンです。
可読性がやや高くなりますが、Builderクラスのボイラープレートが必要になります。
言語にオプショナル引数の機能があれば、
Object Motherの方がシンプルで十分なことが多いです。

ファクトリメソッドの配置場所は、
最初はテストクラス内のprivateメソッドで十分です。
重複が目立ってきたら別のヘルパークラスに移動しましょう。
基底クラスには、クリーンアップなど全テスト共通の処理だけを置きます。

actセクションの整理 — デコレータメソッド

actセクションでは、毎回DBコンテキストの生成、コントローラの組み立て、
メソッドの呼び出し、コンテキストの破棄という定型処理が発生します。

この定型処理をデコレータメソッドに抽出しましょう。
「どのコントローラメソッドを呼ぶか」だけを引数で渡し、
コンテキストの管理はデコレータに任せます。

// Before: 毎回コンテキスト管理を書く
string result;
using (var context = new CrmContext(connectionString))
{
    var sut = new UserController(context, messageBus, logger);
    result = sut.ChangeEmail(userId, "new@gmail.com");
}

// After: デコレータメソッドで数行に短縮
string result = Execute(
    controller => controller.ChangeEmail(userId, "new@gmail.com")
);

actセクションが「何をしているか」だけに集中でき、一目で読み取れるようになります。

assertセクションの整理 — fluent interface

assertセクションでも、DBから状態を取得して検証するたびに
コンテキストを開いてリポジトリを呼び出す処理が繰り返されます。

まずヘルパーメソッドで取得処理を隠蔽し、
さらにfluent interfaceを導入すると、
アサーションが自然言語に近い形で読めるようになります。

// Before: 技術的な詳細がアサーションを圧迫
using (var context = new CrmContext(connectionString))
{
    var repo = new UserRepository(context);
    User userFromDb = repo.GetUserById(userId);
    Assert.Equal("new@gmail.com", userFromDb.Email);
    Assert.Equal(UserType.Customer, userFromDb.Type);
}

// After: fluent interfaceで自然言語に近く
QueryUser(userId)
    .ShouldExist()
    .WithEmail("new@gmail.com")
    .WithType(UserType.Customer);

トランザクション数の増加はトレードオフとして受け入れる

ここまでの整理によって、
テスト内のDBトランザクション数は増えます(3つから5つ程度に)。
これは速度と保守性のトレードオフですが、
DBがローカルにあれば劣化は軽微であり、可読性向上に十分見合います。

実務で迷いやすい判断基準

基盤と整理術を身につけた上で、
実務でよく浮かぶ「これはテストすべきか?」という問いに答えます。

write操作とread操作でテスト戦略を分ける

すべてのDB操作を同じ基準でテストする必要はありません。
write操作(データを変更する操作)とread操作(データを読み取る操作)では、
バグが発生したときの影響度がまったく違います。

  • write操作: データの不整合は自アプリだけでなく外部システムにも波及しうる。
    徹底的にテストすべき
  • read操作: バグの影響はwriteほど深刻ではない。
    テストする場合は、最も複雑で重要なものだけに絞る

もう1つの違いとして、read操作にはドメインモデルが不要という点があります。
ドメインモデルの主な目的はカプセル化(データの一貫性を守ること)ですが、
readにはデータ変更がないためカプセル化の意義が薄いのです。
ORMを介さず直接SQLで取得する方がパフォーマンス面でも有利です。

リポジトリは単体テストしない

リポジトリのマッピング処理を個別にテストしたくなる気持ちはわかります。
マッピングのミスは起きやすいですし、心配になるのは自然です。

しかし、リポジトリを単体テストしない理由は明確です。

  • リポジトリは「複雑さが低く、外部依存(DB)とのやり取りがある」コードに分類される
  • 単体テストの保守コストは統合テストと同等でありながら、
    回帰保護(既存機能が壊れていないことを検出する力)の効果は統合テストとほぼ重複する
  • ORMを使っている場合、マッピングの検証はDBアクセスなしには不可能であり、
    「リポジトリの単体テスト」という概念自体が成立しにくい

正しいアプローチは、リポジトリのマッピング検証を
統合テスト全体の一部として自然にカバーすることです。
コントローラの統合テストが通れば、
そこで使われているリポジトリのマッピングも検証されています。

同様に、ドメインイベントを外部メッセージに変換するような薄い連携クラスも、
単体テストのモック構築コストに見合わないことが多いです。

おわりに

DBテストの「面倒さ」の正体は、
本番環境とテスト環境の間に生じるギャップでした。

この記事で扱った対策を振り返ると、
共通しているのは「テスト環境を本番に近づける」という一貫した方針です。

  • スキーマを本番と同期させる
  • トランザクション境界を本番と揃える
  • DBMSも本番と同じベンダーを使う

テストの信頼性は、この「本番との一致」の上にしか建ちません。

一度基盤を整えれば、DBのリファクタリング、ORMの切り替え、
DBベンダーの変更といった大規模な変更にも、
統合テストが安全網として機能するようになります。

DBテストは最も手間がかかるテストですが、
最も強力な回帰保護を提供するテストでもあります。
「面倒だから避ける」のではなく「基盤を整えて楽にする」。
その投資は必ず返ってきます。

3
8
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
3
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?