モックテストの偽陽性を検知する: SELECT 漏れの安全網

Apex Stem ドキュメント
ApexApexEloquentTestingMockEntryAdvancedBest PracticesSOQL
「テストでは通って本番で落ちる」という偽陽性問題を、ApexEloquent が Scribe との連携でどう構造的に塞いでいるかを、主オブジェクト / 親 / 子 / 集計エイリアスの 4 ケースで実証する Deep Dive。

モックを使った高速な単体テストを書くと、テストは通るのに本番で落ちるという「偽陽性」問題に出くわすことがあります。原因はほとんどの場合「SOQL の SELECT 句に書き忘れたフィールド」です。ApexEloquent は Scribe との連携でこの偽陽性を構造的に塞いでいます。

MockEntry の仕組みの概要は MockEntry: Apex のテストデータ作成を成立させる仕組み で軽く触れています。ここでは SELECT 漏れの 4 つの実用ケース (主オブジェクト / 親リレーション / 子サブクエリ / 集計エイリアス) を深掘りしたうえで、同じ「テストだけ緑になる」を塞ぐ別の 2 つの仕組みにも触れます。

偽陽性問題の核心

Salesforce のテストデータ作成は伝統的に TestDataFactory + DML insert で行われてきました。ここからモック中心のテストに乗り換えると、テストが圧倒的に速くなる代わりに、もう 1 つ別のリスクが生まれます。

本番の SOQL は次のように、SELECT 句で取得すると指定したフィールドだけが値を持ちます。

SOQL
SELECT Id, StageName FROM Opportunity WHERE Id = :oppId

このクエリの結果に対して opp.Amount でアクセスすると、Salesforce は System.SObjectException: SObject row was retrieved via SOQL without querying the requested field を投げます。

一方、単体テストで自作モックの SObject を注入する場合、メモリ上のオブジェクトには どのフィールドを選択した結果かという情報自体を持っていない ので、すべてのフィールドへ自由に値を入れられるし、取り出すこともできてしまいます。

  • 本番: SELECT に無いフィールドへのアクセスで即エラー
  • モックテスト: 未選択フィールドを取り出してもエラーは出ない、テストが通る

これが「テストでは通って本番で落ちる」タイプの障害の構造です。リファクタリングで SOQL を変更したときも、アクセス側のロジックは表面的にはコンパイルが通り、単体テストもパスする → 本番デプロイ後にエラーで発覚、という流れになります。

ApexEloquent は モック注入時に Scribe の SELECT 句 (FieldStructure) と照合し、SELECT に無いフィールドへのアクセスを単体テストの段階で例外として検出します。これが「SELECT 漏れの偽陽性検知」という独自機能です。

ケース 1: 主オブジェクトのフィールド漏れを検知

最もシンプルなケースから。「指定された Opportunity の Name を返す」Usecase を書きますが、開発者は Scribe の SELECT 句に Name を追加し忘れています。

バグ入り Usecase クラス

APEX
public with sharing class GetOppNameUsecase {
    private final Id oppId;
    private final IEloquent eloquent;
 
    public GetOppNameUsecase(Id oppId, IEloquent eloquent) {
        this.oppId = oppId;
        this.eloquent = eloquent ?? new Eloquent();
    }
 
    public String invoke() {
        // ❌ Name を SELECT し忘れた
        Scribe oppScribe = Scribe.of(Opportunity.class)
            .field('Id')
            .whereEqual('Id', this.oppId);
 
        IEntry oppEntry = this.eloquent.first(oppScribe);
 
        // この行で例外が出る (Name は SELECT されていない)
        return oppEntry.getName();
    }
}

正常系を期待するテストが、バグで落ちる

このテストは「Opportunity の Name が取得できる」ことを確認しようとする、ごく普通の正常系テストです。ところが Usecase 側のバグ (Name の SELECT 漏れ) によって Assert まで到達できず、ApexEloquentException で落ちます。

