TraceFlow でテストの経路を検証する
TraceFlow は、直前に実行された Trace の状態を覗くための静的ユーティリティです。テストで「Usecase がどの経路で終わったか」を区別するのに使います。
主な検証メソッド
| メソッド | 検証内容 |
|---|---|
TraceFlow.isLastFinish() | 直前の Trace が finish() で終わったか |
TraceFlow.isLastSkip() | 直前の Trace が skip() で終わったか |
TraceFlow.isLastAbort() | 直前の Trace が abort() で終わったか |
TraceFlow.lastHistoryContains(text) | 最後の 1 エントリに文字列が含まれるか |
TraceFlow.contains(text) | 履歴全体のどこかに文字列が含まれるか |
TraceFlow.isLastStart() / TraceFlow.isLastLog() | 最後のエントリが start() / log() か (終了パスに入らずに終わっていないかの確認に使う) |
TraceFlow.lastHistory() | 直前の TraceHistory オブジェクトを取得 |
TraceFlow.getAllHistories() | 履歴の全エントリ (コピー) を時系列で取得 |
TraceFlow.usageOf(name) | その名前のコンテキストのガバナ消費 (exclusive 合計、v1.2.0+) |
TraceFlow.lastUsage() | 直近に閉じた1 コンテキストのガバナ消費 (バルク IT では usageOf を使う) |
lastHistoryContains と contains の使い分け
lastHistoryContains が見るのは最後の 1 エントリだけです。そのため、あとから t.log(...) を 1 行足しただけでテストが落ちます。
finish/skip/abortの理由メッセージを縛る →lastHistoryContains。直後のエントリなので順序が安定します- 途中の
t.log(...)の内容を縛る / ログ追加に強くしたい →contains
Assert.isTrue(TraceFlow.contains('3 件の商談に業種をコピー')); // 履歴のどこにあっても通る
どちらも null を渡すと false を返します。
invoke() が void でも経路で検証できる
戻り値が void の Usecase は、テストの観察対象が副作用 (DML や field 更新の中身) に偏りがちです。それでも TraceFlow を組み合わせれば、「副作用」と「経路」の 2 軸で挙動を縛れます。これは ApexTrace が Apex Stem に標準装備されている理由のひとつです。
コード例: skip と finish を区別する
「対象がなくてスキップされた」と「正常に完了した」を、別々のテストとして書きます。
@isTest
static void testInvoke_WhenOpportunityHasAccount_ThenIndustryCopied() {
Trace t = Trace.of('正常系: 商談に親取引先の業種がコピーされること');
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('正常系: 対象の商談がないときスキップされること');
t.start();
MockEloquent mock = new MockEloquent();
(new CopyAccountIndustryToOpportunity(
new Set<Id>(), mock
)).invoke();
Assert.isTrue(TraceFlow.isLastSkip());
t.finish();
}
両方のテストとも、戻り値ではない「経路」を TraceFlow で観察しています。finish パスを通ったか skip パスを通ったかが、副作用 (upsertedRecords の中身) とは別の軸で検証できます。
ログ内容の検証も可能です。
Assert.isTrue(TraceFlow.lastHistoryContains('対象の商談がないため終了'));
「想定通りのメッセージで skip したか」までを縛れます。
観測窓を Act に揃える: discardArrange() (v1.4.0+)
isLastFinish() / isLastSkip() / isLastAbort() が見るのは「最後に閉じたコンテキスト」です。ところが Trace の履歴はテストメソッドの先頭から溜まり続けるため、Arrange でレコードを作った DML がトリガー経由で Usecase を走らせていると、その分も履歴に残ります。
問題になるのは、Act が何も起こさなかったときです。Act 側にコンテキストが 1 つも積まれないと、isLastSkip() が読むのは Arrange のコンテキストになります。つまり Act を検証したつもりで Arrange を検証している状態が起こり得ます。
TraceFlow.discardArrange() を Arrange と Act の境界に置くと、履歴がそこで区切られ、以降のアサートは Act だけを見ます。
setupAccountWithOpportunities(); // Arrange (トリガー経由で Usecase が走る)
TraceFlow.discardArrange(); // ← ここ
Test.startTest(); // ← と、ここは同じ境界
new ResummarizeUsecase(ids).invoke();
Test.stopTest();
Assert.isTrue(TraceFlow.isLastSkip()); // Act の経路だけを見る
Test.startTest() を置くのと同じ判断なので、新しく覚える概念はありません。書く場所も隣です。
⚠️ Usecase のコンテキストが開いている最中には呼べません。 境界で呼んでいる限り、開いているのはテスト自身の
Traceが最大 1 つなので問題になりませんが、それ以上開いているとTraceExceptionになります。
履歴を直接読む: TraceHistory
上の isLast* / contains で足りないとき (「特定の Usecase が 1 回だけ完了し、一度も abort していないこと」のように、履歴全体を数えて縛りたいとき) は、TraceFlow.lastHistory() / TraceFlow.getAllHistories() で TraceHistory を受け取って直接読みます。
| メソッド | 返すもの |
|---|---|
getType() | TraceHistory.HistoryType (Start / Log / Skip / Abort / Finish) |
isStart() / isLog() / isSkip() / isAbort() / isFinish() | 種類の判定 |
getContextName() | そのエントリを書いた Trace.of(...) の名前 |
getMessage() | log / skip / finish / abort に渡したメッセージ (無ければ null) |
containsText(text) | メッセージに文字列が含まれるか (null を渡すと false) |
getUsage() | そのコンテキストのガバナ消費 (閉じたエントリだけ。inclusive の生の値) |
getTimestamp() / getDateTime() | 記録した時刻 (エポックミリ秒 / DateTime) |
Integer finishes = 0;
for(TraceHistory h : TraceFlow.getAllHistories()) {
if(h.getContextName() != '商談に親取引先の業種をコピー') {
continue;
}
Assert.isFalse(h.isAbort(), '途中で abort していないこと');
if(h.isFinish()) {
finishes++;
}
}
Assert.areEqual(1, finishes, 'この Usecase は 1 回だけ完了すること');
getAllHistories()はコピーを返すので、受け取ったリストを書き換えても履歴には影響しません- 履歴は Arrange のぶんから溜まっています。Act だけを見たいときは、先に
discardArrange()で区切ってください (上の節) getUsage()は inclusive の生の値です。ガバナのアサートには、exclusive で集計するTraceFlow.usageOf(name)を使います (TraceUsage)
誤用で投げられた TraceException を読む
ライフサイクルの誤用で投げられる TraceException は、状況を getter で持っています。投げられるのは Strict モード (テスト実行時) だけで、操作した Trace がいちばん内側の Trace と一致しないとき (内側を閉じる前に外側を finish した、同じ Trace を 2 回 finish した等) と、Trace が 1 つも走っていないときです。Relaxed モード (本番) は v1.6.0 から例外を投げません (ネストとモード)。メッセージの文字列ではなく、こちらで縛れます。
| メソッド | 返すもの |
|---|---|
getOperationType() | 試みた操作 ('Log' / 'Finish' / 'Skip' / 'Abort') |
getCurrentContext() | 例外の時点でいちばん内側にあったコンテキスト名 (Trace が 1 つも走っていなければ null) |
getExpectedContext() | 操作の対象として期待されたコンテキスト名 |
該当しない場合はどれも null です。なお、Trace インスタンス自身の名前は t.getContextName() で読めます。
関連ドキュメント
- Trace のライフサイクル: 4 メソッドと 3 つの終了パス
- ガバナ消費を縛る: TraceUsage によるバルクの保険
- ApexTrace ガイド: ガイド目次に戻る