Load testing PUT endpoints with Apache JMeter using ASP.NET Core minimal APIs as an example.
Load testing PUT endpoints with Apache JMeter using ASP.NET Core minimal APIs as an example.
One of our customers wanted us to execute an isolated load test for a specific ASP.NET Core Boilerplate PUT endpoint. Based on the requirements, we decided to use Apache JMeter. Apache JMeter is an open-source tool for load testing and performance testing of web applications. It can be used to simulate a heavy load on a server, network, or application to test its performance under different load types. JMeter can send requests to a server and measures the response time, throughput, and other performance metrics.
The challenge here is that resources are verified via Entity Framework Core optimistic concurrency. So sending the same resource via threads / loops was not possible (because the initial save would fail) and might not make too much sense anyway because caching could dilute the results. Instead, we went for a different approach: for the test preparation step we loaded a separate resource for each executed PUT request beforehand. Let's see how this could be done.
Let's pretend we have the following endpoints:
/customer
[GET] - a paginable endpoint which returns customer DTOs/customer/{id}
[GET] - returns the detailed customer DTO which we load and store for testing the PUT endpoint/customer/{id}
[PUT] - the put endpoint which is the target of the load testWe used Bogus for some nice and easy test data. Bogus is a .NET library that can be used to generate fake data for testing purposes such as names, addresses, emails, and more.
To keep things simple, let's use minimal APIs:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
Randomizer.Seed = new Random(123456789);
Faker<Customer> customerData = new Faker<Customer>().RuleFor(u => u.Id, f => f.Random.Guid())
.RuleFor(u => u.Gender, f => f.PickRandom<Gender>())
.RuleFor(u => u.FirstName, (f, u) => f.Name.FirstName(u.Gender.Map()))
.RuleFor(u => u.LastName, (f, u) => f.Name.LastName(u.Gender.Map()));
List<Customer> customers = customerData.Generate(1000);
app.MapGet("/customers", async (int? pageIndex, int? pageSize) =>
{
await Task.Delay(300); // Simulate some processing :)
return customers.Skip((pageIndex ?? 0) * (pageSize ?? 50)).Take(pageSize ?? 50);
});
app.MapGet("/customers/{id}", async (Guid id) =>
{
await Task.Delay(100); // Simulate some processing :)
return customers.SingleOrDefault(x => x.Id == id);
});
app.MapPut("/customers/{id}", async (Guid id, Customer customer) =>
{
await Task.Delay(500); // Simulate some processing :)
});
app.Run();
For reference, this is how the models look like:
public class Customer
{
public string FirstName { get; init; } = null!;
public Gender Gender { get; set; }
public Guid Id { get; init; }
public string LastName { get; init; } = null!;
}
public enum Gender
{
Female,
Male,
NonBinary,
Unknown,
}
Although simplified, that's more or less how the customer endpoints looked like. Now comes the interesting part.
The test can be split into three parts:
/customers/{id}
GET endpointFor easier configuration, these are the global variables used for each of these steps:
api.domain = localhost
api.port = 5555
number_of_threads = 10
number_of_loops = 100
Apache JMeter introduces the concept of Thread Groups which defines a "pool of user" aka threads and specifies a loop count setting the number of iterations for each thread.
So a Thread Group with the above configuration will result into 10 x 100 = 1000
runs (requests).
Each thread should retrieve a different page set to ensure unique ids. This is the path passed to the HTTP request:
customers?pageIndex=${page_index}&pageSize=${number_of_loops}
If the page size is limited by your API, you would have to split this into multiple subrequests. page_index
corresponds to __threadNum
- 1, set by a JSR223 PreProcessor.
The customer ids are extracted by a JSON Extractor with the following path expression: $[*].id
. Each id is stored in a variable in the form of customer_ids_<index>
. First thread is storing customer_ids_1
to customer_ids_100
, second one starting from customer_ids_101
to customer_ids_200
and so on.
The trickiest part of this Thread Group is finding the customer variable index for a specific thread / loop iteration. This is calculated by a JSR223 PreProcessor like this (note that thread number is 1-based and the loop index is 0-based):
int customerIndex = (${__threadNum} - 1) * ${number_of_loops} + (vars.get('__jm__GetCustomers__idx') as Integer) + 1
The HTTP Request path is rather simple:
customers/${customer_id}
The customer result is stored in the form of customer_<index>
.
Now this is the Thread Group everything was setup for. We have to calculate the index again for the current thread / loop iteration, load the customer id and object and send it back to the PUT endpoint.
The put endpoint is simulating 500ms of processing time, so for 10 parallel threads we could expect about 20 requests per second. As shown in the following image, we are not far off the expectation. Note that we run the test in the UI mode, which is not something you should do for real runs :)
This blog post gave a quick overview of a simplified example for load testing with Apache JMeter. If you want to try it out, clone the repository, start the minimal api, open the test\testplan.jmx
with Apache JMeter and give it a shot.