APEX
@isTest
static void testInvoke_WhenOppExists_ThenReturnsName() {
    Trace t = Trace.of('正常系: Opportunity の Name が取得できること');
    t.start();
 
    // Arrange
    MockEntry oppEntry = MockEntry.of(Opportunity.class)
        .alias('opp')
        .autoId(1)
        .set('Name', 'Acme Opportunity');
    IEloquent mockEloquent = new MockEloquent(oppEntry);
    GetOppNameUsecase usecase = new GetOppNameUsecase(oppEntry.getAliasId('opp'), mockEloquent);
 
    // Act
    // ※ Scribe で Name を SELECT 漏れしているため、invoke 内部で
    //   ApexEloquentException が投げられ、以降の Assert には到達しない
    String name = usecase.invoke();
 
    // Assert
    Assert.areEqual('Acme Opportunity', name);
 
    t.finish();
}

このテストを実行すると、Assert.areEqual には到達せずに ApexEloquentException が投げられて失敗します。例外メッセージは次のような複数行のフォーマットで出ます。

CODE
====== APEX ELOQUENT EXCEPTION ======
Location: MockEntry
Error   : The `fieldName` argument is Invalid.
Reason  : The specified field is not selected in Scribe
Action  : Add the field to the Scribe definition (e.g. `.field('Name')`) or call .withoutFieldValidation() when intentional
Provided: [fieldName => 'Name'][objectName => 'Opportunity']

Reason を見れば「Scribe で SELECT していない」ことが分かり、Action を見れば修正案が、Provided を見れば「どのフィールド (Name) を、どのオブジェクト (Opportunity) で取ろうとして引っかかったか」が一目で分かります。開発者は失敗メッセージを見て、Scribe.of(Opportunity.class).fields(new List<String>{'Id', 'Name'}) のように修正してテストを通します。

getId() / getName() も同じく検証対象です。例えば Scribe.of(Account.class).field('Name') (= Id を SELECT 漏れ) で entry.getId() を呼ぶと、Id が SELECT 句に無いとして例外が投げられます。

ケース 2: 親リレーションのフィールド漏れを検知

親リレーション (parentField でつなぐ親項目) も同じく検証されます。「指定された Opportunity の親 Account の Name を返す」Usecase を書きますが、開発者は parentField の中で Name を SELECT 句に追加し忘れています。

バグ入り Usecase クラス

APEX
public String invoke() {
    // ❌ Account.Name を parentField の SELECT 句に追加し忘れた
    Scribe oppScribe = Scribe.of(Opportunity.class)
        .field('Id')
        .parentField(
            Scribe.asParent('AccountId').field('Id')  // Name が抜けている
        )
        .whereEqual('Id', this.oppId);
 
    IEntry oppEntry = this.eloquent.first(oppScribe);
    IEntry accountEntry = oppEntry.getParent('AccountId');
 
    // この行で例外 (Account.Name は SELECT されていない)
    return accountEntry.getName();
}

正常系を期待するテストが、バグで落ちる

このテストは「親 Account の Name が取得できる」ことを確認しようとする正常系テストです。ところが Usecase 側のバグ (Account.Name の SELECT 漏れ) で Assert まで到達せずに ApexEloquentException で落ちます。

APEX
@isTest
static void testInvoke_WhenOppExists_ThenReturnsParentAccountName() {
    Trace t = Trace.of('正常系: Opportunity の親 Account の Name が取得できること');
    t.start();
 
    // Arrange: モック側には Account.Name の値をセットしておく
    //          (Scribe の SELECT に Name が含まれていなくても、モックには値がある状態)
    MockEntry oppEntry = MockEntry.of(Opportunity.class)
        .alias('opp')
        .autoId(1)
        .setParent('AccountId',
            MockEntry.of(Account.class)
                .autoId(1)
                .set('Name', 'Acme Corporation')
        );
    IEloquent mockEloquent = new MockEloquent(oppEntry);
    GetParentAccountNameUsecase usecase =
        new GetParentAccountNameUsecase(oppEntry.getAliasId('opp'), mockEloquent);
 
    // Act
    // ※ Scribe の parentField で Account.Name を SELECT 漏れしているため、
    //   invoke 内部で ApexEloquentException が投げられ、以降の Assert には到達しない
    String accountName = usecase.invoke();
 
    // Assert
    Assert.areEqual('Acme Corporation', accountName);
 
    t.finish();
}

ここで注目すべきは、モック側では Account.Name の値をちゃんとセットしている ことです。普通のモックなら「値があるから取り出せて当然」と動きます。しかし ApexEloquent では Scribe の SELECT 句に Name が無いので例外 が投げられます。例外メッセージは次のような形です。

