MockEntry: Apex のテストデータ作成を成立させる仕組み

Apex Stem ドキュメント
Apex StemApexEloquentMockEntryTestingTest Data FactorySalesforceApex
Apex のテスト最大の壁である「書き込み不可項目」と「親子関係を組むには DML が必要」という 2 つの困難を、MockEntry がどう 1 つの仕組みで解決するかを、典型シナリオに沿って解説します。

Apex でビジネスロジックのテストを書こうとすると、すぐに「テストデータが作れない」という壁にぶつかります。数式項目やロールアップ集計は SObject に直接代入できず、親子関係をコードで組むには DML が必要、子レコードのリレーション名は SObject に書き戻せない、など、動かしてみるまで埋められない項目 がたくさんあります。

ApexEloquent の MockEntry は、これらの 書き込み不可項目を含むテストデータを、DB を介さずに組み立てる ためのコア機能です。数式・ロールアップ・親リレーション・auto-number をそのまま値設定でき、親子の階層もコード上で構造を保ったまま記述できます。

このドキュメントでは「MockEntry が何を解決するか」「どのような書き味で使うか」を、典型シナリオに沿って解説します。個別 API の網羅は API リファレンス: IEntry / Entry / MockEntry を参照してください。

Apex でテストデータを書くのが難しい理由

Apex の SObject には、ビジネスロジックのテストで重要なのに コードからは値を入れられないフィールド が多数あります。

  • 数式項目 (Formula)
  • ロールアップ集計項目 (Roll-Up Summary)
  • 自動採番項目 (Auto Number)
  • システム保持項目 (CreatedDate / LastModifiedDate / Id など)
  • 親子のリレーション名 (例: AccountContacts、lookup の Parent__r など)

これらは Salesforce プラットフォームが計算・付与するもので、純粋な SObject インスタンス (new Opportunity(...)) に手で設定することはできません。結果として、これらの値に依存するビジネスロジックをテストするには:

  • 親 / 子レコードを実際に insert してプラットフォームに数式を計算させる
  • ロールアップが反映されるまで再クエリする
  • リレーション名を辿った結果を確認するため、DML 経由でクエリし直す

といった、本来のロジック検証とは無関係な準備 が増えていきます。テストは遅く、重く、ガバナ制限を気にしながら書く対象になります。

既存の常套手段: JSON シリアライズハックとその限界

書き込み不可項目を埋める手段として、Apex コミュニティでは JSON.deserialize を使ったハック が広く知られています。SObject を JSON 文字列として組み立て、デシリアライズして戻すと、通常はアクセスできない項目にも値を入れられる、というテクニックです。

APEX
// Map<String, Object> で組み立てて、JSON 経由で SObject にデシリアライズする
Map<String, Object> oppMap = new Map<String, Object>{
    'Id' => '006000000000001AAA',
    'Name' => 'Opportunity A',
    'NameWithAccountName__c' => 'Opportunity A_Test Account'
};
Opportunity opp = (Opportunity) JSON.deserialize(JSON.serialize(oppMap), Opportunity.class);
// → 数式項目 NameWithAccountName__c に値が入った SObject インスタンス

単純なケースでは確かに動きます。文字列連結とエスケープのつらさは Map で書くことで避けられますが、それでもこのハックの本質は 「JSON という別レイヤーを経由する」ことに依存している ため、以下の代償が残ります。

  • JSON 往復のコスト: Apex の SObject を扱うのに Map → JSON.serializeJSON.deserialize の 3 ホップが必要。単純なフィールド設定なら気になりませんが、親子や子サブクエリの構造を組む段になると、この「JSON 経由である」ことが効いてきます
  • 親子関係の表現が Salesforce 固有: 親項目や子サブクエリ風のデータを Map で組み立てる場合、Salesforce 独自の JSON 構造 (子サブクエリは 'records' キーの下にリスト、親や子レコードに attributes キーが必要、など) を理解する必要があり、Apex の SObject 操作とは別の規約を覚えることになる
  • テンプレート管理がメソッド量産に逆戻り: 似たような Map 構造が複数のテストに散らばり、共通化のために結局「テストデータ生成メソッドを増やす」ことになって、Selector Pattern のメソッド爆発と同じ問題に陥る

つまり JSON ハックは「動くテストを書く」ことはできても、「保守できるテスト」にはなりにくい性質を持ちます。

MockEntry は、この JSON ハックがやりたかったこと (= 書き込み不可項目への自由な値設定) を、Map と JSON の往復を介さず、Apex 内で完結する専用ファクトリのメソッドチェーンで実現する ためのコア機能です。

