Supabaseを使ったアプリケーションのテストはどうする?効率的なローカルテスト環境の構築法

Dockerコンテナで構築されたSupabaseローカルテスト環境をノートパソコンで実行している開発者 バックエンド

Supabaseを使ったアプリケーション開発において、テスト環境の構築は品質担保の根幹となる。
しかし、クラウド上のプロジェクトを直接使用したテストは、コストの発生、データの汚染、ネットワーク遅延といった多くの制約を生む。
こうした課題を解決するため、ローカル環境でSupabaseを完結させる手法が、近年の開発現場で急速に注目を集めている。

本記事では、DockerベースのSupabaseローカルスタックを活用し、高速かつ再現性の高いテスト環境を構築する具体的なアプローチを解説する。
主に以下の3つの観点から論じていく。

  • ローカル環境と本番環境の整合性を保つためのDocker Compose構成
  • テストデータの初期化とクリーンアップの自動化戦略
  • CI/CDパイプラインへの組み込みと並列実行の最適化

特に、データベーススキーマのマイグレーション管理やRow Level Security(RLS)ポリシーの検証といった、Supabase特有のニーズに対して、どのように効率的に対処するかが焦点となる。
開発の初期段階からテスト環境を整備することで、リグレッションのリスクを大幅に低減し、持続可能な開発サイクルを実現できる。
では、具体的な構築手順と、その背後にある設計思想を詳しく見ていこう。

Supabaseローカルテスト環境の必要性と課題

開発者がノートパソコンでSupabaseのローカルテスト環境を構築している様子

近年、Firebaseのオープンソース代替として急速に普及を遂げたSupabaseは、PostgreSQLを核としたBaaS(Backend as a Service)プラットフォームである。
その包括的な機能セット、認証、リアルタイム、ストレージ、エッジ関数といったモダンなWebアプリケーション開発に必要な基盤を、一元的に提供する点が大きな魅力となっている。
しかし、こうした便利さの裏側には、テスト環境の構築という本質的な課題が潜んでいる。

クラウド直結テストの限界

多くの開発者が最初に陥るアンチパターンは、開発用のSupabaseプロジェクトを直接使用してテストを実行することである。
一見合理的に見えるこのアプローチには、以下の構造的な問題が内在する。

  • コストの発生: 無料プランであっても、APIリクエスト数やデータ転送量に制限があり、継続的なテスト実行はすぐに上限に達する
  • データの汚染と副作用: テストの成否に関わらず、データベースの状態が変化し、他のテストや開発作業に悪影響を及ぼす
  • ネットワーク遅延と不安定性: インターネット経由の通信は、テスト実行時間のばらつきや偶発的な失敗を招く
  • セキュリティリスク: テスト用のAPIキーやサービスロールキーがCIログに残存する可能性があり、情報漏洩のリスクが高まる

これらの問題は、単なる「不便さ」ではなく、テストの信頼性と再現性を根本から損なう深刻な障害である。
コンピューターサイエンスの文脈でいえば、テストは決定的(deterministic)でなければならない。
外部のネットワーク状態や共有リソースの競合に左右されるテストは、厳密な意味で「テスト」と呼ぶに値しない。

ローカル環境で解決する本質

こうした課題に対して、ローカルマシン上でSupabaseの全機能を再現するアプローチが有効である。
Dockerコンテナ技術を活用することで、PostgreSQL、PostgREST、GoTrue、Realtime、Storage APIといったSupabaseの各コンポーネントを、開発者のマシン内に閉じた環境として構築できる。

このアプローチの核心的な利点は、テストの隔離性(isolation)と再現性(reproducibility)を担保できる点にある。
各テスト実行前にデータベースをクリーンな状態にリセットし、特定のスキーマとシードデータを投入することで、常に同一の初期状態から検証を開始できる。
これは、数学的帰納法の基底ケースを確立する作業に喩えられる。
前提条件が揺らがなければ、導出される結果も一貫性を保つ。

さらに、ローカル環境ではネットワークレイテンシが事実上ゼロに近づくため、テストスイートの実行時間が劇的に短縮される。
実測値として、クラウド直結の場合に数分を要していた統合テストが、ローカル環境では数十秒で完了するケースは珍しくない。

導入の障壁と設計思想

もちろん、ローカル環境の構築には初期投資が必要である。
Dockerの知識、Supabaseの内部アーキテクチャの理解、そして継続的なメンテナンスの労力が求められる。
しかし、これは短期的な工数と長期的な品質のトレードオフではなく、正しく設計すれば後者が圧倒的に優位な投資である。

本記事では、このローカルテスト環境を効率的に構築する具体的な手法を、実践的なコード例とともに解説していく。
まずは、DockerベースのSupabaseスタックの全体像を把握するところから始めよう。

DockerベースのSupabaseローカルスタックの全体像

Dockerコンテナで構成されたSupabaseローカル開発環境のアーキテクチャ図

ローカルでSupabaseを実行するための最も堅実なアプローチは、公式が提供するDocker Compose構成をベースにしたスタックの構築である。
この構成は、Supabaseの各コンポーネントを独立したコンテナとして分離し、マイクロサービスアーキテクチャの原則に則った設計となっている。
各サービスは単一責任の原則に従い、明確なインターフェースで連携する。

