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

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

Anycloud PdMの青木です。

担当プロジェクトでSupabaseのRLSテストにpgTAPを使ってみたので、まとめてみました。

SupabaseのRLSテストにおける自動化の重要性については、下記をご覧ください。

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関数(アサーション)

関数名

用途

使用例

plan(n)

テスト計画数を宣言

select plan(3);

ok(condition, description)

条件が真かテスト

select ok((select count(*) from posts) = 1, 'ユーザーは投稿を見ることができる');

is(actual, expected, description)

値の等価性をテスト

select is((select count(*) from posts), 1, '投稿数は1つ');

isnt(actual, unexpected, description)

値の非等価性をテスト

select isnt((select count(*) from posts), 0, '投稿が存在する');

is_empty(query, description)

クエリ結果が空かテスト

select is_empty('select * from posts', '匿名ユーザーは投稿を見れない');

results_eq(query, expected, description)

クエリ結果の比較

select results_eq('select title from posts', ARRAY['Test Post'], 'タイトルが一致');

lives_ok(query, description)

エラーなしで実行されるかテスト

select lives_ok('insert into posts (title) values (''test'')', '投稿を作成できる');

throws_ok(query, description)

エラーが投げられるかテスト

select throws_ok('delete from posts', '削除操作が拒否される');

finish()

テスト終了を宣言

select * from finish();

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との親和性が高く、初学者でも簡単に導入できる優れたテストフレームワークです。セキュリティ脆弱性を早期発見し、信頼性の高いアクセス制御を実現していきましょう。

Anycloudではプロダクト開発の支援を行っています

プロダクト開発をお考えの方はぜひAnycloudにご相談ください。

まずは相談する

記事を書いた人

青木 陸

PdM

青木 陸

Anycloudでプロダクトマネージャーをしています。