TraceUsage でガバナ消費を縛る

Apex Stem ドキュメント
Apex StemApexTraceGovernor LimitsTestingSalesforceApex
バルクで N+1 が混入していないことを緩い上限で縛る保険。ガバナ IT の形、usageOf(name) による exclusive 集計、Limits を stopTest 後に読む罠を扱います。

TraceUsage: コンテキスト単位のガバナ消費

各 Trace コンテキストは、start() からクローズ (finish / skip / abort) までのガバナ消費を自動で記録しています。TraceFlow.usageOf(name) で名前を指定して取り出します (v1.2.0+)。

何のための道具か

まず設計意図を押さえてください。TraceUsage は「この Usecase は SOQL を何本使うべきか」を決める道具ではありません。狙いは保険です。バルクで走ったときに 1 件ごとにクエリを撃つ実装 (N+1) が混入していないこと、それだけを緩い上限で縛ります。

📌 本ページの「実測」は、商談 21 万件規模の 1 組織で計測した値です。

とくに起動回数はカスケードの再入回数に依存するため、あなたの環境では違う数字になります。再入のない実装なら、後述の「30 件 → 2 回」は「30 件 → 1 回」です。数値は「この桁で動く」という感覚をつかむためのもので、そのまま閾値に使わないでください。

3 者の棲み分け

測りたいものによって手段が変わります。

測りたいもの手段
トリガーの同期カスケード全体Limitsブロック内で変数に掴む
特定の Usecase が引き起こした分TraceFlow.usageOf(name) (v1.2.0+)
非同期 (stopTest() で走るバッチ / Queueable)TraceUsage しかない

3 行目が TraceUsage の独壇場です。バッチは Test.stopTest() で初めて走るので、Limits では原理的に測れません。

🚨 1 行目の「ブロック内で」は必須条件です。Test.stopTest() はガバナカウンタを startTest() 前に戻すため、その後で読む Limits.getQueries() は Act ではなく Arrange の値を返し、アサートが何があっても通ります (詳細は テスト戦略)。

最小の形

まず 2 行で始められます。難しいのは「どこに置くか」と「上限をいくつにするか」だけです。

APEX
// Usecase を実行したあと、名前を指定して消費を引く
TraceFlow.usageOf('取引先の商談サマリを再集計')
  .assertInvocationsAtMost(2);

usageOf に渡す名前は、Usecase 側の Trace.of(...) に書いた文字列そのままです。

APEX
private Trace t = Trace.of('取引先の商談サマリを再集計');   // ← この文字列

まずは起動回数だけから始めるのをおすすめします。回数は「この Usecase は 1 回の保存につき 1 回動く」という設計意図そのものなので、実際に走らせる前に決められます。消費量の上限は実測しないと決まらないので、後回しで構いません。

バルク IT の全体像

本来の置き場所は、ApexBlueprinttimes() で本番サイズを量産し、トリガー経由でカスケードを発火させる結合テストです。単体テスト (MockEloquent) は実 SOQL を発行しないのでクエリ数には構造的に盲目で、この死角は実 DML を本番サイズで流す 1 本でしか塞げません。

APEX
@isTest
static void testInsert_WhenBulk_ThenWithinGovernorLimits() {
  Trace t = Trace.of('エッジケース: 商談を一括登録してもガバナに余裕があること');
  t.start();
 
  // ---- Arrange: 本番サイズを宣言する (取引先 1 件 + 商談 201 件) ----
  SOrchestrator o = SOrchestrator.start()
    .add(SBlueprint.of(Account.class)
      .template(Blueprints.accBasic())
      .withChildren(
        SBlueprint.of(Opportunity.class)
          .template(Blueprints.oppBasic())
          .times(201)));   // 201 件以上 (200 件までだとトリガー分割を越えられない)
 
  // ---- Act: create() の実 DML が before/afterInsert をバルクで発火させる ----
  TraceFlow.discardArrange();               // Arrange / Act の境界には必ず置く
  Test.startTest();
  o.create();
  Integer soqlUsed = Limits.getQueries();   // ★ 必ずブロックの中で掴む
  Test.stopTest();
 
  // ---- Assert ----
  // ① 結果の正しさ (1 件でも欠けたら合わない値で縛る。これが主眼)
  Assert.areEqual(201, [SELECT COUNT() FROM Opportunity], '201 件すべてが処理されていること');
 
  // ② 狙った Usecase の消費
  TraceFlow.usageOf('取引先の商談サマリを再集計')
    .assertInvocationsAtMost(6, '201 件 insert 時の実測は 5。増えたら別の入口からも配線された疑い')
    .assertSoqlQueriesAtMost(15, '実測 2 本。件数に比例して増えていたらループ内クエリの疑い');
 
  // ③ トランザクション全体としてもガバナに余白があること
  Assert.isTrue(soqlUsed < Limits.getLimitQueries() / 2,
    'バルクでも SOQL は上限の半分未満。実測 ' + soqlUsed);
 
  t.finish();
}