全体像を俯瞰すると、以下の主要コンポーネントで構成される。

サービス名 役割 デフォルトポート
PostgreSQL データ永続化の中核 5432
PostgREST RESTful API自動生成 3000
GoTrue 認証・認可管理 9999
Realtime WebSocketベースのリアルタイム 4000
Storage API オブジェクトストレージ 5000
Kong APIゲートウェイ・ルーティング 8000

この構成の本質的な美しさは、各コンポーネントの独立性にある。
PostgreSQLは純粋にデータストアとして機能し、PostgRESTはスキーマ introspection により自動的にAPIエンドポイントを生成する。
GoTrueはJWTベースの認証フローを処理し、Kongがそれらを統合して一つのエンドポイントとして提供する。
まさに、関心の分離(separation of concerns)というソフトウェア工学の基本原則が体現されている。

supabase-cliのインストールと初期設定

ローカルスタックの構築は、supabase-cliを通じて行うのが最も効率的である。
このCLIツールは、Docker Composeの複雑な設定を抽象化し、開発者にとって親しみやすいインターフェースを提供する。

インストールは、npm経由で一貫性を保つのが推奨される。

npm install -g supabase

インストール後、supabase --versionでバージョンを確認し、期待通りに動作することを検証する。
次に、プロジェクトルートで以下のコマンドを実行する。

supabase init

このコマンドは、supabaseディレクトリを生成し、設定ファイル群を配置する。
特に重要なのはconfig.tomlである。
このファイルは、ローカル環境の各種パラメータを制御する中核となる。
初期設定では、PostgreSQLのバージョン、ポート番号、JWTシークレットなどが定義されている。
これらの値は、後述する環境変数管理と連携させることで、より柔軟な運用が可能になる。

supabase start

この一つのコマンドで、前述した全コンテナが orchestrate され、ローカルSupabase環境が立ち上がる。
停止時はsupabase stopで済む。
こうした操作の簡潔さが、継続的な開発サイクルを支える基盤となる。

docker-compose.ymlの構成とサービス間の連携

supabase-cliは内部でDocker Composeを利用しているが、高度なカスタマイズが必要な場合は、直接docker-compose.ymlを編集するアプローチも有効である。
公式リポジトリで公開されている構成をベースに、プロジェクト固有の要件に応じた調整を行う。

サービス間の連携は、Dockerのネットワーク機能によって実現される。
各コンテナは同一のブリッジネットワークに接続され、サービス名をホスト名として解決できる。
たとえば、PostgRESTはDB_HOST=dbという環境変数でPostgreSQLに接続する。
これは、DNSによるサービスディスカバリの仕組みであり、ハードコードされたIPアドレスを排除することで、環境の可搬性を高めている。

Kongの設定には特に注意が必要である。
Kongは、各バックエンドサービスへのリクエストを適切にルーティングするAPIゲートウェイとして機能する。
その設定はkong.ymlに記述され、パスベースのルーティングルールが定義されている。
たとえば、/auth/v1へのリクエストはGoTrueに、/rest/v1はPostgRESTに転送される。
この一元的なエンドポイント構成は、クライアント側の実装を簡潔にし、サービス間の依存関係を隠蔽する効果がある。

環境変数とシークレットのローカル管理

セキュリティの観点から、環境変数とシークレットの管理は決して軽視できない。
ローカル環境であっても、本番と同じ水準の情報管理が求められる。

推奨されるアプローチは、.envファイルとconfig.tomlの組み合わせである。
.envファイルには機密性の高い値、たとえばデータベースのパスワードやJWTシークレットキーを格納する。
一方、config.tomlには、それらを参照する形で設定を記述する。

# .env
POSTGRES_PASSWORD=your-local-password
JWT_SECRET=your-256-bit-secret

config.tomlでは、これらの値をenv()関数で参照する。

[db]
password = "env(POSTGRES_PASSWORD)"

[auth]
jwt_secret = "env(JWT_SECRET)"

この分離の意義は、機密情報と設定ロジックの関心を切り分ける点にある。
.envファイルは.gitignoreに追加し、リポジトリに含めない。
チーム開発では、.env.exampleというテンプレートファイルを共有し、各開発者が自身の.envを作成する運用が一般的である。

さらに、CI/CD環境では、これらの値をリポジトリシークレットとして管理し、実行時に注入する。
ローカルとCIで同一の変数名を使用することで、環境間の設定差異を最小化できる。
これは、12-Factor Appの原則に則った実践であり、設定をコードから分離することで、環境の再現性を担保する。

こうした基盤が整えば、次の段階であるテストデータの管理とマイグレーション戦略に進む準備が整う。

テストデータの初期化とマイグレーション戦略

データベースマイグレーションのフロー図とSQLスクリプト

テストの信頼性を担保するためには、実行前のデータベース状態が常に決定的(deterministic)であることが不可欠である。
ランダムなデータ残存やスキーマの不整合が生じれば、テスト結果の解釈が困難になり、いわゆる「フラッキーなテスト(flaky test)」が蔓延する。
Supabaseのローカル環境では、マイグレーションとシードデータの組み合わせにより、この課題を体系的に解決できる。

