The story

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.

A simple demo backend

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 test

We 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 Apache JMeter test plan

The test can be split into three parts:

  • Collecting customer ids from the paginable endpoint (let's assume overview DTOs are returned)
  • Querying complete customer DTOs from the /customers/{id} GET endpoint
  • Load testing the PUT endpoint with previously stored customer DTOs

For 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).

Thread Group: GetCustomerIds

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.

Thread Group: GetCustomer

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>.

Thread Group: PutCustomers

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.

Results & Summary

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 :)

Results of the Apache JMeter test run

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.