CODE
====== APEX ELOQUENT EXCEPTION ======
Location: MockEntry
Error   : The `fieldName` argument is Invalid.
Reason  : The specified field is not selected in Scribe
Action  : Add the field to the Scribe definition (e.g. `.field('Name')`) or call .withoutFieldValidation() when intentional
Provided: [fieldName => 'Name'][objectName => 'Account']

ProvidedobjectName が親側の SObject (Account) を指していて、SELECT 漏れの場所が親リレーションだとピンポイントで分かります。

⚠️ さらに踏み込んで、Scribe.asParent('SomeId')関連として宣言していない親項目getParent('SomeId') で取り出そうとすると、別の reason で例外が投げられます。こちらは validation メッセージが少し違います。

CODE
====== APEX ELOQUENT EXCEPTION ======
Location: MockEntry.getParent(String parentIdFieldName)
Error   : The `parentIdFieldName` argument is Invalid.
Reason  : The specified parentIdFieldName is not defined as a relationship in Scribe. SObject: Opportunity
Action  : Ensure that the parentIdFieldName 'AccountId' is included as a relationship in the Scribe definition for this object.
Provided: [parentIdFieldName => 'AccountId']

ScribeasParent していない親リレーションを取りに行こうとした」こと自体を、アクセスした瞬間に検出します。

ケース 3: 子サブクエリのフィールド漏れを検知

子サブクエリ (withChildren) でも同じです。Account 配下の Contract (契約) の Name を SELECT し忘れたケース。

「指定された Account 配下の Contract の Name 一覧を返す」Usecase を書きますが、開発者は withChildren の中で Name を SELECT 句に追加し忘れています。

バグ入り Usecase クラス

APEX
public List<String> invoke() {
    Scribe accountScribe = Scribe.of(Account.class)
        .field('Id')
        .withChildren(
            Scribe.asChild(Contract.class).field('Id')  // ❌ Name が抜けている
        )
        .whereEqual('Id', this.accountId);
 
    IEntry accountEntry = this.eloquent.first(accountScribe);
    List<IEntry> contracts = accountEntry.getChildren('Contracts');
 
    List<String> contractNames = new List<String>();
    for(IEntry contract : contracts) {
        // この行で例外 (Contract.Name は SELECT されていない)
        contractNames.add((String) contract.get('Name'));
    }
    return contractNames;
}

正常系を期待するテストが、バグで落ちる

このテストは「Account 配下の Contract の Name 一覧が取得できる」ことを確認する正常系テストです。ケース 2 と同様に、モック側では Contract.Name の値をちゃんとセットしている にも関わらず、Scribe の SELECT 句に Name が含まれていないため ApexEloquentException で落ちます。

APEX
@isTest
static void testInvoke_WhenAccountHasContracts_ThenReturnsContractNames() {
    Trace t = Trace.of('正常系: Account 配下の Contract の Name 一覧が取得できること');
    t.start();
 
    // Arrange: モック側には Contract.Name の値をセットしておく
    MockEntry accountEntry = MockEntry.of(Account.class)
        .alias('acc')
        .autoId(1)
        .setChildren('Contracts', new List<MockEntry>{
            MockEntry.of(Contract.class)
                .autoId(1)
                .set('Name', 'Contract 1'),
            MockEntry.of(Contract.class)
                .autoId(2)
                .set('Name', 'Contract 2')
        });
    IEloquent mockEloquent = new MockEloquent(accountEntry);
    GetAccountContractNamesUsecase usecase =
        new GetAccountContractNamesUsecase(accountEntry.getAliasId('acc'), mockEloquent);
 
    // Act
    // ※ Scribe の withChildren で Contract.Name を SELECT 漏れしているため、
    //   invoke 内部で ApexEloquentException が投げられ、以降の Assert には到達しない
    List<String> names = usecase.invoke();
 
    // Assert
    Assert.areEqual(2, names.size());
    Assert.areEqual('Contract 1', names[0]);
    Assert.areEqual('Contract 2', names[1]);
 
    t.finish();
}

この場合の例外メッセージは次のような形です。

CODE
====== APEX ELOQUENT EXCEPTION ======
Location: MockEntry
Error   : The `fieldName` argument is Invalid.
Reason  : The specified field is not selected in Scribe
Action  : Add the field to the Scribe definition (e.g. `.field('Name')`) or call .withoutFieldValidation() when intentional
Provided: [fieldName => 'Name'][objectName => 'Contract']