Supabaseのマイグレーション機構は、データベーススキーマのバージョン管理を可能にする。
supabase/migrationsディレクトリに配置されたSQLファイルは、タイムスタンプ付きのファイル名で順序付けられ、線形的な適用履歴を形成する。
これは、Gitによるソースコード管理と同じ発想であり、スキーマの変更を追跡可能で再現性のある形で管理する。

マイグレーションの作成は以下のコマンドで行う。

supabase migration new create_users_table

このコマンドは、supabase/migrations以下に新しいSQLファイルを生成する。
ファイル内にDDLを記述し、チームメンバー間で共有することで、スキーマの整合性が保たれる。
ローカル環境のリセット時には、supabase db resetコマンドが全テーブルを破棄し、マイグレーションを先頭から再適用する。
この一連の流れにより、常にクリーンで一貫性のあるスキーマ状態が得られる。

シードデータの作成と自動投入

スキーマの整備だけでは不十分である。
テストを有意義なものにするには、現実的なデータセットが事前に投入されている必要がある。
Supabaseでは、シードデータの管理にsupabase/seed.sqlを使用する。
このファイルは、supabase db reset実行時に自動的に読み込まれ、マイグレーション適用後にデータが投入される。

シードデータの設計には、いくつかの原則がある。

  • 網羅性: 正常系だけでなく、境界値や異常系を含む多様なデータパターンを網羅する
  • 安定性: テストの再現性を保つため、ランダム値ではなく固定値を使用する
  • 独立性: 各テストケースが依存しないよう、テスト間でデータを共有しない設計を基本とする
-- seed.sql の例
INSERT INTO users (id, email, role, created_at)
VALUES
  ('550e8400-e29b-41d4-a716-446655440000', 'admin@example.com', 'admin', now()),
  ('550e8400-e29b-41d4-a716-446655440001', 'user@example.com', 'user', now());

ただし、統合テストの文脈では、テストごとに異なるデータセットが必要となるケースも多い。
その場合は、テストフレームワークのセットアップ処理内で、テスト専用のデータを動的に投入するアプローチが有効である。
JestやVitestなどのテストランナーと組み合わせ、beforeEachフックでテーブルをクリーンアップし、テスト固有のシードを投入する。
これにより、テストの隔離性(test isolation)が厳密に保たれる。

Row Level Security(RLS)ポリシーのローカル検証

Supabaseの最大の特徴の一つが、PostgreSQLのRow Level Security(RLS)を活用した細粒度のアクセス制御である。
RLSポリシーは、データベースレイヤーで実行されるため、アプリケーションコードを介さずにセキュリティを強制できる。
この設計は、防御的プログラミングの観点から極めて優秀であるが、同時にテストの複雑さも増す。

RLSポリシーは、認証コンテキストに依存する。
つまり、特定のユーザとしてクエリを発行した場合に、どの行が可視化されるかを検証する必要がある。
ローカル環境では、この検証を安全かつ効率的に行える。

まず、テスト用のユーザをシードデータとして事前に作成する。

-- テストユーザの作成
INSERT INTO auth.users (id, email, raw_user_meta_data)
VALUES
  ('11111111-1111-1111-1111-111111111111', 'test-user@example.com', '{"role":"member"}');

次に、テストコード内で、Supabaseクライアントを特定のユーザのJWTで初期化し、RLSポリシーの動作を検証する。
service_roleキーとanonキーの使い分けも重要である。
管理操作にはservice_roleを、一般ユーザの視点からの検証にはanonキーにユーザのJWTを設定して使用する。

キーの種類 用途 RLSの適用
service_role 管理操作、セットアップ バイパスされる
anon + JWT 一般ユーザの振る舞い模倣 適用される

この二面性を理解することで、「このユーザにはこのデータが見えるはず」という期待値を、コードとして明確に表現できる。
ローカル環境であれば、本番データに影響を与えることなく、RLSポリシーの網羅的な検証が可能である。

たとえば、RLSポリシーが「ユーザは自身の組織に属するデータのみ閲覧可」と定義されている場合、異なる組織のユーザでログインし、データが適切に遮断されることを確認する。
こうした検証は、セキュリティ監査の文脈でも高い価値を持つ。

こうしたデータ層の基盤が固まれば、次の課題はテスト実行そのものの効率化に移る。
大量のテストをいかに高速に、かつ並列に実行するかが、次の焦点となる。

テスト実行の高速化と並列化の実装

複数のDockerコンテナが並列でテストを実行しているモニター画面

テストスイートが肥大化するにつれ、実行時間の増大は避けられない課題となる。
しかし、待ち時間は開発者の生産性を直接的に削減する要因である。
コンピューターサイエンスの文脈でいえば、テスト実行はクリティカルパス上の処理であり、その最適化は開発サイクル全体のスループット向上に寄与する。
ローカルSupabase環境では、コンテナ技術の特性を最大限に活用し、高速化と並列化を両立させる設計が可能である。

まず、テスト実行時間のボトルネックを特定する必要がある。
一般的に、統合テストにおいて最も時間を消費するのは、データベースへのI/O操作である。
INSERT、UPDATE、DELETEの繰り返し、およびそれに伴うインデックスの更新やWAL(Write-Ahead Log)の書き込みが、実行時間の大半を占める。
ローカル環境では、これらの操作がネットワークレイテンシを介さずに行われるため、クラウド環境と比較して桁違いの高速化が実現できる。

