Test Strategy

Apex Stem Docs
Apex StemHandler-Usecase ArchitectureTestingApexEloquentApexBlueprintSalesforceApex
How Apex Stem maps its two layers to two kinds of tests: unit tests on Usecases with ApexEloquent, integration tests on Handlers with ApexBlueprint.

This document covers Apex Stem's test strategy from both the design-decision and the practical-convention angles. Read this when you want to sit down and understand "what kind of tests defend which layer of the Handler-Usecase Architecture".

For hands-on examples (working test code), refer to Step 4 of the Apex Stem Introduction Guide as the canonical source. This document systematizes the judgments and conventions scattered there.

TL;DR

Apex Stem's test strategy starts by structurally separating the two layers of the Handler-Usecase Architecture into "the responsibility scope of your own logic" and "the responsibility of the platform / org configuration".

  • Unit tests (MockEloquent, no DB) cover the former exhaustively
  • Integration tests (ApexBlueprint, real DML) verify the latter with representative cases only

This makes it instantly distinguishable when a test fails: is it a bug in your code or a configuration change made by an admin? In an environment like Salesforce — where the production environment shifts dynamically outside the code — that distinction is a decisive operational advantage.

Reading Guide

The whole document weaves philosophy, conventions, and practice into a single read, but you only need to read the sections that match your concern. First-time readers can go in order; experienced readers can jump to whichever section fits.

