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.ApexMocksExceptionfromverify(...)orwhen(...); - deciding whether a behaviour needs a mock or real DML;
- raising coverage to clear the
apexgate 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 insidestartStubbing()/stopStubbing(). Outside it, the call is recorded as a real invocation and then verified against later, corrupting counts. when(...)must be followed bythenReturn/thenThrow/thenAnswer/thenReturnMulti/thenThrowMulti. A barewhen(...)yieldsApexMocksException: The stubbing is not correct, no return values have been set.at call time.- Unstubbed methods return
null, they do not throw. ANullPointerExceptiondeep 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, noBatchableimplementers, 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 theTest.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
SimpleDMLrunsAccessLevel.SYSTEM_MODEandUserModeDMLrunsAccessLevel.USER_MODE- a mock runs neither; - the real commit order (
publishBeforeevents -> insert -> upsert -> update -> delete -> emptyRecycleBin -> email ->doWork->publishAfterSuccessevents); 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 infflib-apex-mockswith signature, purpose and version note.references/matchers-and-captors.md- fullfflib_Match/fflib_MatcherDefinitionscatalogue, rendered failure text,fflib_ArgumentCaptor, custom matchers.references/mock-injection.md-Application.*.setMockvariants, 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/stopTestflush,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 persf-workflow-orchestration.
Official documentation:
- Build a Mocking Framework with the Stub API
- Using Limits, startTest, and stopTest
- Using Test Setup Methods
- Using the runAs Method
- Testing HTTP Callouts by Implementing the HttpCalloutMock Interface
- Apex Security and Sharing Model - Versioned Behavior Changes
- fflib-apex-mocks | fflib-apex-common | fflib-apex-common-samplecode