データ取得と DML、IEntry、Mock

Apex Stem ドキュメント
Apex StemApexEloquentIEloquentMockEloquentIEntryTestingSalesforceApex
IEloquent インターフェースを通じたデータ取得と DML 実行、IEntry の扱い方、MockEloquent でのテストまでを解説します。

このドキュメントは、ApexEloquent のデータアクセス層を プロダクションコードでどう書くか に焦点を当てた使い方ガイドです。全 API のシグネチャ一覧は API リファレンス: IEloquent / Eloquent / MockEloquentAPI リファレンス: IEntry / Entry / MockEntry を参照してください。

クエリの組み立て方は Scribe でクエリを組み立てる を、他の ApexEloquent トピックは ApexEloquent ガイド を参照してください。

IEloquent とは

IEloquent は、データアクセス (SOQL / DML) を抽象化したインターフェースです。本番では Eloquent (標準 SOQL / DML のラッパー)、テストでは MockEloquent (DB を介さない振る舞いモック) を、同じ呼び出し側コードのまま差し替えられます。

APEX
public with sharing class FindActiveOpportunitiesUsecase {
  private final IEloquent fetchEloquent;
 
  public FindActiveOpportunitiesUsecase() {
    this(null);
  }
 
  @TestVisible
  private FindActiveOpportunitiesUsecase(IEloquent fetchEloquent) {
    this.fetchEloquent = fetchEloquent ?? new Eloquent();
  }
 
  public List<IEntry> invoke() {
    Scribe scribe = Scribe.of(Opportunity.class)
      .field('Id')
      .field('Name')
      .whereEqual('IsClosed', false);
    return this.fetchEloquent.get(scribe);
  }
}

「2 つのコンストラクタで本番デフォルトとテスト DI を両立する」やり方は Layered Constructor Pattern を参照してください。

データ取得

取得系メソッド

ビジネスロジックでは get(scribe)List<IEntry> を受け取る のが基本です。0 件なら空リストが返ります。1 件だけ取りたい時は first (0 件は null) / firstOrFail (0 件で例外) が便利です。

getAsSObject への変換は、外部 API (Messaging.SingleEmailMessage など) に SObject インスタンスを直接渡したいなど、SObject でないと困る具体的な理由がある時だけの最終手段です。同様に rawSoql(soql)Scribe で表現できない特殊な SOQL を投げる時の最終手段で、MockEntry の SELECT 漏れ検知が無効になります。

シグネチャの一覧は API リファレンス: IEloquent を参照。

IEntry のまま扱う利点

IEntrySObject のラッパーで、ApexEloquent の 4 つの仕組みの恩恵を受けられます。

  1. 未 SELECT フィールドアクセスの偽陽性検知: Scribefield() し忘れたフィールドに本番コードがアクセスすると、テスト実行時 (MockEntry 経由) で例外が出ます。SObject に早期変換するとこの保護が外れて、「テストは通るが本番で値が空だった」事故が起きます。
  2. モックデータ構築の自由度: MockEntry.set() はリレーション項目・数式項目・ロールアップ・auto-number など、本来書き込めない non-writable フィールドにも値を書けます。ロジックがそれらに依存する場合でも、SObject のままでは作れないテストデータが IEntry なら作れます。
  3. 取得 → 編集 → 更新が IEntry のまま完結: entry.put('Industry__c', value) でミューテートし、そのまま eloquent.doUpdate(List<IEntry>) に渡せます。SObject への変換は不要です。
  4. SObject と AggregateResult を区別しなくてよい: 通常クエリの結果も集計クエリ (AggregateResult) の結果も、どちらも IEntry として返ります。entry.get('Industry')オブジェクトのフィールド を、entry.get('totalAmount')集計クエリの alias を、同じ書き方で取り出せます。受け手側は SObject か AggregateResult かを意識する必要がありません。

IEntry を扱う

フィールドの読み書き