APEX
// Map / JSON 往復ではなく、 ファクトリ系メソッドで構造的に書く
MockEntry oppEntry = MockEntry.of(Opportunity.class)
    .alias('opp').autoId(1)
    .set('Name', 'Opportunity A')
    .set('NameWithAccountName__c', 'Opportunity A_Test Account');

MockEntry.of(...) を起点に、.set / .setParent / .setChildren / .times / .alias / .autoId といった専用メソッドで 構造のまま テストデータを組めます。親子の階層も、量産パターンも、生成 Id の取り出しも、すべて型付き API で扱えます。

加えて、ApexEloquent では Scribe との連携でフィールド名のタイポが二重に検出されます

  • Scribe レベル: Scribe.of(Account.class).field('TypoField__c') のように存在しないフィールド名を指定すると、.toSoql() を呼ぶ過程で The field TypoField__c does not exist on the SObject Account という例外が投げられる。テスト実行時に検出される
  • MockEntry レベル: Scribe の SELECT に含まれていないフィールドへ entry.get('XXX') でアクセスすると、本物の Entry と同じく即座に例外を投げる (MockEntry の仕組み の「SELECT 漏れの検知」参照)

文字列キーであることのリスクは、Scribe と組み合わせた瞬間にテスト段階で叩き出される構造になっています。

MockEntry の仕組み

MockEntryIEntry インターフェースの実装で、本番用 Entry と同じ契約に従いながら、テストでは任意のフィールドに値をセットできる ように設計されています。内部的には次の 2 つの軸で動きます。

1. 値の上書き (override map)

MockEntry は、値取得時にまず内部の override map をチェックし、そこに値があればそれを返します。無ければ内包する SObject から取得します。SObject には書き込めない数式・ロールアップ・auto-number でも、override map には自由に書き込めるので、結果として 任意のフィールド値を返せる 仕組みです。

APEX
// MockEntry.get() の挙動 (内部実装の単純化)
public override Object get(String fieldName) {
    // 1. override map に値があればそれを優先
    if(fieldToValue.containsKey(fieldName)) {
        return fieldToValue.get(fieldName);
    }
    // 2. 無ければ内包する SObject から取得
    return record.get(fieldName);
}

2. SELECT 漏れの検知

MockEntry には、もう 1 つ強力な安全装置があります。Scribe で組み立てたクエリの SELECT 句を覚えていてentry.get('FieldName') で取り出そうとしたフィールドが Scribe の SELECT 句に入っていなければ、本番の Entry と同じく 即座に例外を投げます

つまり、「テストでは設定したから動くが、本番では SOQL に書き忘れているフィールドにアクセスして落ちる」という典型的なバグを 単体テスト段階で検知できます。この性質の背景については Query Delegation Pattern でも触れています。

📘 詳細: 4 つの実用ケース (主オブジェクト / 親リレーション / 子サブクエリ / 集計エイリアス) で「バグ入り Usecase が正常系テストで叩き出される」動きをコード例つきで掘り下げる Deep Dive を、モックテストの偽陽性を検知する: SELECT 漏れの安全網 で用意しています。

本番コードは標準の Entry を使い、テストコードは MockEntry を inject する、という構造が崩れないので、production と test で動きが乖離する心配がありません。

書き込み不可項目を含むテストを書く

数式項目に依存するビジネスロジックの例を見ます。OpportunityPriceBand__c という数式項目があり、Amount に応じて 'Small' / 'Medium' / 'Large' のいずれかを返します。PriceBand__c'Large' の場合のみ、Description の先頭に [要承認] を付与する、という処理です。

本番コード

APEX
public with sharing class FlagLargeOppForApprovalUsecase {
    private final Id oppId;
    private final IEloquent eloquent;
 
    public FlagLargeOppForApprovalUsecase(Id oppId, IEloquent eloquent) {
        this.oppId = oppId;
        this.eloquent = eloquent ?? new Eloquent();
    }
 
    public Opportunity invoke() {
        Scribe oppScribe = Scribe.of(Opportunity.class)
            .fields(new List<String>{ 'Id', 'Description', 'PriceBand__c' })
            .whereEqual('Id', this.oppId);
 
        IEntry oppEntry = this.eloquent.first(oppScribe);
        if(oppEntry == null) {
            return null;
        }
 
        String priceBand = (String) oppEntry.get('PriceBand__c');
        if(priceBand != 'Large') {
            return(Opportunity) oppEntry.getRecord();
        }
 
        Opportunity opp = (Opportunity) oppEntry.getRecord();
        String currentDescription = opp.Description != null ? opp.Description : '';
        opp.Description = '[要承認] ' + currentDescription;
        return(Opportunity) this.eloquent.doUpdate(opp);
    }
}

