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

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

PL/SQLのプロシージャを自動テストしにくくする最大の理由は、業務ロジックの中に時刻、メール送信、HTTP通信、ファイルI/O、シーケンス、権限、COMMITなどの外部依存が混ざることです。入力と出力だけを見ればよい処理ならテストは簡単ですが、副作用が散らばると、再現性がなくなり、テストデータの掃除も難しくなります。

この記事では、PL/SQLでも使えるポート/アダプタ設計、モックパッケージ、Fake Clock、アプリケーションコンテキスト、GTT、utPLSQLの検証例を使って、変更に強いプロシージャ設計を整理します。例外処理と再試行は PL/SQLの例外設計と再試行パターン、運用ログや観測性は DBMS_APPLICATION_INFOを使ったインストゥルメンテーション設計 もあわせて確認してください。

この記事で扱うこと

  • 外部依存をポート化して本番実装とモック実装を差し替える
  • 時刻、GUID、HTTP、メールなどをテストで固定する
  • GTTやモックテーブルで副作用を観測する
  • utPLSQLで実行結果と副作用を検証する
  • シノニム差し替え、権限、ACL、トランザクション境界の注意点を押さえる
スポンサーリンク

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

テストしやすいPL/SQLにするには、まず副作用を業務ロジックから追い出します。ドメイン処理は入力を受け取り、表を更新し、必要ならポート越しに外部処理を呼びます。現在時刻やGUIDも直接 SYSTIMESTAMP / SYS_GUID() を呼ばず、ラッパーパッケージ越しに取得します。

  • 副作用は薄いポートパッケージへ集約する
  • COMMIT / ROLLBACK は原則として上位レイヤーに寄せる
  • 時刻、採番、HTTP、メールは差し替え可能にする
  • 同じ入力を再実行しても破綻しないよう、自然キーや一意制約で収束させる
  • テスト中に観測したい副作用はモックテーブルやGTTに記録する

REST API向けの返却形式を含めて設計する場合は 例外をJSON形式で返すREST対応API設計 も関連します。

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

次の例では、メール送信を mail_port として切り出し、本番実装とモック実装を同じ仕様で用意します。呼び出し側は常に mail_port.send だけを使うため、業務ロジックにメール送信の詳細が漏れません。

mail-port-adapter.sql
-- 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
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
    -- 実際は UTL_SMTP / UTL_MAIL / ORDS などへ委譲する
    NULL;
  END;
END;
/

-- 3) テストAdapter: 送信せずログに残す
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;
/

差し替えはシノニムやラッパーの条件分岐で行います。ただし、共有スキーマでシノニムを切り替えると他セッションにも影響するため、テスト専用スキーマ、専用PDB、CI用スキーマなど、影響範囲を閉じた環境で行うのが前提です。

mail-port-synonym.sql
-- 本番または開発既定
CREATE OR REPLACE SYNONYM mail_impl FOR mail_impl_smtp;

-- テスト専用スキーマでだけ実行する
CREATE OR REPLACE SYNONYM mail_impl FOR mail_impl_mock;

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越しに外部へ依存する

業務ロジック側は、メール実装がSMTPなのかモックなのかを知る必要がありません。次の例では、注文確定後に通知メールを送りますが、送信処理は mail_port の奥へ閉じ込めています。また、プロシージャ内では COMMIT しないため、テスト側でロールバックできます。

order-service-domain.sql
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, 'amount' VALUE p_amt RETURNING CLOB)
    );
  END;
END;
/

Fake Clock / Fake Idで時刻とGUIDを固定する

現在時刻やGUIDはテストを不安定にしやすい代表です。実装から直接呼ばず、clk_port のような小さなパッケージで包むと、テスト時に固定値へ差し替えられます。

fake-clock-id-package.sql
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 '2026-05-31 09: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 CONTEXT には権限が必要で、コンテキストを設定できるパッケージも明示する必要があります。

application-context-test-mode.sql
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;
/

HTTP連携のように実通信を避けたい処理では、コンテキストを見てモック実装へ逃がす構成が便利です。実際の UTL_HTTP 呼び出しやACL設定は UTL_HTTPでREST API連携する方法 を参照してください。

副作用の観測:GTTとモックテーブルを使う

メール送信やHTTP呼び出しは、テスト中に実行した事実を検証できる形で残します。テストごとに自動で消したいならGTT、後から詳細に追跡したいなら通常テーブルが向いています。

mock-observation-gtt.sql
CREATE GLOBAL TEMPORARY TABLE http_mock_log(
  id     NUMBER,
  url    VARCHAR2(2000),
  method VARCHAR2(10),
  body   CLOB
) ON COMMIT DELETE ROWS;

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_mock_log(id, url, method, body)
    VALUES(1, p_url, 'POST', p_body);

    RETURN 202;
  END;
END;
/

SELECT COUNT(*)
  FROM http_mock_log
 WHERE url LIKE '%/orders%'
   AND method = 'POST';

トランザクション境界をテスト側に寄せる

