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.

Verifying Execution Paths with TraceFlow

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.lastHistory()Get the last TraceHistory object
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 / abortlastHistoryContains. 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 logscontains
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(CopyAccountIndustryToOpportunityUsecase.LBL_FETCH, new List<IEntry>{ oppEntry });
 
  (new CopyAccountIndustryToOpportunityUsecase(
    new Set<Id>{ oppEntry.getAliasId('opp') }, mock
  )).invoke();
 
  Assert.areEqual(1, mock.upsertedRecordsAt(CopyAccountIndustryToOpportunityUsecase.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 CopyAccountIndustryToOpportunityUsecase(
    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.