Detecting False Positives in Mock Tests: The SELECT-Omission Safety Net

•Apex Stem Docs
ApexApexEloquentTestingMockEntryAdvancedBest PracticesSOQL
A deep dive into how ApexEloquent structurally closes off the "passes in tests, fails in production" false positive through its coordination with Scribe, demonstrated across four cases: main object, parent, child, and aggregate alias.

Write fast, mock-based unit tests and sooner or later you hit the "false positive" problem: the tests pass, and production fails. Almost every time, the cause is a field someone forgot to put in the SOQL SELECT clause. ApexEloquent closes off this false positive structurally through its coordination with Scribe.

MockEntry: How Apex Test Data Construction Becomes Possible touches briefly on how MockEntry works. Here we dig into four practical cases of SELECT omission (main object / parent relation / child subquery / aggregate alias), and then look at three other mechanisms that close off the same "green only in tests" failure.

The Core of the False Positive Problem

Test data in Salesforce has traditionally been built with a TestDataFactory and DML inserts. Switching to mock-centred tests makes them dramatically faster, but it opens a different risk.

In production SOQL, only the fields you asked for in the SELECT clause carry values:

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

Access opp.Amount on that result and Salesforce throws System.SObjectException: SObject row was retrieved via SOQL without querying the requested field.

When a unit test injects a hand-built mock SObject, on the other hand, the in-memory object carries no information about which fields were selected, so you can put any value into any field and read any of them back out.

  • Production: accessing a field that is not in the SELECT fails immediately
  • Mock test: reading an unselected field raises no error, and the test passes

That is the anatomy of the "passes in tests, fails in production" kind of incident. Change the SOQL during a refactor and the accessing logic still compiles, the unit tests still pass, and the error only surfaces after the production deploy.

ApexEloquent checks mock access against the Scribe's SELECT clause (its FieldStructure) at injection time, and raises an exception during the unit test for any access to a field that is not in the SELECT. This is its distinctive feature: false positive detection for SELECT omissions.

Case 1: Detecting an Omission on the Main Object

Start with the simplest case. We write a Usecase that "returns the Name of the given Opportunity", but the developer forgot to add Name to the Scribe's SELECT clause.

The buggy Usecase class

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() {
        // ❌ Forgot to SELECT Name
        Scribe oppScribe = Scribe.of(Opportunity.class)
            .field('Id')
            .whereEqual('Id', this.oppId);
 
        IEntry oppEntry = this.eloquent.first(oppScribe);
 
        // This line throws (Name was not selected)
        return oppEntry.getName();
    }
}

A happy-path test fails because of the bug

This is an ordinary happy-path test that checks "the Opportunity's Name can be retrieved". But the bug on the Usecase side (Name missing from the SELECT) stops it before it reaches the Assert, and it fails with an ApexEloquentException.

APEX
@isTest
static void testInvoke_WhenOppExists_ThenReturnsName() {
    Trace t = Trace.of('Happy path: the Opportunity Name can be retrieved');
    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
    // * Because the Scribe omits Name from the SELECT, an ApexEloquentException
    //   is thrown inside invoke and the Assert below is never reached
    String name = usecase.invoke();
 
    // Assert
    Assert.areEqual('Acme Opportunity', name);
 
    t.finish();
}

Run it and the test fails with an ApexEloquentException before Assert.areEqual is reached. The message comes in a multi-line format like this:

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 tells you the field was not selected in the Scribe, Action gives the fix, and Provided shows at a glance which field (Name) on which object (Opportunity) tripped the check. The developer reads the failure, changes the query to something like Scribe.of(Opportunity.class).fields(new List<String>{'Id', 'Name'}), and the test passes.

getName() is validated the same way. Call entry.getName() against Scribe.of(Account.class).field('Id') (i.e. Name omitted) and it throws because Name is not in the SELECT. getId() (and get('Id')), on the other hand, never throw. Real SOQL returns Id regardless of the SELECT clause, so from v3.8.0+ / v2.6.0+ the mock also lets you read Id without declaring field('Id') (so the mock is never stricter than production).

