xUnit v3-native load-style test runner for executing actions concurrently over a duration and reporting throughput/success/failure.
Best for:
- CI performance smoke tests
- Concurrency and regression checks
- Integration-style load tests inside
dotnet test - Quick validation that your API handles N concurrent requests
Not for:
- Distributed load generation across machines
- Protocol-specific clients (HTTP/2, gRPC, WebSocket)
- Full observability platform with dashboards
dotnet add package xUnitV3LoadFrameworkThis framework is a true xUnit v3 extension:
- Runs in
dotnet test— no extra tooling needed - Produces test failures visible in IDE and CI
- Supports filtering with
--filter - Respects xUnit cancellation
- Test method body becomes the action — no manual runner call needed
The test method body runs automatically under load — no manual ExecuteAsync() call needed:
using xUnitV3LoadFramework.Attributes;
public class ApiLoadTests
{
private static readonly HttpClient _httpClient = new();
[Load(concurrency: 5, duration: 3000, interval: 500)]
public async Task Api_Should_Handle_Concurrent_Requests()
{
// This entire method body runs N times under load
var response = await _httpClient.GetAsync("https://api.example.com/health");
response.EnsureSuccessStatusCode();
}
}Test passes if all iterations complete without exception. Test fails if any iteration throws or returns false.
Supported return types:
async Task— success if no exceptionvoid— success if no exceptionTask<bool>/ValueTask<bool>— success if returnstruebool— success if returnstrue
using xUnitV3LoadFramework.Extensions;
public class ApiLoadTests
{
private static readonly HttpClient _httpClient = new();
[Fact]
public async Task Api_Load_Test_Fluent()
{
var result = await LoadTestRunner.Create()
.WithName("HealthCheck_Load")
.WithConcurrency(10)
.WithDuration(TimeSpan.FromSeconds(5))
.WithInterval(TimeSpan.FromMilliseconds(200))
.RunAsync(async () =>
{
var response = await _httpClient.GetAsync("https://api.example.com/health");
response.EnsureSuccessStatusCode();
});
Assert.True(result.Success >= result.Total * 0.95);
}
}[Load], [Fact], and [Theory] can coexist in the same test class:
public class ApiTests
{
private static readonly HttpClient _httpClient = new();
[Fact]
public void Should_Have_Valid_BaseUrl()
{
Assert.NotNull(_httpClient.BaseAddress);
}
[Theory]
[InlineData("/health")]
[InlineData("/ready")]
public async Task Endpoint_Should_Exist(string path)
{
var response = await _httpClient.GetAsync(path);
Assert.True(response.IsSuccessStatusCode);
}
[Load(concurrency: 5, duration: 3000, interval: 500)]
public async Task Api_Should_Handle_Load()
{
var response = await _httpClient.GetAsync("/health");
response.EnsureSuccessStatusCode();
}
}Native [Load] test output:
Load Test Results:
Total: 30, Success: 28, Failure: 2
RPS: 9.8, Avg: 102ms, P95: 150ms, P99: 180ms
Result: FAILED (93.3% success rate)
Fluent API test output:
Load test 'HealthCheck_Load' completed:
Total executions: 50
Successful executions: 48
Failed executions: 2
Execution time: 5.12 seconds
Requests per second: 9.77
Average latency: 102.34ms
Success rate: 96.00%
| Setting | Description |
|---|---|
| Concurrency | Number of concurrent operations launched per interval |
| Duration | Total time the load test runs |
| Interval | Time between launching batches of concurrent operations |
| TerminationMode | How the test stops: Duration (immediate), CompleteCurrentInterval (finish current batch), or StrictDuration (exact timing) |
| GracefulStopTimeout | Max time to wait for in-flight requests after duration expires. Default: 30% of duration, bounded 5-60s |
| Success | Action returns true or completes without exception |
| Failure | Action returns false or throws an exception |
| RequestsPerSecond | Total / Time — completed operations per second |
Every Interval, the framework launches Concurrency concurrent operations. For example:
Concurrency: 5, Duration: 3s, Interval: 500ms= 6 batches × 5 operations = ~30 total operations
For full control, use LoadExecutionPlan with LoadRunner.Run():
using LoadSurge.Models;
using LoadSurge.Runner;
[Fact]
public async Task Advanced_Load_Test()
{
var plan = new LoadExecutionPlan
{
Name = "Database_Connection_Pool",
Settings = new LoadSettings
{
Concurrency = 20,
Duration = TimeSpan.FromSeconds(30),
Interval = TimeSpan.FromMilliseconds(100),
TerminationMode = TerminationMode.CompleteCurrentInterval,
GracefulStopTimeout = TimeSpan.FromSeconds(10)
},
Action = async () =>
{
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
return true;
}
};
var result = await LoadRunner.Run(plan);
Assert.True(result.Success >= result.Total * 0.99);
}| Mode | Behavior |
|---|---|
Duration |
Stops immediately when duration expires (default) |
CompleteCurrentInterval |
Waits for current batch to finish before stopping |
StrictDuration |
Strict timing — may cut off final batch |
Stop after a fixed number of operations regardless of duration:
var result = await LoadTestRunner.Create()
.WithConcurrency(10)
.WithDuration(TimeSpan.FromMinutes(5))
.WithMaxIterations(1000) // Stop after 1000 operations
.RunAsync(async () => { /* ... */ });Use LoadResult fields to fail tests based on performance criteria:
var result = await LoadTestRunner.Create()
.WithConcurrency(10)
.WithDuration(TimeSpan.FromSeconds(10))
.RunAsync(async () => { /* ... */ });
// Success rate gate
var successRate = (double)result.Success / result.Total;
Assert.True(successRate >= 0.99, $"Success rate {successRate:P} below 99%");
// Throughput gate
Assert.True(result.RequestsPerSecond >= 50, $"RPS {result.RequestsPerSecond} below 50");
// Latency gate
Assert.True(result.Percentile95Latency < 500, $"P95 latency {result.Percentile95Latency}ms exceeds 500ms");
Assert.True(result.AverageLatency < 200, $"Avg latency {result.AverageLatency}ms exceeds 200ms");Total,Success,Failure— countsTime— execution time in secondsRequestsPerSecond— throughputAverageLatency,MinLatency,MaxLatency— in millisecondsMedianLatency,Percentile95Latency,Percentile99Latency— percentiles in msPeakMemoryUsage— bytes
The framework runs all iterations to completion before determining pass/fail:
- All iterations execute regardless of individual failures
- Exceptions are caught and counted as failures (not thrown)
- At the end, a report shows Total/Success/Failure counts
- Test is marked FAILED if
Failure > 0
This means you always get complete metrics, even when some iterations fail.
Native [Load] tests: Pass/fail is automatic based on iteration results.
Fluent API tests: You control pass/fail with assertions:
var result = await LoadTestRunner.Create()
.WithConcurrency(10)
.WithDuration(TimeSpan.FromSeconds(5))
.RunAsync(async () => { /* ... */ });
// Allow up to 5% failure rate
var successRate = (double)result.Success / result.Total;
Assert.True(successRate >= 0.95, $"Success rate {successRate:P} below 95%");- Thread-safety: Your action runs concurrently. Avoid shared mutable state unless protected.
- Reuse HttpClient: Create a single
static readonly HttpClient— don't instantiate per request. - Start low: Begin with low concurrency and short duration. Increase gradually.
- Timeouts: Add your own timeout in the action. The framework won't kill hung operations:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await httpClient.GetAsync(url, cts.Token);
- Cancellation: The framework uses
GracefulStopTimeoutto wait for in-flight requests. Long-running actions without cancellation support may delay test completion. - CI filtering: Use
dotnet test --filter "FullyQualifiedName~LoadTests"to run only load tests or exclude them from fast CI runs.
Use this framework when:
- You want load tests as part of
dotnet testwithout extra tooling - You need quick concurrency smoke tests in CI
- Your tests are integration-style (HTTP calls, database queries)
- You want xUnit v3 native attributes and test discovery
Use a dedicated tool when:
- You need distributed load from multiple machines
- You need protocol-specific features (HTTP/2 multiplexing, WebSocket)
- You need real-time dashboards and detailed analytics
Think of this like a playground stress test. You set:
- How many kids play at once (
Concurrency) - How long the playground is open (
Duration) - How often new groups arrive (
Interval)
The framework tells you how many kids had fun (success), how many fell off the swings (failure), and how fast the line moved (RPS).
- .NET 8.0+ or .NET Framework 4.7.2+ (netstandard2.0)
- xUnit v3
PRs welcome. Open an issue for bugs or feature requests.
Made by Vasyl