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

> SupabaseのRLSテストにpgTAPを活用する方法について、基本的な概念からテスト環境のセットアップ、テスト手法を解説し、セキュアなアプリケーション構築に向けた実践的なアプローチを提供します。

- 公開日: 2025-06-05
- 更新日: 2025-06-05
- 著者: 青木 陸
- タグ: Supabase, 自動テスト
- URL: https://tech.anycloud.co.jp/articles/supabase-pg-tap

---

Anycloud PdMの青木です。

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

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

<div class="link-card-wrap"><a class="link-card" href="/articles/supabase-rls-test/" target="_blank" rel="noopener noreferrer"><span class="link-card-body"><span class="link-card-title">Supabase の RLS テストを自動化して、セキュリティ事故を防ぐ方法</span><span class="link-card-description">SupabaseにおけるRow Level Security（RLS）の自動テスト戦略について解説し、セキュリティリスクを防ぐための手法やテスト環境の構築方法、実装例を紹介します。</span><span class="link-card-meta"><img class="link-card-favicon" src="./linkcard-01-favicon.ico" alt=""><span class="link-card-domain">tech.anycloud.co.jp</span></span></span><img class="link-card-image" src="./linkcard-01-image.webp" alt=""></a></div>

## 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でグローバルインストール**

```shell
npm install -g supabase

```

**方法2：npxを使用（インストール不要）**

```shell
# プロジェクト初期化
npx supabase init

# ローカル開発環境を起動
npx supabase start

# テストを実行
npx supabase test db

```

**方法3：パッケージマネージャーを使用**

```shell
# macOS (Homebrew)
brew install supabase/tap/supabase

# Windows (Scoop)
scoop bucket add supabase <https://github.com/supabase/scoop-bucket.git>
scoop install supabase

```

### プロジェクトの初期化

```shell
# プロジェクトディレクトリを作成
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`ファイルを作成します。

```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;

```

### 使用方法

```shell
# セットアップのみ実行
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での管理（推奨）

```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/`ディレクトリに配置されます。ファイルはアルファベット順で実行されるため、番号付きの命名規則を使用します。

```plaintext
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文で始まります。

```sql
begin;
  select plan(5); -- 5つのテストを実行予定

  -- テストをここに書く

  select * from finish();
rollback;

```

### RLSテストで使用される主要関数ガイドライン

### pgTAP関数（アサーション）

<table><tbody><tr><th colspan="1" rowspan="1"><p>関数名</p></th><th colspan="1" rowspan="1"><p>用途</p></th><th colspan="1" rowspan="1"><p>使用例</p></th></tr><tr><td colspan="1" rowspan="1"><p><code>plan(n)</code></p></td><td colspan="1" rowspan="1"><p>テスト計画数を宣言</p></td><td colspan="1" rowspan="1"><p><code>select plan(3);</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>ok(condition, description)</code></p></td><td colspan="1" rowspan="1"><p>条件が真かテスト</p></td><td colspan="1" rowspan="1"><p><code>select ok((select count(*) from posts) = 1, 'ユーザーは投稿を見ることができる');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>is(actual, expected, description)</code></p></td><td colspan="1" rowspan="1"><p>値の等価性をテスト</p></td><td colspan="1" rowspan="1"><p><code>select is((select count(*) from posts), 1, '投稿数は1つ');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>isnt(actual, unexpected, description)</code></p></td><td colspan="1" rowspan="1"><p>値の非等価性をテスト</p></td><td colspan="1" rowspan="1"><p><code>select isnt((select count(*) from posts), 0, '投稿が存在する');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>is_empty(query, description)</code></p></td><td colspan="1" rowspan="1"><p>クエリ結果が空かテスト</p></td><td colspan="1" rowspan="1"><p><code>select is_empty('select * from posts', '匿名ユーザーは投稿を見れない');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>results_eq(query, expected, description)</code></p></td><td colspan="1" rowspan="1"><p>クエリ結果の比較</p></td><td colspan="1" rowspan="1"><p><code>select results_eq('select title from posts', ARRAY['Test Post'], 'タイトルが一致');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>lives_ok(query, description)</code></p></td><td colspan="1" rowspan="1"><p>エラーなしで実行されるかテスト</p></td><td colspan="1" rowspan="1"><p><code>select lives_ok('insert into posts (title) values (''test'')', '投稿を作成できる');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>throws_ok(query, description)</code></p></td><td colspan="1" rowspan="1"><p>エラーが投げられるかテスト</p></td><td colspan="1" rowspan="1"><p><code>select throws_ok('delete from posts', '削除操作が拒否される');</code></p></td></tr><tr><td colspan="1" rowspan="1"><p><code>finish()</code></p></td><td colspan="1" rowspan="1"><p>テスト終了を宣言</p></td><td colspan="1" rowspan="1"><p><code>select * from finish();</code></p></td></tr></tbody></table>