APEX
List<IEntry> accountEntries = eloquent.get(accountScribe);
 
for(IEntry accountEntry : accountEntries) {
  // Id と Name は専用 getter (キャスト不要)
  Id accountId = accountEntry.getId();
  String name = accountEntry.getName();
 
  // その他のフィールドはキャスト必須
  String industry = (String) accountEntry.get('Industry');
  Boolean isActive = (Boolean) accountEntry.get('Active__c');
 
  // 書き込み
  accountEntry.put('Industry', 'Technology');
}

getId / getName だけ専用 getter があってキャスト不要。それ以外のフィールドは get(fieldName) で取り、戻り値をキャストします。書き込みは put(fieldName, value)。親や子レコードへのアクセスは 親項目・子サブクエリ・多対多 を、シグネチャの一覧は API リファレンス: IEntry を参照してください。

変数命名のコツ

抽象化された型 (IEntry / Scribe / IEloquent) を使う時は、変数名で中身の SObject を明示する と読みやすくなります。

APEX
// ❌ r や e のような略は、後で読む人が型を辿らないとわからない
for(IEntry e : eloquent.get(scribe)) { ... }
 
// ✅ {SObject名}Entry の形に揃える
for(IEntry accountEntry : eloquent.get(accountScribe)) { ... }

DML を実行する

IEloquent は標準 DML のラッパーも提供します。doInsert / doUpdate / doUpsert / doDelete の 4 種類で、それぞれ SObject / IEntry / List<SObject> / List<IEntry> のオーバーロードがあります。

APEX
// Scribe で取得した IEntry をそのまま更新できる
List<IEntry> oppEntries = this.eloquent.label(LBL_FETCH).get(oppScribe);
for(IEntry oppEntry : oppEntries) {
  oppEntry.put('Industry__c', 'Technology');
}
this.eloquent.label(LBL_UPDATE).doUpdate(oppEntries);

List<> 版を基本とし、bulk 処理に揃えます。単件版は処理対象が常に 1 件と確定している時だけ使います。各オーバーロードのシグネチャは API リファレンス: IEloquent を参照。

実行モード (v3 系)

v3 系では、SOQL / DML の既定が ユーザーモード (実行ユーザーの項目・オブジェクト権限を尊重) です。集計・焼き付け・データ移行のように「誰が起こしても完遂すべき処理」だけ、systemMode() で明示的にオプトアウトします。

APEX
this.eloquent.systemMode().label(LBL_UPDATE).doUpdate(entries);

一度呼ぶとそのインスタンスの以降すべての操作に効きます (sticky)。MockEloquent では no-op なので、単体テストはモードを意識せずに書けます

⚠️ systemMode() が外すのは 項目・オブジェクト権限だけです。共有 (レコードの可視性) は別軸で、外すには呼び出し元クラスを without sharing にする必要があります。

MockEloquent でテストする

MockEloquentIEloquent のテスト用実装です。Layered Constructor Pattern で Usecase に DI することで、DB に触れずに振る舞いを検証できます。

コンストラクタ

new MockEloquent() で空、new MockEloquent(entry) で 1 件、new MockEloquent(List<IEntry>) で複数件返す Mock を作れます。

APEX
MockEntry oppEntry = MockEntry.of(Opportunity.class)
  .autoId(1)
  .set('Name', 'Test Opp')
  .set('Amount', 1000);
IEloquent fetchEloquent = new MockEloquent(new List<IEntry>{ oppEntry });

Spy で DML を検証する

MockEloquent は本物の doInsert / doUpdate / doUpsert / doDelete を実行する代わりに、渡されたレコードを記録 します。テストで「想定通りの DML が実行されたか」を後から検証できます。

メソッド中身
upsertedRecordsAt(String label)そのラベルで doInsert / doUpdate / doUpsert に渡されたレコード (List<SObject>)
deletedCountAt(String label)そのラベルでの doDelete の件数 (Integer)

