Four API behaviours are rarely covered by basic functional tests but cause expensive production incidents: a retried payment that charges twice, clients that never get fresh data, browsers blocked by CORS, and APIs that collapse or lock out users under bursts of traffic. This guide shows how to test each with REST Assured.

Idempotency: Retries Must Not Duplicate Actions

What is Idempotency?

Idempotency: sending the same request multiple times produces the same result.

Critical for payment APIs — if a payment request times out, can the client safely retry?

Without idempotency: retry creates a DUPLICATE payment. User is charged twice!

With idempotency: server detects duplicate (via Idempotency-Key header), returns cached response.

// Test 1: Duplicate request with same Idempotency-Key returns same response
@Test
public void createPayment_sameIdempotencyKey_doesNotCreateDuplicate() {
    String idempotencyKey = UUID.randomUUID().toString();
    Map<String, Object> paymentBody = Map.of("amount", 1500, "currency", "INR");

    // First request — creates the payment
    Response first = given()
        .spec(withAuth("customer"))
        .header("Idempotency-Key", idempotencyKey)
        .body(paymentBody)
    .when().post("/api/payments")
    .then().statusCode(201).extract().response();

    String firstPaymentId = first.jsonPath().getString("paymentId");

    // Second request — same key, should return SAME response (not create new)
    Response second = given()
        .spec(withAuth("customer"))
        .header("Idempotency-Key", idempotencyKey)
        .body(paymentBody)
    .when().post("/api/payments")
    .then().statusCode(201).extract().response();  // still 201, not duplicate 409

    String secondPaymentId = second.jsonPath().getString("paymentId");

    // Same payment ID — only ONE payment was created
    assertThat(secondPaymentId).isEqualTo(firstPaymentId);

    // Verify only 1 payment exists in DB
    given().spec(withAuth("admin"))
        .queryParam("idempotencyKey", idempotencyKey)
    .when().get("/api/payments")
    .then().statusCode(200)
           .body("data.size()", equalTo(1));  // exactly 1, not 2!
}

// Test 2: Different keys create different payments
@Test
public void createPayment_differentIdempotencyKeys_createsTwoPayments() {
    Map<String, Object> body = Map.of("amount", 500, "currency", "INR");

    String id1 = given().spec(withAuth("customer"))
        .header("Idempotency-Key", UUID.randomUUID().toString()).body(body)
        .when().post("/api/payments")
        .then().statusCode(201).extract().jsonPath().getString("paymentId");

    String id2 = given().spec(withAuth("customer"))
        .header("Idempotency-Key", UUID.randomUUID().toString()).body(body)
        .when().post("/api/payments")
        .then().statusCode(201).extract().jsonPath().getString("paymentId");

    assertThat(id1).isNotEqualTo(id2);  // two different payments created
}
Advertisement

HTTP Caching: ETag and If-None-Match

// Test 1: First request returns ETag header
@Test
public void getProduct_firstRequest_returnsETagHeader() {
    Response response = given().spec(reqSpec)
    .when().get("/api/products/101")
    .then()
        .statusCode(200)
        .header("ETag",          notNullValue())
        .header("Cache-Control", containsString("max-age"))
        .extract().response();

    String etag = response.header("ETag");
    assertThat(etag).matches(""[a-zA-Z0-9]+"");  // quoted hash e.g. "abc123"
}

// Test 2: Second request with ETag → 304 Not Modified (no body)
@Test
public void getProduct_withMatchingETag_returns304() {
    // Get ETag from first request
    String etag = given().spec(reqSpec)
    .when().get("/api/products/101")
    .then().statusCode(200).extract().header("ETag");

    // Second request with If-None-Match header
    given().spec(reqSpec)
        .header("If-None-Match", etag)
    .when().get("/api/products/101")
    .then()
        .statusCode(304)         // Not Modified
        .body(emptyOrNullString()); // NO body (saves bandwidth!)
}

// Test 3: Stale ETag (product was updated) → 200 with new data + new ETag
@Test
public void getProduct_withStaleETag_returns200WithNewData() {
    String oldEtag = ""stale-etag-12345"";  // fake old ETag

    given().spec(reqSpec)
        .header("If-None-Match", oldEtag)
    .when().get("/api/products/101")
    .then()
        .statusCode(200)                    // 200, not 304 — data changed
        .header("ETag", not(equalTo(oldEtag)));  // new ETag returned
}

CORS Testing

What is CORS?

CORS = Cross-Origin Resource Sharing. Browser security mechanism.

When JavaScript on domain-A.com calls API on domain-B.com → browser sends OPTIONS preflight.