⚠️ この例は Arrange (SOrchestrator の組み立て) が DML を発行しないため、discardArrange() に実質的な効果はありません。それでも置いてください。 読者は自分のテストにこの形をコピーし、そちらには Arrange の DML があります。「境界には必ず置く」を型として見せるのが目的です。

times(30) では足りません。Salesforce はトリガーを 200 件ずつに分けて呼ぶため、200 件までのテストは「1 回の呼び出しで全件が来る」前提の実装を素通りさせます (根拠と実測値は テスト戦略 > なぜ 201 件なのか)。

act になっているのは o.create() です。 ApexBlueprint は宣言を実 DML で realize するので、データ生成そのものが発火装置を兼ねます (update / delete のカスケードを見たいときは create() は Arrange なので startTest() の外に出します)。

3 段のアサートが、それぞれ別のものを守る

#アサート何を保険にしているかこれが破れるとき
Assert.areEqual(201, ...)取りこぼしていないこと。主眼はここtake(200) / LIMIT / 「1 回の呼び出しで全件が来る」前提の実装が入った
②-1assertInvocationsAtMost配線。この Usecase が何回起動してよいか別のトリガーからも呼ばれ始めた / 再入が増えた
②-2assertSoqlQueriesAtMost中身。件数に比例してクエリを撃っていないことループの中でクエリを撃つ実装 (N+1) が混入した
Assert.isTrue(soqlUsed < ...)カスケード全体の余白自分以外の場所も含めて、トランザクションが太った

① を省かないでください。 take(200) を仕込んだ実装はクエリ数がむしろ減るので、② と ③ は緑のまま通ります。ガバナだけ見ていると「取りこぼしているのに消費は少ない」を見逃します。

上限の決め方

ちょうどの値にしない

ここが実務で一番間違えやすい点です。実測 2 本に対して assertSoqlQueriesAtMost(2) と書くと、それは実質「ちょうど 2 本」の主張になり、正当なリファクタでクエリが 1 本増えただけで落ちます。1 件ごとにクエリを撃つ実装なら件数ぶんになって確実に落ち、正当な追加 1〜2 本では落ちない水準に置きます。トランザクション全体を Limits.getQueries() < 上限の半分 で見る従来のガバナ IT と同じ考え方です。

例外は「1 本も発行しないこと」自体に意味がある場合です。「差分がなければ何もしない」ようなケースは AtMost(0) で縛って構いません。

実際に「案件ごとにクエリを撃つ」実装へ意図的に劣化させたところ、プラットフォーム上限 100 本に届く前に鳴りました。

CODE
Usage assertion failed: SOQL queries is 32, exceeding the allowed maximum of 10.
Actual usage: Invocations: 2, SOQL: 32 (rows: 92), DML: 1 (rows: 60), Callouts: 0

このメッセージだけで、回数は 2 なのに SOQL が 32、つまり配線が増えたのではなく中身のループが原因、と切り分けられます。

reason には「実測値」と「増えたら何を疑うか」を書く

6 つの assert すべてに (Integer max, String reason) のオーバーロードがあります (v1.3.0+)。理由は失敗メッセージの数字より前に出ます。

CODE
Usage assertion failed: DML statements is 1, exceeding the allowed maximum of 0.
Reason: 案件20件でも実測1本(20行)。件数ぶん増えていたらループ内DMLが混入している
Actual usage: SOQL: 0 (rows: 0), DML: 1 (rows: 20), Callouts: 0

このガードが守るのは、自分が書いていないコードの変更です。Admin が Flow を 1 本足す、別チームがトリガーを増やす。Apex に一切触れていなくてもカスケードは変わります。問題は、赤を見た人がそれを消しにくるまでの時間が短いこと。数字だけでは「上限が厳しすぎる」と解釈され、閾値を上げるか行ごと消されます。ソースコメントは開かないと読めませんが、失敗メッセージは必ず読まれます

書くべきものは 2 つ。実測値 (今いくつか。上限だけでは厳しすぎる設定だと誤解される) と、増えたら何を疑うか (赤を見た人が次に何を見ればいいか) です。

💡 副次効果として、書く時点で実測を強制します。 上限だけなら 5 と書いて放置できますが、理由に数字を入れようとすると測らざるを得ません (実際、導入時にコメントの「実測 2 本」が古くなっていたことが発覚しました)。

回数の上限は「そのテストの件数」とセット

消費本数は正当な変更で揺れますが、回数は設計意図そのものなので動きません。

⚠️ 本ページと テスト戦略 に載せている回数の系列 (30 件 → 2 / 200 件 → 3 / 201 件 → 5) は、discardArrange()置かなかった場合の値です (Arrange の起動を含みます)。置いた場合は Arrange 分だけ少なくなります (201 件の実測: 5 → 4)。

ただし回数だけは件数に依存します。トリガーの分割で起動回数が変わるため、30 件のテストで書いた上限のまま件数を 201 に増やすと、バグが一切無いのに落ちます (消費量の上限は件数にほぼ一定なので、この問題は起きません)。reason に件数を書いておくと防げます。

履歴は Arrange から溜まる — discardArrange() で区切る

Trace の履歴はテストメソッドの先頭から溜まり続けます。 Arrange でレコードを作れば、その DML でトリガー経由で走った Usecase も履歴に残り、usageOf の合算に混ざります。

TraceFlow.discardArrange() (v1.4.0+) を Arrange と Act の境界に置くと、以降のアサートは Act だけを見ます。

APEX
setupAccountWithOpportunities();   // Arrange。トリガー経由で対象 Usecase が 1 回走る
 
TraceFlow.discardArrange();        // ← ここ
Test.startTest();                  // ← と、ここは同じ境界
 
Database.executeBatch(new ResummarizeBatch());
Test.stopTest();
 
TraceFlow.usageOf('取引先の商談サマリを再集計')   // Act だけ。回数も正しい
  .assertInvocationsAtMost(1, 'バッチ 1 チャンクぶん')
  .assertSoqlQueriesAtMost(0, '差分が無ければクエリを撃たないこと');

Test.startTest() を置くのと同じ判断なので、新しく覚える概念はありません。書く場所も隣です。

📌 消費だけでなく経路アサートにも効きます。 isLastSkip() などは「最後に閉じたコンテキスト」を見るため、Act が何も起こさなかった場合に Arrange のコンテキストを読む余地がありました。discardArrange() を置くと観測窓が Act に揃います。

⚠️ 書き忘れても、ライブラリは何も言えません (Arrange の有無を判定できないため)。ただし assertInvocationsAtMost が自然な番人になります。Arrange が 1 回起動していれば回数が 1 つ多く出るので、回数を締めていれば書き忘れは落ちます。「まず回数から始める」の推奨が、ここでも効きます。

入れ子: inclusive と exclusive (ほとんどの人は読み飛ばしてよい)

消費の取り出し方は 3 つあります。

1 回ぶん全部の合計
inclusive (子を含む)lastUsage()(存在しない)
exclusive (自分だけ)usagesOf(name) の要素usageOf(name)

空欄が、exclusive が必要な理由です。ハンドラが 2 つの Usecase を呼ぶ場面を考えます。

CODE
ハンドラ (SOQL 6)
 ├ UsecaseA (SOQL 2)
 └ UsecaseB (SOQL 4)

全部を inclusive のまま足すと 6 + 2 + 4 = 12。実際には 6 本しか撃っていないのに倍です。合計を出す usageOf が exclusive でなければならないのはこのためです (ハンドラ自身の exclusive は 0 になります)。

📌 Usecase にしか Trace を貼っていなければ、inclusive と exclusive は同じ値です。

差が出るのは、ハンドラなど入れ子の外側にも Trace を貼ったときだけです。

⚠️ inclusive / exclusive は「回数」には掛かりません。 exclusive は消費を親子で分解する仕組みですが、getInvocations() が数えているのはそのコンテキストが閉じた回数です。分解の対象ではありません。

usagesOf はいつ使うか

discardArrange() があれば、通常のアサートは usageOf で足ります。usagesOf(name) が要るのは、Act の中で同じ Usecase が複数回走り、その内訳を個別に見たいときだけです (バッチのチャンクごとの消費を調べる、など)。