さらに、テストランナーの並列実行機能と組み合わせることで、スループットは飛躍的に向上する。
VitestやJestなどのモダンなテストランナーは、ワーカープロセスによる並列実行をサポートしている。
各ワーカーが独立したデータベース接続を持ち、テストを並列に処理することで、マルチコアCPUのリソースを有効活用できる。

ただし、並列化にはリソース競合のリスクが伴う。
同一のデータベースインスタンスに対して複数のテストが同時に書き込みを行えば、データの不整合が生じ、テストの信頼性が損なわれる。
この問題を解決するための戦略が、次に述べるコンテナのライフサイクル管理とトランザクション分離である。

テストコンテナのライフサイクル管理

最も厳密なアプローチは、テストスイートごとに独立したデータベースコンテナを起動する方法である。
この手法では、各テストファイルまたはテストグループが専用のPostgreSQLインスタンスを持ち、完全な隔離が保証される。
テスト完了後、コンテナは破棄され、次のテストセットで新しいコンテナが起動する。

実装には、Testcontainersのようなライブラリが有効である。
Testcontainersは、プログラマティックにDockerコンテナを管理し、テストのセットアップ時に起動、ティアダウン時に破棄する機能を提供する。

import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { createClient } from '@supabase/supabase-js';

const container = await new PostgreSqlContainer('postgres:15')
  .withDatabase('test_db')
  .withUsername('postgres')
  .withPassword('password')
  .start();

const connectionString = container.getConnectionUri();

このアプローチの利点は、テスト間の完全な独立性である。
あるテストがデータベーススキーマを変更しても、他のテストには一切影響を与えない。
欠点は、コンテナの起動時間がオーバーヘッドとして加算される点である。
しかし、ローカル環境ではコンテナの起動が数秒で完了するため、実用的な選択肢となる。

より軽量な代替案として、単一コンテナ内に複数のデータベースを動的に作成する手法もある。
テスト開始時にCREATE DATABASEで新しいデータベースを生成し、マイグレーションとシードを適用する。
テスト完了後にDROP DATABASEで破棄する。
この方法では、PostgreSQLプロセスの起動コストが発生せず、コンテナ起動のオーバーヘッドを回避できる。

CREATE DATABASE test_db_$(uuid);

どちらのアプローチを選ぶかは、テストスイートの規模と実行頻度に依存する。
数百件以下のテストであれば、単一コンテナ内の複数データベース方式で十分である。
数千件を超える大規模スイートでは、Testcontainersによる完全分離の価値が高まる。

テスト分離とトランザクションロールバック

コンテナレベルの分離が過剰に感じられる場合、トランザクションロールバックによるより軽量な分離手法が有効である。
このアプローチは、各テストケースをデータベーストランザクション内で実行し、テスト終了時にロールバックすることで、データの変更を元に戻す。

実装は比較的シンプルである。
テストのセットアップ時にトランザクションを開始し、テスト本体をそのトランザクション内で実行する。
テストの成功・失敗に関わらず、必ずロールバックを発行する。

async function withTransaction<T>(callback: () => Promise<T>): Promise<T> {
  const client = await pool.connect();
  await client.query('BEGIN');

  try {
    const result = await callback();
    await client.query('ROLLBACK');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

この手法の核心は、ACID特性の原子性(Atomicity)をテスト分離のメカニズムとして転用している点にある。
トランザクション内で行われた全ての変更は、ロールバックにより完全に取消されるため、次のテストには影響を与えない。

ただし、注意点も存在する。
トランザクションロールバックは、同じデータベース接続内で完結する必要がある。
Supabaseクライアントが内部的にコネクションプールを使用する場合、トランザクションの一貫性が損なわれる可能性がある。
そのため、テスト用のクライアントインスタンスを明示的に管理し、単一の接続を使用するよう設定する必要がある。

また、RLSポリシーの検証においても、トランザクションロールバックは有効に機能する。
認証コンテキストを設定した状態でクエリを発行し、その結果を検証した後、ロールバックにより状態を復元する。
これにより、高速かつ再現性の高いRLSテストが実現できる。

分離手法 オーバーヘッド 隔離レベル 推奨シナリオ
トランザクションロールバック 最低 接続単位 単体・小規模統合テスト
同一コンテナ内の複数DB 中程度 データベース単位 中規模統合テスト
独立コンテナ(Testcontainers) 最高 インスタンス単位 大規模・E2Eテスト

これらの手法を適材適所で使い分けることで、速度と信頼性の最適なトレードオフを実現できる。
次に、こうして構築されたローカルテスト環境を、CI/CDパイプラインに統合する方法を検討する。

CI/CDパイプラインへのローカルテスト統合

GitHub ActionsのワークフローでSupabaseテストが自動実行される画面

ローカル環境で構築したテスト基盤は、継続的インテグレーション(CI)パイプラインに統合することで初めて真価を発揮する。
開発者個人のマシン上で動作するテストだけでは、チーム全体の品質担保には不十分である。
コードの変更がリポジトリにプッシュされた時点で、自動的に同一のテストスイートが実行される仕組みが必要である。
ここでは、GitHub Actionsを例に、ローカルSupabase環境をCIに組み込む具体的な手法を解説する。

CI環境でのローカルSupabase実行の核心は、開発環境とCI環境の再現性である。
ローカルで成功するテストがCIで失敗すれば、その差異の調査に多大な工数が費やされる。
Dockerベースのアプローチは、この再現性の問題を根本から解決する。
同一のDockerイメージ、同一の環境変数、同一のマイグレーションを使用することで、環境間の差異を事実上ゼロに近づけることができる。

GitHub ActionsでのローカルSupabase実行

GitHub Actionsのワークフロー定義では、サービスコンテナ(service container)機能を活用して、PostgreSQLなどの依存サービスをジョブ内で起動できる。
しかし、Supabaseの全コンポーネントを個別にサービスコンテナとして定義するのは冗長である。
より実用的なアプローチは、supabase-cliを直接使用することである。

ワークフローの構成は以下のようになる。

name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Setup Supabase CLI
        uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Start Supabase
        run: supabase start

      - name: Run tests
        run: npm test

      - name: Stop Supabase
        if: always()
        run: supabase stop

このワークフローの重要なポイントは、if: always()をsupabase stopステップに付与している点である。
これにより、テストの成否に関わらず、必ずSupabaseコンテナが停止・破棄される。
リソースのリークを防ぎ、後続のジョブ実行に悪影響を与えない。

環境変数の管理については、GitHubリポジトリのシークレット機能を使用する。
Settings > Secrets and variables > Actions から、必要な値を登録し、ワークフロー内で参照する。

env:
  SUPABASE_URL: ${{ secrets.SUPABASE_TEST_URL }}
  SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_TEST_ANON_KEY }}