ラベルを使わない (lenient) テストでは 'default' を渡します。upsertedRecords / deletedCount というフィールドも後方互換で残っていますが @deprecated なので、新規コードでは *At(...) を使ってください。

APEX
MockEloquent mock = (new MockEloquent())
  .attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List<IEntry>{ oppEntry });
 
(new CopyAccountIndustryToOpportunityUsecase(oppIds, mock)).invoke();
 
// 想定通り 1 件 update された?
List<SObject> updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE);
Assert.areEqual(1, updated.size());
 
// 想定通りの値が入っている?
Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c);

異常系をモックする

Salesforce では、失敗を再現するコストが成功を再現するコストより桁違いに高いという事情があります。実 DML を本当に失敗させようとすると、入力規則を足す、項目権限を落とす、行ロックを競合させる、といった org 側の細工が要ります。遅いうえに、org の状態に依存するので壊れやすい。

MockEloquent は、その失敗を名指しで宣言できるようにしています。

失敗の指定は 4 つの軸でできている

failOn* 系は一見メソッドが多く見えますが、実際には次の 4 軸の組み合わせです。ここを押さえると読み書きが一気に楽になります。

指定するもの既定
何が失敗するかfailOnDoUpdate() / failOnGet() / … メソッドごとに 1 本
何で失敗するか引数に渡す Exception汎用のテスト用例外
どこで失敗するか.whenLabel(ラベル)ラベルなしの呼び出し ('default') 宛て
何回失敗するかfailOn* を積む / .repeat()次の 1 回だけ

4 つは直交していて、必要な分だけ足せます。

APEX
MockEloquent mock = (new MockEloquent())
  .failOnDoUpdate(new DmlException('Simulated failure'))  // 何が + 何で
  .whenLabel(YourUsecase.LBL_UPDATE);                     // どこで
 
try {
  (new YourUsecase(input, mock)).invoke();
  Assert.fail('例外が投げられるはず');
} catch(DmlException e) {
  Assert.isTrue(TraceFlow.isLastAbort());
}

「どこで」を絞る意味

whenLabel を付けると、その 1 箇所だけを落として、残りは正常に流せます。「集計は成功したが、最後の保存だけ失敗した」といったシナリオがそのまま書けます。DML を複数撃つ Usecase では、これが無いと「どれが落ちたのか」をテストから読み取れません。

whenLabel を付けなかった設定は、ラベルなしの呼び出し ('default') 宛てになります。本番コードがラベルを使っているなら、whenLabel は実質必須です (付け忘れは検出されます。後述)。

なお、ラベルが一致しない呼び出しでは設定が消費されません。あとから来る一致した呼び出しが受け取るので、順序を気にせず仕掛けられます。

「何回」は積める

failOn*キューに積まれます。同じメソッドに複数回仕掛ければ、その回数だけ順に失敗します。

APEX
// 1 回目と 2 回目の doUpdate は失敗し、3 回目は成功する
MockEloquent mock = (new MockEloquent())
  .failOnDoUpdate(new DmlException('1 回目'))
  .failOnDoUpdate(new DmlException('2 回目'));

「2 回失敗して 3 回目に成功する」というリトライの成功パスは、この形で書けます。キューはラベルごとに独立しているので、whenLabel を付けた設定でも同じように積めます。

一方 .repeat() は、直前に仕掛けた設定を以降ずっと繰り返します (回数は指定できません)。

APEX
// 何度呼ばれてもずっと失敗する
MockEloquent mock = (new MockEloquent())
  .failOnDoUpdate(new DmlException('Always fails'))
  .whenLabel(YourUsecase.LBL_UPDATE)
  .repeat();

「最大 3 回までリトライ、それでもダメなら中止」というロジックの、中止側のパスを確かめたいときに使います。回数を数える必要がないぶん、こちらのほうが意図が明確です。

ラベル単位のキューになっている