テストコード

APEX
@isTest
static void testInvoke_WhenPriceBandIsLarge_ThenDescriptionIsPrefixed() {
    Trace t = Trace.of('正常系: PriceBand が Large のとき Description に [要承認] が付与されること');
    t.start();
 
    // Arrange: 数式項目 PriceBand__c に直接値を入れた MockEntry を組み立てる
    MockEntry oppEntry = MockEntry.of(Opportunity.class)
        .alias('opp').autoId(1)
        .set('Description', '大型案件')
        .set('PriceBand__c', 'Large');
 
    IEloquent mockEloquent = new MockEloquent(oppEntry);
 
    // Act
    FlagLargeOppForApprovalUsecase usecase =
        new FlagLargeOppForApprovalUsecase(oppEntry.getAliasId('opp'), mockEloquent);
    Opportunity updatedOpp = usecase.invoke();
 
    // Assert
    Assert.areEqual('[要承認] 大型案件', updatedOpp.Description);
 
    t.finish();
}

数式項目 PriceBand__c に直接 'Large' を入れています。通常の new Opportunity(Amount = 10000000) のように本物の Amount を設定しても、数式項目はプラットフォームが計算するので コード上では値が入りません (本物の insert が必要)。MockEntry.set は override map に書くので、数式項目の値そのものを任意に指定できます。これにより、「PriceBand__c'Large' のときのロジック」を DB に触れず、Amount の境界条件を意識する必要もなく 検証できます。

同じ要領で、ロールアップ集計項目・auto-number 項目・システム保持項目 (CreatedDate 等) もモックできます。

MockEntry が提供する機能カタログ

MockEntry には書き込み不可項目への .set 以外にも、テストデータ作成を楽にする機能がいくつかあります。ここでは「何ができるか」の概要だけ示し、具体的なコード例と使い方は データ取得と DML、IEntry、Mock に集約しています。

  • 親子の階層構造: setParent / setChildren で親レコードと子レコードリストを構造のままぶら下げる。コードのインデントがそのままデータのリレーション構造を表すので、「この Account にどんな子レコードがぶら下がっているか」が一目で読める。親子の Id 連結も MockEntry 側で自動。詳細は 親項目・子サブクエリ・多対多 で扱う
  • 大量データのパターン生成: times(){#} / {A} / {a} プレースホルダで 1 つのテンプレートから連番 / 大文字 / 小文字で N 件展開。times(count, startAt, interval) で開始番号と増分の指定も可能 (⚠️ ネストした「親 N 件 × 子 M 件ずつ」のような掛け算展開には未対応)
  • 生成 Id の名前付き取り出し: autoIdMockEntry が自動生成する 18 桁 Id を、alias('opp') で名前付けして getAliasId('opp') で後から取り出せる。「upsert された Opportunity の Id が、モックで設定した Id と一致するか」をアサーションするときに使う
  • 集計クエリ結果のモック: MockEntry.asAggregateResult()AggregateResult 型に紐づかない IEntry を作る。COUNT / SUM / GROUP BY の結果を 1 グループ 1 エントリでモック

「書き込み不可項目」「親子」「量産」「集計」のいずれも、同じ MockEntry の API に対するメソッドチェーンで完結し、JSON シリアライズや手作りファクトリは要らない、というのが MockEntry の設計上の核です。

まとめ

Apex のテストデータ作成が難しい理由は、「書き込み不可項目が多い」と「親子関係を組むには DML が必要」の 2 つに集約されます。これまでの常套手段である JSON シリアライズハックは前者を一応解きますが、文字列の組み立てと Salesforce 固有の JSON 構造を覚える負担を抱え込みます。MockEntry はこの両方を、専用ファクトリのメソッドチェーンで構造のまま解決します。

  • 書き込み不可項目: override map で任意のフィールド値を返す (JSON シリアライズ不要)
  • 親子関係: setParent / setChildren で構造のまま組み立てる
  • 大量データ: times() とプレースホルダで 1 つのテンプレートから展開
  • 本番との一貫性: IEntry インターフェース + SELECT 漏れ検知で、本番と同じ振る舞いを保証
  • 生成 Id: autoId + alias で名前経由で取り出してアサーション可能
  • 集計クエリ: AggregateResult も同じインターフェースでモック

これにより、Apex のテストは「DB を必要とせずに、業務ロジックそのものを検証する」ところまで降りていけます。

関連ドキュメント