Your endpoint returns 200 OK. It also ran the same query 51 times.
A refactor keeps the response correct, keeps the status 200, keeps every test green, and turns one
query into fifty. Nothing about the response says so, because tests assert what came back, not how
many round trips produced it.
QueryGuard counts the EF Core queries your code actually runs, groups the repeated ones, and turns that into something a test can fail on.
Note
It reports repeated-query candidates. Repeated SQL is strong evidence of an N+1, not proof. Some repetition is correct. Every finding says so, and every intentional exception is recorded with a reason instead of hidden.
Install
dotnet add package QueryGuard.AspNetCore.Testing
This package measures real WebApplicationFactory requests and works with any test framework.
Measure one request
Open a measurement for the application's EF Core context:
using var factory = new WebApplicationFactory<Program>();
await using var guard = factory.TrackQueries<Program, AppDbContext>(
"GET /api/companies",
QueryGuardPolicy.Create("companies").WithMaxOccurrencesPerFingerprint(5));
var response = await guard.Client.GetAsync("/api/companies");
response.EnsureSuccessStatusCode();
QueryGuardAssert.Passes(await guard.CompleteAsync());
The helper attaches QueryGuard, preserves the execution context used by TestServer, and prevents the
request middleware from hiding the test scope. Outside a scope nothing is captured. See the
testing guide for service tests, background jobs, and manual scopes.
What a failure tells you
QueryGuard FAILED: GET /api/companies (policy 'companies')
51 read queries in 2 distinct queries
[FAIL] max-occurrences-per-fingerprint: QG-FP-FDB5F469 executed 50 times; the budget is 5.
SQL: SELECT COUNT(*) FROM "Departments" AS "d" WHERE "d"."CompanyId" = ?
origin: samples/QueryGuard.SampleApi/Program.cs:line 89
The last line is the one that saves time: it points at the code that ran the query, not just the SQL.
Or skip picking a number entirely
WithMaxOccurrencesPerFingerprint(5) needs someone to know that five is right. On an endpoint nobody
has measured, nobody does. So record what it costs today and report what changed:
| Scope | Before | Now | Change |
|---|---|---|---|
GET /api/companies |
3 | 51 | +48, most-repeated query +48 |
GET /api/orders |
8 | 8 | most-repeated query +7 |
GET /api/users |
4 | 4 | unchanged |
GET /api/reports |
12 | 3 | -9 (improved) |
The orders row is the one worth a second look: the read count did not move, but one query is now
running seven more of them, which a total-count budget cannot see.
In CI, without writing plumbing:
dotnet tool install -g QueryGuard.Cli
queryguard baseline record # once, then commit the file
queryguard verify --summary artifacts/queryguard/summary.md
- uses: Benziza/queryguard-dotnet@v0.1.0
The action posts that table as a sticky pull request comment. See baselines.
Where to go next
| How it works | Sessions, fingerprints, redaction, analysis |
| Testing | WebApplicationFactory requests and explicit scopes |
| Configuration | Every budget and option, and why each default is what it is |
| Baselines | Recording what a scope costs and reporting what changed |
| Provider support | What is integration-tested and what is merely captured |
| Troubleshooting | Nothing recorded, fingerprints not grouping, middleware ordering |
| When a finding is wrong | The allowlist workflow, end to end |
| Decision records | Why it behaves the way it does |
| API reference | Generated from the source |
Scope
Tested against real SQL Server, PostgreSQL, MySQL, and SQLite in CI, on EF Core 8 and 10. Any relational EF Core provider is captured through the same official interception contract.
It does not prove an N+1, does not see Dapper or raw ADO.NET, produces no execution plans or profiler UI, and will not fix your code. Profilers and APM answer what is slow in production? QueryGuard answers did this change alter how many queries we run? before merge, as a build failure.
It reads SQL, so the defaults are the contract: no parameter values, no connection strings, nothing written into HTTP responses. Redaction runs centrally before any reporter sees a string, so no reporter, including one you write, can leak what was never captured.