Pinning Governor Usage with TraceUsage
TraceUsage: Governor Usage per Context
Every Trace context records its governor usage automatically, from start() through to its close (finish / skip / abort). Pull it out by name with TraceFlow.usageOf(name) (v1.2.0+).
What the tool is for
Get the design intent straight first. TraceUsage is not a tool for deciding how many SOQL queries a Usecase ought to use. It is insurance. It pins one thing and one thing only: that running in bulk has not introduced an implementation that fires a query per record (N+1), and it pins it with a loose ceiling.
📌 The measurements on this page come from a single org holding roughly 210,000 opportunities.
Invocation counts in particular depend on how many times your cascade re-enters, so your numbers will differ. Without re-entry, the "30 records → 2 invocations" below would be "30 records → 1". Treat the figures as a sense of magnitude, not as thresholds to copy.
Three tools, three jobs
What you are measuring decides which tool you reach for.
| What you want to measure | Tool |
|---|---|
| The whole synchronous trigger cascade | Limits, captured into a variable inside the block |
| What one specific Usecase caused | TraceFlow.usageOf(name) (v1.2.0+) |
Asynchronous work (batches / Queueables that run at stopTest()) | TraceUsage, and nothing else |
The third row is where TraceUsage stands alone. A batch only starts running at Test.stopTest(), so Limits cannot reach it in principle. It is the only way to pin the governor usage of asynchronous work.
🚨 "Inside the block" in row one is a hard requirement.
Test.stopTest()restores the governor counters to their pre-startTest()state, so readingLimits.getQueries()afterwards returns the Arrange figure, not the Act one, and the assertion passes no matter what (details and measurements in the test strategy).
The minimal form
Two lines is enough to start. The hard parts are "where to put it" and "what ceiling to pick" — the syntax is not one of them.
// After running the Usecase, pull its usage out by name
TraceFlow.usageOf('Resummarize the account opportunity rollup')
.assertInvocationsAtMost(2);
The name you pass to usageOf is verbatim the string in the Usecase's Trace.of(...).
private Trace t = Trace.of('Resummarize the account opportunity rollup'); // this string
Start with invocation counts only. A count is the design intent itself — "this Usecase runs once per save" — so you can decide it before running anything. Consumption ceilings need a measurement first, so leave them for later.
The full bulk IT
The real home for this is an integration test that mass-produces production-sized data with ApexBlueprint's times() and fires the cascade through a trigger. Unit tests (MockEloquent) issue no real SOQL, so they are structurally blind to query counts; only one test that runs real DML at production size can close that gap.
@isTest
static void testInsert_WhenBulk_ThenWithinGovernorLimits() {
Trace t = Trace.of('Edge case: bulk-inserting opportunities leaves governor headroom');
t.start();
// ---- Arrange: declare production size (1 account + 201 opportunities) ----
SOrchestrator o = SOrchestrator.start()
.add(SBlueprint.of(Account.class)
.template(Blueprints.accBasic())
.withChildren(
SBlueprint.of(Opportunity.class)
.template(Blueprints.oppBasic())
.times(201))); // 201 or more (200 does not cross the chunk boundary)
// ---- Act: the real DML in create() fires before/afterInsert in bulk ----
TraceFlow.discardArrange(); // always place it on the boundary
Test.startTest();
o.create();
Integer soqlUsed = Limits.getQueries(); // capture INSIDE the block
Test.stopTest();
// ---- Assert ----
// (1) Correctness. Pin a number that cannot hold if a record is dropped. This is the point
Assert.areEqual(201, [SELECT COUNT() FROM Opportunity], 'all 201 records were processed');
// (2) Consumption of the Usecase you care about
TraceFlow.usageOf('Resummarize the account opportunity rollup')
.assertInvocationsAtMost(6, 'measured 5 on a 201-record insert; more suggests another entry point got wired in')
.assertSoqlQueriesAtMost(15, 'measured 2; growth proportional to record count means a query inside a loop');
// (3) Transaction-wide governor headroom
Assert.isTrue(soqlUsed < Limits.getLimitQueries() / 2,
'SOQL stays under half the limit even in bulk. Measured ' + soqlUsed);
t.finish();
}
⚠️ In this example the Arrange (assembling the
SOrchestrator) issues no DML, sodiscardArrange()has no practical effect. Place it anyway. Readers copy this shape into their own tests, where the Arrange does run DML. The point is to show "always on the boundary" as a form.
The act here is o.create(). ApexBlueprint realises the declaration through real DML, so generating the data doubles as the trigger (when you want to observe an update / delete cascade instead, create() is Arrange and belongs outside startTest()).
times(30) is not enough. Salesforce invokes triggers in chunks of 200, so a test that stops at 200 lets through implementations that assume "every record arrives in one call" (the reasoning and measurements are in test strategy > why 201 records).
The three tiers each protect something different
| # | Assertion | What it insures against | When it breaks |
|---|---|---|---|
| (1) | Assert.areEqual(201, ...) | Not dropping records. This is the point | take(200) / LIMIT / an "everything arrives in one call" assumption got in |
| (2)-1 | assertInvocationsAtMost | Wiring. How many times this Usecase may fire | Another trigger started calling it / re-entry increased |
| (2)-2 | assertSoqlQueriesAtMost | The body. Queries must not scale with record count | A query inside a loop (N+1) got in |
| (3) | Assert.isTrue(soqlUsed < ...) | Headroom across the cascade | The transaction grew, including places that are not yours |
Do not skip (1). An implementation with take(200) in it actually issues fewer queries, so (2) and (3) both stay green. Watch only the governors and you miss "it dropped records but consumed little".
Choosing the ceiling
Never use the exact measurement
This is the most common mistake in practice. Writing assertSoqlQueriesAtMost(2) against a measured 2 is effectively a claim of "exactly 2", and a legitimate refactor that adds one query breaks it. Put the ceiling where an implementation that queries per record fails for certain (it would scale with the record count), while a legitimate addition of one or two queries does not. It is the same thinking as the classic governor IT that checks Limits.getQueries() < half the limit across the whole transaction.
The exception is when "issues nothing at all" is itself the point. For "does nothing when there is no delta", AtMost(0) is a fair constraint.
Deliberately degrading an implementation to query per record made it fire before it reached the platform ceiling of 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
That message alone tells you invocations is 2 while SOQL is 32 — so the cause is not extra wiring but a loop in the body.
Put the measurement and the suspicion into reason
All six asserts take a (Integer max, String reason) overload (v1.3.0+). The reason prints before the numbers in the failure message.
Usage assertion failed: DML statements is 1, exceeding the allowed maximum of 0.
Reason: measured 1 statement (20 rows) even at 20 opportunities; growth with record count means a DML inside a loop
Actual usage: SOQL: 0 (rows: 0), DML: 1 (rows: 20), Callouts: 0
What this guard protects against is changes to code you did not write. An admin adds a Flow; another team adds a trigger. The cascade changes without anyone touching Apex. The problem is that the window before someone deletes the red is short. Numbers alone read as "the ceiling is too strict", and the threshold gets raised or the line gets removed. A source comment has to be opened to be read; a failure message always gets read.
Write two things: the measured value (what it is today — a bare ceiling reads as an over-strict setting) and what to suspect if it grows (where the person looking at the red should go next).
💡 As a side effect, it forces you to measure at the time of writing. A bare ceiling can be set to
5and forgotten; putting a number in the reason means you have to measure. (When this was adopted, a comment claiming "measured 2 queries" turned out to be stale — it was actually 0.)
The invocation ceiling is meaningless without the record count
Consumption wobbles with legitimate change, but an invocation count is the design intent, so it holds still.
⚠️ The invocation series quoted on this page and in the test strategy (30 → 2 / 200 → 3 / 201 → 5) is what you get without
discardArrange()— it includes the Arrange-time invocations. With it, the Arrange share drops out (measured at 201 records: 5 → 4).
That said, counts alone depend on record volume. Trigger chunking changes the number of invocations, so keeping a ceiling written for 30 records and raising the volume to 201 fails with no bug present (consumption ceilings are roughly flat against volume, so they do not have this problem). Writing the record count into reason prevents it.
The history accumulates from the Arrange — cut it with discardArrange()
The trace history accumulates from the top of the test method. Create records in the Arrange and any Usecase that ran through a trigger on that DML stays in the history, folded into the usageOf total.
Put TraceFlow.discardArrange() (v1.4.0+) on the Arrange / Act boundary and every assertion afterwards sees the Act alone.
setupAccountWithOpportunities(); // Arrange. The target Usecase runs once via the trigger
TraceFlow.discardArrange(); // here
Test.startTest(); // and here are the same boundary
Database.executeBatch(new ResummarizeBatch());
Test.stopTest();
TraceFlow.usageOf('Resummarize the account opportunity rollup') // the Act only; the count is right too
.assertInvocationsAtMost(1, 'one batch chunk')
.assertSoqlQueriesAtMost(0, 'no queries when there is no delta');
It is the same judgement call as placing Test.startTest(), so there is no new concept to learn. It even goes right next to it.
📌 It matters for path assertions too, not just consumption.
isLastSkip()and friends look at the most recently closed context, so when the Act produced none there was room to read an Arrange context instead.discardArrange()aligns the observation window to the Act.
⚠️ If you forget it, the library cannot say anything (it has no way to detect whether an Arrange exists). But assertInvocationsAtMost makes a natural watchdog: one Arrange invocation shows up as one extra count, so a test that pins counts catches the omission. The advice to "start with counts" pays off here as well.
Nesting: inclusive and exclusive (most readers can skip this)
There are three ways to pull usage out.
| One invocation | Total of all | |
|---|---|---|
| inclusive (children included) | lastUsage() | (does not exist) |
| exclusive (self only) | an element of usagesOf(name) | usageOf(name) |
The empty cell is the reason exclusive has to exist. Picture a handler calling two Usecases.
Handler (SOQL 6)
├ UsecaseA (SOQL 2)
└ UsecaseB (SOQL 4)
Add those up as inclusive values and you get 6 + 2 + 4 = 12 — double, when only 6 queries were actually issued. That is why usageOf, which produces a total, has to be exclusive (the handler's own exclusive comes out as 0).
📌 If you only put
Traceon Usecases, inclusive and exclusive are the same number.They diverge only when you also put a
Traceoutside the nesting, on a handler for instance.
⚠️ inclusive / exclusive does not apply to counts. Exclusive is the mechanism that splits consumption between parent and child; getInvocations() counts how many times that context closed. It is not something being split.
When to reach for usagesOf
With discardArrange() in place, ordinary assertions are fine on usageOf. You need usagesOf(name) only when the same Usecase runs several times within the Act and you want the individual breakdown — inspecting the consumption of each batch chunk, say.
🚨 Do not use it as "take the last element to get the Act's single run". That is only correct when the Act fired that Usecase exactly once, and the moment the work splits into two chunks you are measuring the last chunk alone. To measure the whole Act, use discardArrange() + usageOf.
Misuse guards
The places where "using it wrong stays green" have been closed off. All of these apply under test execution (Strict mode) only and are skipped in production.
An ambiguous lastUsage() throws
lastUsage() returns only the single most recently closed context. In a bulk IT where the handler calls several Usecases, which one you measured was decided by accident. From v1.3.0, two or more contexts closing at the same depth raises an exception.
TraceException: lastUsage() is ambiguous — 3 contexts closed at the same level:
Resummarize the account opportunity rollup / Recalculate the opportunity total /
Resummarize the account opportunity rollup
Use TraceFlow.usageOf(contextName) to target one.
The candidate list is the diagnosis. The same name appearing twice means the Arrange DML also ran that Usecase through a trigger — in other words, adding a single DML to the Arrange used to change what you were measuring. Nesting on its own is not ambiguous (LIFO makes the outermost the natural target).
A consumption assert on zero invocations throws
assertSoqlQueriesAtMost(n) claims "this context ran, and stayed under n". When invocations = 0 — a mistyped name, say — that claim has no basis, so it throws.
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).
Because usageOf takes the context name as a string, renaming a Trace.of(...) used to fail silently. Now it goes red. It doubles as a safety net for renames.
assertInvocationsAtMost(0) is exempt on purpose: "it does not run" is a legitimate claim.
discardArrange() can only be called on the boundary
Call it while a Usecase context is open and that Start entry disappears, silently dropping one element from usagesOf. On the boundary, at most the test's own Trace is open, so anything beyond that raises (v1.4.0+).
TraceException: discardArrange() must be called at the Arrange / Act boundary,
while no usecase context is open. Currently open: 2 contexts.
Reference
Only five deterministic metrics are recorded
| Metric | Getter |
|---|---|
| SOQL queries | getSoqlQueries() |
| SOQL rows | getSoqlRows() |
| DML statements | getDmlStatements() |
| DML rows | getDmlRows() |
| Callouts | getCallouts() |
CPU time and heap are deliberately excluded: they vary run to run, and asserting a threshold against them makes tests flaky. Reach for the getters when you want the raw value (counts come from getInvocations()).
TraceUsage usage = TraceFlow.usageOf('Copy the parent account industry to the opportunity');
Integer soql = usage.getSoqlQueries();
Behaviour worth knowing
- There is an inclusive value and an exclusive value. The raw value recorded in the history is inclusive (the total between
startand close, so it includes nested children), butusageOf/usagesOfreturn exclusive (that minus the direct children's share — this context's own consumption).lastUsage()and theUsage:line in the production debug log stay inclusive - A failed assert is a catchable
TraceException(Assert.fail'sAssertExceptioncannot be caught, which would make the helper itself untestable). The message carries the full usage breakdown - It shows up in the production debug log too:
Usage: SOQL: 3 (rows: 120), ...followsFINISH: ..., so the cost per Usecase is visible outside tests as well
⚠️ Under unit tests (
MockEloquent) every usage value is zero. No real SOQL is issued, so of course it is. Put governor assertions on the integration side, where real DML runs. On the unit side they verify nothing. See "make that one representative test a governor IT" in the test strategy.
Related Documents
- Relations, bulk generation, and reference patterns: mass-producing production size with
times() - Test strategy: why the governor IT is one of the representative tests
- ApexTrace guide: back to the guide index