Case 2: Detecting an Omission on a Parent Relation

Parent relations (parent fields joined via parentField) are validated in the same way. We write a Usecase that "returns the Name of the parent Account of the given Opportunity", but the developer forgot to add Name to the SELECT inside parentField.

The buggy Usecase class

APEX
public String invoke() {
    // ❌ Forgot to add Account.Name to the parentField SELECT
    Scribe oppScribe = Scribe.of(Opportunity.class)
        .field('Id')
        .parentField(
            Scribe.asParent('AccountId').field('Id')  // Name is missing
        )
        .whereEqual('Id', this.oppId);
 
    IEntry oppEntry = this.eloquent.first(oppScribe);
    IEntry accountEntry = oppEntry.getParent('AccountId');
 
    // This line throws (Account.Name was not selected)
    return accountEntry.getName();
}

A happy-path test fails because of the bug

This happy-path test checks that "the parent Account's Name can be retrieved". But the bug on the Usecase side (Account.Name missing from the SELECT) stops it before the Assert, and it fails with an ApexEloquentException.

APEX
@isTest
static void testInvoke_WhenOppExists_ThenReturnsParentAccountName() {
    Trace t = Trace.of('Happy path: the Name of the Opportunity\'s parent Account can be retrieved');
    t.start();
 
    // Arrange: set a value for Account.Name on the mock
    //          (the mock has the value even though Name is not in the Scribe's SELECT)
    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
    // * Because the Scribe's parentField omits Account.Name from the SELECT,
    //   an ApexEloquentException is thrown inside invoke and the Assert below is never reached
    String accountName = usecase.invoke();
 
    // Assert
    Assert.areEqual('Acme Corporation', accountName);
 
    t.finish();
}

The point to notice is that the mock does have a value set for Account.Name. An ordinary mock would happily hand it back: the value is there, so of course you can read it. ApexEloquent instead throws, because Name is not in the Scribe's SELECT clause. The message looks like this:

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']

The objectName in Provided points to the parent SObject (Account), so you can tell precisely that the omission is in the parent relation.

⚠️ Go one step further and try to fetch, via getParent('SomeId'), a parent field that was never declared as a relation with Scribe.asParent('SomeId'), and it throws for a different reason, with a slightly different validation message:

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']

The very act of reaching for a parent relation that the Scribe never declared with asParent is detected the moment it happens.

Case 3: Detecting an Omission in a Child Subquery

Child subqueries (withChildren) work the same way. Here the developer forgot to SELECT Name on the Contract records under an Account.

We write a Usecase that "returns the Names of the Contract records under the given Account", but the developer forgot to add Name to the SELECT inside withChildren.

The buggy Usecase class

APEX
public List<String> invoke() {
    Scribe accountScribe = Scribe.of(Account.class)
        .field('Id')
        .withChildren(
            Scribe.asChild(Contract.class).field('Id')  // ❌ Name is missing
        )
        .whereEqual('Id', this.accountId);
 
    IEntry accountEntry = this.eloquent.first(accountScribe);
    List<IEntry> contracts = accountEntry.getChildren('Contract');
 
    List<String> contractNames = new List<String>();
    for(IEntry contract : contracts) {
        // This line throws (Contract.Name was not selected)
        contractNames.add((String) contract.get('Name'));
    }
    return contractNames;
}

A happy-path test fails because of the bug

This happy-path test checks that "the Names of the Contract records under an Account can be retrieved". As in Case 2, the mock does have values set for Contract.Name, yet it fails with an ApexEloquentException because Name is not in the Scribe's SELECT clause.