### supabase\_test\_helpers関数（認証・ユーザー管理）

<table><tbody><tr><th colspan="1" rowspan="1"><p>関数名</p></th><th colspan="1" rowspan="1"><p>用途</p></th><th colspan="1" rowspan="1"><p>使用例</p></th></tr><tr><td colspan="1" rowspan="1"><p>tests.create_supabase_user(email)</p></td><td colspan="1" rowspan="1"><p>テストユーザーを作成</p></td><td colspan="1" rowspan="1"><p>select tests.create_supabase_user('test@example.com');</p></td></tr><tr><td colspan="1" rowspan="1"><p>tests.get_supabase_uid(email)</p></td><td colspan="1" rowspan="1"><p>ユーザーIDを取得</p></td><td colspan="1" rowspan="1"><p>select tests.get_supabase_uid('test@example.com');</p></td></tr><tr><td colspan="1" rowspan="1"><p>tests.authenticate_as(email)</p></td><td colspan="1" rowspan="1"><p>認証コンテキストを設定</p></td><td colspan="1" rowspan="1"><p>select tests.authenticate_as('test@example.com');</p></td></tr><tr><td colspan="1" rowspan="1"><p>tests.clear_authentication()</p></td><td colspan="1" rowspan="1"><p>認証をクリア</p></td><td colspan="1" rowspan="1"><p>select tests.clear_authentication();</p></td></tr></tbody></table>

### 使用上の注意点

**pgTAP関数**：

-   `ok()`は最も基本的なアサーション。複雑な条件でも使用可能
-   `is_empty()`はRLSでブロックされた結果をテストするのに最適
-   `lives_ok()`と`throws_ok()`は操作の成功/失敗をテスト
-   `results_eq()`は具体的なデータ内容を検証

**supabase\_test\_helpers関数**：

-   各テストの前後で認証状態を明示的に管理する
-   `tests.clear_authentication()`は匿名状態をテストする際に必須
-   ユーザーIDが必要な場合は`tests.get_supabase_uid()`を使用

### SELECTポリシーのテスト

**SELECTポリシーのテスト**では、テストユーザーの作成、認証コンテキストの切り替え、データ可視性の検証が必要です。

```sql
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ポリシー**では異なるテストアプローチが必要です。

```sql
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`コマンドを通じてシームレスなローカル開発統合を提供します。

```shell
# すべてのテストを実行
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を活用してコミットごとに自動的にテストを実行します。

```yaml
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

```

### パフォーマンステスト統合

アプリケーションがスケールするにつれて、**パフォーマンステスト統合**が重要になります。

```sql
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ポリシーによるパフォーマンス低下**は、アプリケーションの応答性を壊滅的に悪化させる可能性があります。ポリシー条件で使用される列にインデックスがないと、すべてのクエリで全テーブルスキャンが発生します。

```sql
-- 悪い例：インデックスなし
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を異なって処理するため、多くの初学者を混乱させます。失敗したアップデートはエラーを投げるのではなく、影響を受けた行数ゼロを返すため、異なるアサーション戦略が必要です。

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