Supabase RLSテストにpgTAP使ってみた

Anycloud PdMの青木です。
担当プロジェクトでSupabaseのRLSテストにpgTAPを使ってみたので、まとめてみました。
SupabaseのRLSテストにおける自動化の重要性については、下記をご覧ください。
目次
- RLSとpgTAPの基礎知識
- Row Level Security(RLS)とは
- pgTAPとは
- SupabaseとpgTAPの統合
- テスト環境のセットアップ
- 基本的な環境構築
- Docker Desktopのインストール
- Supabase CLIのインストール
- プロジェクトの初期化
- テストヘルパーのインストール
- セットアップファイルの作成
- 使用方法
- package.jsonでの管理(推奨)
- プロジェクト構造
- 最初のRLSテストを書く
- 基本的なテストパターン
- RLSテストで使用される主要関数ガイドライン
- pgTAP関数(アサーション)
- supabase_test_helpers関数(認証・ユーザー管理)
- 使用上の注意点
- SELECTポリシーのテスト
- INSERT、UPDATE、DELETEポリシーのテスト
- 認証コンテキストの管理
- 開発ワークフローへの統合
- ローカル開発統合
- CI/CDパイプライン統合
- パフォーマンステスト統合
- よくある落とし穴と回避方法
- 認証コンテキストの問題
- パフォーマンス問題
- UPDATE操作テストの複雑さ
- テストアプローチの比較
- pgTAPの利点
- アプリケーションレベルテストとの使い分け
- ハイブリッドアプローチ
- まとめ
RLSとpgTAPの基礎知識
Row Level Security(RLS)とは
RLSはデータベースレベルで動作するセキュリティ機能で、アプリケーションからの接続方法に関係なく、PostgreSQL内で直接アクセスルールを適用します。従来のアプリケーションレベルのセキュリティとは異なり、RLSポリシーはすべてのクエリに自動的にWHERE句を追加するような働きをし、ユーザーが認可されたデータのみにアクセスできることを保証します。
Supabaseでは主に3つのロールが使用されます:
anon
:未認証ユーザーauthenticated
:ログイン済みユーザー- カスタムロール:JWTクレームを通じて定義される独自の役割
pgTAPとは
pgTAPはPostgreSQL専用のユニットテストフレームワークで、PL/pgSQLで書かれ、Test Anything Protocol(TAP)に準拠しています。データベーススキーマ、関数、トリガー、そして重要なRLSポリシーをテストするための100以上のアサーション関数を提供します。
pgTAPの最大の利点は、テストがデータベース内で直接SQLを使用して実行されることです。これにより、複雑なデータベース接続やデータ変換レイヤーが不要になり、従来のテスト手法で問題となっていた課題を解決します。
SupabaseとpgTAPの統合
Supabase CLIバージョン1.11.4以降では、supabase test db
コマンドを通じてpgTAPサポートが組み込まれており、SQLの専門知識に関係なく、開発者がデータベーステストにアクセスできるようになっています。テストは自動的にロールバックされるトランザクション内で実行されるため、開発データを汚染することなくテストの分離が保証されます。
テスト環境のセットアップ
基本的な環境構築
Docker Desktopのインストール
Supabaseローカル開発環境にはDockerが必要です。
Supabase CLIのインストール
Supabase CLIは複数の方法でインストールできます。
方法1:npmでグローバルインストール
npm install -g supabase
方法2:npxを使用(インストール不要)
# プロジェクト初期化
npx supabase init
# ローカル開発環境を起動
npx supabase start
# テストを実行
npx supabase test db
方法3:パッケージマネージャーを使用
# macOS (Homebrew)
brew install supabase/tap/supabase
# Windows (Scoop)
scoop bucket add supabase <https://github.com/supabase/scoop-bucket.git>
scoop install supabase
プロジェクトの初期化
# プロジェクトディレクトリを作成
mkdir my-supabase-project
cd my-supabase-project
# Supabaseプロジェクトを初期化
npx supabase init
# ローカル開発環境を起動(初回は時間がかかります)
npx supabase start
テストヘルパーのインストール
pgTAPでのRLSテストを簡素化するため、テスト専用のセットアップファイルを作成してdbdevとsupabase_test_helpersを自動インストールします。
セットアップファイルの作成
supabase/tests/000-setup-test-helpers.sql
ファイルを作成します。
-- =================================================================
-- テスト環境専用セットアップファイル
-- =================================================================
begin;
-- セットアップ関数の定義
create or replace function setup_test_helpers()
returns void as $
declare
extension_exists boolean;
helper_exists boolean;
begin
-- 既存インストールの確認
raise notice '=== テストヘルパーセットアップ開始 ===';
select exists (
select 1 from pg_extension
where extname = 'basejump-supabase_test_helpers'
) into helper_exists;
if helper_exists then
raise notice 'テストヘルパーは既にインストール済みです。';
return;
end if;
-- 必要な拡張機能の準備
raise notice 'ステップ1/4: 必要な拡張機能を準備中...';
create extension if not exists http with schema extensions;
create extension if not exists pg_tle;
-- dbdevのインストール
raise notice 'ステップ2/4: dbdevをインストール中...';
drop extension if exists "supabase-dbdev";
perform pgtle.uninstall_extension_if_exists('supabase-dbdev');
-- 最新のdbdevを取得・インストール
perform pgtle.install_extension(
'supabase-dbdev',
resp.contents::text,
'Supabase package manager',
resp.version,
'PostgreSQL'
)
from (
select *
from extensions.http_get('<https://api.database.dev/rest/v1/package_versions?select=sql,version&package_name=eq.supabase-dbdev&order=version.desc&limit=1>')
) x,
lateral (
select (row_to_json(x)::jsonb -> 'content' -> 0)::jsonb as resp
) r(resp);
create extension "supabase-dbdev";
perform dbdev.restart();
-- supabase_test_helpersのインストール
raise notice 'ステップ3/4: supabase_test_helpersをインストール中...';
perform dbdev.install('basejump-supabase_test_helpers');
create extension "basejump-supabase_test_helpers"
version '0.0.6'
cascade;
raise notice '✓ テストヘルパーのインストール完了';
end;
$ language plpgsql;
-- セットアップを実行
select setup_test_helpers();
commit;
使用方法
# セットアップのみ実行
supabase test db tests/000-setup-test-helpers.sql
# 全テストを実行(セットアップ含む)
supabase test db
# 特定のテスト実行(セットアップ含む)
supabase test db tests/000-setup-test-helpers.sql tests/001-users-rls-test.sql
package.jsonでの管理(推奨)
{
"scripts": {
"test:db": "supabase test db",
"test:setup": "supabase test db tests/000-setup-test-helpers.sql",
"test:users": "supabase test db tests/000-setup-test-helpers.sql tests/001-users-rls-test.sql"
}
}
プロジェクト構造
プロジェクト構造は標準化されたパターンに従い、テストはsupabase/tests/
ディレクトリに配置されます。ファイルはアルファベット順で実行されるため、番号付きの命名規則を使用します。
supabase/
├── tests/
│ ├── 000-setup-test-helpers.sql # テストヘルパーのセットアップ
│ ├── 001-users-rls-test.sql # ユーザーRLSテスト
│ ├── 002-posts-rls-test.sql # 投稿RLSテスト
│ └── 003-comments-rls-test.sql # コメントRLSテスト
├── migrations/
└── config.toml
ファイル命名規則:
000-setup-*
:テスト環境のセットアップ001-099
:実際のテストファイル999-cleanup-*
:クリーンアップ(必要に応じて)
この構造により、テストは常に正しい順序で実行され、依存関係が適切に管理されます。
最初のRLSテストを書く
基本的なテストパターン
基本的なRLSテストは、初学者でも簡単に理解できる一貫したパターンに従います。すべてのテストは、トランザクション宣言と予期されるテスト数を示すplan文で始まります。
begin;
select plan(5); -- 5つのテストを実行予定
-- テストをここに書く
select * from finish();
rollback;
RLSテストで使用される主要関数ガイドライン
pgTAP関数(アサーション)
関数名 | 用途 | 使用例 |
---|---|---|
| テスト計画数を宣言 |
|
| 条件が真かテスト |
|
| 値の等価性をテスト |
|
| 値の非等価性をテスト |
|
| クエリ結果が空かテスト |
|
| クエリ結果の比較 |
|
| エラーなしで実行されるかテスト |
|
| エラーが投げられるかテスト |
|
| テスト終了を宣言 |
|
supabase_test_helpers関数(認証・ユーザー管理)
関数名 | 用途 | 使用例 |
---|---|---|
tests.create_supabase_user(email) | テストユーザーを作成 | select tests.create_supabase_user('test@example.com'); |
tests.get_supabase_uid(email) | ユーザーIDを取得 | select tests.get_supabase_uid('test@example.com'); |
tests.authenticate_as(email) | 認証コンテキストを設定 | select tests.authenticate_as('test@example.com'); |
tests.clear_authentication() | 認証をクリア | select tests.clear_authentication(); |
使用上の注意点
pgTAP関数:
ok()
は最も基本的なアサーション。複雑な条件でも使用可能is_empty()
はRLSでブロックされた結果をテストするのに最適lives_ok()
とthrows_ok()
は操作の成功/失敗をテストresults_eq()
は具体的なデータ内容を検証
supabase_test_helpers関数:
- 各テストの前後で認証状態を明示的に管理する
tests.clear_authentication()
は匿名状態をテストする際に必須- ユーザーIDが必要な場合は
tests.get_supabase_uid()
を使用
SELECTポリシーのテスト
SELECTポリシーのテストでは、テストユーザーの作成、認証コンテキストの切り替え、データ可視性の検証が必要です。
begin;
select plan(3);
-- テストユーザーを作成
select tests.create_supabase_user('test_user');
-- テストデータを挿入
insert into posts (title, content, user_id)
values ('Test Post', 'Content', tests.get_supabase_uid('test_user'));
-- 認証コンテキストを設定
select tests.authenticate_as('test_user');
-- ユーザーが自分の投稿を見ることができることをテスト
select ok(
(select count(*) from posts) = 1,
'ユーザーは自分の投稿を見ることができる'
);
-- 認証をクリア
select tests.clear_authentication();
-- 匿名ユーザーは投稿を見ることができないことをテスト
select is_empty(
'select * from posts',
'匿名ユーザーは投稿を見ることができない'
);
select * from finish();
rollback;
INSERT、UPDATE、DELETEポリシーのテスト
INSERT、UPDATE、DELETEポリシーでは異なるテストアプローチが必要です。
begin;
select plan(4);
select tests.create_supabase_user('test_user');
select tests.authenticate_as('test_user');
-- INSERTテスト
select lives_ok(
'insert into posts (title, content, user_id)
values (''Test'', ''Content'', auth.uid())',
'ユーザーは投稿を作成できる'
);
-- UPDATEテスト(自分の投稿)
select lives_ok(
'update posts set title = ''Updated'' where user_id = auth.uid()',
'ユーザーは自分の投稿を更新できる'
);
-- DELETEテスト(他人の投稿)
select tests.create_supabase_user('other_user');
insert into posts (title, content, user_id)
values ('Other Post', 'Content', tests.get_supabase_uid('other_user'));
select throws_ok(
'delete from posts where user_id != auth.uid()',
'ユーザーは他人の投稿を削除できない'
);
select * from finish();
rollback;
認証コンテキストの管理
認証コンテキストはRLSテストの最も重要な側面です。tests.authenticate_as()
関数は現在のユーザーコンテキストを設定し、Supabaseクライアントライブラリが認証を処理する方法を模倣します。匿名アクセスをテストする際は、tests.clear_authentication()
で明示的に認証をクリアし、テスト間でのコンテキストリークを防ぐ必要があります。
開発ワークフローへの統合
ローカル開発統合
Supabase CLIは、supabase test db
コマンドを通じてシームレスなローカル開発統合を提供します。
# すべてのテストを実行
supabase test db
# 特定のテストファイルを実行
supabase test db tests/001-users-rls-test.sql
# デバッグモードで実行
supabase test db --debug
CI/CDパイプライン統合
CI/CDパイプライン統合では、GitHub Actions、GitLab CI、またはCircleCIを活用してコミットごとに自動的にテストを実行します。
name: Test Database
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: supabase/setup-cli@v1
with:
version: latest
- run: supabase start
- run: supabase test db
パフォーマンステスト統合
アプリケーションがスケールするにつれて、パフォーマンステスト統合が重要になります。
begin;
select plan(2);
-- RLSポリシーが適切なインデックスを使用することをテスト
select ok(
(select count(*) from (
explain (analyze, buffers)
select * from posts where user_id = auth.uid()
) as plan
where plan like '%Index Scan%') > 0,
'RLSポリシーがインデックススキャンを使用している'
);
select * from finish();
rollback;
よくある落とし穴と回避方法
認証コンテキストの問題
認証コンテキストはテスト失敗の最も頻繁な原因です。テストは通常、デフォルトでpostgresスーパーユーザーとして実行され、RLSを完全にバイパスしてしまいます。開発者は各テストシナリオで認証コンテキストを明示的に設定し、テスト間でクリアしてコンテキストリークを防ぐ必要があります。
パフォーマンス問題
最適化されていないRLSポリシーによるパフォーマンス低下は、アプリケーションの応答性を壊滅的に悪化させる可能性があります。ポリシー条件で使用される列にインデックスがないと、すべてのクエリで全テーブルスキャンが発生します。
-- 悪い例:インデックスなし
CREATE POLICY "users_select_own" ON posts
FOR SELECT USING (user_id = auth.uid());
-- 良い例:インデックスあり
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE POLICY "users_select_own" ON posts
FOR SELECT USING (user_id = auth.uid());
UPDATE操作テストの複雑さ
UPDATE操作テストは、PostgreSQLがRLS下でUPDATEとDELETEを異なって処理するため、多くの初学者を混乱させます。失敗したアップデートはエラーを投げるのではなく、影響を受けた行数ゼロを返すため、異なるアサーション戦略が必要です。
-- 間違った方法
select throws_ok(
'update posts set title = ''test'' where user_id != auth.uid()',
'ブロックされたアップデートがエラーを投げる'
); -- これは失敗する
-- 正しい方法
select is(
(update posts set title = 'test' where user_id != auth.uid()),
0,
'ブロックされたアップデートは行を更新しない'
);
テストアプローチの比較
pgTAPの利点
pgTAPは以下の点で優れています:
- データベースレベルでの直接的なテスト
- ネットワークオーバーヘッドの排除
- トランザクション分離
- RLSポリシー、スキーマ検証、データベース関数のテストに最適
アプリケーションレベルテストとの使い分け
アプリケーションレベルテスト(Jest、Vitestなど)は、複数のサービスにまたがるエンドツーエンドワークフローに適しています。これらのフレームワークは、認証フローや複数ステップのユーザージャーニーを含む実際のクライアント体験をテストします。
ハイブリッドアプローチ
ハイブリッドアプローチが最も効果的です:
- pgTAP:個別のRLSポリシーの正確性を検証
- アプリケーションレベルテスト:完全なユーザーワークフローの検証
まとめ
Supabase RLSポリシーのテストは、セキュアなアプリケーション構築のための必須実践となっています。そのアプローチとして、pgTAPはSupabaseとの親和性が高く、初学者でも簡単に導入できる優れたテストフレームワークです。セキュリティ脆弱性を早期発見し、信頼性の高いアクセス制御を実現していきましょう。