ここで使用するのは、ローカル環境用のキーである点に注意が必要である。
本番環境のキーをCIテストに使用することは、セキュリティリスクを招く。
ローカルSupabaseのデフォルト設定では、予測可能な匿名キーが使用されるため、テスト用として十分である。

テスト実行後、カバレッジレポートの生成とアップロードも併せて行うと、品質の可視化が進む。

- name: Upload coverage
  uses: codecov/codecov-action@v4
  with:
    files: ./coverage/lcov.info

キャッシュ戦略によるCI実行時間の短縮

CI環境では、毎回依存関係のインストールやDockerイメージのプルが発生し、実行時間の増大が無視できない問題となる。
特にsupabase-cliの初回起動時には、複数のDockerイメージをプルする必要があり、これがボトルネックとなる。

GitHub Actionsのキャッシュ機能を活用することで、この問題を効果的に緩和できる。
主に以下の3つのキャッシュ層を設計する。

  • npm依存関係のキャッシュ: actions/setup-nodeのcacheオプションで自動的に管理される
  • Dockerイメージのキャッシュ: docker saveとdocker loadを組み合わせ、イメージレイヤーをキャッシュする
  • Supabase CLIのキャッシュ: CLIバイナリ自体をキャッシュし、毎回のダウンロードを省略する

Dockerイメージのキャッシュは、以下のように実装する。

- name: Cache Docker images
  uses: actions/cache@v4
  with:
    path: /tmp/docker-images
    key: docker-images-${{ hashFiles('supabase/.temp/project-ref') }}

- name: Load cached Docker images
  run: |
    if [ -d /tmp/docker-images ]; then
      docker load -i /tmp/docker-images/supabase.tar || true
    fi

- name: Start Supabase
  run: supabase start

- name: Save Docker images
  run: |
    mkdir -p /tmp/docker-images
    docker save \
      $(docker images --format '{{.Repository}}:{{.Tag}}' | grep supabase) \
      -o /tmp/docker-images/supabase.tar

このキャッシュ戦略の効果は、初回実行時には目立たないが、2回目以降の実行で劇的に現れる。
実測値として、キャッシュなしで5分を要していたジョブが、キャッシュありでは1分半まで短縮されるケースもある。

さらに、マトリックス戦略を活用して、複数のNode.jsバージョンやOS環境での並列テストも可能である。

strategy:
  matrix:
    node-version: [18, 20, 22]
    os: [ubuntu-latest]

jobs:
  test:
    runs-on: ${{ matrix.os }}

ただし、マトリックスの組み合わせが増えれば増えるほど、並列ジョブ数の上限(GitHub Freeでは20同時実行)に抵触するリスクがある。
プロジェクトの規模とチームのニーズに応じて、適切な粒度でマトリックスを設計する必要がある。

こうしたCI/CD統合が整えば、開発者はプルリクエストを作成するだけで、自動的に包括的なテストが実行される環境を得る。
「ローカルでは動いたのに」という言葉が過去のものとなる。
次に、実践的な運用におけるエッジケースとトラブルシューティングについて考察する。

エッジケースと実践的なトラブルシューティング

開発者がSupabaseローカル環境のエラーログを解析している画面

理論的に完璧な設計であっても、実際の運用では予期せぬ障害が必ず発生する。
これは、分散システムの本質的な複雑性を反映している。
Supabaseのローカル環境も例外ではなく、ポート競合、ネットワーク設定、認証フローの違いなど、様々なエッジケースに直面する。
ここでは、実務で頻出する問題とその解決策を体系的に整理する。