失敗の設定は ラベルごとの FIFO キュー に積まれます (whenLabel を付けなかった設定は、ラベルなしの呼び出し = 'default' 宛てになります)。

そのため、同じメソッドに対して別々のラベルへ別々の失敗を仕掛けられます。順番も気にする必要はありません。

APEX
// 取得は 'fetch' で、保存は 'update' で、それぞれ別の理由で落とす
MockEloquent mock = (new MockEloquent())
  .failOnGet(new QueryException('取得に失敗')).whenLabel(YourUsecase.LBL_FETCH)
  .failOnDoUpdate(new DmlException('保存に失敗')).whenLabel(YourUsecase.LBL_UPDATE);

リトライは同じラベルのまま書ける

ラベルの「1 度だけ」制約が数えるのは、成功した操作です。失敗した操作はラベルを解放するので、本番コードが例外を catch して同じラベルでやり直す実装も、そのままテストできます。

APEX
// 1 回目は失敗し、2 回目で成功する — 同じラベルのまま
MockEloquent mock = (new MockEloquent())
  .attach(YourUsecase.LBL_UPDATE, entries)
  .failOnDoUpdate(new DmlException('1 回目')).whenLabel(YourUsecase.LBL_UPDATE);

repeat() を付ければ、何度リトライしても失敗し続けるので「上限まで試して中止」のパスを確かめられます。

落とし穴: 戻り値を受け取る

テスト設定のビルダー (attach / failOn* / whenLabel / failSave / repeat) は、いずれも this を変更せず新しいインスタンスを返します。戻り値を捨てると設定が消えます。

APEX
// ❌ 効かない。mock 自身には何も仕掛かっていない
MockEloquent mock = new MockEloquent();
mock.failOnDoUpdate(new DmlException('...'));
 
// ✅ チェーンでつなぐか、戻り値を受け直す
MockEloquent mock = (new MockEloquent())
  .attach(YourUsecase.LBL_FETCH, entries)
  .failOnDoUpdate(new DmlException('...'))
  .whenLabel(YourUsecase.LBL_UPDATE);

落とし穴: whenLabel の付け忘れは検出される

ラベルを使っている (strict mode の) テストで whenLabel を付け忘れると、その設定はラベルなしの呼び出し宛てになります。ところが strict mode ではすべての操作にラベルが要るので、その設定は永久に発火しません

黙って無視されると「例外が飛ばないので Assert.fail が発火する」という分かりにくい落ち方をするため、その場で診断メッセージ付きの例外になります。付けるべきラベル名もメッセージに出ます。

failSave: 例外ではなく「一部だけ保存されない」

failOn* が「呼ぶと例外が飛ぶ」なのに対し、failSave呼び出し自体は成功するが、指定したレコードだけ保存されないという別種の失敗です。allOrNone 付き DML の部分失敗を再現します。

APEX
Id badId = MockEntry.of(Account.class).autoId(2).getId();
 
MockEloquent mock = (new MockEloquent())
  .failSave(badId, '入力規則で拒否されました');
  • allOrNone = false — そのレコードの SaveResultsuccess = false になり、Spy には積まれません (保存されていないため)
  • allOrNone = true — 実際の all-or-nothing DML と同じく、何も記録される前に操作全体が例外になります

対象を index ではなく レコード Id で指名するので、MockEntry.autoId() と対で使います。リストの順序に依存しないため、あとから件数が増えても壊れません。

「失敗したレコードだけログに残して続行する」ような ETL / 増分同期のパスを、org を細工せずに検証できます。

failOn* の全一覧は API リファレンス: MockEloquent を参照してください。

同じ IEloquent で複数のクエリを区別する: ラベル多重化 (重要)

MockEloquentScribe の WHERE 条件を 評価しません。渡された IEntry リストをそのまま返します。これは「先月のクエリ今期のクエリ のように、同じ IEloquent で複数のクエリを区別したい」ケースを そのままでは扱えない ことを意味します。

