Skip to main content

Convert Your Integration Tests To Load Tests

In this article, I want to cover the topic of how you can effectively reuse your integration tests and convert them to load tests to speed up your load test adoption.

This article will be useful for developers who use .NET platform to write integration tests that cover HTTP API, microservices.

Load testing adoption#

Nowadays, it is difficult to find a project that does not use integration tests, especially in building microservices or distributed systems, etc. In addition to this, some companies start adopting load testing and applying it as a must-have quality attribute. Honestly, load tests are still kind of exotic practice for most web projects, and usually, folks consider them a bit late. One of the main reasons is that the load tests require additional development and maintenance. It would be awesome to reduce time by converting our integration tests to load tests.

Converting integration tests to load tests#

Let's take a look at a simple integration test where a user tries to log in and buy a product. This test example is already a bit prepared for conversion to load test.

let ``logged user should be able to by product`` () = async {
let productId = "productId"
let userName = "userName"
let password = "password"
use httpClient = new HttpClient()
// Async<HttpResponseMessage>
let! loginResponse = httpClient |> UserOperations.login userName password
// string
let jwtToken = loginResponse |> parseJwtToken |> Result.getOk
// Async<HttpResponseMessage>
let! paymentResponse = httpClient |> UserOperations.buyProduct productId jwtToken
// Result<Payment,AppError>
let paymentResult = paymentResponse |> parsePaymentResult
test <@ Result.isOk paymentResult @>

To convert integration tests to load tests, we need to separate all business operations from test assertions to a separate module. After this, we can use the same business operation for load test and integration test. The main idea can be described as the following expression.

IntegrationTest = BusinessOperations + Assertions
LoadTest = BusinessOperations + Assertions

Business operations module#

The business operations module represents such operations as login, buy products.

module UserOperations
let login: string -> string -> HttpClient -> Async<HttpResponseMessage>
let buyProduct: string -> string -> HttpClient -> Async<HttpResponseMessage>

As you may have noticed, all these operations are contained in a single UserOperations module, and each of its functions returns a standard HttpResponseMessage.

NBomber response type#

HttpResponseMessage is a well-suported type in .NET and a key thing here is that NBomber.Http contains a helper function that converts HttpResponseMessage to NBomber's Response type, and you can reuse such operations in your load tests. For C#, it works via extension method.

module Response
let ofHttp: HttpResponseMessage -> Response

Load test#

Now let's see the final example of converting an integration test to a load test.

let ``load test operation - buy a product`` () =
let productId = "productId"
let userName = "userName"
let password = "password"
use httpClient = new HttpClient()
let login = Step.create("login_step", fun context -> task {
let! loginResponse = httpClient |> UserOperations.login userName password
return Response.ofHttp(loginResponse)
let buyProduct = Step.create("buy_product", fun context -> task {
let loginResponse = context.GetPreviousStepResponse<HttpResponseMessage>()
let jwtToken = loginResponse |> parseJwtToken |> Result.getOk
let! paymentResponse = httpClient |> UserOperations.buyProduct productId jwtToken
return Response.ofHttp(paymentResponse)
Scenario.create "buy_product_scenario" [login; buyProduct]
|> Scenario.withLoadSimulations [InjectPerSec(rate = 100, during = minutes 5)]
|> NBomberRunner.registerScenario
|> ignore
// here you can apply your assertions based on received stats

For a more realistic load test, you can leverage the power of the DataFeed and the ClientFactory. With these abstractions, you will be able to inject test data, configure your HttpClient, and so on.


The ability to convert integration tests to load tests can significantly reduce your time on developing load tests. Also, I don't want to seem like a salesperson to you, so I want to dispel myths right away: this technique cannot completely replace writing your own load tests since you definitely will have some particular cases that require writing more advanced scenarios.

NBomber 2.0

Hey folks, we were busy for nearly half a year working on a new big release of NBomber. I am so happy to announce that we finally completed it!

In this release, we focused on improving engine performance, fixing RAM consumption issues, improving UI/UX, including reporting, extending API to provide flexibility but keep it SIMPLE as before.

UI/UX Improvements#


In the previous version, we always had a desire to improve the console output. The main pain point for us was the progress bar, which did not scale well while resizing the console, and sometimes it literally broke down when you are writing log messages to the console. The second most important problem for us was the table rendering, which was also displayed crookedly while resizing windows, and we wanted to fix this. We started considering Spectre.Console project for a long time as a good candidate for replacement and then we started a smooth transition. It was good with tables, but we still had some problems with the progress bar. Since for logs, we use Serilog, we figure out that we need to develop a proper integration between Serilog and Spectre.Console to fix our issue. After all, we developed Serilog.Sinks.SpectreConsole that nicely integrates Serilog with Spectre.Console. It's open-source, and you can use it for your integrations too. Now our console UI is much more stable, and I hope more beautiful :)

HTML report#

We use Vue.js, which amazed us with its simplicity and minimalism for rendering HTML. Compared to the previous version, we have significantly expanded the functionality by adding: fail stats, status codes, hints analyzer results. You can take a look at the new HTML report here Also, we switched to Google Charts for rendering charts since it's free and under Apache 2.0 license.

Runtime Improvements#

In this section, we will discuss some internals of NBomber that improve the performance and stability of the system.

Stats collecting#

We refactored our stats collecting module to use the actor model via F# MailboxProcessor and to have one StatsScenarioActor per scenario. Also, we send metrics by batches to eliminate the overload of one actor by many concurrent ScenarioActors that execute steps and send metrics.

Minimizing RAM usage#

Compression of stats results#

We refactored our statistics module to fix the issue with memory growing footprint for long-running tests. Now you can run tests that will work for years and have a stable (usually small) memory footprint. This is achieved essentially due to one important optimization: all results are located in memory but decomposed into buckets with the same or very similar results. Each such bucket has its own counter, which is monotonically growing. Due to this optimization, we got quite effective compression. Aside from this, such optimizations are often used in time-series databases where many metrics of the same value or very similar can arrive at one point in time.

Default step timeout#

Another important thing was introducing default timeout for steps. NBomber v1 did not support any timeouts out of the box, and this task shifted to the shoulders of the developers. For example, the HTTP plugin added its own timeout, which was quite sane. But on the other hand, everyone needs such a basic thing as a timeout, and forcing everyone to implement it is not a good idea. Also, newbies often weren't aware of this, and afterward, it led to the thread pool starvation problem. It especially was visible when testing very slow API using a big request rate, say 2-4K requests per sec from 1 node. It leads to many tasks (.NET Task) was activated, consumed RAM, and never finished but growing. In the new version, each step by default contains a timeout of 1 second and can be changed by the user.

Removed uneccesary memory allocations#

We refactored a few places where we did extra memory allocations on every step invocation. For example, StepContext was previously created on every invocation.

Task computation expression#

Internally, we use FsToolkit.ErrorHandling library that provides handy F# computation expressions and corresponding extensions. With the latest updates to this library for work with Task, we have automatically switched to using Ply library, which provides a low overhead Task computation expression.

Low-level optimizations#

GC tuning#

Also, we tuned GC a bit. At runtime, we set:

GCSettings.LatencyMode <- GCLatencyMode.SustainedLowLatency

And also, for the project settings, we apply:


New Statistics#

One of the major changes we had been working on is the statistics module. Initially, we didn't really want to change it, but using NBomber v1 in production, we often ran into bottlenecks or questions regarding providing advanced statistics.

Ok and Fail stats#

An important addition to the previous statistics was that we started tracking the fail stats with the full scope, including data transfer for fails, latency, and percentiles.

type OkStepStats = {
Request: RequestStats
Latency: LatencyStats
DataTransfer: DataTransferStats
StatusCodes: StatusCodeStats[]
type FailStepStats = {
Request: RequestStats
Latency: LatencyStats
DataTransfer: DataTransferStats
StatusCodes: StatusCodeStats[]
type StepStats = {
StepName: string
Ok: OkStepStats
Fail: FailStepStats

Now you can write your assertions for fails stats too. Also, you will see them in reporting, including real-time reporting too.

// here is an example of assertion
let step1 = scenarioStats.StepStats.[0]
// ok stats
test <@ step1.Ok.Request.Count >= 4 @>
test <@ step1.Ok.Latency.MinMs <= 503.0 @>
test <@ step1.Ok.Latency.Percent50 <= 505.0 @>
test <@ step1.Ok.DataTransfer.MinBytes = 100 @>
// fail stats
test <@ step1.Fail.Request.Count = 0 @>
test <@ step1.Fail.Latency.MinMs = 0.0 @>
test <@ step1.Fail.DataTransfer.MinBytes = 0 @>

Unit of measure#

In the new version, we changed basic data types to represent latency and data transfer.


We started using float instead of integer to expand the range of possible results for latency, for example: [0.2 ms, 1.5 ms, 9.3 ms]. We were pushed to this by the case of testing a high-speed in-memory database that had a latency of less than 1 ms, and in our reports, we saw it as 0 ms instead of 0.1 - 0.5 ms. Just for the record, most load test tools represent latency as integer, meaning using them, you cannot cover such cases since your results will be 0 ms.

type LatencyStats = {
MinMs: float
MeanMs: float
MaxMs: float
Percent50: float
Percent75: float
Percent95: float
Percent99: float
StdDev: float
LatencyCount: LatencyCount

Data transfer#

We switched to using Byte instead of KB to keep parity with standard tools for data transfer stats.

type DataTransferStats = {
MinBytes: int
MeanBytes: int
MaxBytes: int
Percent50: int
Percent75: int
Percent95: int
Percent99: int
StdDev: float
AllBytes: int64

In addition, we provide helper functions for converting Byte to KB and MB. This can be useful when you want to assert on KB or MB.

test <@ kb (step1.Ok.DataTransfer.MinBytes) <= 50.0 @>
test <@ mb (step1.Ok.DataTransfer.MinBytes) <= 50.0 @>

Hint Analyzer#

In this release, we have integrated a new HintAnalyzer feature that allows to analise the results of statistics and, based on this, displays hints. HintAnalyzer may refer not only to the statistics data but also to the use of certain plugins. Any NBomber plugin can add new analyzers. For example, PingPlugin pings the target host before starting any test, and after that, it analyzes received results and prints hints.

plugin stats: NBomber.Plugins.Network.PingPlugin

HostStatusAddressRound Trip TimeTime to LiveDon't FragmentBuffer Size
nbomber.comSuccess104.248.140.12858 ms128False32 bytes

Here's a hint by PingPlugin.

WorkerPluginNBomber.Plugins.Network.PingPluginPhysical latency to host: '' is bigger than 2ms which is not appropriate for load testing. You should run your test in an environment with very small latency.

Also, HintAnalyzer can suggest some other hints related to the usage of NBomber.

Scenariosimple_httpStep 'fetch_html_page' in scenario 'simple_http' didn't track data transfer. In order to track data transfer, you should use Response.Ok(sizeInBytes: value)

You can disable HintAnalyzer if you want

Scenario.create "simple_http" [step]
|> NBomberRunner.registerScenario
|> NBomberRunner.disableHintsAnalyzer

New API#

In the new version, we have added a couple of additions that improve the convenience of writing test scripts without adding new abstractions to NBomber itself. We are very scrupulous about adding any new features to NBomber as they can introduce additional abstractions and hence additional complexity. Our initial goal was to build an easy-to-use framework with as few abstractions as possible. Therefore, any new feature that we consider should be easy to understand and harmoniously fit into NBomber.

Status code#

We have added the ability to specify any status codes you want or your protocol, or your API service returns.

Response.ok(statusCode = 100)

Also, we provide statistics on status codes.

type StatusCodeStats = {
StatusCode: int
Message: string
Count: int

Step invocation count#

For certain tests, it may be necessary to understand the current step invocation count. For example, you want to change the behavior of your step execution when the step's invocation counter is reached 100 invocations. For such cases, you need to start a counter and manually increment it. NBomber now supports this out of the box.

let step = Step.create("step", fun context -> task {
// invocation count of the current step
// (will be incremented on each invocation)
context.InvocationCount // int
return Response.ok()

Step timeout#

In the new version, each step by default contains a timeout of 1 second and can be changed by the user.

let step = Step.create("step",
timeout = seconds 0.5,
execute = fun context -> task {
return Response.ok()

Dynamic step order#

We decided to provide the ability to change the order of the steps at runtime dynamically. Also, it allows changing the number of steps per scenario iteration. This feature opens up a new horizon of possibilities for writing load tests. We came up with this idea from a real case when we tested the database, and we needed to introduce a certain randomity into our tests. For a better understanding, I suggest looking at an example:

// by default these steps will run sequentially
Scenario.create "test_redis" [step1; step2]
// in this case, only step2 will be invoked
|> Scenario.withDyncamicStepOrder(fun () -> [| 1 |])
// in this case, we reversed the order of steps
|> Scenario.withDyncamicStepOrder(fun () -> [| 1; 0 |])
// in this case, we introduce randomity
|> Scenario.withDyncamicStepOrder(fun () ->
let index1 = random.Next(0, 1)
let index2 = random.Next(0, 1)
[| index1; index2 |]

Basically, with this feature, you can introduce convenient load distribution. For example, you can define a scenario where you will test Redis database with the following request distribution: 30% for write requests and 70% for read requests.

Scenario.create "test_redis" [write_redis; read_redis]
|> Scenario.withDyncamicStepOrder(fun () ->
// using a randomator you can specify:
// - 30% will be write_redis
// - 70% will be read_redis

Scenario info#

It's a context property that contains info about the current Scenario. The main use case is to use ScenarioInfo.ThreadId as correlation id for your requests. Another one is to use ScenarioInfo.ThreadNumber for partition your requests. For example, you have a database, and you want to split all your requests into 4 partitions.

let step = Step.create("step", fun context -> task {
// gets the current scenario thread id
// you can use it as correlation id
return Response.ok()

TimeSpan extensions#

We added TimeSpan extensions for F# and C# to have a more expressive API.

milliseconds 500
seconds 2
minutes 1

Init-only scenarios#

NBomber had the ability to provide Scenario initialization via Scenario.init in the very first version. It had a mandatory restriction to create a Scenario you should provide at least one Step.

// you should provide at least one step
Scenario.create "write_scenario" [write_step]
|> Scenario.init populateKafka

This was convenient until the moment when several scenarios had a dependency on the same initialization. In this case, only one initializer should be executed, and it's getting tricky since NBomber v1 didn't provide any functionality for this.

// both scenarios depend on populateKafka
Scenario.create "write_scenario" [write_step]
|> Scenario.init populateKafka
Scenario.create "read_scenario" [read_step]
|> Scenario.init populateKafka

In NBomber v2, we can define Scenario without steps and have only the init or clean function.

let initScenario =
Scenario.create "init_scenario" [] // no steps
|> Scenario.init populateKafka
|> Scenario.clean cleanKafka
let writeScenario = Scenario.create "write_scenario" [write_step]
let readScenario = Scenario.create "read_scenario" [read_step]
// now all scenarios will be executed
NBomberRunner.registerScenarios [initScenario; writeScenario; readScenario]
// now you can simply chnage scenarios
NBomberRunner.registerScenarios [initScenario; writeScenario]
// or
NBomberRunner.registerScenarios [initScenario; readScenario]

LoadSimulation inject random#

Injects a random number of scenario copies (threads) per 1 sec during a given duration. Use it when you want to maintain a random rate of requests without being affected by the target system's performance under test.

Scenario.create "scenario_2" [read_step]
|> Scenario.withLoadSimulation [
InjectPerSecRandom(minRate = 10, maxRate = 50, during = seconds 10)


In the new version, we write a log file to the current session folder.


HTTP plugin#

NBomber.Http has been slightly modified to make it easier to be used. In the previous version, this plugin provided an HttpStep wrapper over the standard Step that hid many details and added extra magic. In the new version, we have reworked the approach with DSL plugins to use standard NBomber abstractions instead of creating new ones. Now, the HTTP plugin contains HttpClientFactory and has an HTTP module with functions for building HTTP requests.

let httpFactory = HttpClientFactory.create()
let step = Step.create("simple step",
clientFactory = httpFactory,
execute = fun context ->
Http.createRequest "GET" ""
|> Http.withHeader "Accept" "text/html"
|> Http.withBody(new StringContent("{ some JSON }"))
|> Http.withCheck(fun response -> task {
return if response.IsSuccessStatusCode then Response.ok()
|> Http.send context

InfluxDb plugin#

InfluxDb plugin is supplemented with tracking fail stats and load simulation value. In Grafana, you can render your load simulation timeline and compare it with what your target system can handle.