ポート競合とネットワーク設定の解決

ローカル環境で最も遭遇しやすい問題がポート競合である。
Supabaseのデフォルト構成では、PostgreSQLの5432番、Kongの8000番、PostgRESTの3000番など、よく使用されるポートを占有する。
開発者のマシン上で既に他のPostgreSQLインスタンスやWebサーバが動作していれば、コンテナの起動に失敗する。

この問題の根本的な原因は、ポート空間の有限性にある。
TCPポートは65535個しか存在せず、そのうちwell-knownポート(0-1023)はシステムに予約されている。
開発環境では、複数のプロジェクトやサービスが同時に動作することが常であり、ポートの衝突は避けられない。

解決策として、まずconfig.tomlで使用ポートを明示的に変更する手法がある。

[db]
port = 54322

[api]
port = 54321

[studio]
port = 54323

このように、デフォルトポートにオフセットを加えることで、既存のサービスとの競合を回避する。
5432を54322に変更するなど、規則性を持たせたポート番号の割り当てが、チーム内での可読性と一貫性を保つ。

より根本的なアプローチとして、Dockerのランダムポート割り当て機能を活用する方法もある。
ホスト側のポートを0に指定すると、Dockerが利用可能なポートを自動的に選択する。

services:
  db:
    ports:
      - "0:5432"

ただし、この方法では、テストコード内で動的にポート番号を取得する必要がある。
docker portコマンドや、コンテナの inspect API を使用して、実際に割り当てられたポートを確認する。

docker port supabase_db 5432

ネットワーク設定の問題も頻出する。
特に、Docker DesktopのMacやWindows環境では、ホストからコンテナへの通信経路がLinuxネットワーク名前空間と異なる。
host.docker.internalという特殊なDNS名を使用することで、ホストマシンからコンテナ内部のサービスに到達できる。

const supabaseUrl = process.env.CI
  ? 'http://localhost:54321'
  : 'http://host.docker.internal:54321';

この分岐は、CI環境(Linuxコンテナ)とローカル開発環境(Docker Desktop)の差異を吸収する。
環境変数CIは、GitHub Actionsなどの多くのCIプラットフォームで自動的に設定されるため、環境判定の信頼性が高い。

ファイアウォールやプロキシ設定の影響も無視できない。
企業内の開発環境では、プロキシサーバを介した通信が強制されるケースがある。
この場合、Dockerデーモン自身のプロキシ設定と、コンテナ内のアプリケーションのプロキシ設定を両方正しく行う必要がある。

問題の種類 症状 解決策
ポート競合 bind: address already in use config.tomlでポート変更
DNS解決失敗 getaddrinfo ENOTFOUND host.docker.internal使用
プロキシ遮断 タイムアウト Dockerデーモンのプロキシ設定

認証フローのローカルモック化

Supabase Auth(GoTrue)は、ローカル環境でも本番と同じJWTベースの認証フローを提供する。
しかし、メール確認フローやOAuth連携など、外部サービスに依存する機能は、ローカルでは完全に再現できない。
これらの機能をテストするには、適切なモック戦略が必要である。

メール確認フローの場合、GoTrueはローカル環境で実際のメールを送信しない。
代わりに、確認用のトークンをログに出力するか、特定のエンドポイントで確認できる。
テストでは、このトークンをプログラマティックに取得し、確認APIを呼び出す。

const { data: signUpData } = await supabase.auth.signUp({
  email: 'test@example.com',
  password: 'password123'
});

const userId = signUpData.user?.id;

// ローカル環境では、メール確認をバイパスする
await supabase.auth.admin.updateUserById(userId, {
  email_confirm: true
});

auth.adminクライアントは、service_roleキーを使用して認証され、通常のユーザ操作では不可能な管理機能にアクセスできる。
この方法により、メール送信の非同期性を回避し、テストの決定性を保つ。

OAuthプロバイダ(Google、GitHubなど)の連携テストは、より複雑である。
実際のOAuthフローを実行するには、ブラウザの自動操作と外部サービスへの通信が必要となり、テストの不安定性が増す。
ここでは、OAuthフローの最終出力であるJWTトークンを直接生成するモック手法が有効である。

function createMockJWT(userId: string, email: string): string {
  const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
  const payload = Buffer.from(JSON.stringify({
    sub: userId,
    email: email,
    aud: 'authenticated',
    exp: Math.floor(Date.now() / 1000) + 3600
  })).toString('base64url');

  const signature = createHmac('sha256', process.env.JWT_SECRET)
    .update(`${header}.${payload}`)
    .digest('base64url');

  return `${header}.${payload}.${signature}`;
}

このJWTは、ローカルSupabaseのJWT_SECRETで署名されているため、GoTrueによって正当なトークンとして認識される。
テストでは、このトークンをsupabase.auth.setSessionに渡すことで、OAuthログイン後の状態を再現する。

const mockToken = createMockJWT('user-id', 'user@example.com');
await supabase.auth.setSession({
  access_token: mockToken,
  refresh_token: 'mock-refresh-token'
});

このモック化の本質は、認証の「結果」に着目し、「過程」を抽象化する点にある。
OAuthプロバイダとの通信プロトコルは、テスト対象のビジネスロジックではない。
したがって、それを適切に抽象化することで、テストの焦点を本質的な部分に絞ることができる。