v2.1 で追加された IEloquent.label(String) が、これを解決します。1 本の IEloquentラベルで多重化 することで、用途別に IEloquent を分割 DI する代わりに、同じインスタンスをラベルで仕分けて使えます。

APEX
public with sharing class AggregateAccountActivityUsecase {
  @TestVisible static final String LBL_LAST_MONTH = 'lastMonthEvent';
  @TestVisible static final String LBL_THIS_YEAR = 'thisYearEvent';
  @TestVisible static final String LBL_ACCOUNT_UPDATE = 'accountUpdate';
 
  private final Set<Id> accountIds;
  private final IEloquent eloquent;
 
  public AggregateAccountActivityUsecase(Set<Id> accountIds) {
    this(accountIds, null);
  }
 
  @TestVisible
  private AggregateAccountActivityUsecase(
    Set<Id> accountIds,
    IEloquent eloquent
  ) {
    this.accountIds = accountIds;
    this.eloquent = eloquent ?? new Eloquent();
  }
 
  public void invoke() {
    List<IEntry> lastMonthEvents = this.eloquent.label(LBL_LAST_MONTH).get(lastMonthScribe);
    List<IEntry> thisYearEvents = this.eloquent.label(LBL_THIS_YEAR).get(thisYearScribe);
    // ... 集計 ...
    this.eloquent.label(LBL_ACCOUNT_UPDATE).doUpdate(updatedAccounts);
  }
}

テストでは 1 本の MockEloquent に対して .attach(LBL_LAST_MONTH, ...) / .attach(LBL_THIS_YEAR, ...) で各ラベルにクエリ結果を 個別にプリロード し、DML の検証は mock.upsertedRecordsAt(LBL_ACCOUNT_UPDATE) で取り出します。用途別 DI と同じ独立性を保ったまま、コンストラクタの引数を 1 つに圧縮できます。

なお、.label() が初めて呼ばれた瞬間に opt-in strict mode が有効になり、以降のすべての操作にラベル付けが必須となります (ラベル忘れの footgun が実行時に塞がれます)。詳しくは API リファレンス: IEloquent を参照してください。

MockEntry を構築する

MockEntry はテスト用の IEntry 実装で、SObject では作れないテストデータを作れます。

基本

APEX
MockEntry oppEntry = MockEntry.of(Opportunity.class)
  .autoId(1)
  .set('Name', 'Test Opp')
  .set('Amount', 1000);

MockEntry.of(Type) でエントリ生成、.set でフィールドへの書き込み (書き込み不可項目も OK)、.autoId で 18 桁の ID 自動生成、.alias で後から ID を取り出すための名前付け。シグネチャ一覧は API リファレンス: MockEntry を参照。

fetchedBy で SELECT 契約を付ける

MockEloquentattach 経由で返すエントリには、get(scribe) に渡した Scribe から SELECT 契約が自動的に付きます。SELECT していない項目に触れば例外になる、あの仕組みです。

一方、エントリを SUT に直接渡す場合は契約がありませんScribe を通っていないので、どの項目が SELECT 済みかを知りようがないからです。そこに契約を後付けするのが fetchedBy です。

APEX
MockEntry card = MockEntry.of(BusinessCard__c.class)
  .autoId(1)
  .set('CompanyName__c', 'Acme')
  .fetchedBy(RematchCompanyCardsHandler.scope());

効くのは主にバッチです。execute(bc, scope) のレコードは IEloquent を通らないため、本番は実クエリの結果としてプラットフォームが検査してくれますが、テストは自前で組んだエントリなので無検査になります。クエリの組み立てを @TestVisible なメソッドに切り出しておけば、本番と同じ Scribe をテストから渡せます (詳細は API リファレンス: Scribe)。

alias で生成 ID を取り出す

autoId で生成された ID を、テストのアサーションで使いたい場合があります。

