Verifying Execution Paths with TraceFlow
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
| Method | What 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/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
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.
@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.
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.
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
Traceis open, so this never bites; open more than that and it raisesTraceException.
Related Documents
- Trace's four lifecycle methods: the four methods and the three exit paths
- Pinning governor usage: the bulk insurance TraceUsage buys you
- ApexTrace guide: back to the guide index