さらに、Supabase Authのトリガー関数(auth.usersテーブルへのINSERT時に発火するデータベーストリガー)のテストも、ローカル環境で完全に再現できる。
これらのトリガーは、ユーザ登録時にプロファイルテーブルへの自動レコード作成などを行うが、ローカルのPostgreSQLインスタンス上でそのまま動作する。

こうしたエッジケースへの対処法を習得することで、ローカルテスト環境の信頼性は飛躍的に向上する。
次に、リアルタイム機能とストレージといった、Supabase特有の高度な機能のローカル検証について論じる。

リアルタイム機能とストレージのローカル検証

Supabaseリアルタイムサブスクリプションとストレージ機能の検証画面

Supabaseを他のBaaSと差別化する最大の特徴の一つが、リアルタイムサブスクリプションとオブジェクトストレージの提供である。
これらの機能は、モダンなWebアプリケーションにおいて不可欠な要素であり、ローカル環境での検証が可能かどうかは、開発効率に直結する。
幸い、Supabaseのローカルスタックは、これらの高度な機能も含めて完全に再現する。

リアルタイム機能は、PostgreSQLの論理レプリケーション(logical replication)を基盤として実装されている。
データベースの変更を捕捉し、WebSocket経由でクライアントにプッシュするこの仕組みは、イベント駆動アーキテクチャの典型例である。
ローカル環境では、このリアルタイムサーバも独立したコンテナとして起動し、本番と同一の動作を再現できる。

リアルタイムサブスクリプションのテスト

リアルタイム機能のテストでは、非同期性と状態遷移の検証が核心となる。
クライアントがテーブルの変更を購読し、INSERTやUPDATEが発生した際にコールバックが呼ばれる。
この一連の流れをテストするには、適切な待機機構とアサーションが必要である。

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_ANON_KEY
);

test('リアルタイムサブスクリプションがINSERTイベントを受信する', async () => {
  const receivedEvents: any[] = [];

  const channel = supabase
    .channel('test-channel')
    .on(
      'postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'messages' },
      (payload) => {
        receivedEvents.push(payload);
      }
    )
    .subscribe();

  // サブスクリプション確立まで待機
  await new Promise((resolve) => setTimeout(resolve, 500));

  await supabase.from('messages').insert({ content: 'hello' });

  // イベント受信まで待機
  await new Promise((resolve) => setTimeout(resolve, 500));

  expect(receivedEvents).toHaveLength(1);
  expect(receivedEvents[0].new.content).toBe('hello');

  await channel.unsubscribe();
});

このテストのポイントは、適切な待機時間の設計にある。
WebSocketの接続確立とイベント伝達には、ネットワークレイテンシに依存する不定の時間が必要となる。
setTimeoutによる固定待機は簡便だが、環境によっては不安定になりうる。
より堅牢なアプローチとして、waitForユーティリティを使用し、条件が満たされるまでポーリングする方法がある。

async function waitFor<T>(
  predicate: () => T | undefined,
  timeout = 5000,
  interval = 100
): Promise<T> {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    const result = predicate();
    if (result !== undefined) return result;
    await new Promise((r) => setTimeout(r, interval));
  }
  throw new Error('Timeout');
}

const event = await waitFor(() => receivedEvents[0], 3000);
expect(event.new.content).toBe('hello');

リアルタイム機能のRLSとの相互作用も検証すべき重要なポイントである。
購読チャンネルにRLSポリシーが適用される場合、ユーザの権限に応じてイベントの可視性が変化する。
この挙動は、ローカル環境で異なるユーザコンテキストを切り替えて検証できる。

ストレージAPIのローカル検証

Supabase Storageは、S3互換のオブジェクトストレージを提供する。
ローカル環境では、MinIOというオープンソースのS3互換ストレージがコンテナとして動作し、本番と同一のAPIインターフェースを模倣する。

ストレージのテストでは、ファイルのアップロード、ダウンロード、削除、およびバケットポリシーの検証が主な対象となる。

test('ファイルをアップロードし署名付きURLで取得する', async () => {
  const file = new Blob(['test content'], { type: 'text/plain' });

  const { data: uploadData, error: uploadError } = await supabase
    .storage
    .from('documents')
    .upload('test-file.txt', file);

  expect(uploadError).toBeNull();
  expect(uploadData?.path).toBe('test-file.txt');

  const { data: urlData } = await supabase
    .storage
    .from('documents')
    .createSignedUrl('test-file.txt', 60);

  expect(urlData?.signedUrl).toContain('token=');
});

ストレージのRLSポリシーも、データベースのRLSと同様に厳密に検証できる。
バケットレベルおよびオブジェクトレベルで、誰がどの操作を許可されるかを定義し、異なるユーザコンテキストでアクセスを試みる。

-- バケットポリシーの例
CREATE POLICY "Authenticated users can upload"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'documents');

テストでは、認証済みユーザと未認証ユーザの両方でアップロードを試行し、期待通りのアクセス制御が機能することを確認する。
未認証ユーザからのアップロードが403 Forbiddenで拒否されることをアサーションする。