API must respond with correct Access-Control-Allow-Origin headers, else browser blocks the call.

CORS bugs cause: "API works in Postman but fails in browser" — classic interview trap!

// Test 1: Preflight OPTIONS request returns correct CORS headers
@Test
public void corsPreflightRequest_returnsCorrectHeaders() {
    given().spec(reqSpec)
        .header("Origin",                         "https://app.mycompany.com")
        .header("Access-Control-Request-Method",  "POST")
        .header("Access-Control-Request-Headers", "Authorization,Content-Type")
    .when().options("/api/orders")
    .then()
        .statusCode(anyOf(equalTo(200), equalTo(204)))
        .header("Access-Control-Allow-Origin",
            anyOf(equalTo("https://app.mycompany.com"), equalTo("*")))
        .header("Access-Control-Allow-Methods",  containsString("POST"))
        .header("Access-Control-Allow-Headers",  containsString("Authorization"))
        .header("Access-Control-Max-Age",        notNullValue());  // preflight cache
}

// Test 2: Disallowed origin is rejected
@Test
public void corsRequest_fromDisallowedOrigin_isRejected() {
    given().spec(reqSpec)
        .header("Origin", "https://evil-hacker.com")
    .when().get("/api/users/42")
    .then()
        // Either no ACAO header (browser will block) or explicit deny
        .header("Access-Control-Allow-Origin",
            not(equalTo("https://evil-hacker.com")));
}

// Test 3: Credentials are allowed for authenticated endpoints
@Test
public void corsRequest_withCredentials_returnsAllowCredentials() {
    given().spec(withAuth("customer"))
        .header("Origin", "https://app.mycompany.com")
    .when().get("/api/orders")
    .then()
        .statusCode(200)
        .header("Access-Control-Allow-Credentials", equalTo("true"))
        // When credentials=true, Allow-Origin CANNOT be "*"
        .header("Access-Control-Allow-Origin",
            not(equalTo("*")));
}

Rate Limiting Tests

// Test that rate limiting headers are present and correct
@Test
public void rateLimitHeaders_presentInEveryResponse() {
    Response response = given().spec(withAuth("customer"))
    .when().get("/api/products")
    .then().statusCode(200).extract().response();

    // These headers must be present
    int limit     = Integer.parseInt(response.header("X-RateLimit-Limit"));
    int remaining = Integer.parseInt(response.header("X-RateLimit-Remaining"));
    int reset     = Integer.parseInt(response.header("X-RateLimit-Reset"));

    assertThat(limit)    .isPositive();
    assertThat(remaining).isGreaterThanOrEqualTo(0)
                         .isLessThanOrEqualTo(limit);
    assertThat(reset)    .isGreaterThan((int)(System.currentTimeMillis()/1000));
}

// Verify 429 response structure
@Test
public void rateLimitExceeded_returns429WithRetryAfter() {
    // Exhaust rate limit (send requests beyond limit)
    int limit = 10; // known rate limit for this endpoint
    for (int i = 0; i <= limit; i++) {
        Response r = given().spec(withAuth("customer"))
            .when().post("/api/auth/login");
        if (r.statusCode() == 429) {
            assertThat(r.header("Retry-After")).isNotNull();
            assertThat(Integer.parseInt(r.header("Retry-After"))).isPositive();
            assertThat(r.jsonPath().getString("message")).contains("rate limit");
            return; // test passes
        }
    }
    fail("Rate limit was never triggered after " + limit + " requests");
}

Which Methods Should Be Idempotent?

MethodIdempotent by definition?What to test
GET, HEAD, OPTIONSYes (and safe: no state change)Repeated calls return the same data and change nothing
PUT, DELETEYesSecond identical call leaves the same final state (DELETE may return 404 or 204 the second time)
POSTNoWith an Idempotency-Key header, a retried POST returns the original result instead of creating a duplicate
PATCHNot guaranteedDepends on the operation: "set status to X" is idempotent, "increment by 1" is not

FAQs

What is an idempotency key?

A unique value the client sends with a request (usually in an Idempotency-Key header) so the server can recognise retries of the same operation and return the original result instead of performing it again.

How do you test rate limiting?

Send requests faster than the documented limit and assert that the API returns 429 Too Many Requests with a Retry-After header, then wait for the window to reset and check requests succeed again.

Can CORS be tested without a browser?

Yes. Send an OPTIONS preflight request with Origin and Access-Control-Request-Method headers and assert on the Access-Control-Allow-* response headers; then confirm the behaviour once in a real browser.