Declarative Data Specification: なぜ blueprint 形式か
この記事の対象読者: ApexBlueprint の 設計判断の背景・哲学 を理解したい方。「なぜこの API 形になったのか」を、従来の手続き型ファクトリパターンとの対比から読み解きたい開発者・アーキテクト向け。ApexBlueprint の内部実装には踏み込まないので、そちらに興味がある方は姉妹記事 依存解決の仕組み を参照してください。
ApexBlueprint の設計の中心にあるのは、結合テストデータの作成を 「手順を書き下す行為」から「データの最終状態を宣言する行為」へと置き換える という発想です。このページでは、手続き型のテストデータファクトリパターン全般 との概念対比から、「Declarative Data Specification」がどう違う角度で問題を解いているかを掘り下げます。
このページでの対比対象は、特定の OSS ライブラリではなく、「データ生成ロジックをメソッド内部に持つ一般的なファクトリパターン」 です。個別の TestDataFactory 実装が良いか悪いかという話ではなく、「メソッドにデータ生成を閉じ込める」という形式そのものが持つ性質を見ていきます。
手続き型のテストデータファクトリパターン
ここでの「手続き型ファクトリ」とは、静的メソッドの中にレコード作成のロジックを書いておき、利用側はメソッドを呼ぶだけで完成済みレコードを受け取る タイプの設計を指します。Salesforce の結合テストで広く採用される書き方で、単純なケースでは十分機能しますが、大別すると 2 つの典型パターンに分かれて、それぞれ別の弱点が出てきます。
パターン A. SObject ごとの「とりあえず一式作る」メソッドを置く
createAccount() / createOpportunity() / createContact() のように、オブジェクトごとに「典型値で 1 件作る」メソッドを並べる形。利用側はそれらを順に呼び、戻ってきた ID で関連付けを組み立てます。
@isTest
static void someTest() {
Account acc = TestDataFactory.createAccount();
Contact con = TestDataFactory.createContact(acc.Id); // 親 Id を持ち回り
Opportunity opp = TestDataFactory.createOpportunity(acc.Id);
// ...
}
このパターンで出やすい弱点:
- ID バケツリレーが利用側に残る: 親レコードの ID を受け取って子の引数に渡す手続きを、利用側で毎回書く。階層が深くなると親 Id 用のローカル変数が増えて、テスト本体より接続コードの方が多くなる
- どんなデータが作られたか見えない: メソッド名から「Account 1 件、Contact 1 件、Opportunity 1 件」のような 件数感 までは読めても、内部で何が起きているか (どんなフィールドが入っているか、数式が走るか、必須項目が満たされているか) はメソッド定義を辿らないと分からない
- 親子の構造が形式的な ID 引数からしか読み取れない: 「Contact が Account にぶら下がっている」という関係性が、単に
acc.Idを引数に渡しているという事実からしか窺えない
パターン B. シナリオごとに専用メソッドを置く
createOppForX01WithFlagA() / createOppForX02WithFlagB() のように、検証シナリオに応じた専用メソッドを並べる形。利用側は 1 行呼ぶだけで複雑なデータ構造が手に入ります。
@isTest
static void someTest() {
Opportunity opp = TestDataFactory.createOppForX01WithFlagA();
// ...
}
このパターンで出やすい弱点:
- メソッド爆発 / 引数爆発: シナリオの組み合わせが増えるたびに、新しいメソッドが増えるか、既存メソッドの引数 (ブール値羅列) が増える。
createOpp(true, false, true, false)のような呼び出しが出てきたら、「何を作っているか」を読み解くにはメソッド定義側を辿るしかない - ファクトリ内部の責務肥大化: 「どのシナリオを作るか」という条件分岐がファクトリ内に集積し、修正が局所化できなくなる
- 後から読み直したときの理解コストが高い: 利用側コードを読んでも、メソッド名だけでは中身を予測できず、必ずファクトリ側の実装を開かないと意図が掴めない
パターン C. 業務シナリオ一式を共通の巨大ファクトリで作る
A と B が混ざった形として、現場で最もよく見られるのが 「業務シナリオに必要なオブジェクトを、共通の 1 メソッドで全部作る」 パターンです。createTestData() のような汎用メソッドが、内部で Account / Opportunity / Quote / QuoteLineItem / Product まで一気に生成し、全てのテストがこれを呼んで使い回す、という形です。
@isTest
static void someTest() {
TestDataFactory.createTestData();
// Account / Opportunity / Quote / QuoteLineItem / Product が全部作られた状態
Opportunity opp = [SELECT Id, Amount FROM Opportunity LIMIT 1];
// ... opp に対する検証 ...
}
利用側は 1 行で済み、ID バケツリレーもメソッド爆発も一見回避できているように見えます。しかし、規模が大きくなるにつれて別種の構造的な弱点が表面化してきます:
- 副作用が読めない: テスト本体には
createTestData()しか見えないため、「このテストに対して Quote が同時に存在することは何を意味するのか?」「QuoteLineItem の存在は検証対象に影響するのか?」を判断するには、結局ファクトリ実装を開いて全レコードの中身を把握する必要がある - テストが落ちたときの切り分けが半日コース: 失敗の原因がロジックの問題か、createTestData が作った副次レコードのトリガー副作用かを、容易に判別できない
- 不要なレコードによる governor 圧迫: 1 つの仕様検証のために、そのテストには関係ないレコードが毎回 5 オブジェクト分作られる。テスト件数が増えるとガバナ制限に追われる
- テスト独立性の崩壊:
createTestDataを少しいじると 無関係に見えるテスト 20 件が連鎖的に落ちる 現象が定期的に起きる - 結局 A / B の弱点を回避できない: シナリオ違いに応じて
createTestDataForApproval()/createTestDataForCancellation()のような派生メソッドが増殖し、やがて引数のブール値羅列も増える。「メソッド爆発と引数爆発の両方が起きる」という最悪の状態に至る
A / B の表面的な手間を回避するために導入されたパターンですが、規模が大きくなるほど テスト本体からデータの全体像と副作用が見えなくなる という、より根の深い問題に置き換わっていきます。
3 つのパターンに共通する根っこ
A も B も C も、弱点の構造的な原因は同じで、データ生成ロジックがメソッドの内側に閉じている ことです。メソッドの出力は完成済みのレコードで、「どんなフィールド構成で、どんな関連付けで作られたか」という形は利用側のコードには現れません。
加えて、ファクトリのメソッドそれ自体に「条件 → 値」の生成ロジックが住んでいるので、シナリオが増えるたびに メソッド or 引数を増やす以外の打ち手がない という構造的な制約も生まれます。
そしてここで効いてくるのが、AI 時代における コードとの向き合い方の変化 です。AI がコードを書く時代になると、人間の役割は「コードを 書く こと」から「出てきたコードが どんな意図で書かれたか を理解すること」にシフトします。言い換えれば、意図の理解こそが人間の仕事になる。
このとき手続き型ファクトリには、構造的に決定的な弱点があります。「Account 1 件に Contact 3 件をぶら下げて、そのうち 1 件にだけ Opportunity を紐付ける」のようなシナリオの意図が、メソッド呼び出しの裏側 (ファクトリ実装) に隠れていて、利用側のテストコードを読むだけでは復元できない のです。メソッド名は 意図のラベル にはなれても、意図そのもの にはなれません。ラベルが正確かどうかは、結局ファクトリ実装を開いてみないと分からない。
Declarative Data Specification が解こうとしているのは、まさにこの「意図を構造体として表に出す」という問題です。「最終的にどんなデータが存在してほしいか」が コードの構造そのもの として書かれていれば、メソッド定義を辿る必要がなくなり、AI が書いたコードでも人間が書いたコードでも、意図を最短で読み取れる 形になります。読み手にとってのコストが下がるだけでなく、コード自体が意図のドキュメントを兼ねる という性質を持つことになります。
Declarative Data Specification とは何か
ApexBlueprint が選んだのは、これらの疲弊に対して「API を増やす」のではなく「書く対象を変える」という方向の解決です。
具体的には、「データを作るための手順」を書く代わりに、「最終的に存在してほしいデータの構造」を 1 つの式として書きます。親 Id の転記、insert 順、alias 解決といった機械的な作業はすべてフレームワークに肩代わりさせます。
SOrchestrator.start()
.add(
SBlueprint.of(Account.class)
.template(Blueprints.accBasic())
.alias('acc')
.withChildren(
SBlueprint.of(Contact.class)
.set('LastName', 'Contact-{#}')
.times(3)
)
.withChildren(
SBlueprint.of(Opportunity.class)
.set('Name', 'TestOpportunity')
.set('StageName', 'Prospecting')
.set('CloseDate', Date.today().addDays(30))
)
)
.create();
このコードを上から下に読むと、そのまま「Account 1 件、その下に Contact 3 件と Opportunity 1 件」という最終データ構造が現れます。ID の持ち回りも insert 順の管理も書きません。コードのインデント階層がそのままデータ階層と一致しています。
これが Declarative Data Specification という発想の核です。「データの作り方」ではなく「データの設計図」を書く、という転換。そしてこの転換は、コードの構造そのものが意図のドキュメントになる という副次的な性質を生みます。「このテストは何を作って何を検証しているか」が、別ファイルを開かなくても、そのテスト本体の 形 から直接読み取れるようになります。
構造から消えるもの
宣言的に書くことで、テストコードから以下の要素が消えます:
- 親レコードの Id を一時変数で持ち回るコード
- insert 順を考えて並べるコード
- 子の lookup にコピーするコード
- 「最終的にどんなデータができるか」をコメントで補足する必要
その代わり、「データ階層」「件数」「フィールド値」という テストの本筋に必要な情報だけ がコードに残ります。
自動化される機械的作業
利用者から見えないところで、ApexBlueprint は次の作業を引き受けています:
- 全 blueprint の 依存解析とトポロジカルソート
- 親 → 子の insert 順 の自動決定
- 親 Id を子の lookup に 自動転記
- alias で参照された値の 階層的な解決 (
{P0}/{P1}を含む) - 循環依存 / alias 重複 / 不正参照 / 複数 lookup の曖昧 などの 整合性チェック
これらは利用者には「依存を宣言したら勝手に動く」ように見えますが、内部では複数の問題が一手に解かれています。
テンプレートは「メソッド」ではなく「フィールドのプリセット」
冒頭で見た手続き型ファクトリと特に違うのは、共通設定の再利用の仕組み です。ApexBlueprint のテンプレート (Blueprints.cls) は、メソッドではなく、フィールド値の Map<String, Object> をプリセットとして保持する 形を取ります。
public with sharing class Blueprints {
public static Map<String, Object> accBasic() {
return new Map<String, Object>{
'Name' => 'TestAccount',
'Industry' => 'Technology',
'AnnualRevenue' => 500000
};
}
}
これを .template(...) で取り込み、各テストの 検証対象の項目だけ .set(...) で上書きします。
SBlueprint.of(Account.class)
.template(Blueprints.accBasic())
.set('Name', '○○商事'); // このテストで本筋の項目だけ
ポイントは:
- テンプレートはフィールド組み合わせの宣言だけで、ロジックを持たない (
if分岐や条件付きの値生成は入らない) - シナリオの違いはテンプレート側ではなく、テスト本体の
.set(...)で表現する - そのため、テンプレート側でメソッドを増やす必要は基本的になく、「SObject ごとの典型形」数種類で十分小さく保てる
- テスト本体を読めば「このテストが何を変えて何を検証しているか」が
.set(...)の行から直接読み取れる
つまり、「メソッドを増やすか引数を増やすか」という二者択一の構造そのものから抜け出して、「テンプレート = プリセットを置く場所、テスト本体 = 差分を書く場所」 という分業を持ち込んでいます。
DRY 原則との関係
ここまで読んで「データの最終状態をテストごとに直書きするのは DRY 原則に反するのでは?」という疑問を持つかもしれません。似たような blueprint チェーンが複数のテストに繰り返し現れる構造は、確かに「コードの形」として重複しているように見えます。
ただ、DRY 原則の本来の定義は "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system" (The Pragmatic Programmer) であり、「知識 は 1 箇所に集約する」であって「コードの形の重複を避ける」ではありません。
この観点で ApexBlueprint の設計を見直すと、「何を DRY するべきで、何を露出するべきか」を意識的に分けている ことが見えてきます:
- 共通の値の組み合わせ (= 知識):
Blueprints.accBasic()などのテンプレートに集約 → DRY を満たす - テスト固有のデータ階層と検証意図 (= そのテストの主張): テスト本体に直書き → 意図として露出させる
似たような構造が複数テストで繰り返し現れる場合でも、各テストが検証しているのは別の意図であり、これは知識の重複ではなく 意図ごとの個別具体性 です。DRY 原則を額面通り適用して「似た構造のテストは全部共通メソッドにまとめよう」とすると、結果として 意図がメソッド名の裏に隠れる という、AI 時代に最も避けたい現象に逆戻りしてしまいます。
つまり ApexBlueprint の立場は次のようにまとめられます:
知識は DRY、意図は露出
この分業によって、DRY の本来の精神 (知識の単一源) と、AI 時代の要求 (意図の透明性) を両立させる。「コードの形の重複」は表面的には許容するが、それは 意図を表に出すための意図的な選択 であり、DRY 原則そのものへの反抗ではない、という立て付けです。
なぜ Salesforce で特にこれが効くか
Declarative Data Specification の発想自体は、結合テストを書く環境であれば一般的に有用です。ただし Salesforce には、この発想を 特に必要とする 構造的な理由があります。
多項目・複雑なリレーション
Salesforce のオブジェクトモデルは、標準オブジェクト + カスタムオブジェクト + カスタム項目 + 複数の lookup + RecordType + 必須項目 + 検証ルール、という前提に立っています。1 つの Opportunity を「正しく」作るには、数十項目を意識する必要があり、そのうち何が必須・何がデフォルト・何が検証ルールでチェックされるかは組織ごとに異なります。
「手順」として書き下すと、この複雑性がテスト本体に流れ込みます。Blueprints.cls への共通設定の集約と .template(...) + .set(...) の組み合わせは、この複雑性を テストの外側に追いやる ための仕組みでもあります。
アドミン設定でテスト前提が変わる
Salesforce では、開発者の知らないところでアドミンが項目を追加したり、バリデーションを増やしたり、RecordType を切ったりします。手続き型ファクトリでこの変更を吸収するには、メソッド内部に書かれた条件分岐と値生成を順に修正 する必要があります。「いつ何が変わっているか分からない実行環境」という前提の上で、手続き型のテストデータ作成は綻びが大きくなりがちです。
宣言的に書いておくと、共通設定は Blueprints.cls で一元化されているため、変更の波及範囲が 構造的に絞られる ことになります。
「全部記憶するのは無理」という現実
複雑な業務システムでは、1 人の開発者が 全ての結合テストの手続きを把握しておく ことは非現実的です。半年前に自分が書いたファクトリメソッドの中身を、半年後の自分が読み解けるかは怪しいものです。
宣言的なコードは、「コードを読むこと自体がデータ構造の理解になる」という性質を持ちます。これは、半年後の自分や他のチームメンバーがそのテストに介入するときの認知負荷を下げる、という長期的な利益につながります。
Apex Stem における位置づけ
Apex Stem は 4 つの OSS で構成されており、ApexBlueprint はその中で 結合テストデータ生成 (Test Data Factory) を担います。
テスト戦略 のページで詳しく書いていますが、Apex Stem は「Usecase 単体テスト」と「Handler 結合テスト」を構造的に分離しており、それぞれに異なる OSS を当てています:
| テスト種別 | 担当 OSS | データ生成 | DML |
|---|---|---|---|
| Usecase 単体テスト | ApexEloquent (MockEloquent / MockEntry) | メモリ上のモック | なし |
| Handler 結合テスト | ApexBlueprint (SBlueprint / SOrchestrator) | 実 DML で組織にレコード生成 | あり |
両者は「テストデータ作成」という同じ困難に対して、異なる文脈での解を提供しています。単体テストはロジックの網羅 (ApexEloquent の MockEntry が必要)、結合テストは「実プラットフォーム挙動との整合」(ApexBlueprint の SOrchestrator が必要)、という棲み分けです。
ApexBlueprint の Declarative Data Specification は、結合テストでも宣言的な書き心地を維持する ことを目的とした設計です。「テストの種類が違っても、テストコードの読み心地は揃える」という Apex Stem 全体の一貫性を支える役割を担っています。
関連ドキュメント
- 親子・量産・参照のパターン: 宣言的記法の応用 (withChildren / times / {Pn})
- 依存解決の仕組み: トポロジカルソート + alias 解決: 内部で起きていることを掘り下げる Deep Dive
- テスト戦略: ApexBlueprint / ApexEloquent の役割分担
- Apex Stem 導入ガイド: 全体像の入り口
- ApexBlueprint ガイドへ戻る