ストレージの大きなファイル扱いについても、エッジケースとして検討に値する。
アップロードサイズの上限、マルチパートアップロード、画像の変換オプションなどは、ローカルのMinIO環境で同様に動作する。
ただし、大容量ファイルのテストは、CI環境ではディスク容量や実行時間の制約を受けるため、適切なファイルサイズの選択が必要である。

検証項目 ローカルでの再現性 注意点
リアルタイムサブスクリプション 完全再現 待機時間の設計が重要
ストレージアップロード 完全再現 MinIOがS3互換APIを提供
画像変換 完全再現 大容量ファイルはCIで制限あり
バケットRLS 完全再現 データベースRLSと同じ検証手法

こうした高度な機能も含めて、ローカル環境で包括的に検証できることは、Supabaseローカルスタックの大きな強みである。
次に、本記事の内容を総括し、今後の展開について考察する。

Supabaseローカルテスト環境構築のまとめと次のステップ

完成したSupabaseローカルテスト環境と開発者が親指を立てている様子

本記事を通じて、Supabaseを使ったアプリケーション開発におけるローカルテスト環境の構築法を、基礎から実践的なテクニックまで体系的に解説してきた。
ここでは、論じてきた内容を整理し、今後の展開について考察する。

まず、ローカルテスト環境の必要性について再確認する。
クラウド上のSupabaseプロジェクトを直接使用したテストは、コスト、データ汚染、ネットワーク遅延、セキュリティリスクといった多くの制約を生む。
これらは単なる「不便さ」ではなく、テストの決定性と信頼性を損なう構造的な障害である。
Dockerベースのローカルスタックは、これらの課題を根本から解決する。

構築の具体的な手順として、以下の流れを提示してきた。

  • supabase-cliのインストールとsupabase initによるプロジェクト初期化
  • config.tomlと.envを組み合わせた環境設定の管理
  • supabase/migrationsによるスキーマのバージョン管理とsupabase/seed.sqlによるテストデータの自動投入
  • トランザクションロールバック、複数データベース、独立コンテナという3段階のテスト分離戦略
  • GitHub Actionsへの統合と、DockerイメージキャッシュによるCI実行時間の最適化
  • RLSポリシー、リアルタイムサブスクリプション、ストレージAPIといったSupabase特有の機能のローカル検証

これらの要素が統合されることで、開発者はコードを書きながら即座にフィードバックを得られ、CIパイプラインはプルリクエストごとに包括的な品質検証を自動実行する。
このサイクルこそが、持続可能なソフトウェア開発の基盤となる。

次のステップとして考慮に値するのは、テストカバレッジの拡大とE2Eテストの統合である。
これまで解説してきたのは、主にバックエンドAPIとデータベースレイヤーに焦点を当てた統合テストである。
しかし、実際のアプリケーションはフロントエンドからバックエンドまでの一連の流れとして動作する。
PlaywrightやCypressのようなE2Eテストフレームワークと、ローカルSupabase環境を組み合わせることで、ユーザ視点での包括的な検証が可能になる。

具体的には、E2Eテストのセットアップ時にsupabase startでローカル環境を起動し、テストシナリオに応じたシードデータを投入する。
フロントエンドアプリケーションをビルドし、E2Eテストランナーがそれに対して操作を行う。
この一連の流れをCI上で自動化することで、デプロイ前に本番と等価の検証が完了する。

さらに、スキーマ駆動開発(Schema-Driven Development)の実践も推奨する。
Supabaseの強みは、PostgreSQLスキーマから自動的に型安全なAPIクライアントを生成できる点にある。
supabase gen typesコマンドをCIパイプラインに組み込み、スキーマの変更を検知した際に自動的にTypeScriptの型定義を更新する。
これにより、データベーススキーマとアプリケーションコードの同期が機械的に保証される。

長期的な展望として、マルチテナントアーキテクチャのテストもローカル環境で検討に値する。
組織単位でデータを分離する設計では、テナント間のデータアクセス制御が極めて重要である。
RLSポリシーを用いたテナント分離の検証は、ローカル環境で十分に再現でき、セキュリティ監査の文脈でも高い価値を持つ。

最後に、本記事で提示した手法は、Supabaseに限らず他のBaaSやデータベースプラットフォームにも応用可能であるという点を強調しておきたい。
Dockerベースのローカルスタック、マイグレーション管理、トランザクションロールバックによるテスト分離、CI統合という一連のパターンは、現代のWebアプリケーション開発における普遍的なベストプラクティスである。
Supabaseは、PostgreSQLという堅牢な基盤の上にこれらのパターンを自然に実現する優れたプラットフォームである。

効率的なローカルテスト環境の構築は、一見すると初期工数の増大に見えるかもしれない。
しかし、これは短期的な投資と長期的なリターンの関係を正しく理解する必要がある。
テストの信頼性が向上し、バグの検出が早期化し、リグレッションのリスクが低減する。
結果として、開発チーム全体の生産性が向上し、ユーザへの価値提供のサイクルが加速する。

本記事の内容を実践の場で活用し、各プロジェクトの特性に応じた調整を加えていただければ幸いである。
技術的な選択には常にトレードオフが存在するが、ローカルテスト環境の構築においては、投資の価値が圧倒的に大きいと確信している。

コメント

タイトルとURLをコピーしました