APEX
MockEntry oppEntry = MockEntry.of(Opportunity.class)
  .alias('opp')
  .autoId(1);
 
Id oppId = oppEntry.getAliasId('opp');  // 生成された ID を取り出し
 
// テストで:
Opportunity updated = (Opportunity) mock.upsertedRecordsAt(MyUsecase.LBL_UPDATE)[0];
Assert.areEqual(oppId, updated.Id);

times() で複数件を量産

APEX
List<MockEntry> contactEntries = MockEntry.of(Contact.class)
  .autoId('{#}')
  .set('LastName', 'Contact-{#}')
  .alias('con_{#}')
  .times(3);
// → con_1 / con_2 / con_3 で 3 件、それぞれ Id と LastName が自動展開

template で複数フィールドをまとめてセット

「複数テストで再利用するデフォルト値」を Map にまとめて渡すと、テストで .set を繰り返さずに済みます。

APEX
Map<String, Object> defaults = new Map<String, Object>{
  'StageName' => 'Prospecting',
  'CloseDate' => Date.today().addDays(30),
  'Amount' => 1000
};
 
MockEntry oppEntry = MockEntry.of(Opportunity.class)
  .template(defaults)
  .set('Name', 'Specific name for this test');  // 上書き・個別フィールド追加

集計結果のモック

集計クエリの結果をモックする時は、MockEntry.asAggregateResult() を使います。これは「集計結果は SObject 型に紐づかない」という性質を反映していて、MockEntry.of(SomeType.class) のように特定の SObject 型を選ぶ必要がなくなります (読み手の認知ノイズも減ります)。

APEX
MockEloquent eventEloquent = new MockEloquent(
  new List<IEntry>{
    MockEntry.asAggregateResult()
      .set('WhatId', oppAId)
      .set('eventCount', 3),
    MockEntry.asAggregateResult()
      .set('WhatId', oppBId)
      .set('eventCount', 1)
  }
);

set で渡す値は、本物の SOQL 集計結果と同じく Decimal で渡しておくと、ロジック側の ((Decimal) entry.get('eventCount')).intValue() キャストもそのまま動きます。

親子のモック

子から親を見る場合は setParent、親から子を見る場合は setChildren で構造のまま組み立てられます。コードのインデントがそのままリレーション構造を表すので、後から読み返しても「この親にぶら下がっている子」が一目で分かります。

APEX
// 親 Account に複数の Contact / Opportunity をぶら下げる
MockEntry accountEntry = MockEntry.of(Account.class)
  .alias('acc').autoId(1)
  .set('Name', 'Acme Corporation')
  .setChildren('Contacts', new List<MockEntry>{
    MockEntry.of(Contact.class).autoId(1).set('FirstName', 'John'),
    MockEntry.of(Contact.class).autoId(2).set('FirstName', 'Jane')
  })
  .setChildren('Opportunities', new List<MockEntry>{
    MockEntry.of(Opportunity.class).autoId(1).set('Name', 'Deal 1').set('Amount', 100000),
    MockEntry.of(Opportunity.class).autoId(2).set('Name', 'Deal 2').set('Amount', 150000)
  });

逆方向 (子 Opportunity から親 Account を見る) は setParent:

APEX
MockEntry oppEntry = MockEntry.of(Opportunity.class)
  .autoId(1)
  .set('Name', 'Major Deal')
  .setParent('AccountId',
    MockEntry.of(Account.class).set('Name', 'Acme Corporation').set('Type', 'Customer')
  );

親子の Id 連結 (例: Contact.AccountId に親の Id を埋める) は MockEntry が内部で処理するので、手で埋める必要はありません。

⚠️ Scribe 側で子リレーション名を relationName('CustomOpportunities__r') で指定している場合は、setChildren の第 1 引数も同じ文字列を使ってください。リレーション操作の全体像 (多対多や Junction Object も含む) は 親項目・子サブクエリ・多対多 を参照。

次に読む