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.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
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(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.
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.
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.
| Method | Returns |
|---|---|
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) |
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, useTraceFlow.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).
| Method | Returns |
|---|---|
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().
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