sf-fflib-testing

Covers every testing variant available in an Apex Enterprise Patterns (fflib) codebase - ApexMocks stub lifecycle (fflib_ApexMocks, startStubbing/when/thenReturn/stopStubbing, verify and verification

SKILL.md

GitHub

fflib Testing

Testing an fflib codebase means choosing, per class under test, between a mocked unit test (fast, proves collaboration, proves nothing about the org) and a real-DML test (slow, proves the platform actually behaves). Getting that choice wrong is the single largest source of worthless Apex test suites: 90% coverage where every selector's SOQL is unproven.

Framework grounding: fflib-apex-mocks @ master commit d81e9e1 and fflib-apex-common @ master commit dab5977 (both apex-enterprise-patterns). Platform grounding: Apex Developer Guide, Summer '26 / API version 67.0 (doc version 262.0) - the apiVersion in config/vibe-force.defaults.json.

Read sf-fflib-foundations for the Application/factory wiring these tests inject into, sf-fflib-selector-layer for selector construction, sf-fflib-domain-service-uow for the layers under test, and sf-fflib-operations for CI/packaging of the framework itself.

When to use

Use this skill when a repo contains fflib_ApexMocks, fflib_SObjectDomain, fflib_SObjectSelector, fflib_SObjectUnitOfWork, or an Application class, and you are:

  • writing a new test for a service, domain, selector, controller, trigger, or async job;
  • diagnosing an fflib_ApexMocks.ApexMocksException from verify(...) or when(...);
  • deciding whether a behaviour needs a mock or real DML;
  • raising coverage to clear the apex gate without adding tests that assert nothing.

For non-fflib Apex testing fundamentals (@IsTest, @TestSetup, System.runAs, Assert) see sf-apex-testing. For LWC component tests see sf-lwc-jest-testing. For limit budgets consumed by real-DML tests see sf-governor-limits.

Test-variant decision table

# Variant What it proves What it cannot prove Speed Org data fflib machinery Coverage contribution Typical failure mode
1 Service unit test with mocks Service orchestration: which selector/domain/UoW calls happen, in what shape SOQL validity, triggers, FLS, validation rules ~10-40 ms none fflib_ApexMocks, Application.*.setMock, fflib_Match Service impl class only Over-specified verify breaks on harmless refactor
2 Domain unit test, no DML, direct construction Pure field logic on in-memory records Trigger routing, save order ~5-20 ms none fflib_IDGenerator for ids; no mocks needed Domain class Asserting through the UoW mock instead of the record
3 Domain trigger test via fflib_SObjectDomain.Test.Database onApplyDefaults/onValidate/onBefore*/onAfter* routing and error registration Real DML errors, validation rules, roll-ups, chained triggers ~10-30 ms none fflib_SObjectDomain.Test.Database.onInsert/onUpdate/onDelete/onUndelete + fflib_SObjectDomain.triggerHandler(Type), fflib_SObjectDomain.Errors Domain + trigger handler Forgetting Errors.clearAll() between assertions in one method
4 Selector test against real data The SOQL compiles, field list is right, filters and ordering work Behaviour of callers 200 ms - 2 s inserted records Real selector, no mocks, fflib_QueryFactory output Selector class Mocking the selector - proves nothing
5 Service integration test with real DML End-to-end: service + domain + selector + UoW + triggers + validation Isolation of a single failure cause 0.5-5 s inserted records Application.UnitOfWork.newInstance(), no setMock Every layer it crosses Slow suite; used as the only test style
6 Trigger test with real DML Save order, recursion, cross-object roll-ups, validation rules Nothing about isolated units 0.5-3 s inserted records Real trigger + fflib_SObjectDomain.triggerHandler Trigger + domain Assumes @TestSetup data survives Test.startTest() limits reset
7 Controller / @AuraEnabled test Wire adapter contract: return shape, AuraHandledException messages Client rendering ~20-80 ms mocked none if service mocked Application.Service.setMock(IFooService.class, mock) Controller class Testing the service again through the controller
8 Flow-invocable test (@InvocableMethod) Bulk request/response contract and per-row mapping Flow itself ~20-80 ms none if service mocked Application.Service.setMock Invocable class Single-row-only test on a bulk API
9 Async test: Queueable / Schedulable The job enqueues, runs, and commits inside Test.startTest()/stopTest() Behaviour under real 2-minute chaining/limits 0.5-3 s usually yes Real UoW; mock the service the job calls where possible Job class Asserting before Test.stopTest()
10 Async test: Batchable start/execute/finish wiring, one chunk Multi-chunk state, real scope sizes 1-5 s yes Batchable classes cannot be stubbed (Stub API limit) - mock its collaborators instead Batch class Expecting finish after an unhandled execute exception in test context
11 Platform-event test Event publication and the subscriber trigger Real bus latency/replay 0.3-2 s sometimes uow.registerPublishAfterSuccessTransaction, then Test.getEventBus().deliver() Publisher + subscriber Missing deliver(), so the subscriber never runs
12 Callout test Request shape and response handling The remote system ~30-200 ms none Test.setMock(HttpCalloutMock.class, ...) inside the mocked service boundary Callout class Callout mock set after the callout is triggered
13 FLS / CRUD negative test USER_MODE DML and selector security actually reject Anything under system mode 0.5-3 s user + perm set fflib_SObjectUnitOfWork.UserModeDML, System.runAs, Assert.fail() + catch (SecurityException) Security paths Running as a System Administrator, so nothing is denied
14 LWC Jest test Client behaviour, wire handling, DOM Any Apex ~50-300 ms none none (see sf-lwc-jest-testing) JS coverage (jestCoverageMin) Duplicating Apex assertions in JS

Rows 1-3, 7, 8 are the fast tier; rows 4-6, 9-13 are the slow tier. A healthy fflib org is roughly 70% fast tier / 25% slow tier / 5% end-to-end, described under "Test pyramid" below.

Core patterns

1. The ApexMocks lifecycle

fflib_ApexMocks implements System.StubProvider; mocks.mock(Type) returns Test.createStub(classToMock, this). The stub is named <Type>__sfdc_ApexStub, which is what you see in failure messages.

@IsTest
private class OpportunitiesServiceTest {
    @IsTest
    static void applyDiscountRegistersDirtyOpportunities() {
        // 1. Arrange the mock control object and the stubs
        fflib_ApexMocks mocks = new fflib_ApexMocks();
        IOpportunitiesSelector selectorMock = (IOpportunitiesSelector) mocks.mock(IOpportunitiesSelector.class);
        fflib_ISObjectUnitOfWork uowMock = (fflib_ISObjectUnitOfWork) mocks.mock(fflib_ISObjectUnitOfWork.class);

        Opportunity opp = new Opportunity(
            Id = fflib_IDGenerator.generate(Opportunity.SObjectType),
            Name = 'Test',
            StageName = 'Open',
            CloseDate = System.today(),
            Amount = 1000);
        Set<Id> oppIds = new Set<Id>{ opp.Id };

        // 2. Stub: everything between startStubbing/stopStubbing records, it does not execute
        mocks.startStubbing();
        mocks.when(selectorMock.sObjectType()).thenReturn(Opportunity.SObjectType);
        mocks.when(selectorMock.selectByIdWithProducts(oppIds)).thenReturn(new List<Opportunity>{ opp });
        mocks.stopStubbing();

        // 3. Inject
        Application.Selector.setMock(selectorMock);
        Application.UnitOfWork.setMock(uowMock);

        // 4. Exercise the real implementation class, not the static facade
        System.Test.startTest();
        new OpportunitiesServiceImpl().applyDiscounts(new Map<Id, Decimal>{ opp.Id => 10 });
        System.Test.stopTest();

        // 5. Verify collaboration
        ((IOpportunitiesSelector) mocks.verify(selectorMock, 1)).selectByIdWithProducts(oppIds);
        ((fflib_ISObjectUnitOfWork) mocks.verify(uowMock, 1)).registerDirty(
            fflib_Match.sObjectWith(new Map<SObjectField, Object>{
                Opportunity.Id => opp.Id,
                Opportunity.Amount => 900 }));
        ((fflib_ISObjectUnitOfWork) mocks.verify(uowMock, 1)).commitWork();
    }
}

Rules that follow from the source:

  • Every when(...) must be inside startStubbing()/stopStubbing(). Outside it, the call is recorded as a real invocation and then verified against later, corrupting counts.
  • when(...) must be followed by thenReturn/thenThrow/thenAnswer/thenReturnMulti/ thenThrowMulti. A bare when(...) yields ApexMocksException: The stubbing is not correct, no return values have been set. at call time.
  • Unstubbed methods return null, they do not throw. A NullPointerException deep inside the class under test almost always means "you forgot to stub something".
  • mocks.mock() uses the Stub API, so the platform's stub limitations apply: no static or private methods, no properties, no triggers, no inner classes, no system types, no Batchable implementers, no classes with only private constructors, and iterators cannot be a parameter or return type. The mocked type must be in the same namespace as the Test.createStub() call. (Apex Developer Guide, Build a Mocking Framework with the Stub API)

2. Generated mocks vs Stub API mocks

Two modes exist. Only the first is current.

Mode How a mock is made Needs regeneration on interface change Use
Stub API (current) (IFoo) mocks.mock(IFoo.class) -> Test.createStub No Default for everything
ApexMocks Generator (legacy) Pre-generated FooMocks.Foo class implementing the interface and delegating to mocks.mockNonVoidMethod(...) / mocks.mockVoidMethod(...) Yes - stale generated mocks silently drift Only for types the Stub API refuses to stub

mockVoidMethod(Object, String, List<Type>, List<Object>) and mockNonVoidMethod(...) remain public on fflib_ApexMocks for generated classes. The generator itself is an external tool, not part of the fflib-apex-mocks repo.

fflib_ApexMocksConfig.HasIndependentMocks (default false) controls whether two mock instances of the same type share stubbing and counts. fflib_QualifiedMethod.equals/hashCode include the mock instance only when it is true. Set it to true in @IsTest setup when a single test holds two mocks of one interface and must distinguish them:

fflib_ApexMocksConfig.HasIndependentMocks = true;

3. Verification API

((IFoo) mocks.verify(fooMock)).bar();                                   // exactly 1
((IFoo) mocks.verify(fooMock, 3)).bar();                                // exactly 3
((IFoo) mocks.verify(fooMock, fflib_ApexMocks.NEVER)).bar();            // NEVER == 0
((IFoo) mocks.verify(fooMock, mocks.atLeastOnce())).bar();
((IFoo) mocks.verify(fooMock, mocks.atLeast(2))).bar();
((IFoo) mocks.verify(fooMock, mocks.atMost(5))).bar();
((IFoo) mocks.verify(fooMock, mocks.between(1, 3))).bar();
((IFoo) mocks.verify(fooMock, mocks.times(2).description('two writes expected'))).bar();

fflib_InOrder inOrder = mocks.inOrder(new List<Object>{ fooMock, uowMock });
((IFoo) inOrder.verify(fooMock)).bar();
((fflib_ISObjectUnitOfWork) inOrder.verify(uowMock)).commitWork();
inOrder.verifyNoMoreInteractions();

Verified against source: modes are times, calls, atLeast, atMost, atLeastOnce, between, never, plus description(String) for a custom assert message. calls(n) is InOrder-only (fflib_AnyOrder throws The calls() method is available only in the InOrder Verification.); atMost and between are not available in InOrder (The <mode> method is not implemented for the fflib_InOrder class). verifyNoMoreInteractions() and verifyNoInteractions() exist only on fflib_InOrder, not on fflib_ApexMocks; there is no verifyZeroInteractions. Use mocks.verify(mock, fflib_ApexMocks.NEVER) per method for the non-ordered equivalent.

Full API table: references/apexmocks-api.md.

4. Matchers, captors, answers

All-or-nothing rule: if one argument uses a matcher, every argument must. Mixing raw values with matchers throws The number of matchers defined (n). does not match the number expected (m).

((IFoo) mocks.verify(fooMock)).save(fflib_Match.anyId(), fflib_Match.eqString('OPEN'));

fflib_ArgumentCaptor captor = fflib_ArgumentCaptor.forClass(List<Account>.class);
((fflib_ISObjectUnitOfWork) mocks.verify(uowMock)).registerDirty((List<SObject>) captor.capture());
List<Account> registered = (List<Account>) captor.getValue();
Assert.areEqual(2, registered.size(), 'Both accounts should be registered dirty');

Dynamic returns and side effects use fflib_Answer; void-method throwing uses mocks.doThrowWhen(e, mock) inside stubbing. See references/matchers-and-captors.md for the complete matcher catalogue, each matcher's toString() (which is what the failure message prints) and custom fflib_IMatcher implementations.

5. Fake SObjects: ids, relationships, read-only fields

Account acct = new Account(Id = fflib_IDGenerator.generate(Account.SObjectType), Name = 'A');

// Parent with children, as if returned by a subquery
List<Account> withContacts = (List<Account>) fflib_ApexMocksUtils.makeRelationship(
    List<Account>.class,
    new List<Account>{ acct },
    Contact.AccountId,
    new List<List<Contact>>{ new List<Contact>{ new Contact(LastName = 'X') } });

// Formula / rollup / system audit fields
acct = (Account) fflib_ApexMocksUtils.setReadOnlyFields(
    acct, Account.class, new Map<SObjectField, Object>{ Account.LastActivityDate => Date.today() });

makeRelationship and setReadOnlyFields both work by serialising to JSON, injecting tokens, and deserialising - which is exactly why they can set fields SObject.put() rejects. Details and the raw JSON.deserialize fallback: references/fake-data-and-ids.md.

6. Mocking the Unit of Work

Mocking fflib_ISObjectUnitOfWork replaces "query the database and assert" with "assert the DML intent". It is fast and precise, and it is blind to:

  • triggers that would have fired on commitWork();
  • validation rules, required fields, and duplicate rules;
  • FLS/CRUD, because SimpleDML runs AccessLevel.SYSTEM_MODE and UserModeDML runs AccessLevel.USER_MODE - a mock runs neither;
  • the real commit order (publishBefore events -> insert -> upsert -> update -> delete -> emptyRecycleBin -> email -> doWork -> publishAfterSuccess events);
  • Database.SaveResult, which a mocked UoW never produces.

Version boundary that matters here: in API version 67.0 and later Apex runs in user context by default, so object permissions and FLS are enforced unless an operation explicitly opts into system mode; WITH SECURITY_ENFORCED is no longer allowed in Apex SOQL (use WITH USER_MODE), and a class without an explicit sharing declaration behaves as with sharing. fflib_SObjectUnitOfWork.SimpleDML passes AccessLevel.SYSTEM_MODE explicitly, so it still bypasses CRUD/FLS; UserModeDML passes AccessLevel.USER_MODE. Neither path executes under a mock. See sf-security-model.

Every assertion you would have made on saved data must be re-proved by at least one real-DML test (rows 5, 6, 13). State that explicitly in the test's header comment.

7. Domain trigger tests without DML

fflib_SObjectDomain.Test.Database feeds the trigger handler synthetic Trigger.new / Trigger.oldMap data. fflib_SObjectDomain.triggerHandler(Type) detects Test.isRunningTest() && Test.Database.hasRecords() and runs the before phase then the after phase against that data.

@IsTest
static void insertValidationFailsWithoutAccount() {
    Opportunity opp = new Opportunity(Name = 'Test', Type = 'Existing Account');
    fflib_SObjectDomain.Test.Database.onInsert(new List<Opportunity>{ opp });
    fflib_SObjectDomain.triggerHandler(OpportunitiesTriggerHandler.class);

    Assert.areEqual(1, fflib_SObjectDomain.Errors.getAll().size(), 'One validation error expected');
    Assert.areEqual(
        'You must provide an Account for existing Customers.',
        fflib_SObjectDomain.Errors.getAll()[0].message);
    Assert.areEqual(
        Opportunity.AccountId,
        ((fflib_SObjectDomain.FieldError) fflib_SObjectDomain.Errors.getAll()[0]).field);
}

Available: onInsert(List<SObject>), onUpdate(List<SObject>, Map<Id, SObject>), onDelete(Map<Id, SObject>), onUndelete(List<SObject>), hasRecords(). fflib_SObjectDomain.Errors exposes getAll() and clearAll().

This proves routing and error registration only. It does not prove the error surfaces as a DML error, that the trigger is deployed and active, or that the record was rejected. Pair it with one real-DML trigger test per validation that matters.

8. Where mocks are the wrong tool

Concern Why a mock cannot prove it Correct variant
Selector SOQL The query string is never parsed by the platform 4 - real records, real selector
Validation rules / required fields Metadata, evaluated only on real DML 5, 6
Sharing, CRUD, FLS Enforced by USER_MODE/WITH USER_MODE at runtime 13, under System.runAs
Save order, roll-up summaries, recursion Platform execution order 6
Flow / record-triggered automation Runs outside Apex 5 or 6
Platform-event subscription Requires the event bus 11

9. Test pyramid and coverage gates

Gates enforced by vf-check apex: apexOrgCoverageMin 85, apexClassCoverageMin 75. Mocked unit tests cover the class under test, never its mocked collaborators - so a suite of only mocked tests leaves selectors at 0%.

Layer Variant Share of test methods Covers
Domain / service logic 1, 2, 3, 7, 8 ~70% Domain, service impl, controllers, invocables
Selectors + integration + security 4, 5, 13 ~25% Selectors, triggers, UoW, security paths
Async, events, callouts 9, 10, 11, 12 ~5% Jobs, subscribers, HTTP boundaries

Per-class rule of thumb for the 75% class gate: every selector needs at least one real-data test, every domain needs a no-DML test, every service impl needs a mocked test plus one integration test across its happy path. See sf-apex-testing for the general coverage strategy and sf-code-analyzer-quality for the static rules that flag assertion-free tests.

Anti-patterns

Anti-pattern Failing shape Fix
Over-mocked test asserting implementation verify on ten methods including private helpers exposed only for the test Verify the boundary calls (selector in, UoW out) and assert record state
Mocking the class under test IFooService svc = (IFooService) mocks.mock(IFooService.class); svc.doIt(); then verifying doIt Instantiate the real FooServiceImpl; mock only its collaborators
Missing stopStubbing() Later real calls are swallowed as stubs; verify counts are 0 Always bracket: startStubbing() ... stopStubbing()
Bare when(...) with no thenReturn ApexMocksException: The stubbing is not correct, no return values have been set. Add a thenReturn/thenThrow/thenAnswer
Mixing matchers and literals The number of matchers defined (1). does not match the number expected (2) Wrap every argument: fflib_Match.eq(value)
Stale generated mocks after an interface change Generated mock compiles but silently no longer overrides the new method Migrate to mocks.mock(IFoo.class) (Stub API)
@IsTest(SeeAllData=true) Suite passes locally, fails in a fresh scratch org Build data in the test or @TestSetup
One @TestSetup shared by mocked and DML tests Mocked tests pay the DML cost and get non-deterministic ids Split into two test classes
Asserting Database.SaveResult off a mocked UoW A mocked UoW never executes DML, so results are null Assert registerNew/registerDirty interactions, or use a real-DML test
Test that passes with the framework bypassed Calls new FooServiceImpl() but never injects, so production Application wiring is unverified Route through Application.Service.newInstance(...) in at least one test
Coverage-padding tests new Foo(); Assert.isNotNull(foo); Delete them; they defend nothing and hide the real gap
System.assertEquals in new code Legacy assertion API without a message Use Assert.areEqual(expected, actual, message)

Verification

# Full Apex gate: runs the org test level configured for the target and enforces coverage gates
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-dev

# Single test method while iterating
sf apex run test --target-org vf-dev \
  --tests OpportunitiesServiceTest.applyDiscountRegistersDirtyOpportunities \
  --code-coverage --result-format json --wait 30

# A whole class, synchronously (fastest feedback for mocked tests)
sf apex run test --target-org vf-dev --class-names OpportunitiesServiceTest \
  --synchronous --code-coverage --result-format human --wait 20

# A curated suite (ApexTestSuite metadata) for the fast tier
sf apex run test --target-org vf-dev --suite-names FflibUnitTests \
  --code-coverage --result-format json --wait 30

# Local gate before any org round-trip
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local

Reading a failed mock verification - the message is produced by fflib_MethodVerifier.throwException:

EXPECTED COUNT: 1
ACTUAL COUNT: 0
METHOD: OpportunitiesSelector__sfdc_ApexStub.selectByIdWithProducts(Set<Id>)
---
ACTUAL ARGS: ({"..."} )
---
EXPECTED ARGS: [[SObject with fields {"Amount":900}]]

ACTUAL COUNT: 0 with a populated ACTUAL ARGS means the method ran with different arguments - compare the two blocks. ACTUAL ARGS: () means the method never ran at all: check injection (Application.*.setMock) before blaming the matcher. EXPECTED ARGS renders each matcher's toString(); raw values render as JSON.

References

  • references/apexmocks-api.md - every public class/method in fflib-apex-mocks with signature, purpose and version note.
  • references/matchers-and-captors.md - full fflib_Match / fflib_MatcherDefinitions catalogue, rendered failure text, fflib_ArgumentCaptor, custom matchers.
  • references/mock-injection.md - Application.*.setMock variants, mockable interface design, controller/domain/service injection, HasIndependentMocks.
  • references/test-variants-cookbook.md - one complete test class per decision-table row.
  • references/fake-data-and-ids.md - fflib_IDGenerator, makeRelationship, setReadOnlyFields, JSON tricks, dual-mode builders.
  • references/async-and-events-testing.md - Queueable/Batch/Schedulable, Test.startTest/stopTest flush, Test.getEventBus().deliver(), finalizers, callout mocks.
  • references/troubleshooting-mocks.md - exception text -> cause -> fix, plus a debugging procedure.
  • references/migrating-legacy-tests.md - converting DML-heavy suites to the fflib pyramid without losing coverage; run it as a multi-wave change per sf-workflow-orchestration.

Official documentation: