TraceUsage でガバナ消費を縛る
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 行で始められます。難しいのは「どこに置くか」と「上限をいくつにするか」だけです。
// Usecase を実行したあと、名前を指定して消費を引く
TraceFlow.usageOf('取引先の商談サマリを再集計')
.assertInvocationsAtMost(2);
usageOf に渡す名前は、Usecase 側の Trace.of(...) に書いた文字列そのままです。
private Trace t = Trace.of('取引先の商談サマリを再集計'); // ← この文字列
まずは起動回数だけから始めるのをおすすめします。回数は「この Usecase は 1 回の保存につき 1 回動く」という設計意図そのものなので、実際に走らせる前に決められます。消費量の上限は実測しないと決まらないので、後回しで構いません。
バルク IT の全体像
本来の置き場所は、ApexBlueprint の times() で本番サイズを量産し、トリガー経由でカスケードを発火させる結合テストです。単体テスト (MockEloquent) は実 SOQL を発行しないのでクエリ数には構造的に盲目で、この死角は実 DML を本番サイズで流す 1 本でしか塞げません。
@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 回の呼び出しで全件が来る」前提の実装が入った |
| ②-1 | assertInvocationsAtMost | 配線。この Usecase が何回起動してよいか | 別のトリガーからも呼ばれ始めた / 再入が増えた |
| ②-2 | assertSoqlQueriesAtMost | 中身。件数に比例してクエリを撃っていないこと | ループの中でクエリを撃つ実装 (N+1) が混入した |
| ③ | Assert.isTrue(soqlUsed < ...) | カスケード全体の余白 | 自分以外の場所も含めて、トランザクションが太った |
① を省かないでください。 take(200) を仕込んだ実装はクエリ数がむしろ減るので、② と ③ は緑のまま通ります。ガバナだけ見ていると「取りこぼしているのに消費は少ない」を見逃します。
上限の決め方
ちょうどの値にしない
ここが実務で一番間違えやすい点です。実測 2 本に対して assertSoqlQueriesAtMost(2) と書くと、それは実質「ちょうど 2 本」の主張になり、正当なリファクタでクエリが 1 本増えただけで落ちます。1 件ごとにクエリを撃つ実装なら件数ぶんになって確実に落ち、正当な追加 1〜2 本では落ちない水準に置きます。トランザクション全体を Limits.getQueries() < 上限の半分 で見る従来のガバナ IT と同じ考え方です。
例外は「1 本も発行しないこと」自体に意味がある場合です。「差分がなければ何もしない」ようなケースは AtMost(0) で縛って構いません。
実際に「案件ごとにクエリを撃つ」実装へ意図的に劣化させたところ、プラットフォーム上限 100 本に届く前に鳴りました。
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+)。理由は失敗メッセージの数字より前に出ます。
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 だけを見ます。
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 を呼ぶ場面を考えます。
ハンドラ (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 つ以上のコンテキストが閉じている場合は例外になります。
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 のときは、その主張に根拠がないため例外になります。
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+)。
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())。
TraceUsage usage = TraceFlow.usageOf('商談に親取引先の業種をコピー');
Integer soql = usage.getSoqlQueries();
知っておくべき挙動
- inclusive と exclusive がある。履歴に記録される生の値は inclusive (
startからクローズまでの総量なので、ネストした子の消費も含む) ですが、usageOf/usagesOfが返すのは exclusive (そこから直下の子のぶんを引いた、自分だけの消費) です。lastUsage()と本番デバッグログのUsage:行は inclusive のままです - アサート失敗は catch 可能な
TraceException(Assert.failのAssertExceptionは catch 不能でヘルパー自体をテストできないため)。メッセージには消費の内訳が全部載ります - 本番のデバッグログにも出ます。
FINISH: ...の直後にUsage: SOQL: 3 (rows: 120), ...が付くので、テスト外でも Usecase 単位のコストが常時見えます
⚠️ 単体テスト (
MockEloquent) では usage はすべてゼロになります。 実 SOQL を発行しないので当然です。ガバナのアサートは実 DML を流す結合テスト側に書いてください。単体側に書いても、何も検証していないテストになります。詳しくは テスト戦略 の「その代表 1 本は、ガバナ IT にする」を参照してください。
関連ドキュメント
- 親子・量産・参照のパターン:
times()で本番サイズを量産する - テスト戦略: ガバナ IT を代表 1 本に据える理由
- ApexTrace ガイド: ガイド目次に戻る