APEX
@isTest
static void testInvoke_WhenAccountHasContracts_ThenReturnsContractNames() {
    Trace t = Trace.of('Happy path: the Names of the Contracts under the Account can be retrieved');
    t.start();
 
    // Arrange: set values for Contract.Name on the mock
    MockEntry accountEntry = MockEntry.of(Account.class)
        .alias('acc')
        .autoId(1)
        .setChildren('Contract', 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
    // * Because the Scribe's withChildren omits Contract.Name from the SELECT,
    //   an ApexEloquentException is thrown inside invoke and the Asserts below are never reached
    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();
}

The message in this case looks like this:

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']

The mechanism that checks, at test time, whether the shape of the mock matches the shape of the Scribe works identically in both directions, parent and child. If you later add logic that reads Name and forget to add .field('Name') to the Scribe at the same time, this test fails immediately and you find out.

Case 4: Detecting a Missing Alias in an Aggregate Query

Aggregate queries (COUNT / SUM / AVG / GROUP BY and so on) return AggregateResults, which you access by the alias of each selected aggregate function. Getting the alias wrong, or reading under a different alias, is a common bug.

We write a Usecase that "returns the total amount per Stage as a Map", but the developer forgot to write sum('Amount', 'totalAmount') in the Scribe. The query still selects a different aggregate (average), while the reading side refers to totalAmount.

The buggy Usecase class

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')    // ❌ The intent was sum as totalAmount, but only average is written
            .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');
            // This line throws (the Scribe never selected totalAmount)
            Decimal total = (Decimal) result.get('totalAmount');
            stageToTotal.put(stage, total);
        }
        return stageToTotal;
    }
}

A happy-path test fails because of the bug

This happy-path test checks that "the total amount per Stage comes back as a Map". The mock does have a value set for totalAmount, yet because totalAmount (the alias) is not in the Scribe's SELECT clause, result.get('totalAmount') throws.

APEX
@isTest
static void testInvoke_WhenOppsExist_ThenReturnsStageToTotal() {
    Trace t = Trace.of('Happy path: the total amount per Stage is returned as a Map');
    t.start();
 
    // Arrange: set totalAmount on the mock (it is not in the Scribe's SELECT)
    IEloquent mockEloquent = new MockEloquent(
        MockEntry.asAggregateResult()
            .set('StageName', 'Prospecting')
            .set('totalAmount', 10000)
    );
    AggregateOppByStageUsecase usecase = new AggregateOppByStageUsecase(mockEloquent);
 
    // Act
    // * Because the Scribe never selects the totalAmount alias, an ApexEloquentException
    //   is thrown inside invoke and the Assert below is never reached
    Map<String, Decimal> stageToTotal = usecase.invoke();
 
    // Assert
    Assert.areEqual(10000, stageToTotal.get('Prospecting'));
 
    t.finish();
}

Run it and an ApexEloquentException is thrown with a multi-line message like this:

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 shows which field or alias (totalAmount) was used for the access, so "does the alias declared in the Scribe match the name used on the reading side?" is checked structurally at test time.

Two mechanisms combine for aggregate queries:

  • ApexEloquent's Scribe makes you write aggregates with a mandatory alias, as in count('Id', 'eventCount') (avoiding Salesforce's standard expr0 trap)
  • Access is only possible through that alias, and a typo throws immediately

The combination of "alias required" and "typo detection" structurally prevents both aggregate-query traps (mysterious access through expr0 / reading under a different name and getting null).

Three Safety Nets Beyond SELECT Omission

All four cases so far were about "checking access against the Scribe's SELECT clause". There are three more mechanisms that close off the same "green only in tests" failure.

Forgetting to feed the mock throws

In a test that uses labels, if you fetch with label('X') but forget to write attach('X', ...), or mistype the label name, that query returns zero rows. The code then takes the "nothing to do, skip" branch, and the test goes green while verifying nothing.

Today, calling get / first / firstOrFail under a label with nothing attached throws (the error lists the labels that are attached).

When you do mean to test the zero-row path, attach an empty list explicitly to declare that intent.

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

The heart of this design is that "nothing attached" and "empty attached" are treated as different states.

Entries passed directly can carry the contract too

