Repository navigation
Improve load test quality and expand scenario coverage #165
Description
Summary
TournamentAPI.LoadTests has 4 tests in 3 classes. All of them send the same anonymous read query (GetAllWithBracketAndMatches). Two cover rate limiting and two cover latency. There are no writes, no auth flows, no steps and no runtime thresholds, and one test appears to exercise the wrong limiter. This issue tracks fixing the existing tests and adding the missing coverage.
Problems in the current tests
ConcurrencyLimiter_Should_LimitConcurrentRequestsmost likely tests the token bucket, not the concurrency limiter.- The
Task.Delay(1000)runs after the response returns, so it does not hold a concurrency permit. Requests are in flight for milliseconds and never approach the limit of 100. - The 429s come from the per-IP token bucket (100 tokens, 50 per minute), which
LoadTestWebAppFactorystill enables. - The delay also counts toward measured latency.
- The
- Test classes run in parallel. There is no
xunit.runner.json,[Collection]orDisableTestParallelization, so the classes compete for CPU and make the p95 assertions flaky. - Responses carry no status code or payload size.
Response.Ok()andResponse.Fail()are called bare, soOk.StatusCodesis empty andDataTransferis zero. Status-mix and payload thresholds cannot work until this changes. - A 200 with the wrong body counts as success. Nothing checks that
edgesis non-empty, andHasErrorsis the only failure signal. - There are no steps or thresholds. Everything is a scenario-level
Assertafter the run, so nothing is reported per business operation. Api_ShouldDegradeGracefully_AsLoadIncreasesasserts one p95 aggregated over three phases (5, 15 and 20 copies), so the top phase is diluted by the easier ones.- Smaller issues:
TestClientdeserializes the full JSON inside the timed path.TestClient._lastResponseis a shared mutable field, and itsHttpResponseMessageis never disposed.- The 429 handling is copy-pasted between two tests.
azure-sql-edge:latestis unpinned, and Azure SQL Edge may be retired. Worth checking.- The seed data is small (8 users, 17 tournaments), so N+1 and pagination costs will not show.
TournamentAPI.Benchmarks/BenchmarkDatabaseSeeder.csalready has Small, Medium and Large sizes. - The tests run in-process on TestServer, so numbers are relative, not absolute. This may deserve an ADR.
Requirements
HttpClient reuse
Already met for the current read-only tests: each test creates one client and closes over it. Two things break once auth is added:
SetAuthTokenmutates the sharedDefaultRequestHeaders. Use one client per virtual user, created once inWithInitand kept inScenarioInstanceData.WebApplicationFactoryclients handle cookies by default, so therefreshTokencookie would be shared across every virtual user. SetHandleCookies = false.
Setup outside the measured path
- Log in once per user in
WithInitand reuse the tokens. Identity password hashing is too expensive to run per iteration. - Write scenarios need tournaments, users and slots created up front, with ids derived from the invocation number.
- Mutation tests dirty the class-shared DB, so give them their own fixture or reseed in init.
Steps named after business operations
Use Step.Run("list_tournaments", context, ...), view_tournament_details, join_tournament, login and so on. Scenario names should describe the workload, for example browse_tournaments.
Threshold types (NBomber 6.4.1)
| Type | Example threshold | Needs |
|---|---|---|
| Error budget | Fail.Request.Percent < 1, or == 0 for pure reads |
Expected rejections (409, domain errors, 429) returned as Ok with a status code so they do not burn the budget |
| Tail latency | Ok.Latency.Percent99 < X plus max |
One threshold per step, with different limits for login and list |
| Status mix | Ok.StatusCodes.Find("200")?.Percent >= 99, no 500 in Fail.StatusCodes |
Real HTTP status on every response |
| Payload guard | Ok.DataTransfer.MeanBytes inside a min and max band |
sizeBytes on the response. The minimum catches empty results and the maximum catches unbounded over-fetch |
| Step health | Threshold.Create("join_tournament", s => ...) |
Steps |
Runtime thresholds are checked once per reporting interval (5s by default). For the 2 to 5 second rate-limit tests, assert result.Thresholds.All(t => !t.IsFailed) after the run, plus explicit asserts.
Proposed scenarios, in priority order
- Browse mix (list, by-id, participants, name filter, sort) as concurrent weighted scenarios. This is the baseline the others compare against.
- Expensive shapes:
- Owner and participant history (
wonTournaments,playedTournaments,wonMatches), which is the known N-per-user gap. - Standings on the round-robin branch.
- The excessive-page-size query, as a payload guard that should be rejected quickly.
- Owner and participant history (
- Join contention on "Almost Full Tournament", which has exactly one slot. Expect one success, the rest domain rejections and no 500s. This exercises the optimistic concurrency path.
- Auth flow: login,
me, refresh and logout, with per-user clients. - Concurrent match play or correction (
Timestampand version tokens), plus bracket generation at larger participant counts for both strategies. - Spike (a burst on top of normal load), then soak (10 to 15 minutes, watching for leaks and connection-pool exhaustion), then breakpoint (ramp until the error budget breaks).
- A corrected concurrency-limiter test with the token bucket off and permits actually held. Optionally add limiter recovery after the token bucket refills.
- Medium and Large dataset variants of 1 and 2.
Suggested structure
- A shared
GraphQLStephelper that runs one operation and returns aResponsewith status code, byte size and a body check, so every step gets the status-mix and payload thresholds. - A shared thresholds builder, so error budget, tail latency and step health are defined once.
- Feature-named classes such as
TournamentBrowseLoadTestsandTournamentJoinLoadTests, tagged[Trait("Category","Load")]so they can be excluded from normal runs.
Tasks
- Serialize the load test classes (collection or assembly-level setting)
- Fix the concurrency-limiter test (separate factory with the token bucket off, permits held)
- Add
GraphQLStephelper (status code, size, body check) and the thresholds builder - Convert existing tests to named steps with thresholds for all five types
- Split the ramp test so each phase is checked on its own
- Add per-user client setup and login in
WithInit - Add scenarios 1 to 6 above, one at a time
- Add Medium and Large dataset seeding (reuse or move
BenchmarkDatabaseSeeder) - Decide whether the in-process vs Kestrel choice needs an ADR
Definition of done
dotnet buildsucceeds, and the unit and integration tests still pass.- Each new scenario has a named step per business operation and thresholds for the applicable types.
- The load tests do not run in parallel with each other.
Activity