モックの設計と Scribe の設計が一致しているか」をテスト段階で照合する仕組みが、親 / 子の両方向で同じく働きます。もし Name の値を取り出すロジックを後から追加したとき、同時に Scribe.field('Name') を加えるのを忘れていれば、即座にこのテストが落ちて気付けます。

ケース 4: 集計クエリのエイリアス漏れを検知

集計クエリ (COUNT / SUM / AVG / GROUP BY 等) では、結果は AggregateResult として返り、SELECT した集計関数の エイリアス名 でアクセスします。エイリアスを間違える / 別のエイリアスでアクセスする、もよく起きるバグです。

「Stage ごとの合計金額を Map で返す」Usecase を書きますが、開発者は Scribesum('Amount', 'totalAmount') を書き忘れ、別の集計関数 (average) の結果を SELECT した状態のままアクセス側で totalAmount を参照しています。

バグ入り Usecase クラス

APEX
public with sharing class AggregateOppByStageUsecase {
    private final IEloquent eloquent;
 
    public AggregateOppByStageUsecase(IEloquent eloquent) {
        this.eloquent = eloquent ?? new Eloquent();
    }
 
    public Map<String, Decimal> invoke() {
        Scribe analyticsScribe = Scribe.of(Opportunity.class)
            .field('StageName')
            .average('Amount', 'avgAmount')    // ❌ 本当は sum を totalAmount で取りたいのに、average しか書いていない
            .groupByField('StageName');
 
        List<IEntry> results = this.eloquent.get(analyticsScribe);
 
        Map<String, Decimal> stageToTotal = new Map<String, Decimal>();
        for(IEntry result : results) {
            String stage = (String) result.get('StageName');
            // この行で例外 (Scribe では totalAmount を SELECT していない)
            Decimal total = (Decimal) result.get('totalAmount');
            stageToTotal.put(stage, total);
        }
        return stageToTotal;
    }
}

正常系を期待するテストが、バグで落ちる

このテストは「Stage ごとの合計金額が Map で返る」ことを確認する正常系テストです。モック側では totalAmount の値をちゃんとセットしているにも関わらず、Scribe の SELECT 句に totalAmount (エイリアス) が含まれていないため、result.get('totalAmount') で例外が投げられます。

APEX
@isTest
static void testInvoke_WhenOppsExist_ThenReturnsStageToTotal() {
    Trace t = Trace.of('正常系: Stage ごとの合計金額が Map で返ること');
    t.start();
 
    // Arrange: モック側には totalAmount をセット (Scribe の SELECT には含まれていない)
    IEloquent mockEloquent = new MockEloquent(
        MockEntry.asAggregateResult()
            .set('StageName', 'Prospecting')
            .set('totalAmount', 10000)
    );
    AggregateOppByStageUsecase usecase = new AggregateOppByStageUsecase(mockEloquent);
 
    // Act
    // ※ Scribe で totalAmount エイリアスを SELECT していないため、
    //   invoke 内部で ApexEloquentException が投げられ、以降の Assert には到達しない
    Map<String, Decimal> stageToTotal = usecase.invoke();
 
    // Assert
    Assert.areEqual(10000, stageToTotal.get('Prospecting'));
 
    t.finish();
}

実行すると ApexEloquentException が投げられて、次のような複数行のメッセージが出ます。

CODE
====== APEX ELOQUENT EXCEPTION ======
Location: MockEntry.get(String fieldName)
Error   : The `fieldName` argument is Invalid.
Reason  : The specified field or alias is not exist in Scribe.
Action  : Add the field or alias to the Scribe definition for this AggregateResult
Provided: [fieldName => 'totalAmount']

Provided に「どのフィールド / エイリアス (totalAmount) でアクセスしたか」が出ているので、「Scribe 側のエイリアス宣言とアクセス側の参照名が一致しているか」を、テスト実行時に構造的に照合する仕組みになっています。

集計クエリでは 2 つの仕組みが組み合わさっています。

  • ApexEloquent の Scribe は集計関数を count('Id', 'eventCount') のように エイリアス必須 で書かせる (Salesforce 標準の expr0 罠を回避)
  • そのエイリアスでしかアクセスできない、タイポしたら即例外

