Load tools like JMeter measure how an API behaves under heavy traffic, but two smaller checks belong in every API suite: response-time assertions on important endpoints, and concurrency tests that send the same request in parallel to catch race conditions such as double bookings, overselling and lost updates.

Response-Time Assertions in REST Assured

// SLA enforcement in every test
@Test
public void getProductList_respondsWithinSLA() {
    given().spec(reqSpec)
        .queryParam("page", 1)
        .queryParam("limit", 20)
    .when().get("/api/products")
    .then()
        .statusCode(200)
        .time(lessThan(1000L));  // must respond in < 1 second
}

// Measure and log response time for monitoring
@Test
public void measureApiResponseTimes() {
    String[] endpoints = {"/api/users","/api/products","/api/orders"};

    for (String endpoint : endpoints) {
        long start = System.currentTimeMillis();
        given().spec(withAuth("admin")).when().get(endpoint)
            .then().statusCode(200);
        long elapsed = System.currentTimeMillis() - start;

        // Log to Allure for trend tracking
        Allure.addAttachment(endpoint + " response time", elapsed + "ms");

        assertThat(elapsed)
            .as("Endpoint %s must respond in < 2000ms", endpoint)
            .isLessThan(2000L);
    }
}
Advertisement

Concurrency Testing with Parallel Requests

// Simulate concurrent users with ExecutorService
@Test
public void createOrder_under100ConcurrentUsers_allSucceed() throws Exception {
    int concurrentUsers = 100;
    ExecutorService executor = Executors.newFixedThreadPool(concurrentUsers);
    List<Future<Integer>> futures = new ArrayList<>();

    for (int i = 0; i < concurrentUsers; i++) {
        final int userId = i + 1;
        futures.add(executor.submit(() -> {
            Map<String, Object> order = Map.of(
                "userId", userId,
                "items",  List.of(Map.of("productId", 101, "qty", 1))
            );
            return given().spec(withAuth("customer"))
                .body(order)
            .when().post("/api/orders")
            .then().extract().statusCode();
        }));
    }

    executor.shutdown();
    executor.awaitTermination(30, TimeUnit.SECONDS);

    long successCount = futures.stream().mapToInt(f -> {
        try { return f.get(); } catch (Exception e) { return 0; }
    }).filter(code -> code == 201).count();

    // At least 99% success rate under load
    assertThat(successCount).isGreaterThanOrEqualTo((long)(concurrentUsers * 0.99));
}

Race Conditions Worth Testing

  • Last item in stock: two users buy the final unit at the same moment; exactly one order should succeed.
  • Double submit: the same payment or booking request sent twice in parallel must not create two records (see idempotency keys).
  • Counters and balances: concurrent increments or withdrawals must not lose updates.
  • Unique constraints: two registrations with the same email at once must not both succeed.

Start all threads together with a CountDownLatch, collect every response, then assert on the final state through the API or the database, not only on status codes.

Example: Only One Buyer Gets the Last Item

@Test
public void onlyOneOrderSucceedsForLastItem() throws Exception {
    int buyers = 10;
    String productId = api.createProductWithStock(1);           // exactly one unit
    ExecutorService pool = Executors.newFixedThreadPool(buyers);
    CountDownLatch start = new CountDownLatch(1);
    List<Future<Integer>> results = new ArrayList<>();

    for (int i = 0; i < buyers; i++) {
        String token = api.tokenForNewUser();
        results.add(pool.submit(() -> {
            start.await();                                           // all threads fire together
            return given().auth().oauth2(token).contentType(ContentType.JSON)
                    .body(Map.of("productId", productId, "qty", 1))
                    .post("/api/orders").statusCode();
        }));
    }
    start.countDown();

    List<Integer> codes = new ArrayList<>();
    for (Future<Integer> f : results) codes.add(f.get(30, TimeUnit.SECONDS));
    pool.shutdown();

    assertEquals(codes.stream().filter(c -> c == 201).count(), 1L, "exactly one order created");
    assertTrue(codes.stream().allMatch(c -> c == 201 || c == 409), "others rejected cleanly: " + codes);
    assertEquals(api.getStock(productId), 0, "stock never goes negative");
}

The assertions check three things: one success, clean rejections (409 Conflict rather than 500), and a consistent final state. Run it several times, because race conditions don't show up on every run.

Response-Time SLAs Without Flakiness

  • Assert generous limits (for example 2 seconds for an endpoint that normally answers in 200 ms) so the check catches real regressions, not network noise.
  • Put the limit in a shared ResponseSpecification so every critical endpoint gets it consistently.
  • Log the actual time on every run; trends over weeks matter more than a single slow call.

FAQs

What is concurrency testing for APIs?

Sending the same or conflicting requests at the same moment from several threads to check that the API keeps data consistent, for example that only one of two simultaneous purchases of the last item succeeds.

Should response-time checks be in functional tests?

A loose SLA assertion on critical endpoints catches big regressions early; detailed performance measurement under load still belongs in JMeter, k6 or Gatling.