Verifying Execution Paths with TraceFlow

•Apex Stem Docs
Apex StemApexTraceTestingSalesforceApex
Pin a void Usecase along two independent axes — side effects and the path it took. Covers isLastFinish / isLastSkip / isLastAbort and when to reach for contains over lastHistoryContains.

TraceFlow is a static utility for inspecting the state of the trace that just ran. Use it in tests to tell which path a Usecase exited on.

The main verification methods

MethodWhat it checks
TraceFlow.isLastFinish()Whether the last trace ended on finish()
TraceFlow.isLastSkip()Whether the last trace ended on skip()
TraceFlow.isLastAbort()Whether the last trace ended on abort()
TraceFlow.lastHistoryContains(text)Whether the last entry contains the string
TraceFlow.contains(text)Whether anywhere in the history contains the string
TraceFlow.isLastStart() / TraceFlow.isLastLog()Whether the last entry is a start() / log() (useful for spotting a trace that never reached an exit path)
TraceFlow.lastHistory()Get the last TraceHistory object
TraceFlow.getAllHistories()Get every history entry (a copy), in order
TraceFlow.usageOf(name)Governor usage for contexts with that name (exclusive total, v1.2.0+)
TraceFlow.lastUsage()Governor usage of the single most recently closed context (in a bulk IT, use usageOf)

lastHistoryContains vs contains

lastHistoryContains looks at the last entry only, so adding one t.log(...) line later breaks the test.

  • Pinning the reason message on finish / skip / abort → lastHistoryContains. It is the entry that immediately follows, so the ordering is stable
  • Pinning the content of an intermediate t.log(...), or staying resilient to added logs → contains
APEX
Assert.isTrue(TraceFlow.contains('Copied the industry onto 3 opportunities'));  // passes wherever it sits in the history

Both return false when given null.

A void invoke() can still be verified by path

When a Usecase returns void, tests tend to observe only side effects (the DML, the field values). Combine them with TraceFlow and you can pin the behaviour along two axes: side effects and path. That is one of the reasons ApexTrace ships as standard in Apex Stem.

Example: telling skip and finish apart

Write "skipped because there was nothing to do" and "completed normally" as separate tests.

APEX
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
  Trace t = Trace.of('Normal: the parent account industry is copied onto the opportunity');
  t.start();
 
  MockEntry oppEntry = MockEntry.of(Opportunity.class)
    .alias('opp').autoId(1)
    .setParent('AccountId',
      MockEntry.of(Account.class).set('Industry', 'Technology'));
  MockEloquent mock = (new MockEloquent())
    .attach(CopyAccountIndustryToOpportunity.LBL_FETCH, new List<IEntry>{ oppEntry });
 
  (new CopyAccountIndustryToOpportunity(
    new Set<Id>{ oppEntry.getAliasId('opp') }, mock
  )).invoke();
 
  Assert.areEqual(1, mock.upsertedRecordsAt(CopyAccountIndustryToOpportunity.LBL_UPDATE).size());
  Assert.isTrue(TraceFlow.isLastFinish());
 
  t.finish();
}
 
@isTest
static void testInvoke_WhenNoOpportunityIds_ThenSkipped() {
  Trace t = Trace.of('Normal: it skips when there are no target opportunities');
  t.start();
 
  MockEloquent mock = new MockEloquent();
 
  (new CopyAccountIndustryToOpportunity(
    new Set<Id>(), mock
  )).invoke();
 
  Assert.isTrue(TraceFlow.isLastSkip());
 
  t.finish();
}

Both tests observe the path, which is not a return value, through TraceFlow. Whether it went down finish or skip is verified on an axis independent of the side effects (upsertedRecords).

You can pin the log content too.

APEX
Assert.isTrue(TraceFlow.lastHistoryContains('No target opportunities'));

That constrains it all the way down to "did it skip with the message we expected".

Aligning the observation window to the Act: discardArrange() (v1.4.0+)

isLastFinish() / isLastSkip() / isLastAbort() look at the most recently closed context. But the trace history accumulates from the top of the test method, so if the DML that created your Arrange records fired a Usecase through a trigger, that context is in the history too.

The problem shows up when the Act produces nothing. With no context pushed during the Act, isLastSkip() ends up reading an Arrange context. You can be verifying the Arrange while believing you are verifying the Act.

Put TraceFlow.discardArrange() on the Arrange / Act boundary and the history is cut there, so every assertion afterwards sees the Act alone.

APEX
setupAccountWithOpportunities();   // Arrange (a Usecase runs through the trigger)
 
TraceFlow.discardArrange();        // here
Test.startTest();                  // and here are the same boundary
 
new ResummarizeUsecase(ids).invoke();
Test.stopTest();
 
Assert.isTrue(TraceFlow.isLastSkip());   // looks at the Act's path only

It is the same judgement call as placing Test.startTest(), so there is no new concept to learn — and it goes right next to it.

⚠️ You cannot call it while a Usecase context is open. At the boundary, at most the test's own Trace is open, so this never bites; open more than that and it raises TraceException.

Reading the history directly: TraceHistory

When isLast* / contains are not enough (you want to count across the whole history, such as "this Usecase finished exactly once and never aborted"), take the TraceHistory entries from TraceFlow.lastHistory() / TraceFlow.getAllHistories() and read them directly.

MethodReturns
getType()TraceHistory.HistoryType (Start / Log / Skip / Abort / Finish)
isStart() / isLog() / isSkip() / isAbort() / isFinish()The type check
getContextName()The Trace.of(...) name that wrote the entry
getMessage()The message passed to log / skip / finish / abort (null when none)
containsText(text)Whether the message contains the string (false for null)
getUsage()The context's governor usage (closing entries only; the raw inclusive value)
getTimestamp() / getDateTime()When it was recorded (epoch milliseconds / DateTime)
APEX
Integer finishes = 0;
for(TraceHistory h : TraceFlow.getAllHistories()) {
  if(h.getContextName() != 'Copy parent Account industry to Opportunity') {
    continue;
  }
  Assert.isFalse(h.isAbort(), 'Never aborted along the way');
  if(h.isFinish()) {
    finishes++;
  }
}
Assert.areEqual(1, finishes, 'This Usecase finishes exactly once');
  • getAllHistories() returns a copy, so changing the list you receive does not touch the history
  • The history accumulates from the Arrange onward. To look at the Act only, cut it first with discardArrange() (previous section)
  • getUsage() is the raw inclusive value. For governor assertions, use TraceFlow.usageOf(name), which aggregates exclusive usage (TraceUsage)

Reading a TraceException raised by misuse

A TraceException raised by lifecycle misuse carries the situation in getters, so you can assert on those instead of the message string. It is raised only in Strict mode (under test): when the Trace you operate on is not the innermost one (finishing the outer trace before the inner one, finishing the same Trace twice, and so on), and when no Trace is running at all. From v1.6.0, Relaxed mode (production) never throws (nesting and modes).

MethodReturns
getOperationType()The attempted operation ('Log' / 'Finish' / 'Skip' / 'Abort')
getCurrentContext()The innermost context when it was raised (null when no Trace was running)
getExpectedContext()The context the operation expected

Each is null when it does not apply. The name of a Trace instance itself is available from t.getContextName().