🚨 「末尾を取れば Act の 1 回ぶんになる」という使い方はしないでください。 それが正しいのは Act がその Usecase をちょうど 1 回だけ起動したときに限られ、チャンクが 2 つに分かれた瞬間に「最後のチャンクだけ」を測ることになります。Act 全体を測りたいなら discardArrange() + usageOf です。

誤用ガード

「間違った使い方が緑で通る」面が塞がれています。いずれもテスト実行時 (Strict モード) のみで、本番ではスキップされます。

曖昧な lastUsage() は例外

lastUsage() が返すのは「最後に閉じた 1 コンテキスト」だけです。ハンドラが Usecase を複数呼ぶバルク IT では、測る対象がたまたま決まっている状態でした。v1.3.0 から、同じ深さで 2 つ以上のコンテキストが閉じている場合は例外になります。

CODE
TraceException: lastUsage() is ambiguous — 3 contexts closed at the same level:
  取引先の商談サマリを再集計 / 商談の合計金額を再計算 / 取引先の商談サマリを再集計
Use TraceFlow.usageOf(contextName) to target one.

候補リストがそのまま診断になります。 同じ名前が 2 回出ているのは、Arrange の DML でもトリガー経由で同じ Usecase が走っていたためです。つまり従来は Arrange に DML を 1 つ足すだけで測る対象が変わる状態でした。なおネストは曖昧ではありません (LIFO なので一番外側が自然な対象)。

起動 0 回への消費アサートは例外

assertSoqlQueriesAtMost(n) は「このコンテキストは走った。その上で n 以下」という主張です。名前を間違えたなどで invocations = 0 のときは、その主張に根拠がないため例外になります。

CODE
TraceException: Usage assertion on SOQL queries is unfounded:
no context with this name closed in the transaction (invocations = 0).
Check the context name for typos, the test layer, and the wiring.
To assert that the context does not run, use assertInvocationsAtMost(0).

usageOf はコンテキスト名を文字列で渡すため、Trace.of(...) をリネームすると従来は黙って空振りしていました。これで赤くなります。リネームの安全網としても効きます。

なお assertInvocationsAtMost(0) は「走らないこと」の正当な主張なので、意図的に対象外です。

discardArrange() は境界でしか呼べない

Usecase のコンテキストが開いている最中に呼ぶと、その Start エントリが消えて usagesOf から静かに 1 件落ちます。境界で呼んでいる限り、開いているのはテスト自身の Trace が最大 1 つなので、それ以上開いていれば例外になります (v1.4.0+)。

CODE
TraceException: discardArrange() must be called at the Arrange / Act boundary,
while no usecase context is open. Currently open: 2 contexts.

リファレンス

記録するのは決定的な 5 指標だけ

指標getter
SOQL 数getSoqlQueries()
SOQL 行数getSoqlRows()
DML 文数getDmlStatements()
DML 行数getDmlRows()
コールアウト数getCallouts()

CPU 時間とヒープは意図的に対象外です。実行ごとにぶれるため、閾値でアサートするとテストが不安定になります。生の値が欲しいときは getter を使います (回数は getInvocations())。

APEX
TraceUsage usage = TraceFlow.usageOf('商談に親取引先の業種をコピー');
Integer soql = usage.getSoqlQueries();

知っておくべき挙動

  • inclusive と exclusive がある。履歴に記録される生の値は inclusive (start からクローズまでの総量なので、ネストした子の消費も含む) ですが、usageOf / usagesOf が返すのは exclusive (そこから直下の子のぶんを引いた、自分だけの消費) です。lastUsage() と本番デバッグログの Usage: 行は inclusive のままです
  • アサート失敗は catch 可能な TraceException (Assert.failAssertException は catch 不能でヘルパー自体をテストできないため)。メッセージには消費の内訳が全部載ります
  • 本番のデバッグログにも出ますFINISH: ... の直後に Usage: SOQL: 3 (rows: 120), ... が付くので、テスト外でも Usecase 単位のコストが常時見えます

⚠️ 単体テスト (MockEloquent) では usage はすべてゼロになります。 実 SOQL を発行しないので当然です。ガバナのアサートは実 DML を流す結合テスト側に書いてください。単体側に書いても、何も検証していないテストになります。詳しくは テスト戦略 の「その代表 1 本は、ガバナ IT にする」を参照してください。

関連ドキュメント