プロシージャ内部で無条件に COMMIT すると、テスト後にロールバックできず、ケース間の独立性が壊れます。通常の業務更新は呼び出し元が確定し、監査ログなどどうしても残すべき処理だけを自治トランザクションへ切り出します。自治トランザクションの注意点は AUTONOMOUS_TRANSACTIONの実装ガイド が参考になります。

test-transaction-savepoint.sql
SAVEPOINT sp_test;

BEGIN
  order_service.place('REQ-1', 100, 5000);

  -- 検証SQLを実行
  -- ...

  ROLLBACK TO sp_test;
END;
/

utPLSQLでドメインとモックを検証する

utPLSQLを使う場合は、テストパッケージを作り、セットアップでモックへ差し替え、実行後に業務表とモックログを検証します。ここでは1ケースだけに絞り、注文が登録され、メール送信がモックログに記録されることを確認します。

utplsql-order-service-test.pks-pkb
CREATE OR REPLACE PACKAGE test_order_service AS
  --%suite(order_service)
  --%beforeeach
  PROCEDURE setup;

  --%test(注文登録時にメール通知が記録される)
  PROCEDURE place_records_order_and_mail;
END;
/

CREATE OR REPLACE PACKAGE BODY test_order_service AS
  PROCEDURE setup IS
  BEGIN
    DELETE FROM orders;
    DELETE FROM mail_mock_log;

    EXECUTE IMMEDIATE
      'CREATE OR REPLACE SYNONYM mail_impl FOR mail_impl_mock';
  END;

  PROCEDURE place_records_order_and_mail IS
    v_order_cnt NUMBER;
    v_mail_cnt  NUMBER;
  BEGIN
    order_service.place('REQ-UT-1', 100, 5000);

    SELECT COUNT(*)
      INTO v_order_cnt
      FROM orders
     WHERE req_id = 'REQ-UT-1'
       AND amount = 5000;

    SELECT COUNT(*)
      INTO v_mail_cnt
      FROM mail_mock_log
     WHERE rcpt = 'notify@example.com'
       AND subj = 'order placed';

    ut.expect(v_order_cnt).to_equal(1);
    ut.expect(v_mail_cnt).to_equal(1);
  END;
END;
/

この例はテスト専用スキーマを前提にしています。共有開発DBで同じシノニムを切り替えると、別の開発者や別テストセッションの結果が変わるため、CIではスキーマを分けるか、コンテキスト分岐方式に寄せる方が安全です。

HTTP/外部APIを薄いポートで吸収する

UTL_HTTP を業務ロジックから直接呼ぶと、ACL、Wallet、ネットワーク障害、レスポンスの揺れがすべてテストに流れ込みます。本番は実通信、テストはモックログへ記録するだけ、という薄いポートを挟みます。

http-port-mocking.sql
CREATE OR REPLACE PACKAGE http_port AS
  FUNCTION post_json(p_url VARCHAR2, p_body CLOB) RETURN NUMBER;
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し、HTTPステータスを返す
    RETURN 200;
  END;
END;
/

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

権限と環境差で詰まりやすいポイント

モック化の設計はコードだけでなく、権限設計も重要です。開発環境では動くのにCIや本番相当環境で失敗する場合、以下のような差分を確認します。

  • CREATE SYNONYM / CREATE ANY SYNONYM の権限
  • CREATE CONTEXT / CREATE ANY CONTEXT と設定用パッケージ
  • UTL_HTTP のネットワークACL、Wallet、プロキシ設定
  • メール送信パッケージの実行権限と外部送信制限
  • テスト用GTTやモックテーブルを掃除する権限
  • シノニム変更後の依存オブジェクト無効化と再コンパイル

CIや夜間テストで実行する場合は、ジョブ履歴の確認も大切です。スケジュール実行の設計は DBMS_SCHEDULERでジョブ管理する方法 と相性がよいです。

アンチパターンと回避策

業務ロジックから外部I/Oを直接呼ぶHTTP、メール、ファイルはポート化し、本番実装とモック実装を分けます。
プロシージャ内部で無条件COMMITする呼び出し元がトランザクション境界を持てるようにし、テスト後にロールバックできる構造にします。
現在時刻やGUIDを直接呼ぶFake Clock / Fake Idで固定できるようにし、期待値が安定するテストにします。
共有スキーマのシノニムをテストで切り替える他セッションへ影響するため、テスト専用スキーマかコンテキスト方式を使います。
例外を握りつぶす原因調査できなくなるため、例外分類、ログ、再試行可否を設計します。

まとめ

PL/SQLのテスト容易性は、テストコードを書く前の設計でほぼ決まります。外部依存をポート化し、時刻やGUIDを固定できるようにし、副作用をモックテーブルやGTTで観測し、トランザクション境界を上位に寄せるだけで、プロシージャはかなり検証しやすくなります。

実務では、utPLSQLのテストパッケージ、テスト専用スキーマ、権限とACLの整理、CIでの実行履歴管理まで含めて設計すると、変更に強いPL/SQL開発基盤になります。本番実装とテスト実装を同じ仕様で差し替えられる形にしておくことが、長期的な保守性を支える一番の近道です。