TraceFlow でテストの経路を検証する

•Apex Stem ドキュメント
Apex StemApexTraceTestingSalesforceApex
戻り値が void の Usecase を「副作用」と「経路」の 2 軸で縛る方法。経路の区別と、lastHistoryContains と contains の使い分けを実例で解説します。

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
APEX
Assert.isTrue(TraceFlow.contains('3 件の商談に業種をコピー'));  // 履歴のどこにあっても通る

どちらも null を渡すと false を返します。

invoke() が void でも経路で検証できる

戻り値が void の Usecase は、テストの観察対象が副作用 (DML や field 更新の中身) に偏りがちです。それでも TraceFlow を組み合わせれば、「副作用」と「経路」の 2 軸で挙動を縛れます。これは ApexTrace が Apex Stem に標準装備されている理由のひとつです。

コード例: skip と finish を区別する

「対象がなくてスキップされた」と「正常に完了した」を、別々のテストとして書きます。

APEX
@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 の中身) とは別の軸で検証できます。

ログ内容の検証も可能です。

APEX
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 だけを見ます。

APEX
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)
APEX
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() で読めます。

関連ドキュメント