はじめに
この記事では
- データベースに対するテストの準備のやり方と注意点
- データベースに対するテストの注意点
- テストコードの再利用のやり方
についてまとめました。
データベースに対するテストは注意点が多く、
不用意に何も考えずにテストコードを書くと、
リファクタリングに弱く、保守コストが無駄に高くなってしまいます。
実際にそのような現場を僕も見てきて、
今後テストケースを書く際にはこれだけは注意した方が良い。
という部分を厳選してまとめています。
データベースに対するテストの準備のやり方と注意点
管理下にあるプロセス外依存で最もよくある依存は、
テスト対象のアプリケーションだけがアクセスするデータベースです。
統合テストの際、
このようなデータベースをテストダブルに置き換えずに直接利用することで、
非常に強力な退行(リグレッション)に対する保護を得られるようになります。
しかしデータベースに対するテストは準備が非常に大変です。
何に注意してどう準備すると良いか詳しく見ていきましょう。
スキーマをプロダクションコードとセットでバージョン管理する
スキーマはプロダクションコードとセットで、
Gitなどでバージョン管理し、
Flywayなどのマイグレーションツールでバージョン毎の断面をサクッと再現できるようにしておきます。
-- 加盟店企業テーブル
CREATE TABLE AFFILIATE_COMPANY (
AFFILIATE_COMPANY_CODE SERIAL PRIMARY KEY,
AFFILIATE_COMPANY_NAME VARCHAR(255) NOT NULL,
CONTRACT_START_DATE DATE,
CONTRACT_END_DATE DATE
);
-- 店舗テーブル
CREATE TABLE STORE (
AFFILIATE_COMPANY_CODE INT REFERENCES AFFILIATE_COMPANY(AFFILIATE_COMPANY_CODE),
STORE_CODE SERIAL PRIMARY KEY,
STORE_NAME VARCHAR(255) NOT NULL,
LOCATION TEXT
);
-- 会員テーブル
CREATE TABLE MEMBER (
MEMBER_CODE SERIAL PRIMARY KEY,
MEMBER_NAME VARCHAR(255) NOT NULL,
JOIN_DATE DATE,
ADDRESS TEXT,
GENDER VARCHAR(10),
DATE_OF_BIRTH DATE,
POINT_BALANCE INT
);
・・・
例えば、プロダクトの新規機能開発に伴い、カラム追加が発生したら、
V1.2.1__add_column_XXX.sqlというファイルを追加し、
カラム追加のクエリをそこに記述します。
開発、ステージング、本番環境をバージョンアップする際は、
元のバージョンからの差分だけを適用させます。
これによってデータベースの断面をバージョン管理でき、
アプリケーションの断面とデータベース断面をセットで管理できます。
また、ローカル開発環境やCI環境ではコンテナ上にデータベースサーバを立てて、
統合テストなどのテストコードではそのデータベースと繋いでテストすることもあるでしょう。
その場合でも、ローカル開発環境やCI環境で
アプリケーションのバージョンに該当するデータベースの断面をサクッと再現して、
テストコードを回すことができます。
もしデータベースのバージョン管理をしていなければ、
開発環境はデータベースのバージョンが進んでいて、
ステージング以降の環境はバージョンが古い。
ステージング以降の環境をバージョンアップするには、
開発環境とステージングとの差分からバージョンアップ用のクエリを作って・・・
などかなり危うい作業手順が必要になったり、
ソースコードとデータベースのバージョンを揃えるために、
複数の情報源(Gitとどこかの環境のデータベース)を追いかける必要があり、
管理のハードルが高まり作業ミスにも繋がります。
何かしらのトラブルで過去断面にアプリケーションを戻す必要が出ても、
スキーマをバージョン管理していなければ難しいでしょう。
参照データもスキーマとセットでバージョン管理する
そもそも、
参照データとはアプリケーションが動くために、
事前にセットアップしておかなければならないデータです。
(アプリケーションが動いた際にinsertやupdateがかからないデータ)
例えば〇〇カテゴリーテーブル、
在庫管理などをしないような商品カタログテーブルなどは
参照データとして管理。
逆に、会員テーブルや支払テーブルなど、
アプリケーションが動くとinsertやupdateされるようなテーブルは
テストコードの先頭で都度リフレッシュ。
するようなイメージです。
-- AFFILIATE_COMPANY
INSERT INTO AFFILIATE_COMPANY (AFFILIATE_COMPANY_NAME, CONTRACT_START_DATE, CONTRACT_END_DATE)
VALUES ('Company A', '2021-01-01', '2023-12-31'),
('Company B', '2021-02-01', '2023-12-31'),
('Company C', '2021-03-01', '2023-12-31');
-- STORE
INSERT INTO STORE (AFFILIATE_COMPANY_CODE, STORE_NAME, LOCATION)
VALUES (1, 'Store A1', 'Location A1'),
(1, 'Store A2', 'Location A2'),
(2, 'Store B1', 'Location B1');
-- AFFILIATE_PRODUCT
INSERT INTO AFFILIATE_PRODUCT (AFFILIATE_COMPANY_CODE, CROSS_ANALYSIS_PRODUCT_CODE, AFFILIATE_PRODUCT_NAME, JAN_CODE)
VALUES (1, 1, 'Product A1', 'JAN001'),
(1, 2, 'Product A2', 'JAN002'),
(2, 3, 'Product B1', 'JAN003');
・・・
この参照データをスキーマデータとセットで管理することで、
ローカル開発環境、CI環境で統合テストやE2Eテストといったテストコードを流す際、
マイグレーションツールでスキーマとセットで参照データもセットアップできます。
そこまでセットアップできていれば、
例えば参照データを都度テストコードの先頭で消したりセットアップする必要がなく、
テストコードの保守工数が減らせ、テストコード実行時間も短縮できます。
(会員や会員の取引情報はテストコードで登録・更新が発生するため参照データではないので
ここで管理はしません)
データベースに対するテストの注意点
データベースに対するテストは、
統合テストやE2Eテストで行い、
単体テストでは行わないというのが一般的だと思います。
また、
単体テストと比べると、
統合テストやE2Eテストはテストのための準備が大変でテストコードの保守コストが高く、
テスト実行速度も遅く迅速なフィードバックも得ることが難しいでしょう。
自分の経験上、
特にデータベースに対するテストデータの管理をテキトーにやってしまって、
より保守コストを上げてしまったり、
テスト実行速度が遅くなったり、
テストケース間で依存してしまったり
するケースが多い印象です。
では、このような事態に陥ってしまわないように
何に注意すれば良いかみていきましょう。
テストデータのライフサイクル管理
データベースに対するテストコードでは、
恐らくほとんどの場合、
複数のテストケースが1つのデータベースを共有することになるでしょう。
そのため、とあるテストケースの実行が他のテストケースの実行に影響を
与えてしまう問題を常に抱えているということに注意しましょう。
テストケース間で影響を出さないために以下をする必要があります。
- テストケースを1つずつ実行する
- テストケースの実行でできたデータの後始末をする
特にデータの後始末については重要です。
僕の経験上データの後始末をテキトーにやったり、間違えた考え方でやったせいで、
とあるテストケースでNGになったら以降のテストケースが全て落ちる。。。
結局どのテストケースが本当にNGで、
どのテストケースはテストデータが壊れたせいで落ちただけなのか
いちいち調査に時間がかかる。
といった事態に陥ったことがあります。
では統合テストやE2Eテストにおいて、
どのようにテストデータを後始末すれば良いか具体的な方法をみていきましょう。
@Transactional
class TestListPointLogByMemberQueryService : DatabaseTestBase() {
@Autowired
private lateinit var target: ListPointLogByMemberQueryService
@BeforeEach
fun setup() {
db {
deleteAllTables()
insert(MemberFixture().default())
insert(PaymentFixture().default())
insert(PaymentFixture().default().copy(receipt_number = 2))
insert(PointTransactionLogFixture().default())
insert(PointTransactionLogFixture().default().copy(receipt_number = 2))
insert(PointTransactionLogFixture().default().copy(receipt_number = 2, point_log_type = IListPointLogByMemberQueryService.PointLogType.USE.name))
}
}
@Test
fun `ポイント付与利用履歴リストが取得できる`() {
val memberCode = MemberCode("1")
val result = target.list(memberCode)
assertEquals(3, result.items.size)
}
上記は現時点での自分の理想的なデータベースに対するテストケースです。
fixtureやテストサーポートクラスの細かい説明は後述しますが、
ここでは全体像をざっくり眺めてみてください。
データの後始末というとテストケースの最後にするようなイメージを持ってしまいますが、
各テストケースの先頭で他のテストケースが流れたことによって残っているデータを消して、
テストケースに必要なデータを積み直しています。
例えば、テストケースの実行後にデータの後始末をする場合
何らかの理由でテストケース実行を途中で中断したときに後始末がされず問題になります。
実はこれが先ほど挙げた、とあるテストケースが落ちると、
以降のテストケースが全て落ちる問題につながる要因でした。
データベースの読み書きに対するテスト
テスト対象システムにとって書き込みの機能を徹底的にテストすることは重要です。
書き込み時に何か間違いがあれば、そのことがデータの崩壊に繋がります。
その結果、自身のデータベースだけでなく、
それを参照している外部のアプリケーションにも
影響を与えてしまうことが起こるかもしれません。
外部に公開しているデータベースがある場合、
データベースへの書き込みはアプリケーションを外から見た振る舞いとなり、
確認フェーズで確認する対象です。
また、外部に公開していないアプリケーションだけが参照するデータベースだとしても、
書き込みに不具合があると、
本番環境のデータベースを壊してしまい、
データパッチの作業が必要になったり、
ダメージがかなり大きいため、
書き込みテストをすることは非常に価値があり、
このテストを行うことで書き込み時の間違いを防ぐ保護を得られるようになります。
一方で読み込みの機能にバグが含まれていたとしても、
通常、大きな害をもたらすことはないため書き込みに対するテストよりも重要性が低いです。
読み込みが非常に複雑もしくは重要な役割を担う場合だけにすべきで、
そうではない読み込みであれば無視するというテスト方針でも良いでしょう。
リポジトリへのテスト
リポジトリはデータベースの操作を抽象化して使いやすくしたものです。
リポジトリはプロダクションコードの分類においてコントローラに属するものです。
あまり複雑にならない一方でプロセス外依存を扱うコードのためです。
そして、プロセス外依存の存在テストに対する保守コストを大きくします。
保守コストはリポジトリのテストと統合テストと同じくらいになります。
しかし、通常の統合テストほどの価値はありません。
リポジトリの検証をすることで退行に対する保護は
通常の統合テストで得られる保護と多くの部分が重なります。
そのためリポジトリをテストしたところで新たな価値はもたらされないでしょう。
もし、リポジトリが複雑になっているのであれば、
その複雑さを自己完結型のアルゴリズムとして抽出し、
そのアルゴリズムを検証対象とすべきです。
ただし、ORマッパーを使っている場合、
データの関連付け(複雑な部分)とデータベースとのやり取りを分離できません。
そしてORマッパーが行なっているデータの関連付けの検証は、
データベースの呼び出しを行わなければ確認できず、
それを行うとリファクタリングへの耐性は損なわれます。
そのため、ORマッパーを使っているのであればリポジトリを直接検証するのではなく、
統合テストのシナリオの一部に含めて検証するのが一般的には良いと言われています。
(が、自分が参画している現場ではリポジトリのテストガッツリ書いてます)
テストコードの再利用のやり方
データベースに対するテストははすぐに肥大化してしまい、
保守がしづらくなる傾向にあります。
テストコードの量を減らす最も効率的な方法は、
ビジネスロジックに関わらない技術的なコードを
プライベートなメソッドやヘルパークラスに抽出することです。
これによって
同じようなコードをさまざまなテストケースに記述しなくても済むようになります。
Fixture(フィクスチャ)とObject Mother(オブジェクトマザー)、その他テストサポートクラス
データベースに対するテストでは特に
テストコードの準備フェーズでのコードの再利用を計画に行う必要があります。
例えば、
至る所に同じようなデータベースセットアップ処理を記載した場合、
テーブルにカラム追加があった場合に
それらすべての処理を修正して回らなければなりませんが、
データベースセットアップを抽出してFixtureを作るテストコード作成ルールを決めておけば、
Fixtureの1箇所を修正するだけでカラム追加に対応できます。
// Fixture
data class MemberFixture(
val member_code: Int = 0,
val member_name: String = "",
val join_date: LocalDate? = null,
val address: String? = null,
val gender: String? = null,
val date_of_birth: LocalDate? = null,
val point_balance: Int? = null
)
fun DbSetupBuilder.insertMemberFixture(f: MemberFixture) {
insertInto("member") {
mappedValues(
"member_code" to f.member_code,
"member_name" to f.member_name,
"join_date" to f.join_date,
"address" to f.address,
"gender" to f.gender,
"date_of_birth" to f.date_of_birth,
"point_balance" to f.point_balance
)
}
}
fun DbSetupBuilder.insert(f: MemberFixture) {
insertMemberFixture(f)
}
// ExtensionでFixtureをdefault値で生成
fun MemberFixture.default(): MemberFixture {
val motherPaymentMember = MotherPaymentMember.default()
return MemberFixture(
member_code = motherPaymentMember.memberCode.value.toInt(),
member_name = "てすと たろう",
point_balance = motherPaymentMember.getDataModel().remainingPoints
)
}
// Object Motherでドメインオブジェクトをdefault値で生成
object MotherPaymentMember {
fun default(): PaymentMember {
return PaymentMember(
memberCode = MemberCode("1"),
remainingPoints = Point(1_000)
)
}
}
上記のコードはFixtureをfactlinで自動生成しているため、
data classでのdefault値は空文字やnullや0となっています。
そのためExtensionを別途作ってそちらでFixtureのdefault値を設定しています。
このdefault値は該当するドメインオブジェクトがある場合は、
そのObject Motherの値に揃えることでできる限り確認フェーズのassertionの期待値の
管理コストを減らす意図で作っています。
Fixtureはデータベースのセットアップなど一連の環境設定を担わせており、
ここではテストデータをinsertする役割を持っています。
また、参照データ以外をテストケースの先頭で削除したいので、
データベース削除ヘルパーも用意しています。
fun DbSetupBuilder.deleteAllTables() {
deleteAllFrom(
listOf(
"point_transaction_log",
"coupon_distribution",
"coupon_usage",
"purchase_product_detail",
"payment_method_detail",
"payment",
"member"
)
)
}
これらを事前に用意しておくことで、
各テストケースでは準備フェーズの記述を至る所に記述したり、
確認フェーズで期待値を至る所に記述したりする必要を減らすことができます。
・・・
@BeforeEach
fun setup() {
db {
deleteAllTables()
insert(MemberFixture().default())
insert(PaymentFixture().default())
insert(PaymentFixture().default().copy(receipt_number = 2))
insert(PointTransactionLogFixture().default())
insert(PointTransactionLogFixture().default().copy(receipt_number = 2))
insert(PointTransactionLogFixture().default().copy(receipt_number = 2, point_log_type = IListPointLogByMemberQueryService.PointLogType.USE.name))
}
}
・・・
ヘルパークラスなどは、
一般的にまずはプライベートに作ってコードの重複が目立ってきたら
個別のヘルパークラスに移すようにするのが良いそうなのですが、
開発初期段階で、
例えばtest-libパッケージにお手本のFixtureを参考に作るなど、
テストコードのコーディングルールとしてあらかじめ決めておくほうが、
各開発者の判断でバラバラな場所に作ったり、そもそも作らなかったり、
せず綺麗に運用が回ると個人的には経験上感じています。
(Fixtureを作りすぎてテストコードの保守コストが跳ね上がるということも
あまりないと思うので)
今回のテーマについては以上です。
テストコード初めての方へ向けて記事を書いたので
是非こちらもご覧ください。
初めてテストコードを書く方に向けて|新人プログラマー時代の自分に伝えたいこと参考文献
第10章 データベースに対するテスト
単体テストの考え方/使い方