The four cases above were about entries returned through MockEloquent. They pass through a Scribe, so the contract comes attached automatically.

Entries handed directly to the SUT, however, carry no contract. The typical case is a batch: the records in execute(bc, scope) never go through IEloquent. In production the platform checks them as the results of a real query, but in a test they are entries you built yourself, so nothing checks them.

fetchedBy(scribe) retrofits the contract onto them.

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

Extract the query construction into a @TestVisible method and the test can pass in exactly the same Scribe as production. See API Reference: Scribe for details.

A query that forgot to filter is caught by verifying its shape

SELECT-omission detection looks at "which fields were fetched". The other axis, "which rows are fetched" (the WHERE clause), is something MockEloquent never evaluates in the first place. It returns whatever you attached, so a query that forgot to filter by the Ids the Usecase received still gets exactly the right data back in the test, and goes green. Only production touches every matching record in the org, and no amount of polishing the mock data will expose it.

From v3.11.0+ / v2.9.0+, MockEloquent records the queries it was given, and you can pull one out with scribeAt(label) and verify its conditions.

APEX
MockEloquent mock = (new MockEloquent()).attach(MyUsecase.LBL_FETCH, contacts);
(new MyUsecase(contactIds, mock)).invoke();
 
mock.scribeAt(MyUsecase.LBL_FETCH)
  .assertCondition(new InConditionClause('Id', contactIds));

In addition, from v3.12.0+ / v2.10.0+, field names in the WHERE clause are also checked against the describe at build time. Before that, a misspelled field name was emitted as a quoted literal, and since MockEloquent never runs the SOQL, unit tests stayed green all the way to production.

See "Verifying the query shape" in API Reference: MockEloquent for details.

Where It Pays Off

SELECT-omission false positive detection pays off less in the first tests of freshly written code than when a codebase grows and changes.

  • A safety net for refactoring: change a Usecase's SOQL (drop or reorder fields) and leave out a field the accessing logic still uses, and the tests that pass through that access fail immediately
  • When a new feature forgets to extend the SELECT: you add a new field reference to the accessing logic but forget to update the SOQL at the same time, and the tests that pass through that logic fail
  • Less cognitive load in team development: nobody has to check "does this SOQL still agree with this logic?" every time; the tests guarantee it structurally

The "passes in tests, fails in production" kind of incident wrecks the development experience in many cases: after a refactor, after a feature addition, after a code review has already approved it. ApexEloquent closes it off structurally by checking mocks against the Scribe at injection time.

Summary

False positives from SELECT omissions tend to become hidden bugs that only surface in production, but ApexEloquent knocks them out structurally at test time.

What is checkedBug detectedException reason
Main object fieldsentry.get('B') with only Scribe.of(...).field('A')The specified field is not selected in Scribe
Parent relation (field omitted)SELECT omission inside parentFieldThe specified field is not selected in Scribe
Parent relation (no asParent at all)getParent on a parent field the Scribe never declared with asParentThe specified parentIdFieldName is not defined as a relationship in Scribe
Child subquerySELECT omission inside withChildrenThe specified field is not selected in Scribe (with the child SObject name)
Aggregate querySelected via average('Amount', 'avgAmount'), read under a different aliasThe specified field or alias is not exist in Scribe
Mock not fedFetching with label('X') but forgetting attach('X', ...) / a mistyped label namelabel 'X' has no attached entries
Entries that bypass IEloquentSELECT omission on entries handed straight to the SUT, e.g. a batch scope (contract added with fetchedBy(scribe))The specified field is not selected in Scribe
Query not filteredWHERE not narrowed by the received Ids (verified with scribeAt(...).assertCondition(...))System.AssertException (assertion failure)

DB-less tests that still strictly guarantee the SOQL and the logic agree. That is the core of ApexEloquent's testing philosophy.

Other OSS libraries can build mocks too, but a safety net that checks the mock's contents against the Scribe's SELECT clause is a design unique to ApexEloquent.