Load Testing .NET with NBomber: Scenarios, HTTP Testing, and CI Integration
Load testing is one of those disciplines where the ecosystem gap between runtimes is painfully visible. k6 runs JavaScript, Locust runs Python, Gatling runs Scala. If you are a .NET shop writing C# all day, reaching for a Python-based tool to test your ASP.NET service means learning a second language just to write assertions. NBomber solves that. It is a load testing framework written in F#, with a fully supported C# API, designed to run inside your existing .NET test infrastructure.
This post covers everything you need to go from zero to a production-ready load test suite: scenario setup, HTTP and gRPC testing, load shape simulation, percentile assertions, reporting, and CI/CD integration with hard thresholds.
NBomber vs k6 and Locust for .NET Developers
The argument for k6 is strong: excellent documentation, a built-in metrics pipeline, and first-class Grafana integration. But k6 scenarios are JavaScript, which means your team maintains two languages, two dependency managers, and two sets of idioms. Locust has the same problem with Python.
NBomber's advantages for .NET teams:
- Same language and toolchain. Tests are C# projects. You use
dotnet test, NuGet, xUnit, and the same CI pipeline you already have. - Full access to your application's types. You can reference your domain models, serializers, and test helpers directly from the load test project.
- Built-in data feeds and scenario composition. NBomber has first-class support for CSV and JSON data feeds, load simulation DSL, and step chaining without custom plugins.
- Runs in xUnit. You can embed NBomber scenarios in xUnit facts and fail the test on threshold violations — the same way you fail unit tests.
The trade-off: k6 has a larger ecosystem of pre-built scenarios and better cloud execution options. For teams already invested in .NET, NBomber removes enough friction to make consistent load testing actually happen.
Installation and Basic Scenario
Create a new xUnit project and add the core package:
dotnet new xunit -n MyApp.LoadTests
cd MyApp.LoadTests
dotnet add package NBomber
dotnet add package NBomber.HttpA minimal scenario that hammers a local endpoint:
using NBomber.CSharp;
using NBomber.Contracts;
var scenario = Scenario.Create("ping_scenario", async context =>
{
await Task.Delay(100); // simulate work
return Response.Ok();
})
.WithLoadSimulations(
Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromSeconds(30))
);
NBomberRunner
.RegisterScenarios(scenario)
.Run();Simulation.Inject sends 50 requests per second for 30 seconds — a constant open-loop load. The runner collects latency, RPS, and error rate, then writes a summary to the console and an HTML report to ./reports/.
HTTP Load Testing with NBomber.Http
Real-world scenarios need HTTP. The NBomber.Http package wraps HttpClient with NBomber's context model:
using NBomber.CSharp;
using NBomber.Http.CSharp;
var httpClient = new HttpClient();
var scenario = Scenario.Create("api_load", async context =>
{
var request = Http.CreateRequest("GET", "https://api.myapp.com/products")
.WithHeader("Accept", "application/json")
.WithHeader("Authorization", $"Bearer {GetToken()}");
var response = await Http.Send(httpClient, request);
return response;
})
.WithoutWarmUp()
.WithLoadSimulations(
Simulation.RampingInject(rate: 100, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(2))
);Http.Send returns a typed Response<HttpResponseMessage> that NBomber uses to record success/failure and response size. It automatically marks responses with 4xx/5xx status codes as failures unless you override the check:
var response = await Http.Send(httpClient, request);
// Custom success check — treat 404 as valid for this scenario
if (response.StatusCode == HttpStatusCode.NotFound)
return Response.Ok(statusCode: "404");
return response;POST with JSON body
var scenario = Scenario.Create("create_order", async context =>
{
var payload = new CreateOrderRequest
{
ProductId = context.ScenarioInfo.ThreadId % 1000,
Quantity = 1
};
var request = Http.CreateRequest("POST", "https://api.myapp.com/orders")
.WithHeader("Content-Type", "application/json")
.WithJsonBody(payload);
var response = await Http.Send(httpClient, request);
// Extract and use the response
if (response.IsError)
return Response.Fail($"status: {response.StatusCode}");
var order = await response.Payload!.Content.ReadFromJsonAsync<OrderResponse>();
return Response.Ok(payload: order, sizeBytes: response.SizeBytes);
});Testing gRPC Services
Add the gRPC package alongside NBomber:
dotnet add package Grpc.Net.Client
dotnet add package Google.ProtobufNBomber has no special gRPC adapter — you just use the gRPC client directly inside the step function:
using Grpc.Net.Client;
using MyApp.Protos;
var channel = GrpcChannel.ForAddress("https://grpc.myapp.com");
var client = new ProductService.ProductServiceClient(channel);
var scenario = Scenario.Create("grpc_get_product", async context =>
{
try
{
var reply = await client.GetProductAsync(new ProductRequest
{
Id = (context.ScenarioInfo.ThreadId % 500) + 1
});
return reply.Found
? Response.Ok(sizeBytes: reply.CalculateSize())
: Response.Fail("product not found");
}
catch (RpcException ex)
{
return Response.Fail($"gRPC error: {ex.StatusCode}");
}
});The pattern is identical regardless of protocol: your step function returns Response.Ok() or Response.Fail(). NBomber records everything else.
Custom Step Definitions and Data Feeds
For realistic multi-step user journeys, compose steps into a single scenario using separate async methods:
var scenario = Scenario.Create("checkout_flow", async context =>
{
// Step 1: browse
var browseReq = Http.CreateRequest("GET", "https://api.myapp.com/products?page=1");
var browseResp = await Http.Send(httpClient, browseReq);
if (browseResp.IsError) return Response.Fail("browse failed");
// Step 2: add to cart
var addReq = Http.CreateRequest("POST", "https://api.myapp.com/cart/items")
.WithJsonBody(new { ProductId = 42, Quantity = 1 });
var addResp = await Http.Send(httpClient, addReq);
if (addResp.IsError) return Response.Fail("add to cart failed");
// Step 3: checkout
var checkoutReq = Http.CreateRequest("POST", "https://api.myapp.com/orders/checkout")
.WithJsonBody(new { CartId = "test-cart" });
var checkoutResp = await Http.Send(httpClient, checkoutReq);
return checkoutResp;
});Each step's latency rolls into the scenario's total stats. For per-step metrics, use context.Logger to emit custom spans or wire in a custom sink.
Data Feeds
NBomber provides DataFeed<T> to supply unique or circular test data:
// Load from a list — each virtual user gets a different item in round-robin
var users = DataFeed.Circular(new[]
{
new { Username = "user1@test.com", Password = "pass1" },
new { Username = "user2@test.com", Password = "pass2" },
// ...
});
var scenario = Scenario.Create("login", async context =>
{
var user = users.GetNextItem(context.ScenarioInfo, context.NodeInfo);
var request = Http.CreateRequest("POST", "https://api.myapp.com/auth/login")
.WithJsonBody(new { user.Username, user.Password });
return await Http.Send(httpClient, request);
})
.WithDataFeed(users);Use DataFeed.Shuffle for randomized access or DataFeed.Random for random-with-replacement.
Simulating Load Patterns
NBomber's load simulation DSL covers the common patterns without custom code.
Ramp-up to steady state
.WithLoadSimulations(
// Ramp from 0 to 100 RPS over 2 minutes
Simulation.RampingInject(rate: 100, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(2)),
// Hold at 100 RPS for 5 minutes
Simulation.Inject(rate: 100, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(5)),
// Ramp down over 1 minute
Simulation.RampingInject(rate: 0, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(1))
)Spike test
.WithLoadSimulations(
Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(3)),
// Spike to 500 RPS for 30 seconds
Simulation.Inject(rate: 500, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromSeconds(30)),
Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(3))
)Concurrent users (closed-loop model)
The simulations above use open-loop injection — rate is fixed regardless of response time. For closed-loop (fixed concurrent users), use KeepConstant:
.WithLoadSimulations(
Simulation.KeepConstant(copies: 200, during: TimeSpan.FromMinutes(10))
)KeepConstant maintains 200 virtual users, each running the scenario function in a loop. If responses slow down, throughput drops. This matches how a real user population behaves.
Assertions on p99 Latency and RPS
Thresholds turn load test results into pass/fail gates. NBomber's threshold API attaches directly to the runner:
NBomberRunner
.RegisterScenarios(scenario)
.WithThresholds(
Threshold.Create(
scenarioName: "api_load",
reportKey: "ok.latency.p99",
check: ms => ms <= 250 // p99 must be under 250ms
),
Threshold.Create(
scenarioName: "api_load",
reportKey: "ok.rps",
check: rps => rps >= 80 // must sustain at least 80 RPS
),
Threshold.Create(
scenarioName: "api_load",
reportKey: "fail.percent",
check: percent => percent < 1.0 // error rate under 1%
)
)
.Run();Available report keys:
ok.latency.mean,ok.latency.p50,ok.latency.p75,ok.latency.p95,ok.latency.p99,ok.latency.maxok.rps,ok.countfail.rps,fail.count,fail.percent
When a threshold is violated, NBomberRunner.Run() returns a result with IsFailed = true. In CI, you check this and exit non-zero.
Reporting: HTML Reports and Grafana Integration
By default, NBomber writes an HTML report to ./reports/report_<timestamp>.html. Configure the output directory and format:
NBomberRunner
.RegisterScenarios(scenario)
.WithReportFolder("./load-test-results")
.WithReportFormats(ReportFormat.Html, ReportFormat.Csv, ReportFormat.Txt)
.Run();Grafana and InfluxDB
For real-time dashboards during long runs:
dotnet add package NBomber.Sinks.InfluxDBusing NBomber.Sinks.InfluxDB;
var influxSink = new InfluxDBSink(new InfluxDbSinkConfig
{
Url = "http://localhost:8086",
Database = "nbomber",
UserName = "admin",
Password = "admin"
});
NBomberRunner
.RegisterScenarios(scenario)
.WithReportingSinks(influxSink)
.WithReportingInterval(TimeSpan.FromSeconds(5))
.Run();Import the community NBomber Grafana dashboard (ID 12166) and point it at your InfluxDB source. You get live RPS, latency percentiles, and error rate without any additional configuration.
Running NBomber in xUnit
Running inside xUnit lets you fail builds on threshold violations using the same test runner you use for unit tests:
using NBomber.CSharp;
using NBomber.Http.CSharp;
using Xunit;
public class ApiLoadTests
{
[Fact]
public void Products_endpoint_sustains_100rps_under_250ms_p99()
{
var httpClient = new HttpClient();
var scenario = Scenario.Create("products_load", async context =>
{
var request = Http.CreateRequest("GET", "http://localhost:5000/api/products");
return await Http.Send(httpClient, request);
})
.WithLoadSimulations(
Simulation.Inject(rate: 100, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromSeconds(60))
);
var result = NBomberRunner
.RegisterScenarios(scenario)
.WithThresholds(
Threshold.Create("products_load", "ok.latency.p99", ms => ms <= 250),
Threshold.Create("products_load", "ok.rps", rps => rps >= 90),
Threshold.Create("products_load", "fail.percent", p => p < 1.0)
)
.WithReportFolder("./load-test-reports")
.Run();
Assert.False(result.IsFailed, "Load test thresholds were violated. Check the HTML report for details.");
}
}Run with dotnet test --filter Category=Load (add [Trait("Category", "Load")] to the class to control inclusion). This separates load tests from unit/integration tests in your CI matrix.
One important note: load tests need a running application. In CI, start the service before the load test step, either as a Docker container or using WebApplicationFactory<T> for in-process testing.
In-process testing with WebApplicationFactory
public class InProcessLoadTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public InProcessLoadTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public void Healthcheck_handles_high_concurrency()
{
var httpClient = _factory.CreateClient();
var scenario = Scenario.Create("healthcheck_stress", async context =>
{
var response = await httpClient.GetAsync("/health");
return response.IsSuccessStatusCode
? Response.Ok()
: Response.Fail($"status {(int)response.StatusCode}");
})
.WithLoadSimulations(
Simulation.KeepConstant(copies: 50, during: TimeSpan.FromSeconds(30))
);
var result = NBomberRunner
.RegisterScenarios(scenario)
.WithThresholds(
Threshold.Create("healthcheck_stress", "ok.latency.p99", ms => ms <= 50)
)
.Run();
Assert.False(result.IsFailed);
}
}This runs the entire load test against an in-process server — no Docker, no ports, no startup scripts. It is faster and more repeatable for catching regressions in CI.
CI/CD Integration with Thresholds as Quality Gates
A GitHub Actions workflow that runs load tests as a quality gate:
name: Load Tests
on:
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * *' # nightly against staging
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Start application
run: |
dotnet run --project src/MyApp.Api &
echo "Waiting for API to start..."
timeout 60 bash -c 'until curl -sf http://localhost:5000/health; do sleep 1; done'
env:
ASPNETCORE_URLS: http://localhost:5000
- name: Run load tests
run: dotnet test tests/MyApp.LoadTests --filter "Category=Load" --logger "trx;LogFileName=load-results.trx"
env:
TARGET_URL: http://localhost:5000
- name: Upload load test report
if: always()
uses: actions/upload-artifact@v4
with:
name: load-test-reports
path: tests/MyApp.LoadTests/load-test-reports/The if: always() on the upload step ensures you get the HTML report even when the test fails — which is exactly when you need it most.
Threshold strategy for CI vs nightly
In CI on pull requests, use conservative thresholds with a short duration:
// PR gate: 30-second smoke load, tight thresholds
Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromSeconds(30))In nightly runs against staging, use realistic load for a longer duration:
// Nightly: 10-minute sustained load at production-like levels
Simulation.RampingInject(rate: 200, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(2)),
Simulation.Inject(rate: 200, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromMinutes(8))Read the target URL and thresholds from environment variables to share the same test code:
var targetUrl = Environment.GetEnvironmentVariable("TARGET_URL") ?? "http://localhost:5000";
var p99Threshold = int.Parse(Environment.GetEnvironmentVariable("P99_THRESHOLD_MS") ?? "250");
var minRps = int.Parse(Environment.GetEnvironmentVariable("MIN_RPS") ?? "80");This lets you tighten or loosen thresholds per environment without changing test code.
Key Takeaways
NBomber is the right choice for .NET teams that want load tests in the same language and toolchain as their application code. The ability to reference your own types, use NuGet packages, and run inside xUnit removes most of the friction that causes teams to skip load testing entirely.
Prefer open-loop injection for throughput testing (Simulation.Inject, Simulation.RampingInject) and closed-loop constant users (Simulation.KeepConstant) when you want to model a fixed population of concurrent users. Mixing them up produces misleading results.
Thresholds are the only thing that makes load tests actionable. A report with numbers is interesting; a build that fails when p99 exceeds 250ms is a quality gate. Define thresholds before you run your first test, not after you see the numbers.
gRPC, WebSockets, SignalR, and custom protocols all work because NBomber's step model is just an async function that returns Response.Ok() or Response.Fail(). There is no protocol-specific abstraction to fight.
Start with in-process WebApplicationFactory tests for fast feedback during development. Move to an external running service for staging and production-representative tests. Both patterns use the same NBomber scenario code.
Gate PRs with a short smoke load test and run full sustained tests nightly. The PR gate catches obvious regressions in seconds; the nightly run catches slower degradation trends before they reach production.