【PL/SQL】テスト容易性を高めるプロシージャ設計とモック化手法

【PL/SQL】テスト容易性を高めるプロシージャ設計とモック化手法 PL/SQL

ビジネスロジックを確実に自動テストするには、「外部依存(時刻・メール・HTTP・シーケンス・ファイル・権限)を分離して差し替え可能にする」ことが要点です。PL/SQLでもポート/アダプタ発想・薄いファサード・依存の抽象化(インターフェース)・シノニム差し替え・アプリケーションコンテキスト・条件付きコンパイルを組み合わせると、テスト容易性を大幅に高められます。この記事では、実運用を想定したプロシージャ設計とモック化の実装パターンを、まとまったコード例で解説します。

基本方針:テスタブルなPL/SQLの設計原則

テスタビリティを上げる最短ルートは、(1) 副作用の局所化、(2) トランザクション境界の上位集約、(3) 依存の抽象化、(4) 冪等な設計、の4点です。具体的には「DBMS_UTL*やUTL_HTTP等の外部I/Oは直接呼ばずに“ポート”パッケージに包む」「COMMITはなるべくファサード(呼び出し元)に集約」「現在時刻やUUIDはラッパー越しに取得」「同じ入力を繰り返しても結果が変わらないよう自然キーや一意制約で収束」を徹底します。

ポート/アダプタ設計:仕様(Port)を固定し実装(Adapter)を差し替える

以下は「メール送信」を外部依存として切り出し、実装を本番用とモックで差し替える最小構成です。呼び出し側は常にmail_portの仕様だけを参照し、実体はシノニムで選択します。

-- 1) 仕様(Port):公開インターフェースだけ定義
CREATE OR REPLACE PACKAGE mail_port AUTHID DEFINER AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB);
END;
/

-- 2) 本番アダプタ(Adapter):実際の送信(例:UTL_SMTP/ORDSなど)
CREATE OR REPLACE PACKAGE mail_impl_smtp AUTHID DEFINER AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB);
END;
/
CREATE OR REPLACE PACKAGE BODY mail_impl_smtp AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB) IS
  BEGIN
    -- 実際の送信処理(簡略化)
    NULL; -- UTL_SMTP/UTL_MAIL 等を使用
  END;
END;
/

-- 3) モックアダプタ:送信内容を記録するだけ(テスト観測点)
CREATE TABLE mail_mock_log(
  id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  rcpt VARCHAR2(320), subj VARCHAR2(4000), body CLOB, at TIMESTAMP DEFAULT SYSTIMESTAMP
);
CREATE OR REPLACE PACKAGE mail_impl_mock AUTHID DEFINER AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB);
END;
/
CREATE OR REPLACE PACKAGE BODY mail_impl_mock AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB) IS
  BEGIN
    INSERT INTO mail_mock_log(rcpt, subj, body) VALUES(p_to, p_subj, p_body);
  END;
END;
/

-- 4) シノニムで実体を差し替え(本番:SMTP / テスト:MOCK)
CREATE OR REPLACE SYNONYM mail_impl FOR mail_impl_smtp; -- 本番既定
-- テスト環境では ↓ を発行
-- CREATE OR REPLACE SYNONYM mail_impl FOR mail_impl_mock;

-- 5) Portの実装は常にシノニム越しに実体へ委譲
CREATE OR REPLACE PACKAGE BODY mail_port AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB) IS
  BEGIN
    mail_impl.send(p_to, p_subj, p_body); -- 実体は同名手続きで互換
  END;
END;
/

ドメインサービスはPort越しに外部へ依存する

業務ロジックは外部依存を一切知らない構造にします。下例は「注文確定」でメールを通知するユースケースです。COMMITは上位レイヤー(APIファサードやバッチ)で行う前提にし、プロシージャ自身は純粋に“状態変換”だけを担当させるとテストが簡単になります。

CREATE TABLE orders(
  req_id    VARCHAR2(64) PRIMARY KEY,
  cust_id   NUMBER NOT NULL,
  amount    NUMBER NOT NULL,
  created_at TIMESTAMP DEFAULT SYSTIMESTAMP
);

CREATE OR REPLACE PACKAGE order_service AS
  PROCEDURE place(p_req_id VARCHAR2, p_cust NUMBER, p_amt NUMBER);
END;
/
CREATE OR REPLACE PACKAGE BODY order_service AS
  PROCEDURE place(p_req_id VARCHAR2, p_cust NUMBER, p_amt NUMBER) IS
  BEGIN
    MERGE INTO orders d
    USING (SELECT p_req_id req_id, p_cust cust, p_amt amt FROM dual) s
       ON (d.req_id = s.req_id)
     WHEN MATCHED THEN UPDATE SET d.amount=s.amt
     WHEN NOT MATCHED THEN INSERT(req_id,cust_id,amount) VALUES(s.req_id,s.cust,s.amt);

    mail_port.send('notify@example.com', 'order placed',
                   JSON_OBJECT('reqId' VALUE p_req_id, 'amt' VALUE p_amt RETURNING CLOB));
  END;
