Definition
- Status code – Assert the exact expected code: 200 for success, 201 for created, 400 for bad request, 401 for unauthenticated, 403 for forbidden, 404 for not found.
- Response headers – Assert content type, security headers, caching headers, and a correlation identifier for tracing.
- Body contents – Assert required fields present, correct types, correct values, and no sensitive fields leaking.
- JSON schema – Validate the body against a schema to catch structural drift across versions in one assertion.
- Response time – Assert the request completes within the SLA, for example under 200ms at p95.
- Auth behavior – Confirm missing tokens return 401, insufficient permissions return 403, and valid tokens succeed.
- Error messages – Assert structured errors with stable codes and user-safe messages, never stack traces or SQL.
When the National Bank of India's transfer API returns a response, checking only that it said "200 OK" is like a teller glancing at a form and seeing it is stamped, without reading what it says. A careful tester reads everything: is the stamp correct, is the paper the right form with the right header, does it contain the account number, the exact amount, and a transaction reference, did it come back within the promised time, did it refuse an unauthorized request, and if it failed did the error message explain the problem without leaking secrets.
Understanding the Concepts
Why is status code alone insufficient?
The status code is only the first signal. You assert the exact expected code because the code communicates the category of outcome, and a wrong code is itself a bug even if the body looks right.
// REST Assured: assert exact status codes
package com.bank.api;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
public class TransferApiStatusTest {
@Test
void successfulTransferReturns200() {
given()
.header("Authorization", "Bearer " + validToken)
.contentType("application/json")
.body(validTransferRequest)
.when()
.post("/api/transfer")
.then()
.statusCode(200);
}
@Test
void invalidRequestReturns400() {
given()
.header("Authorization", "Bearer " + validToken)
.contentType("application/json")
.body("{ \"amount\": -100 }")
.when()
.post("/api/transfer")
.then()
.statusCode(400);
}
}
How do you validate response headers?
Response headers carry important contract details. Assert content type, security headers, and a correlation identifier.
@Test
void responseHeadersAreCorrect() {
given()
.header("Authorization", "Bearer " + validToken)
.body(validTransferRequest)
.when()
.post("/api/transfer")
.then()
.statusCode(200)
.header("Content-Type", containsString("application/json"))
.header("X-Request-Id", notNullValue())
.header("X-Content-Type-Options", "nosniff")
.header("Cache-Control", containsString("no-store"));
}
How do you validate body contents?
Assert that required fields are present, each field has the correct type, values are correct, and no sensitive fields leak.
@Test
void transferResponseContainsRequiredFields() {
given()
.header("Authorization", "Bearer " + validToken)
.body(validTransferRequest)
.when()
.post("/api/transfer")
.then()
.statusCode(200)
.body("transactionId", notNullValue())
.body("status", equalTo("COMPLETED"))
.body("amount", equalTo(5000.00f))
.body("currency", equalTo("INR"))
.body("timestamp", notNullValue())
// No sensitive fields should leak
.body("$", not(hasKey("internalId")))
.body("$", not(hasKey("passwordHash")));
}
How does JSON schema validation work?
JSON schema validation raises body checking from field-by-field to contract-level. One assertion catches a renamed field, a changed type, or a missing required property.
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
@Test
void transferResponseMatchesSchema() {
given()
.header("Authorization", "Bearer " + validToken)
.body(validTransferRequest)
.when()
.post("/api/transfer")
.then()
.statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/transfer-response.json"));
}
// schemas/transfer-response.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["transactionId", "status", "amount", "currency", "timestamp"],
"properties": {
"transactionId": { "type": "string" },
"status": { "type": "string", "enum": ["COMPLETED", "PENDING", "FAILED"] },
"amount": { "type": "number" },
"currency": { "type": "string", "minLength": 3, "maxLength": 3 },
"timestamp": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}
How do you validate response time against the SLA?
A correct but slow response still breaches the service-level objective, so response time is part of the contract.
@Test
void transferRespondWithinSLA() {
given()
.header("Authorization", "Bearer " + validToken)
.body(validTransferRequest)
.when()
.post("/api/transfer")
.then()
.statusCode(200)
.time(lessThan(200L)); // Under 200ms
}
How do you test authentication and authorization?
Confirm that security is actually enforced, not assumed.
@Test
void missingTokenReturns401() {
given()
.contentType("application/json")
.body(validTransferRequest)
// No Authorization header
.when()
.post("/api/transfer")
.then()
.statusCode(401)
.body("error", equalTo("UNAUTHORIZED"))
.body("message", equalTo("Authentication required"));
}
@Test
void insufficientPermissionsReturns403() {
given()
.header("Authorization", "Bearer " + readOnlyToken)
.body(validTransferRequest)
.when()
.post("/api/transfer")
.then()
.statusCode(403)
.body("error", equalTo("FORBIDDEN"));
}
How do you validate error messages?
Error messages must be structured, safe, and stable.
@Test
void invalidAmountReturnsStructuredError() {
given()
.header("Authorization", "Bearer " + validToken)
.contentType("application/json")
.body("{ \"fromAccount\": \"ACC-001\", \"amount\": -100 }")
.when()
.post("/api/transfer")
.then()
.statusCode(400)
.body("errorCode", equalTo("INVALID_AMOUNT"))
.body("message", equalTo("Amount must be positive"))
// Must NOT leak internals
.body("message", not(containsString("Exception")))
.body("message", not(containsString("SQL")))
.body("message", not(containsString("stack")));
}
How do you design negative scenarios?
Pair each happy case with its failure cases. Both positive and negative assertions are needed because in a bank the rejection behavior matters as much as success.
| Scenario | Method | Expected Status | Key Assertion |
|---|---|---|---|
| Valid transfer | POST | 200 | transactionId present, correct amount |
| Invalid amount | POST | 400 | errorCode INVALID_AMOUNT |
| Insufficient balance | POST | 422 | errorCode INSUFFICIENT_FUNDS |
| Missing auth token | POST | 401 | error UNAUTHORIZED |
| Read-only token | POST | 403 | error FORBIDDEN |
| Account not found | GET | 404 | errorCode ACCOUNT_NOT_FOUND |
What are common interview follow-up questions?
- "Why validate the schema and not just individual fields?" Schema validation catches structural changes like a removed field or a type change in one assertion, whereas field-by-field checks might miss a new unexpected field or a renamed one.
- "How do you handle dynamic values in the response?" Use matchers: notNullValue for IDs, matchesPattern for UUIDs, closeTo for timestamps.
- "What is the most important negative test for a banking API?" Authentication and authorization enforcement, because a missing 401 or 403 means anyone can access the API.
Visual Overview
Full API Response Validation Checklist
──────────────────────────────────────
Request ──> API ──> Response
│
┌────────────────────┼────────────────────┐
│ 1. STATUS CODE │ 200, 201, 400, │
│ │ 401, 403, 404 │
├────────────────────┼────────────────────┤
│ 2. HEADERS │ Content-Type, │
│ │ X-Request-Id, │
│ │ Security headers │
├────────────────────┼────────────────────┤
│ 3. BODY FIELDS │ Required present, │
│ │ correct types, │
│ │ correct values │
├────────────────────┼────────────────────┤
│ 4. JSON SCHEMA │ Structural drift │
│ │ caught in one │
│ │ assertion │
├────────────────────┼────────────────────┤
│ 5. RESPONSE TIME │ Under SLA (200ms) │
├────────────────────┼────────────────────┤
│ 6. AUTH BEHAVIOR │ 401, 403 enforced │
├────────────────────┼────────────────────┤
│ 7. ERROR MESSAGES │ Stable codes, │
│ │ safe messages, │
│ │ no stack traces │
├────────────────────┼────────────────────┤
│ 8. BUSINESS RULES │ Overdraft rejected│
│ │ Amounts correct │
└────────────────────┴────────────────────┘
Key Takeaway
When I validate a REST API response I treat the whole response as a contract and assert every part of it, not just the status code. I check the exact status code, the response headers including content type and correlation ID, every required body field with correct types and values, JSON schema conformance to catch structural drift in one assertion, response time against the SLA, authentication and authorization enforcement with correct 401 and 403 rejections, and structured error messages with stable codes that never leak internals.
Crucially I design negative scenarios as deliberately as positive ones, pairing each happy case with its failures, because in a bank the rejection behavior matters as much as success. A missing 401 means anyone can access the API; a missing schema check means a refactor can silently rename a field.
At the National Bank of India I built exactly this with REST Assured, asserting status, time under 200ms, content type, JSON schema, and specific field values on the success path, and asserting 401, 403, 400, and safe error codes on the negative paths. That contract-level checking caught a release where a refactor silently renamed a field and changed the amount from a number to a string, which a status-code-only check would have passed straight through.
Remember one sentence: validate the full response contract across status, headers, body, schema, SLA, auth, and errors, because checking only the status code misses everything that actually matters.
---