SQLクエリに対するスナップショットテストの実践例
はじめに
こんにちは、株式会社ログラスでエンジニアをしている田中です。
以前、弊社の龍島が書いた記事でUIコンポーネント以外でのスナップショットテストの活用例として、集計や数値計算を行うエンドポイントの結合テストを紹介しました。
今回の記事では、ORMを活用した際のSQLクエリに対するスナップショットテストの実践例をご紹介します。
SQLクエリに対するスナップショットテストの実装例
SQLクエリに対するスナップショットテストの実装例を示すために、簡単なユーザー取得用の関数を取り上げて説明します。以下は、ユーザー情報を取得するためのクラスUserRepository
の例です。この例では、ORMとしてjOOQを使用しています。
class UserRepository(private val dslContext: DSLContext) {
// UsersRecord や USERS はjOOQによって自動生成されたコード
fun find(userId: String): UsersRecord? {
return dslContext.selectFrom(USERS)
.where(USERS.USER_ID.eq(userId))
.fetchOne()
}
}
こちらのSQLクエリに対するスナップショットテストを実装する場合、まずUserRepository
のfind()
関数をSQL組み立て部分とUserRecord
の取得部分に関数を分けます。
class UserRepository(private val dslContext: DSLContext) {
fun find(userId: String): UsersRecord? {
- return dslContext.selectFrom(USERS)
- .where(USERS.USER_ID.eq(userId))
- .fetchOne()
+ val query = buildFindQuery(userId)
+ return query.fetchOne()
}
+ internal fun buildFindQuery(userId: String): SelectConditionStep<UsersRecord> {
+ return dslContext.selectFrom(USERS).where(USERS.USER_ID.eq(userId))
+ }
}
続いて、テスト用のクラスで、buildFindQuery()
メソッドに対するテストを記述します。このテストには、origin-energy/java-snapshot-testingを使用しています。
@ExtendWith(SnapshotExtension::class)
internal class UserRepositoryTest {
@Test
fun `SQLクエリスナップショットテスト`(expect: Expect) {
val result = userRepository.buildFindQuery("userId1").getSQL(ParamType.INLINED)
expect.toMatchSnapshot(result)
}
}
上記のテストによって生成されたsnapファイルの内容は以下の通りです。
UserRepositoryTest.SQLクエリスナップショットテスト=[
select "users"."user_id" from "users" where "users"."user_id" = 'userId1'
]
SQLクエリに対するスナップショットテストのメリット
スナップショットテストの適した場面について、冒頭で紹介した龍島の記事では以下のように説明しています。
別の手動テスト等で既に動作の妥当性が保証されている場面
インプット、アウトプットのパターンが多く、アサーションを記述するテストはコストが高い場面
SQLクエリに対するスナップショットテストを実装する際の追加のメリットとして、以下の2点が挙げられます。
- DB接続を伴うテストは実行に時間がかかる場合が多いが、SQLクエリに対するスナップショットテストは実行が比較的高速
- ORMを使用して複雑なクエリを組み立てる場合、実際に実行されるクエリを把握することが困難であることが多いが、スナップショットテストを用いることで実際に実行されるSQLクエリを確認することができる
弊社の開発でも、実際に使用している集計や数値計算を行うエンドポイントの結合テストにおいて約60パターンのSQLのviewの結果をスナップショットテストの対象としていますが、このテストをSQLクエリのみに限定した場合、実行時間は2.4倍短縮されます。SQLクエリが変更されない限り、実行結果は正しいと期待できるため、実行時間の短縮は大きなメリットです。
また、上記のようなユーザー取得用の関数は比較的単純なロジックのため、どのようなSQLクエリが実行されるか想像しやすいですが、以下のような実装の場合はどうでしょうか?
fun listAll(
tenantId: String,
isActive: Boolean,
nameOrEmail: String?,
departmentId: String?,
limit: Int?,
offset: Int?,
): List<UsersRecord> {
val query = buildListAllQuery(
tenantId,
isActive,
nameOrEmail,
departmentId,
limit,
offset,
)
return query.fetch()
}
internal fun buildListAllQuery(
tenantId: String,
isActive: Boolean,
nameOrEmail: String?,
departmentId: String?,
limit: Int?,
offset: Int?,
): SelectForUpdateStep<UsersRecord> {
return dslContext
.selectFrom(USERS)
.where(USERS.TENANT_ID.eq(tenantId))
.apply {
if (isActive) {
and(USERS.IS_ACTIVE.eq(true))
} else {
and(USERS.IS_ACTIVE.eq(false))
}
nameOrEmail?.let {
and((USERS.NAME.like("%$it%")).or(USERS.EMAIL_ADDRESS.like("%$it%")))
}
departmentId?.let {
andExists(
dslContext.selectOne()
.from(USER_DEPARTMENTS)
.where(USER_DEPARTMENTS.TENANT_ID.eq(tenantId))
.and(USER_DEPARTMENTS.USER_ID.eq(USERS.USER_ID))
.and(USER_DEPARTMENTS.DEPARTMENT_ID.eq(it))
)
}
}
.limit(limit ?: 10)
.offset(offset ?: 0)
}
パターンの分岐が多く、実行されるSQLクエリを想像するのが難しい場合もあります。また、実際の開発現場では、より複雑な処理が必要なことがあります。そのような状況では、スナップショットテストの結果を活用することで、実行されるSQLクエリをパターンごとに簡単に確認することが可能です。
まとめ
この記事を通して、SQLクエリに対するスナップショットテストの実践例を紹介しました。しかし、スナップショットテストを乱用すると、新規実装や改修のコストが増加する可能性があります。したがって、どの部分を担保したいのかを明確にし活用することが重要であり、それによって、プロダクトの品質向上に貢献することができます。
最後までお読みいただきありがとうございました!
Discussion