ConcernReading Order
Want to understand the judgment philosophy (why this design)The Big Picture → What Unit Tests Defend (and Don't) → Salesforce-Specific Context: Dynamic Runtime → Test-Failure Decision Matrix → Why This Strategy Pays Off Long-Term
About to write tests (need the conventions now)Usecase Layer: Unit Tests → Handler Layer: Integration Tests (through "Make One of Those Representatives a Governor IT") → Conventions for Writing Tests
Debugging help when tests failTest-Failure Decision Matrix → Judgments and Pitfalls
Summary of design decisions to share with the team (material to convey the why)What Unit Tests Defend → Salesforce-Specific Context → CI/CD Operating Recommendations → Why This Strategy Pays Off Long-Term

The Big Picture

Apex Stem's test strategy is designed so that the two layers of the Handler-Usecase Architecture, the two test types, and the two OSS map 1:1.

LayerTest TypeDB AccessOSS Used
Usecase layerUnit testNone (mocks)ApexEloquent (MockEloquent / MockEntry)
Handler layerIntegration testYes (real DML)ApexBlueprint (SBlueprint / SOrchestrator)

Why This Convention Should Be Explicit

Making this 1:1 mapping explicit as a team convention has three practical benefits.

  • Easy to remember. The "which test type and which tool for what" decision cost evaporates. Usecase means MockEloquent; Handler means ApexBlueprint — instant.
  • Onboarding-friendly. A new teammate can absorb the test-design foundation from a single line: "this layer gets this kind of test".
  • AI integration. When you hand an AI coding assistant (like Claude Code) the rules file (CLAUDE.md), a two-line description of this 1:1 mapping is enough for the AI to pick the right test type and OSS.

The Principle: Cover with Units, Verify Representative Cases with Integration

Each layer's tests carry its own responsibility.

  • Usecase unit tests: cover every logic branch — per-phase aggregation, null / empty input, multi-key combinations, and both the skip() and finish() TraceFlow exit paths. Fast and isolated with MockEloquent.
  • Handler integration tests: limit to 1–3 representative cases of "the Trigger / Batch / REST invokes the Usecase, and the full chain works as expected". Logic coverage is the Usecase's job. The Handler side does not cover exhaustively.

Flipping this division makes integration tests slow and bloated, and time and coverage collapse on the logic-coverage side.

What Unit Tests Defend (and Don't)

Treating "unit tests and integration tests as differences of granularity" tends to mislead in the Salesforce context. Apex Stem treats them as layers with different responsibility scopes. Once you fix that here, the downstream decisions (initial response to test failure / CI/CD operations) fall out automatically.

Defend: Logic You Wrote

What Usecase unit tests verify is limited to the correctness of the logic you wrote.

  • The output (invoke()'s return value) for the given input (constructor arguments)
  • Side effects on the DB (verified via MockEloquent's spy)
    • The contents (field values) of changed records
    • The count of changed records
    • The count of deleted records

Don't Defend: Platform and Org-Configuration Responsibility

Conversely, the following are intentionally out of scope for unit tests.

  • Salesforce platform behavior (Database.upsert's allOrNone, External-Id upsert internals, etc.)
  • Trigger chaining, Workflow, Flow, Process Builder behavior
  • Validation rules, required-field checks
  • Permissions and field-level security (FLS)
  • Duplicate rules, assignment rules
  • Record types, page layouts

These are not "the logic you wrote" — they're the responsibilities of the Salesforce platform and org configuration. Pulling them into unit tests blurs the responsibility scope of the tests and makes failure triage difficult.

Can't Defend: Governor Consumption (Yours, but Invisible from Units)

Splitting into "defend" and "don't defend" leaves something out — things inside your own responsibility that unit tests structurally cannot observe. The prime example is governor consumption (SOQL and DML counts).

MockEloquent issues no real SOQL. That is what makes it fast and isolated, but the flip side is that it is blind by construction to query counts, subquery shape and governor consumption.

  • An implementation that walks down a hierarchy firing whereIn at each level stacks up one SOQL per kind of SObject it reads
  • With a trigger cascade, the SOQL count of a single pass is multiplied by the number of re-entries
  • Both stay green in unit tests. They surface for the first time in a production bulk run, as Too many SOQL queries: 101

This is not the platform's responsibility. It is entirely yours — the efficiency of the queries you wrote — and yet units cannot see it. So this one gap gets closed separately, by a governor integration test (real DML, in bulk) described later.

"Cover with units, verify representative cases with integration" is the right division. But which representative cases you pick is what matters here. See "Make One of Those Representatives a Governor IT" under "Handler Layer: Integration Tests".

Why Separate Responsibility Scopes by Structure (Test Diagnostic Value)

Separating responsibility scopes by structure means that when a test fails, you can instantly tell where the problem is. That's the core value of a test suite: its diagnostic value.

  • A Usecase unit test fails → there's a problem in your logic (= the code needs fixing)
  • Unit passes but integration fails → your logic is innocent; the platform / configuration changed

Conversely, pulling trigger chains and permissions into unit tests explodes the investigation surface when they fail: "is it a logic bug? platform behavior? permission settings? a duplicate rule?". The information "a unit test failed" stops pinpointing anything, and the test loses value.

The same reasoning applies to why we don't care whether MockEloquent's internals (partial success of Database.upsert, External-Id upsert semantics) match the real Eloquent precisely. That's Salesforce platform territory, not your code's responsibility scope. Behavior that depends on platform semantics is confirmed in integration tests.

Salesforce-Specific Context: Dynamic Runtime

In typical software development, production behavior is determined by the deployed code. Code changes go through PR review and history is traceable in Git.

In Salesforce, however:

  • Admins can change Flows (no deploy needed)
  • Field-required toggles and validation rules are added from the UI
  • Permission sets and profiles change during operation
  • Duplicate rules and assignment rules get added

These changes happen outside of the code, and their history is hard to track in Git. To developers, the Salesforce production environment is "a runtime where you don't know when what changed".

The Two Wholly Different Roles This Demands

In a Salesforce environment, a test suite is asked to play two wholly different roles.

RoleContentOwned by
Guarantee that your logic is correctNo matter what admins do, the branches / calculations / data shaping you wrote behaves as intendedUsecase unit tests
Guarantee consistency with the platform environmentUnder the current settings, your code runs correctlyHandler integration tests

Separating these two structurally makes the initial response to a test failure instantly decidable. We cover this in the "Test-Failure Decision Matrix" later.

Usecase Layer: Unit Tests

Why No DB

The Usecase is a class that implements a single piece of business logic, with data access going through IEloquent. In tests, swapping IEloquent for MockEloquent lets us verify just the logic itself, without touching the database.

What you gain by not touching the DB is threefold.

  • Tests are fast. Without real DML, they finish in milliseconds.
  • Tests are isolated. Not affected by record types, org configuration, or other tests' side effects.
  • Verification stays focused. "Given these conditions, this update happens" — assert without noise.

MockEloquent and Eloquent both implement the IEloquent interface, and production code depends on the interface. From the Usecase's perspective, the production Eloquent and the unit-test MockEloquent are objects under the same contract — there's no need for their internals (partial success of Database.upsert, External-Id upsert semantics, etc.) to match. That's Salesforce platform territory, not the responsibility scope of your code (see "What Unit Tests Defend" above for details).

There is a footnote on speed. It is not that speed raises productivity. What it changes is whether a test gets run at all.

Real-DML tests take tens of seconds to minutes, dragged along by the state of the org. So mid-development you stop running them and carry on assuming "it'll probably pass". If a unit test finishes in milliseconds, you run it right after writing it. The feedback loop never breaking is the real value of speed.

⚠️ That does not make unit tests a substitute for integration tests. Something goes invisible in exchange for the speed — see "Can't Defend: Governor Consumption" above.

The Role of ApexEloquent (MockEloquent / MockEntry)

ClassRole
MockEloquentThe swap target for IEloquent. Feed the IEntry list that get(scribe) returns via attach(label, ...). The DML that ran comes back through upsertedRecordsAt(label) / deletedCountAt(label)
MockEntryThe swap target for SObject. With set('Field__c', value) you can set even non-writable fields (formula, rollup, parent relationship). Accessing a field that wasn't field()'d in the Scribe throws, surfacing SELECT omissions

What to Verify (unit)

In Usecase unit tests, cover the following angles.

  • Business-logic branches. Each if / switch path, behavior differences by field value, correctness of aggregations spanning multiple records
  • Early-return paths. "Skip when target is empty", "exit when conditions don't apply" — confirm they properly end in the skip path
  • DML contents. What was pushed onto upsertedRecordsAt(label), and which fields hold which values

TraceFlow.isLastFinish() and TraceFlow.isLastSkip() are the mechanism to distinguish which code path was taken, beyond just the return value. "Skipped because there was no target" and "completed normally" can be verified separately, even when invoke() returns void.

Code Excerpt (unit test)

From the CopyAccountIndustryToOpportunityUsecase tests in Step 4 of the Apex Stem Introduction Guide, here's the skeleton.

APEX
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
  Trace t = Trace.of('Happy path: industry is copied from the parent Account to the Opportunity');
  t.start();
 
  // Arrange
  MockEntry oppEntry = MockEntry.of(Opportunity.class)
    .alias('opp').autoId(1)
    .setParent('AccountId',
      MockEntry.of(Account.class).set('Industry', 'Technology'));
  MockEloquent mock = (new MockEloquent())
    .attach(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List<IEntry>{ oppEntry });
 
  // Act
  (new CopyAccountIndustryToOpportunityUsecase(
    new Set<Id>{ oppEntry.getAliasId('opp') }, mock
  )).invoke();
 
  // Assert
  List<SObject> updated = mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.LBL_UPDATE);
  Assert.areEqual(1, updated.size());
  Assert.areEqual('Technology', ((Opportunity) updated[0]).Industry__c);
  Assert.isTrue(TraceFlow.isLastFinish());
 
  t.finish();
}

Points to notice:

  • MockEntry's parent record. setParent('AccountId', ...) hangs the parent Account, letting you assemble "an Opportunity whose parent Account has an industry" without going through real SOQL.
  • Consolidate IEloquent with label multiplexing. By labeling the same mock with fetch (LBL_FETCH) and update (LBL_UPDATE), you can observe the DML in isolation via upsertedRecordsAt(LBL_UPDATE) without splitting into per-purpose instances. See Layered Constructor Pattern for details.
  • TraceFlow.isLastFinish(). The non-return-value paths (skip / finish / abort) are verified by TraceFlow.

The "skipped because there were no targets" branch can be written in the same form by replacing it with TraceFlow.isLastSkip(). Both canonical versions live in Step 4 of the Introduction Guide.

Handler Layer: Integration Tests

Why Real DML Is Needed

The Handler is the class tied to an entry point — Trigger / Batch / REST / Flow / Schedulable / etc. — and its responsibility is to absorb entry-point-specific conventions and hand off to the Usecase.

The wiring "the Trigger fires, the TriggerHandler is called, and the Usecase runs as expected" cannot be reproduced without real DML. The values of Trigger.new / Trigger.oldMap, record type resolution, and interaction with other triggers are precisely reproduced only when real DML is run in the Apex test runtime.

The Role of ApexBlueprint (SBlueprint / SOrchestrator)

ClassRole
SBlueprintA record definition for one SObject. template() for default values, set() for individual overrides, withChildren() to hang children, alias() to name records for retrieval
SOrchestratorGathers multiple SBlueprints, topologically sorts dependencies, and inserts in order. Real DML happens here

With ApexBlueprint, a hierarchy like "an Account with an industry → its child Opportunity" can be declared structurally, and the insert order is automatically resolved.

What to Verify (integration)

The angles for integration tests are completely different from unit tests.

  • Is the wiring correct? The Trigger / Batch / REST invokes the right Handler, and the Handler invokes the right Usecase.
  • End-to-end chain consistency. Handler → Usecase → ApexEloquent → real DB — does the final saved value come out as expected?
  • Representative scenarios. Limit to 1–3 entry-point-driven representative cases like "inserting an Opportunity copies the parent Account's industry".

Logic-branch coverage is the responsibility of Usecase unit tests. Covering all branches via Handler integration tests makes them extremely slow and the failure triage difficult.

Code Excerpt (integration test)

From Step 4 of the Introduction Guide's Handler integration test, here's the skeleton.

APEX
@isTest
static void testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied() {
  Trace t = Trace.of('Happy path: inserting an Opportunity copies the parent Account industry');
  t.start();
 
  // Arrange: assemble the hierarchy with ApexBlueprint
  SOrchestrator orchestrator = SOrchestrator.start()
    .add(SBlueprint.of(Account.class)
      .alias('acc')
      .template(Blueprints.accBasic())
      .set('Industry', 'Technology')
      .withChildren(
        SBlueprint.of(Opportunity.class)
          .alias('opp')
          .template(Blueprints.oppBasic())
      ));
 
  // Act: create() inserts Account → Opportunity in order; the Trigger fires
  Test.startTest();
  orchestrator.create();
  Test.stopTest();
 
  // Assert: the Opportunity has the industry copied
  Opportunity opp = (Opportunity) orchestrator.getByAlias('opp');
  Opportunity refetched = [
    SELECT Id, Industry__c FROM Opportunity WHERE Id = :opp.Id
  ];
  Assert.areEqual('Technology', refetched.Industry__c);
 
  t.finish();
}

Points to notice:

  • withChildren declarations match the data hierarchy. The shape "Account with an Opportunity child" is visible just by reading the code.
  • Wrap DML with Test.startTest() / Test.stopTest(). Without them, async-trigger counts and governor counters can diverge between test-time and production.
  • Refetch after insert. The Opportunity returned by orchestrator.getByAlias('opp') is a pre-insert snapshot. If you want to verify a field update done by the Trigger, re-query with SOQL explicitly.

Make One of Those Representatives a Governor IT

Keeping integration tests to one to three representative cases still stands. But make one of those representatives a test that pushes a production-sized bulk through and measures the governor headroom.

The reason is the earlier "Can't Defend: Governor Consumption". MockEloquent issues no real SOQL, so query inefficiency is never visible from unit tests. And a single-scenario integration test sails past too — a handful of records never approaches the 100-SOQL ceiling. The only thing that closes this gap is bulk × real DML × asserting on governors.

APEX
@isTest
static void testCascade_WhenBulk_ThenWithinGovernorLimits() {
  Trace t = Trace.of('Edge case: governor limits still have headroom at production-sized bulk');
  t.start();
 
  // Arrange: mass-produce a production-like volume with times()
  SOrchestrator orchestrator = SOrchestrator.start()
    .add(SBlueprint.of(Account.class)
      .template(Blueprints.accBasic())
      .withChildren(
        SBlueprint.of(Opportunity.class)
          .template(Blueprints.oppBasic())
          .alias('opp_{#}')
          .times(201)          // 201 or more — see below
      ));
 
  // Act: fire the whole cascade with a single DML
  Test.startTest();
  orchestrator.create();
  Integer soqlUsed = Limits.getQueries();       // capture INSIDE the block
  Test.stopTest();
 
  // (1) Correctness. Pin it to a number that cannot hold if a record is dropped
  Assert.areEqual(201, [SELECT COUNT() FROM Opportunity], 'all 201 records were processed');
 
  // (2) Per-context consumption. Is it issuing queries in proportion to volume?
  TraceFlow.usageOf('Regenerate collection records')
    .assertInvocationsAtMost(6, 'measured 5 on a 201-record insert; more suggests extra wiring')
    .assertSoqlQueriesAtMost(15, 'queries must not scale with record count');
 
  // (3) Transaction-wide governor headroom
  Assert.isTrue(soqlUsed < Limits.getLimitQueries() / 2,
    'SOQL should stay under half the limit even in bulk. Measured: ' + soqlUsed);
 
  t.finish();
}

The point is that the assertions come in three tiers. (1) is the primary one — watching governors alone lets "it dropped records but consumed little" slip through. Logic coverage is already handled by the unit layer, so there is no need to grow branches here.

🚨 Never read Limits after stopTest()

Test.stopTest() restores the governor counters to their state before startTest(). Read Limits.getQueries() after it and you get the Arrange figure, not the Act one.

CODE
Measured (201 Opportunities created through create()):
  before startTest    soql=0  dml=3   <- Arrange
  just after startTest soql=0         <- reset
  after Act (in block) soql=7  dml=2   <- the real cascade consumption
  after stopTest      soql=0  dml=3   <- back to the pre-startTest state

So Assert.isTrue(Limits.getQueries() < limit/2) written after Test.stopTest(); merely evaluates 0 < 50 — it passes no matter what, even while 7 queries were actually spent. Always capture into a variable inside the block.

Why 201 records

There are two reasons, and the second one matters more.

The first is making N+1 visible. At times(2) even a per-record implementation lands on 2 invocations / 2 queries, which slips under any threshold.

The second is that Salesforce invokes triggers in chunks of 200. This is platform behaviour distinct from the Data Loader batch size — plain Apex doing insert 201 records hits it too.

Records insertedUsecase invocations (measured)
302
2003
2015

⚠️ These counts are what you get without TraceFlow.discardArrange() — they include the Arrange-time invocations. With it, the Arrange share drops out (measured at 201 records: 5 → 4). See Pinning governor usage with TraceUsage.

A test that never exceeds 200 lets through implementations that assume "every record arrives in one call", or that cap what they fetch (take(200) / LIMIT / only the first N). Planting a take(200) in an aggregate query proved it: unit tests, the representative cases and the 30-record bulk test all stayed green, and only the 201-record test failed (1 of 29).

⚠️ Mind the tug-of-war with the 10,000 DML row limit. Hanging deep children off 201 parents overflows it (times multiplies through nesting). Keep children minimal in the test that exercises chunking.

Naming the culprit: TraceUsage (ApexTrace v1.1.0+)

Limits.getQueries() is a transaction-wide number. It tells you that you are close to the ceiling, but not which Usecase consumed it. TraceUsage records governor consumption automatically from each Trace context's start() through to close, so you can pin it down per Usecase.

APEX
TraceFlow.usageOf('Regenerate collection records')
  .assertSoqlQueriesAtMost(15, 'queries must not scale with record count');

🚨 Do not use TraceFlow.lastUsage() here. In a bulk IT where the handler calls several Usecases, it returns only the last one to close. From v1.3.0 that ambiguous case raises TraceException under test execution, listing the candidate names so you can move straight to usageOf.

It records only five deterministic metrics (SOQL count / SOQL rows / DML statements / DML rows / callouts). CPU time and heap vary run to run, so they are deliberately left out.

⚠️ Under unit tests (MockEloquent) no real SOQL is issued, so every TraceUsage value is zero. Put governor assertions on the integration side, where real DML runs. On the unit side they verify nothing.

A Different Job from Large-Input Limit Tests

"Production-sized test" covers a completely different second thing. Don't conflate them.

What you want to seeWhere it belongsWhy
CPU time / heap / string-length limits (parsing, splitting, normalising)Pure-function unit testAssembled in memory. Real DML adds nothing but slowness
SOQL / DML count limits (trigger cascades)Real-DML bulk integration testMocks issue no real SOQL, so it is unobservable in principle

Run the former through integration tests and you gain nothing but a slow suite. Try to see the latter with pure-function tests and you cannot observe it at all. They are different jobs.

Test-Failure Decision Matrix

With unit and integration tests separated by responsibility scope, the initial response to a test failure organizes neatly.

CaseUnitIntegrationMeaningInitial Response
Pattern 1Bug in code logicFix the code
Pattern 2Change in platform environment (config change, etc.)Verify config, ask the admin
Pattern 3All goodOK to deploy
Pattern 4Edge case, needs investigation (possibly a test-design mistake)Test review

The decisive operational advantage is that Pattern 2 (✅ ❌) is instantly triagable.

  • "Integration failed, but I haven't touched the code" → someone fiddled with the config is instantly visible
  • The developer can immediately conclude "my logic is innocent"
  • The investigation focus narrows to "recent flow changes", "recent permission changes", "recent field changes"

If unit and integration are mixed in the design, on the other hand, you can't tell from a failure whether it's a code or a config issue, and the investigation surface explodes. Worse, "an admin changed a flow and now the developer's CI fails" creates organizational friction. The value of structurally separating responsibility scopes shows up here in practice.

CI/CD Operating Recommendations

The responsibility-scope split also reflects naturally onto CI/CD scheduling.

TimingTests RunPurpose
Every PRUnit tests onlyVerify code-change responsibility scope quickly
Before deployUnit + integration testsFinal environment-consistency check
Scheduled (e.g. nightly)Integration testsEarly detection of config changes

Scheduled runs in particular function as a proactive watch on the Salesforce environment. If integration tests fail without a code change, that's detection of a configuration change — an early-warning system for Salesforce operations.

This isn't an Apex Stem-specific rule so much as a pattern that naturally follows from "the dynamic runtime of Salesforce". Adjust to your team's size and CI environment freely — run integration on every PR, narrow nightly to weekly, and so on.

Conventions for Writing Tests

Naming Convention

Test method names follow test{Method}_When{Condition}_Then{Result}.

APEX
testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied()
testInvoke_WhenNoOpportunityIds_ThenSkipped()
testAfterInsert_WhenOpportunityInserted_ThenIndustryCopied()

The method name alone makes "what's being verified" readable.

Consolidate the Test Description in Trace.of

At the top of each test method, place Trace.of('Happy path: ...') and write what the test verifies as a complete sentence. Method names are machine-readable identifiers; the Trace.of argument is the human-readable explanation — split the roles.

APEX
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
  Trace t = Trace.of('Happy path: industry is copied from the parent Account to the Opportunity');
  t.start();
  // ...
  t.finish();
}

Don't put an independent // Happy path: ... comment above the method — it duplicates Trace.of.

Use Assert.areEqual

Use Salesforce-recommended Assert.areEqual / Assert.isTrue / Assert.isNull and friends. Don't use legacy System.assertEquals and the like.

Only .set() the Verification Targets

The fields you set via MockEntry.set() or SBlueprint.set() should be limited to the fields this test is verifying. Copying .set() calls wholesale from other tests buries "what this test is verifying" in noise. Leave defaults to template() (ApexBlueprint) or MockEntry's defaults.

Spy Verification Granularity: Look at Final State, Not Call Order

When verifying DML outcomes via MockEloquent's spy methods (upsertedRecordsAt / deletedCountAt etc.), look at final state. As a rule, avoid asserting on call counts or order directly.

⭕ Good: Business-Requirement-Level Verification

APEX
// Verify count and content
List<SObject> upserted = mockEloquent.upsertedRecordsAt(Usecase.LBL_UPDATE);
Assert.areEqual(1, upserted.size());
Account account = (Account) upserted[0];
Assert.areEqual('High Priority', account.Priority__c);

❌ Bad: Depends on Internal Call Order

APEX
// Verify call count and order (tightly coupled)
Assert.areEqual(3, mockEloquent.callCount);
Assert.areEqual('upsert', mockEloquent.callHistory[0]);
Assert.areEqual('delete', mockEloquent.callHistory[1]);

ℹ️ The callCount / callHistory used here are fictional properties that don't actually exist on MockEloquent. They're shown as a hypothetical "what would go wrong if you wrote tests this way" example.

This pattern produces false-positive failures during internal refactors like "batch two upserts into one". The logic stays correct, only the test fails — and trust in the test suite erodes. Verifying the final state and avoiding call-count / order assertions is safer.

That said, when "consolidating into one call for DML efficiency" is explicitly a non-functional requirement, asserting on call counts is reasonable. Make the non-functional requirement explicit, or you'll lose the criterion when call-count assertions break; leaving the intent in the Trace.of comment helps later readers.

Refactor Resilience

The combination of Apex Stem's Usecase structure — only invoke() public, internals hidden as private — and the "look at final state" spy-verification granularity above structurally enforces refactor-resilient tests.

  • Splitting / merging / renaming internal methods is invisible to tests, so it doesn't break them
  • Changes that consolidate or split DMLs continue to pass as long as final state matches
  • As a result, "rearrange the structure without changing behavior" refactors can be done confidently

Conversely, verifications that reach into the internals (unit-testing private methods, asserting on call order) break on every refactor. That's dependency on implementation noise that has nothing to do with the business value the test is supposed to verify. Apex Stem prevents that dependency at the structural level.

How to Write Orchestrator-Usecase Tests

Orchestrator Usecase tests (Usecases that integrate components like Reader / Validator / Mapper) get extra options.

Mock Component Classes, or Use the Real Ones?

Thanks to the Layered Constructor Pattern, component classes can be swapped at any granularity per test.

  • Mock AccountReader, real OpportunityMapper → "close off external I/O while running the logical center on the real thing"
  • All mocked → "purely verify this orchestrator's own assembly"
  • All real → "near-integration granularity, with only data access closed via MockEloquent"

There's no absolute right answer; choose by the business and the orchestrator's complexity.

Write a Unit Test, or Not?

"Don't write a unit test for the orchestrator itself; let the Handler integration test guarantee it" is also a valid call.

  • Reasons to write: localize logic, catch bugs early
  • Reasons not to write: effectively covered by Handler integration test, double maintenance

Apex Stem allows both. Write one if the orchestrator is complex; settle for the Handler integration test if it just bundles. Defer to the field.

Judgments and Pitfalls

What to Cover in Units, What to Limit to Representative Cases in Integration

Thing to VerifyTest Type
All branches of business logic (per-phase aggregation, null / empty, multi-key, etc.)Usecase unit test
The wiring "Trigger fires → Handler → Usecase" is correctHandler integration test
The skip / finish paths of TraceFlowUsecase unit test
The result of DB-side computation like formula fields and rollups(Not the main focus. Observe in integration tests if needed)

Cross this line and tests turn bloated, slow, and noisy.

Pitfall 1: Units Without Integration

Cover all logic with MockEloquent but skip the trigger wiring, and you'll discover only in production that "the expected Usecase was never invoked" or "it was invoked, but in the wrong before / after phase". Write at least one representative case per Handler.

Pitfall 2: Trying to Cover with Integration

Conversely, deciding "do everything in integration" forces you to combine huge piles of data patterns with ApexBlueprint, and test time explodes. Pushing logic to Usecase units keeps integration to 1–3 representative cases.

Pitfall 3: Reusing One MockEloquent Without Labels

MockEloquent does not evaluate WHERE conditions — it hands back the IEntry list you gave it, as-is. So reuse it without labels and one MockEloquent cannot tell "last month's query" from "this quarter's". Both get the same list.

The recommendation is to label each query and multiplex a single IEloquent. Production calls through label(LBL_FETCH) / label(LBL_UPDATE); the test feeds each purpose through attach(LBL_..., ...). You still DI exactly one field.

APEX
// Production (Usecase)
List<IEntry> entries = this.eloquent.label(LBL_FETCH).get(oppScribe);
this.eloquent.label(LBL_UPDATE).doUpdate(entries);
 
// Test
MockEloquent mock = (new MockEloquent())
  .attach(Usecase.LBL_FETCH, new List<IEntry>{ oppEntry });
List<SObject> updated = mock.upsertedRecordsAt(Usecase.LBL_UPDATE);

Before labels existed you DI'd a separate IEloquent per purpose. That still works, but the constructor grows with every dependency.

Once label() is called even once, every subsequent operation on that instance requires a label (a forgotten label, or consuming the same label twice, throws). Making "reuse it without labels by accident" structurally impossible is the point of the mechanism.

Pitfall 4: An Unattached Label Quietly Returns Empty

This is the textbook case of a test telling you a lie. Mistype a label, or forget to attach, and that query returns zero rows. You fall into the "nothing to do, skip" branch and the test goes green — having verified nothing.

Current ApexEloquent throws when you call get / first / firstOrFail under a label that was never attached (strict mode is automatic under test). The error lists the attached labels, so a typo is obvious on the spot.

When you genuinely want to test the zero-row path, attach an empty list to declare the intent.

APEX
// Declare "the fetch returns zero rows" explicitly
MockEloquent mock = (new MockEloquent())
  .attach(Usecase.LBL_FETCH, new List<IEntry>());

⚠️ Upgrading from an older version can turn some tests red here. Those are the tests that were green while verifying nothing because of a missing attach. Rather than mechanically adding empty attaches to get back to green, check what data should have been injected in the first place.

Pitfall 5: Forgetting to Wrap with Test.startTest / Test.stopTest

Forgetting to wrap the part that fires DML or async work with Test.startTest() / Test.stopTest() in an integration test can desync governor counts and async-queue flushing between test-time and production. Remember to "wrap around the Act that triggers real DML".

Why This Strategy Pays Off Long-Term

Everything above collapses into one sentence.

"Make the responsibility scope of your own logic explicit by structure, and cover that scope exhaustively with unit tests. For things outside that scope, separate the layer and verify with integration tests."

The architectural decisions on Apex Stem's side back this up.

  • The Handler-Usecase Architecture convention of "only invoke() is public" → what can be observed from unit tests is structurally narrowed to "input → output + side effects"
  • The Layered Constructor Pattern design of "IEloquent is swappable via DI" → platform responsibility and code responsibility are clearly separated at test time

In other words, Apex Stem enforces test quality not by individual discipline but by the structure itself. Verifications that reach into internals can't be written, even if you try; platform behavior and code logic are inevitably partitioned at the DI boundary. The result is a structure where developers unconsciously write good tests — phrased differently, a structure in which bad tests can't be written.

This property is especially powerful in the era of developing with AI coding assistants. The architecture closes off, by structure, the risk of "bad tests slipping in" — whether through AI-generated tests or under the pressure of high-load PR reviews. You don't have to memorize the conventions; following the structure produces good tests naturally. That's the core of long-term maintainability.

On the practical side, this strategy delivers:

  1. Instant triage on test failure (as seen in Pattern 2 of the decision matrix)
  2. Proactive detection of admin-driven configuration changes (via nightly integration runs)
  3. A "bad tests can't be written" structure that withstands AI auto-generation
  4. Effectively zero cost for combinatorial coverage, thanks to fast DB-less execution
  5. A long-term maintainable test suite with high refactor resilience