END;
/

テスト用“時刻/UUID”の差し替え(Fake Clock/Id)

テストが壊れやすい代表が「現在時刻」と「GUID」です。これもPort化して固定値に差し替えられるようにします。

-- 仕様
CREATE OR REPLACE PACKAGE clk_port AS
  FUNCTION now RETURN TIMESTAMP;
  FUNCTION guid RETURN VARCHAR2;
END;
/
-- 実体(本番)
CREATE OR REPLACE PACKAGE clk_impl_real AS
  FUNCTION now RETURN TIMESTAMP; FUNCTION guid RETURN VARCHAR2;
END;
/
CREATE OR REPLACE PACKAGE BODY clk_impl_real AS
  FUNCTION now RETURN TIMESTAMP IS BEGIN RETURN SYSTIMESTAMP; END;
  FUNCTION guid RETURN VARCHAR2 IS BEGIN RETURN RAWTOHEX(SYS_GUID()); END;
END;
/
-- モック(テストで上書き)
CREATE OR REPLACE PACKAGE clk_impl_mock AS
  g_now  TIMESTAMP := TIMESTAMP '2025-10-07 12:00:00';
  g_guid VARCHAR2(64) := 'TEST-GUID-0001';
  FUNCTION now RETURN TIMESTAMP; FUNCTION guid RETURN VARCHAR2;
END;
/
CREATE OR REPLACE PACKAGE BODY clk_impl_mock AS
  FUNCTION now  RETURN TIMESTAMP IS BEGIN RETURN g_now;  END;
  FUNCTION guid RETURN VARCHAR2  IS BEGIN RETURN g_guid; END;
END;
/
CREATE OR REPLACE SYNONYM clk_impl FOR clk_impl_real; -- 本番
-- テストで:CREATE OR REPLACE SYNONYM clk_impl FOR clk_impl_mock;

-- Port本体
CREATE OR REPLACE PACKAGE BODY clk_port AS
  FUNCTION now  RETURN TIMESTAMP IS BEGIN RETURN clk_impl.now;  END;
  FUNCTION guid RETURN VARCHAR2  IS BEGIN RETURN clk_impl.guid; END;
END;
/

アプリケーションコンテキストで「テストモード」を切り替える

環境に応じた分岐は、コンテキストに寄せるとテストしやすくなります。以下はメール送信の内側でモックログへフォールバックする例です。

-- コンテキスト作成
CREATE CONTEXT app_ctx USING app_ctx_pkg;
CREATE OR REPLACE PACKAGE app_ctx_pkg AS
  PROCEDURE set_mode(p_test BOOLEAN);
  FUNCTION  is_test RETURN BOOLEAN;
END;
/
CREATE OR REPLACE PACKAGE BODY app_ctx_pkg AS
  PROCEDURE set_mode(p_test BOOLEAN) IS
  BEGIN
    DBMS_SESSION.SET_CONTEXT('APP_CTX','TEST_MODE', CASE WHEN p_test THEN '1' ELSE '0' END);
  END;
  FUNCTION is_test RETURN BOOLEAN IS
  BEGIN
    RETURN SYS_CONTEXT('APP_CTX','TEST_MODE') = '1';
  END;
END;
/

-- mail_portの中で参照(必要な場合のみ)
CREATE OR REPLACE PACKAGE BODY mail_port AS
  PROCEDURE send(p_to VARCHAR2, p_subj VARCHAR2, p_body CLOB) IS
  BEGIN
    IF app_ctx_pkg.is_test THEN
      mail_impl_mock.send(p_to, p_subj, p_body); -- 強制モック
    ELSE
      mail_impl.send(p_to, p_subj, p_body);
    END IF;
  END;
END;
/

条件付きコンパイルでテスト分岐をビルド時に切り替える

CCFLAGSを使えば、ビルド時に振る舞いを丸ごと切り替えられます。CIで「本番ビルド」と「テストビルド」を分けたい場合に有効です。

ALTER SESSION SET plsql_ccflags = 'TESTING:TRUE';
-- パッケージ内
$IF $$TESTING $THEN
  -- テスト専用の軽量ロジック
$ELSE
  -- 本番ロジック
$END

副作用の観測:モックテーブルとGTT(グローバル一時表)

メール・HTTP・外部通知などは、モック実装で「呼び出し履歴」をテーブルに書き出して検証します。テスト間の独立性が必要なら、グローバル一時表を使うとクリーンです。

CREATE GLOBAL TEMPORARY TABLE http_mock_log(
  id NUMBER, url VARCHAR2(2000), method VARCHAR2(10), body CLOB
) ON COMMIT DELETE ROWS;