「エイリアス必須」と「タイポ検出」の組み合わせで、集計クエリの両方の罠 (expr0 で謎にアクセス / 別名でアクセスして null) を構造的に防いでいます。

SELECT 漏れ以外の 2 つの安全網

ここまでの 4 ケースは、いずれも「Scribe の SELECT 句とアクセスの照合」でした。同じ「テストだけ緑になる」を塞ぐ仕組みが、ほかに 2 つあります。

モックにデータを差し込み忘れると例外になる

ラベルを使ったテストで、label('X') で取得しているのに attach('X', ...) を書き忘れる、あるいはラベル名を打ち間違えると、そのクエリは 0 件を返します。すると「対象が無いのでスキップ」の分岐に入り、何も検証していないのにテストが緑になります

現在は、attach していないラベルで get / first / firstOrFail を呼ぶと例外になります (エラーには attach 済みのラベル一覧が付きます)。

「0 件の経路」を意図してテストしたいときは、空リストを明示的に attach して意図を宣言します。

APEX
MockEloquent mock = (new MockEloquent())
  .attach(MyUsecase.LBL_FETCH, new List<IEntry>());

「attach していない」と「空を attach した」は別の状態として扱われる、というのがこの設計の要点です。

直接渡すエントリにも契約を焼き付けられる

上の 4 ケースは、MockEloquent 経由で返すエントリの話でした。Scribe を通るので契約が自動的に付きます。

一方、エントリを SUT に直接渡す場合は契約がありません。典型はバッチで、execute(bc, scope) のレコードは IEloquent を通らないため、本番では実クエリの結果としてプラットフォームが検査してくれますが、テストでは自前で組んだエントリなので無検査になります。

fetchedBy(scribe) は、そこに契約を後付けします。

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

クエリの組み立てを @TestVisible なメソッドに切り出しておけば、本番とまったく同じ Scribe をテストから渡せます。詳細は API リファレンス: Scribe を参照してください。

どんな場面で効くか

「SELECT 漏れの偽陽性検知」は、単発で書いたコードの初期テストよりも、コードベースが育って変化するときに効きます

  • リファクタリングの安全網: ある Usecase の SOQL を変更 (フィールドを削る / 並べ替える) したとき、アクセス側のロジックでまだ使われているフィールドを SELECT 漏れさせれば、そのアクセスを通るテストが即座に落ちる
  • 新機能追加で SELECT を継ぎ足し忘れた時: アクセス側のロジックに新しいフィールド参照を加えたが、同時に SOQL を更新し忘れた → そのロジックを通るテストが落ちる
  • チーム開発での認知負荷削減: 「この SOQL とこのロジックは整合しているか」を都度確認しなくても、テストが構造的に保証する

「テスト通っても本番で落ちる」タイプの障害は、リファクタリング後 / 機能追加後 / コードレビューを通った後、多くのケースで開発体験を崩します。ApexEloquent はこれを モック注入時の Scribe との照合 で構造的に塞いでいます。

まとめ

SELECT 漏れの偽陽性は本番でしか発覚しない隠れたバグになりがちですが、ApexEloquent ではテスト段階で構造的に叩き出せます。

検証対象検出されるバグ例外メッセージの reason
主オブジェクトのフィールドScribe.of(...).field('A') のみで entry.get('B')The specified field is not selected in Scribe
親リレーション (フィールド漏れ)parentField の中で SELECT 漏れThe specified field is not selected in Scribe
親リレーション (asParent 自体なし)ScribeasParent していない親項目を getParentThe specified parentIdFieldName is not defined as a relationship in Scribe
子サブクエリwithChildren で SELECT 漏れThe specified field is not selected in Scribe (子の SObject 名で)
集計クエリaverage('Amount', 'avgAmount') で SELECT、別エイリアスでアクセスThe specified field or alias is not exist in Scribe
モックの差し込み漏れlabel('X') で取得するのに attach('X', ...) を書き忘れ / ラベル名の打ち間違いlabel 'X' has no attached entries

DB なしテストでありながら、SOQL とロジックの整合性を厳密に保証する。これが ApexEloquent のテスト哲学の核です。

モック作成だけなら他の OSS にもありますが、「モックの中身と Scribe の SELECT 句を照合する」安全網 は ApexEloquent 独自の設計です。

関連ドキュメント