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
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
}
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
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?
| Method | Idempotent by definition? | What to test |
|---|---|---|
| GET, HEAD, OPTIONS | Yes (and safe: no state change) | Repeated calls return the same data and change nothing |
| PUT, DELETE | Yes | Second identical call leaves the same final state (DELETE may return 404 or 204 the second time) |
| POST | No | With an Idempotency-Key header, a retried POST returns the original result instead of creating a duplicate |
| PATCH | Not guaranteed | Depends 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.