-- テスト中の確認例
SELECT COUNT(*) FROM http_mock_log WHERE url LIKE '%/orders%' AND method='POST';

トランザクションとロールバック戦略(テストフレンドリー化)

プロシージャ内部でのCOMMIT/ROLLBACKは極力避け、上位で制御します。テストでは1テスト=1トランザクションとして最後にロールバックすれば、データ掃除が不要になります。どうしても内部で確定すべき監査は、最小限の自治トランザクションへ切り出します。

-- テストスイート冒頭
SAVEPOINT sp_test;
-- ... 複数ケース実行 ...
ROLLBACK TO sp_test; -- 初期状態に戻す

サンプル:utPLSQLでドメインとモックを検証する

軽量に1ケースだけ示します。実装は任意のテストFW(utPLSQL等)を想定してください。

-- 1) テストセットアップ:モード切替&シノニム差し替え
BEGIN
  app_ctx_pkg.set_mode(TRUE);
  EXECUTE IMMEDIATE 'CREATE OR REPLACE SYNONYM mail_impl FOR mail_impl_mock';
  EXECUTE IMMEDIATE 'CREATE OR REPLACE SYNONYM clk_impl  FOR clk_impl_mock';
  clk_impl_mock.g_now  := TIMESTAMP '2025-10-07 09:00:00';
  clk_impl_mock.g_guid := 'GUID-TEST-1234';
END;
/

-- 2) 実行
BEGIN
  order_service.place(p_req_id => 'REQ-1', p_cust => 100, p_amt => 5000);
END;
/

-- 3) 検証(観測点はDB)
DECLARE
  v_mail_cnt NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_mail_cnt FROM mail_mock_log WHERE rcpt='notify@example.com';
  IF v_mail_cnt < 1 THEN
    RAISE_APPLICATION_ERROR(-20000, 'メール送信が記録されていない');
  END IF;
END;
/

HTTP/外部APIのモック:薄いHTTPポートで吸収する

UTL_HTTPを直接使わず、薄いポートを経由します。本番はUTL_HTTP、テストはモックテーブルへ記録する実装に差し替えます。

CREATE OR REPLACE PACKAGE http_port AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER; -- 返り値=HTTPステータス
END;
/
CREATE OR REPLACE PACKAGE http_impl_real AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER;
END;
/
CREATE OR REPLACE PACKAGE BODY http_impl_real AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER IS
  BEGIN
    -- UTL_HTTPでPOSTし、ステータス返却(省略)
    RETURN 200;
  END;
END;
/
CREATE TABLE http_calls_mock(url VARCHAR2(2000), body CLOB, at TIMESTAMP);
CREATE OR REPLACE PACKAGE http_impl_mock AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER;
END;
/
CREATE OR REPLACE PACKAGE BODY http_impl_mock AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER IS
  BEGIN
    INSERT INTO http_calls_mock(url, body, at) VALUES(p_url, p_body, SYSTIMESTAMP);
    RETURN 202; -- 疑似応答
  END;
END;
/
CREATE OR REPLACE SYNONYM http_impl FOR http_impl_real; -- 本番
-- テストで:CREATE OR REPLACE SYNONYM http_impl FOR http_impl_mock;

CREATE OR REPLACE PACKAGE BODY http_port AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER IS
  BEGIN
    RETURN http_impl.post_json(p_url, p_body);
  END;
END;
/

依存注入のもう一手:エイリアス/エイリアステーブルでの切り替え

「参照先表を環境ごとに差し替えたい」場合は、Editioning Viewや同名シノニム(v_orders)を経由し、本番は実表、テストはGTT/薄いコピーに向けると、クエリ本文を一切変えずに検証できます。

-- 本番
CREATE OR REPLACE VIEW v_orders AS SELECT * FROM orders;
-- テスト
-- CREATE GLOBAL TEMPORARY TABLE orders_test ON COMMIT DELETE ROWS AS SELECT * FROM orders WHERE 1=0;
-- CREATE OR REPLACE VIEW v_orders AS SELECT * FROM orders_test;

アンチパターンと回避

プロシージャ内部での無差別COMMIT、UTL_HTTP/UTL_SMTP直呼び、SYSTIMESTAMP/ SYS_GUIDのべた書き、例外握りつぶし、巨大な暗黙依存(パッケージ変数)――これらはテスト不能・再現不能の主要因です。必ずポート化して差し替え可能にし、状態は引数から受け取り、終了時に返すスタイルを貫きます。

まとめ

PL/SQLでも「ポート/アダプタ」「シノニム差し替え」「コンテキスト/CCFLAGS」「GTTモック」「上位でのトランザクション制御」を組み合わせれば、外部依存を自在に切り離してテストできます。ドメインは純粋化し、副作用はポートへ追放、観測点はDBテーブルに残す――この基本を守るだけで、ユニット/結合/回帰テストが安定し、変更に強いプロシージャ設計が実現します。