API Testing + REST Assured — Complete Interview Guide (5+ Yrs Automation Engineer)¶
Comprehensive API testing reference grounded in your actual code: REST Assured in
OrdersAPI.javaandOrdersAPITest.javafrom yourB2BProjectTest/Avysh framework, plus the Axios-basedBaseApi.ts/AgentsApi.ts/AuthApi.tsfrom your Morrie Playwright framework. Every concept includes spoken-style interview answers and pointers to the real code.
How this file is organised¶
| Section | Topic |
|---|---|
| 1 | Why API testing — the test-pyramid argument |
| 2 | HTTP fundamentals — methods, status codes, idempotency |
| 3 | REST vs SOAP vs GraphQL vs gRPC |
| 4 | What to test — the 9-dimension API test plan |
| 5 | REST Assured deep dive — given/when/then, BDD style |
| 6 | Request Specifications + POJO bodies + Jackson |
| 7 | Response validation — extract, JsonPath, schema |
| 8 | Authentication patterns — Basic, OAuth2, JWT, OTP |
| 9 | Filters (logging, audit, auto-auth) |
| 10 | Chained calls (login → use token → cleanup) |
| 11 | Data-driven via DataProvider + JSON files |
| 12 | Schema validation with JSON Schema |
| 13 | API mocking — WireMock + Playwright route |
| 14 | Performance + load (k6, JMeter, Gatling intro) |
| 15 | Security testing for APIs |
| 16 | The Axios pattern in Morrie (interceptors, rate limit, 429 retry) |
| 17 | REST Assured vs Axios vs requests vs httpx — pick by stack |
| 18 | API + UI combined tests (your e2e-lifecycle pattern) |
| 19 | Tech debt in B2BProjectTest OrdersAPI — what I'd refactor |
| 20 | Contract testing (Pact basics) |
| 21 | 40+ interview Q&A with full spoken answers |
1. WHY API TESTING — THE TEST-PYRAMID ARGUMENT¶
1.1 The pyramid¶
Most regressions live in business logic. Business logic is exposed via APIs. Therefore most regressions are catchable at the API layer — faster, cheaper, more stable than UI.
1.2 The numbers I quote in interviews¶
| Test type | Typical run time | Stability | Maintenance |
|---|---|---|---|
| Unit | ms | 99%+ | Low |
| API | 100ms-1s | 95-99% | Low-Medium |
| UI / E2E | 5-60s | 80-95% | High |
"At Questt, our API suite catches roughly 70% of regressions before UI tests even start. UI tests then only verify presentation and user flow — which is what they're good at. Total PR pipeline goes from 30 minutes (mostly UI) to under 10 minutes (mostly API), and the team gets feedback within one coffee break instead of one meeting."
1.3 What API tests catch that UI can't¶
- Business logic edge cases — UI form may prevent invalid input, but the API still has to.
- Authorization — UI hides "Delete" button for viewers, but API must enforce too.
- Concurrency — race conditions on stock decrement, double-billing, etc.
- Contracts — schema changes, field renames, type changes.
- Performance — UI tests don't tell you the 99th percentile latency of the underlying API.
2. HTTP FUNDAMENTALS¶
2.1 The methods — memorize idempotency¶
| Method | Purpose | Idempotent? | Safe? |
|---|---|---|---|
| GET | Read | Yes | Yes |
| POST | Create new | No | No |
| PUT | Replace entire resource | Yes | No |
| PATCH | Partial update | Sometimes | No |
| DELETE | Remove | Yes | No |
| HEAD | Headers only (no body) | Yes | Yes |
| OPTIONS | List allowed methods, CORS preflight | Yes | Yes |
Idempotent vs Safe — the precise definition¶
- Idempotent = calling N times has the same effect as calling once.
PUT /users/42with body X always leaves user 42 == X regardless of call count. - Safe = doesn't change server state.
GETdoesn't change state,POSTdoes.
Why PATCH is "sometimes" idempotent¶
"PATCH
{name: 'Rohan'}is idempotent if the field is just being set — calling twice still leaves name == Rohan. But PATCH{counter: counter+1}(increment style) is NOT idempotent — each call moves the value. The spec doesn't enforce it; it's the API designer's choice."
Spoken answer — "Difference between PUT and PATCH?"¶
"PUT replaces the entire resource — if you PUT
{name: 'Rohan'}to a user that had email and age, those fields can be wiped. PATCH updates only the fields you send — other fields stay. PUT is strictly idempotent. PATCH usually is but doesn't have to be. In practice, I check the API contract — some teams use PUT for partial updates anyway, which is non-standard but real."
2.2 Status codes — the full list you should know¶
2xx Success¶
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Successful GET/PUT/PATCH with body |
| 201 | Created | POST that creates a resource (usually with Location header) |
| 202 | Accepted | Async operation started, processing in background |
| 204 | No Content | Successful DELETE or PUT with no body to return |
3xx Redirect¶
| Code | Meaning |
|---|---|
| 301 | Moved Permanently |
| 302 | Found (temporary redirect) |
| 304 | Not Modified (cache hit) |
| 307 | Temporary Redirect (preserve method) |
4xx Client Error¶
| Code | Meaning | How to test |
|---|---|---|
| 400 | Bad Request | Malformed JSON, missing required field |
| 401 | Unauthorized | No or invalid token |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource ID doesn't exist |
| 405 | Method Not Allowed | Hitting POST on a GET-only endpoint |
| 409 | Conflict | Duplicate email, version mismatch |
| 413 | Payload Too Large | Body exceeds server limit |
| 415 | Unsupported Media Type | Wrong Content-Type |
| 422 | Unprocessable Entity | Validation failed — format OK, values invalid |
| 429 | Too Many Requests | Rate limit hit |
5xx Server Error¶
| Code | Meaning |
|---|---|
| 500 | Internal Server Error |
| 502 | Bad Gateway (upstream returned bad response) |
| 503 | Service Unavailable |
| 504 | Gateway Timeout |
2.3 401 vs 403 — the classic interview trap¶
- 401 Unauthorized — "I don't know who you are" → missing or invalid credentials
- 403 Forbidden — "I know you, but you can't do this" → authenticated but no permission
"If an app returns 401 when a viewer hits an admin endpoint, that's wrong — the token was valid, so 403 is correct. Returning 401 for everything is a junior pattern that leaks information about which endpoints exist."
2.4 Idempotency keys — beyond the method¶
Modern APIs use an Idempotency-Key header (Stripe pattern) — client supplies a UUID, server returns the cached response if the same key was used before. Lets POST be safely retried.
POST /payments
Idempotency-Key: 7f8e2a1c-9b3d-4c5e-8a7b-2f9d3c4e5a6b
{"amount": 5000, "currency": "INR"}
3. REST vs SOAP vs GRAPHQL vs GRPC¶
| REST | SOAP | GraphQL | gRPC | |
|---|---|---|---|---|
| Format | JSON (mostly) | XML | JSON | Protobuf (binary) |
| Transport | HTTP | HTTP/SMTP | HTTP | HTTP/2 |
| Endpoints | Many (resource-based) | One (/api) |
One | Service methods |
| Schema | Optional (OpenAPI) | Strict (WSDL) | Strict (GraphQL Schema) | Strict (.proto) |
| Speed | Fast | Slow | Fast (one query) | Fastest (binary) |
| Tooling | Excellent | Heavy | Good | Growing |
| Use case | Most modern APIs | Banking, legacy | Frontends with selective field needs | Microservice-to-microservice |
3.1 GraphQL testing — the parts that differ from REST¶
- Single endpoint (
/graphql) — every test is a POST to the same URL - Test query variations — same data, different selected fields
- Schema introspection — test that schema hasn't drifted
- Error response shape — GraphQL returns 200 even on logical errors;
errorsarray is what you check
String query = "query { user(id: \"42\") { email name } }";
given()
.contentType(JSON)
.body(Map.of("query", query))
.when()
.post("/graphql")
.then()
.statusCode(200)
.body("data.user.email", equalTo("a@x.com"))
.body("errors", nullValue());
4. WHAT TO TEST — THE 9-DIMENSION API TEST PLAN¶
Memorize this — when asked "how would you test a POST /users API?" this is the structure.
| # | Dimension | What you check |
|---|---|---|
| 1 | Positive (happy path) | Valid payload → 201, correct response body, ID returned. Follow up with GET to confirm persistence. |
| 2 | Input validation | Missing fields → 400 with field-specific error. Invalid format (email, password policy) → 400. Boundary values, Unicode, emoji. |
| 3 | Authentication | No token → 401. Expired token → 401. Tampered token → 401. |
| 4 | Authorization | Token from user without permission → 403 (not 401). Cross-tenant access → 403 or 404. |
| 5 | Conflict | Duplicate email → 409. Version conflict (If-Match header) → 412. |
| 6 | Edge cases | Empty strings, nulls in optional fields, very long inputs near limits, SQL-injection-shaped strings. |
| 7 | Schema | Run response through JSON Schema validator to catch contract drift. |
| 8 | Performance + Limits | Response time under SLA. Payload over max_request_body → 413. Rate limit — N rapid calls → 429. |
| 9 | Security | SQL/NoSQL injection payloads sanitized. XSS in stored fields encoded on read. HTTPS enforced. Error responses don't leak stack traces. |
Spoken answer — "How would you test a POST /users API?"¶
"Nine dimensions. One: positive — valid payload, expect 201, response body has the created user with server-generated ID, follow-up GET confirms persistence. Two: input validation — missing required fields, invalid email format, password policy, Unicode, emoji, boundary lengths. Three: authentication — no token, expired token, tampered token, all return 401. Four: authorization — token from a user without permission returns 403, not 401 — that distinction matters. Five: conflict — duplicate email returns 409, not 500. Six: edge cases — empty optional fields, very long inputs, SQL-injection-shaped strings. Seven: schema — pipe the response through a JSON Schema validator so a backend rename fails my test immediately. Eight: performance — response time under SLA (typically 500ms for a write), oversized payload returns 413, rapid calls trigger 429. Nine: security — SQLi payloads sanitized, XSS payloads encoded, no stack-trace leaks. And cleanup — every test deletes what it created."
5. REST ASSURED DEEP DIVE¶
5.1 Why REST Assured¶
- BDD syntax —
given().when().then()reads like English - Built-in JSON/XML parsing via JsonPath/XmlPath
- Schema validation via
matchesJsonSchemaInClasspath - Integrates with TestNG/JUnit seamlessly
- Fluent assertions chain naturally with Hamcrest matchers
5.2 The given/when/then pattern¶
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
@Test
public void getUser() {
given()
.baseUri("https://api.example.com")
.header("Authorization", "Bearer " + token)
.header("Accept", "application/json")
.when()
.get("/users/42")
.then()
.statusCode(200)
.body("name", equalTo("Rohan"))
.body("email", containsString("@"))
.time(lessThan(2000L));
}
- given() — setup the request (base URI, headers, body, auth)
- when() — the HTTP action (
.get(),.post(), etc.) - then() — assertions on status, headers, body, time
5.3 Your B2BProjectTest OrdersAPI (real code, annotated)¶
public String postOrder(String baseUrl, JsonObject orderData) {
RestAssured.baseURI = baseUrl; // ⚠️ static mutation (see tech debt)
RequestSpecification httpRequest = RestAssured.given();
httpRequest.header("Content-Type", "application/json");
httpRequest.body(orderData);
Response response = httpRequest.request(Method.POST, "orderManager/postRequest");
JsonPath jsonpath = response.jsonPath();
String orderId = jsonpath.getString("orderDetails.dealId");
int statuscode = response.getStatusCode();
Assert.assertEquals(statuscode, 200); // ⚠️ assertion in helper (see tech debt)
System.out.println("OrderId: " + orderId);
return orderId;
}
What the helper does¶
- Sets base URI (statically — issue)
- Builds request with
Content-Type: application/json - Posts JSON body (using
com.google.gson.JsonObject) - Parses response with JsonPath, extracts
orderDetails.dealId - Asserts 200 in the helper (issue — see tech debt)
- Returns the order ID
Tech debt — what I'd refactor¶
"Two things. First,
RestAssured.baseURI = baseUrlis a static field mutation — two parallel API tests can overwrite each other's base URI. The right shape isgiven().baseUri(baseUrl).when()...— instance-scoped. Second,Assert.assertEquals(statuscode, 200)inside the helper makes it useless for negative tests — I can't reusepostOrderto test what happens with an invalid auth key, because the helper will throw before the test gets a chance. Helpers should return the Response; tests should assert."
6. REQUEST SPECIFICATIONS + POJO BODIES + JACKSON¶
6.1 RequestSpecBuilder — DRY your setup¶
public class TestSpecs {
public static RequestSpecification authRequest(String token) {
return new RequestSpecBuilder()
.setBaseUri("https://api.example.com")
.setContentType(ContentType.JSON)
.addHeader("Authorization", "Bearer " + token)
.addHeader("Accept", "application/json")
.addFilter(new RequestLoggingFilter())
.addFilter(new ResponseLoggingFilter())
.build();
}
public static ResponseSpecification okJson() {
return new ResponseSpecBuilder()
.expectStatusCode(200)
.expectContentType(ContentType.JSON)
.expectResponseTime(Matchers.lessThan(2000L))
.build();
}
}
Use in tests:
given()
.spec(TestSpecs.authRequest(token))
.pathParam("id", 42)
.when()
.get("/users/{id}")
.then()
.spec(TestSpecs.okJson())
.body("name", equalTo("Rohan"));
Why this matters: without specs, every test repeats base URI, auth header, content-type. With specs, change once, applies everywhere.
6.2 POJO bodies — the senior pattern¶
The anti-pattern (string concatenation)¶
Why bad: no compile-time field checking, no autocomplete, breaks on quotes in the values, no IDE refactor support.The right pattern (POJOs + Jackson)¶
public class User {
private String name;
private String email;
private int age;
// getters, setters, no-arg constructor
}
User u = new User();
u.setName("Rohan");
u.setEmail("r@x.com");
u.setAge(28);
given()
.contentType(JSON)
.body(u) // POJO → JSON automatically (Jackson)
.when()
.post("/users")
.then()
.statusCode(201)
.extract().as(User.class); // JSON → POJO automatically
REST Assured auto-detects Jackson (or Gson, or JSON-B) on the classpath and uses it for serialization.
Field-level control via Jackson annotations¶
public class User {
@JsonProperty("user_name")
private String name;
@JsonProperty("email_address")
private String email;
@JsonIgnore
private String internalId; // not serialized
@JsonProperty(access = Access.READ_ONLY)
private String createdAt; // read but never sent
}
Spoken answer — "Why POJO over string body?"¶
"Compile-time field-name safety. If the backend renames a field from
emailAddress, the POJO compile-fails immediately when I update the Jackson annotation, and every test using that POJO fails fast — caught at build. With a JSON string, the test runs and just gets a 400 with no obvious cause. POJOs also let me reuse the same class for request and response — type-checked end-to-end. The only cases I use strings are for very simple one-off bodies or when testing malformed JSON intentionally."
6.3 The OrdersAPITest pattern — JsonObject (the in-between)¶
Your code uses com.google.gson.JsonObject:
JsonObject ordersData = new ReadTestData().readJsonElementForAPI(
"orders/ordersapidata.json", "orderData");
ordersAPIHelper.postOrder(shopfrontUri, ordersData);
7. RESPONSE VALIDATION — EXTRACT, JSONPATH, SCHEMA¶
7.1 Status code, headers, time¶
.then()
.statusCode(200) // exact
.statusCode(anyOf(is(200), is(201))) // one of
.statusLine("HTTP/1.1 200 OK")
.header("Content-Type", "application/json")
.header("X-RateLimit-Remaining", notNullValue())
.time(lessThan(2000L)) // milliseconds
.cookie("session_id", notNullValue());
7.2 Body assertions with JsonPath + Hamcrest¶
.then()
.body("name", equalTo("Rohan"))
.body("age", greaterThan(18))
.body("email", endsWith("@x.com"))
.body("address.city", equalTo("Bangalore")) // nested
.body("orders[0].id", equalTo(101)) // array index
.body("orders.size()", greaterThan(0)) // array length
.body("orders.id", hasItems(101, 102)) // collected
.body("orders.findAll { it.status == 'paid' }.size()", equalTo(3)) // Groovy
.body("emails", hasItem("test@x.com"))
.body("createdAt", matchesRegex("\\d{4}-\\d{2}-\\d{2}T.*"));
Your real assertion pattern (from OrdersAPITest.getOrder)¶
softAssertions.assertThat(jsonpath.getString("description[0].orderId"))
.as("orderId").isEqualTo(orderId);
softAssertions.assertThat(jsonpath.getString("description[0].orderStatus"))
.as("orderStatus").isEqualTo("created");
softAssertions.assertThat(jsonpath.getString("description[0].productDetails[0].name"))
.as("product name").isEqualTo(ordersData.get("productName").getAsString());
.as("...") gives a descriptive label so failure messages are clear; nested-array path notation like description[0].productDetails[0].name mirrors the JSON shape.
7.3 Extract — capture values for chaining¶
// Extract a single path
String token = given().body(loginBody)
.post("/auth/login").then()
.statusCode(200)
.extract().path("data.access_token");
// Extract whole response
Response r = given().get("/users/1").then().extract().response();
int count = r.jsonPath().getInt("data.size()");
List<String> emails = r.jsonPath().getList("data.email");
// Extract typed list of POJOs
List<User> users = r.jsonPath().getList("data", User.class);
// Extract single POJO
User user = given().get("/users/1").then().extract().as(User.class);
7.4 JsonPath in REST Assured uses Groovy GPath¶
.body("orders.findAll { it.amount > 1000 }.id", hasItems(101, 105))
.body("users.collect { it.email }", hasItem("a@x.com"))
.body("users.max { it.age }.name", equalTo("Rohan"))
.body("orders.sum { it.amount }", equalTo(15000))
Powerful for complex assertions without extracting to Java code.
8. AUTHENTICATION PATTERNS¶
8.1 Basic Auth¶
given().auth().basic("user", "pass")...
given().auth().preemptive().basic("user", "pass")... // skip 401 challenge
8.2 Bearer token / OAuth2¶
8.3 Form auth¶
8.4 API key¶
given().header("apiKey", apiKey)... // header
given().queryParam("api_key", apiKey)... // query param
given().header("X-API-Key", apiKey)... // common naming
Your OrdersAPI uses the header form:
8.5 Cookie-based session¶
String session = given().auth().basic(user, pass)
.post("/login")
.then().extract().cookie("JSESSIONID");
given().cookie("JSESSIONID", session).get("/protected")...
8.6 OTP flow (your Morrie pattern)¶
// AuthApi.ts (TypeScript / Axios)
async requestOtp(email: string) {
return this.client.post(API_ENDPOINTS.AUTH.LOGIN, { email },
{ headers: { ...(process.env.API_KEY ? { 'x-api-key': process.env.API_KEY } : {}) } });
}
async verifyOtp(email: string, otp: string) {
return this.client.post(API_ENDPOINTS.AUTH.VERIFY_OTP, { email, otp },
{ headers: { ...(process.env.API_KEY ? { 'x-api-key': process.env.API_KEY } : {}) } });
}
REST Assured equivalent:
// Step 1: request OTP
given()
.contentType(JSON)
.header("x-api-key", apiKey)
.body(Map.of("email", "u@x.com"))
.when()
.post("/auth/login")
.then()
.statusCode(200);
// Step 2: verify (test env returns deterministic OTP)
String token = given()
.contentType(JSON)
.header("x-api-key", apiKey)
.body(Map.of("email", "u@x.com", "otp", "111111"))
.when()
.post("/auth/verify")
.then()
.statusCode(200)
.extract().path("access_token");
9. FILTERS — LOGGING, AUDIT, AUTO-AUTH¶
9.1 Built-in logging¶
// Per request
given().log().all()
.filter(new RequestLoggingFilter())
.filter(new ResponseLoggingFilter())
.when().get("/users")
.then().log().all();
// Global
RestAssured.filters(new RequestLoggingFilter(), new ResponseLoggingFilter());
// Only on failure (saves CI log noise)
given()...
.then()
.log().ifValidationFails();
9.2 Custom filter — auto-inject auth header (eliminates per-test setup)¶
public class AuthFilter implements Filter {
private final String token;
public AuthFilter(String token) { this.token = token; }
@Override
public Response filter(FilterableRequestSpecification req,
FilterableResponseSpecification res,
FilterContext ctx) {
req.header("Authorization", "Bearer " + token);
return ctx.next(req, res);
}
}
RestAssured.filters(new AuthFilter(token));
9.3 Custom filter — audit every call¶
RestAssured.filters((req, res, ctx) -> {
long start = System.currentTimeMillis();
Response r = ctx.next(req, res);
long elapsed = System.currentTimeMillis() - start;
AuditLog.log(req.getMethod(), req.getURI(), r.statusCode(), elapsed);
return r;
});
9.4 Allure / ExtentReports attachment via filter¶
public class AllureFilter implements Filter {
@Override
public Response filter(...) {
Response r = ctx.next(req, res);
Allure.addAttachment("Request", "application/json", reqBody);
Allure.addAttachment("Response", "application/json", r.asString());
return r;
}
}
10. CHAINED API CALLS — LOGIN → USE TOKEN → CLEANUP¶
The bread and butter of API testing.
@Test
public void createReadDeleteUser() {
// 1. LOGIN
String token = given()
.contentType(JSON)
.body(Map.of("email", "admin@x.com", "password", "Test@123"))
.when()
.post("/auth/login")
.then()
.statusCode(200)
.extract().path("token");
// 2. CREATE
String uniqueEmail = "rohan+" + System.currentTimeMillis() + "@x.com";
int userId = given()
.auth().oauth2(token)
.contentType(JSON)
.body(Map.of("name", "Rohan", "email", uniqueEmail))
.when()
.post("/users")
.then()
.statusCode(201)
.body("email", equalTo(uniqueEmail))
.extract().path("id");
// 3. READ
given()
.auth().oauth2(token)
.when()
.get("/users/" + userId)
.then()
.statusCode(200)
.body("name", equalTo("Rohan"));
// 4. DELETE
given()
.auth().oauth2(token)
.when()
.delete("/users/" + userId)
.then()
.statusCode(204);
// 5. VERIFY DELETED
given()
.auth().oauth2(token)
.when()
.get("/users/" + userId)
.then()
.statusCode(404);
}
Best practices in this pattern¶
- Unique data — timestamp suffix on email avoids test collisions in parallel runs
- Extract once, reuse — token extracted at top, used throughout
- Self-cleanup — DELETE in the same test, even after assertions
- Verify cleanup — GET after DELETE confirms 404, catching soft-delete-as-200 bugs
11. DATA-DRIVEN TESTING¶
11.1 TestNG @DataProvider¶
@DataProvider(name = "userData")
public Object[][] userData() {
return new Object[][] {
{ "rohan@x.com", "Rohan", 28, 201 },
{ "asha@x.com", "Asha", 25, 201 },
{ "invalid-email", "Bob", 30, 400 },
{ "", "Bob", 30, 400 },
{ "rohan@x.com", "Dup", 28, 409 }, // duplicate
};
}
@Test(dataProvider = "userData")
public void createUserCases(String email, String name, int age, int expectedStatus) {
given()
.contentType(JSON)
.body(Map.of("email", email, "name", name, "age", age))
.when()
.post("/users")
.then()
.statusCode(expectedStatus);
}
11.2 JSON file as data source (your B2BProjectTest pattern)¶
// JSON file: orders/ordersapidata.json
{
"orderData": {
"apiKey": "517f...",
"buyerName": "Rohan",
"productName": "Widget A",
"skuId": "SKU-001",
...
}
}
// ReadTestData utility
JsonObject ordersData = new ReadTestData().readJsonElementForAPI(
"orders/ordersapidata.json", "orderData");
// Use in test
String orderId = ordersAPIHelper.postOrder(uri, ordersData);
Pros: test data is data, not code — non-engineers can edit it. Test cases for many scenarios fit in one file. Cons: no compile-time field check; typos in field names fail at runtime; harder to refactor.
11.3 CSV via Apache Commons CSV¶
@DataProvider(name = "csvUsers")
public Object[][] csvUsers() throws IOException {
try (Reader r = new FileReader("src/test/resources/users.csv");
CSVParser parser = new CSVParser(r, CSVFormat.DEFAULT.withFirstRecordAsHeader())) {
return parser.getRecords().stream()
.map(rec -> new Object[]{ rec.get("email"), rec.get("name") })
.toArray(Object[][]::new);
}
}
11.4 Excel via Apache POI (legacy, mostly avoided now)¶
Still asked in interviews. POI reads .xlsx; rows become test cases. Useful when test data lives in shared spreadsheets the QA team maintains.
12. SCHEMA VALIDATION¶
12.1 JSON Schema Validator¶
Add the dependency:
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>
Schema file (src/test/resources/schemas/user.json):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["id", "email", "name"],
"properties": {
"id": { "type": "integer", "minimum": 1 },
"email": { "type": "string", "format": "email" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"age": { "type": "integer", "minimum": 0, "maximum": 150 },
"role": { "type": "string", "enum": ["admin", "viewer", "editor"] }
},
"additionalProperties": false
}
Test:
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
given().get("/users/1")
.then()
.statusCode(200)
.body(matchesJsonSchemaInClasspath("schemas/user.json"));
What this catches¶
- A field renamed by the backend (
email→emailAddress) - A type change (
agewas integer, now string) - A missing required field
- An unexpected new field (when
additionalProperties: false)
This is your contract testing safety net within a single team's repo.
12.2 OpenAPI / Swagger integration¶
For full contract tests, use the spec itself as the source of truth. Tools like atlassian.openapi-validator validate every response against the OpenAPI spec at runtime.
13. API MOCKING¶
13.1 Why mock APIs in tests¶
- Test error states the real backend can't easily produce (500, slow response, malformed JSON)
- Decouple from external services that cost money or have quotas (Stripe, Twilio)
- Speed up tests by removing network latency
- Achieve deterministic data for assertions
13.2 WireMock — standalone mock server (Java)¶
@RegisterExtension
static WireMockExtension wm = WireMockExtension.newInstance()
.options(wireMockConfig().port(8089))
.build();
@Test
void testWithMockedDownstream() {
wm.stubFor(get(urlEqualTo("/payments/pi_123"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"id\":\"pi_123\",\"status\":\"succeeded\",\"amount\":5000}")
.withFixedDelay(200))); // simulate latency
PaymentInfo info = paymentService.fetch("pi_123"); // hits localhost:8089
assertEquals("succeeded", info.getStatus());
wm.verify(getRequestedFor(urlEqualTo("/payments/pi_123"))
.withHeader("Authorization", containing("Bearer")));
}
13.3 Conditional responses — return different responses by request body¶
wm.stubFor(post(urlEqualTo("/orders"))
.withRequestBody(matchingJsonPath("$.amount", lessThan(100)))
.willReturn(aResponse().withStatus(400).withBody("{\"error\":\"min_order\"}")));
wm.stubFor(post(urlEqualTo("/orders"))
.withRequestBody(matchingJsonPath("$.amount", greaterThanOrEqualTo(100)))
.willReturn(aResponse().withStatus(201).withBody("{\"id\":\"ord_1\"}")));
13.4 Playwright route mocking (your Morrie alternative)¶
await page.route('**/api/users/*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 1, name: 'Mocked User' }),
});
});
14. PERFORMANCE + LOAD TESTING¶
14.1 The metrics that matter¶
- Throughput — requests per second
- P50, P95, P99 latency — percentile response times (NOT averages)
- Error rate — anything above 1% invalidates throughput
- Saturation — at what RPS does latency climb?
14.2 JMeter — the classic¶
GUI-based test plan: Thread Group → HTTP Request → Listener. Good for QA-led perf tests.
14.3 k6 — modern, JS-scripted¶
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // ramp up
{ duration: '1m', target: 100 }, // hold
{ duration: '30s', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<2000'], // SLO
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.post('https://api.example.com/orders', JSON.stringify({...}));
check(res, {
'status 201': (r) => r.status === 201,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
14.4 Gatling — Scala/Java DSL¶
Useful when perf tests need to live alongside Maven build.
14.5 What I'd actually use in 2026¶
- Functional API tests: REST Assured (Java) or Pytest + requests (Python)
- Perf smoke in CI: k6 with thresholds
- Full load test: Gatling or k6 distributed via k6 Cloud / Grafana Cloud
15. SECURITY TESTING FOR APIs¶
15.1 OWASP API Security Top 10 (2023) — the API-specific list¶
| # | Risk | Test approach |
|---|---|---|
| API1 | Broken Object-Level Authorization (BOLA / IDOR) | Try accessing other users' resources by ID manipulation |
| API2 | Broken Authentication | Brute force, expired tokens, JWT tampering |
| API3 | Broken Object Property-Level Authorization | Field-level access — viewer reading salary field |
| API4 | Unrestricted Resource Consumption | Rate limits, large payloads, expensive queries |
| API5 | Broken Function-Level Authorization | Viewer hitting admin endpoint |
| API6 | Unrestricted Access to Sensitive Business Flows | Bulk operations not behind workflow |
| API7 | Server Side Request Forgery (SSRF) | Inject URLs server-side fetches |
| API8 | Security Misconfiguration | CORS too permissive, debug endpoints exposed |
| API9 | Improper Inventory Management | Old API versions still live |
| API10 | Unsafe Consumption of APIs | Trusting upstream responses without validation |
15.2 Concrete test cases I'd write¶
@Test
public void idor_userCantReadAnotherUsersOrder() {
String tokenA = login("userA");
int orderId = createOrder(tokenA, ...);
String tokenB = login("userB");
given().auth().oauth2(tokenB)
.get("/orders/" + orderId)
.then()
.statusCode(anyOf(is(403), is(404))); // never 200
}
@Test
public void sqlInjection_inEmailFieldIsSafe() {
given().contentType(JSON)
.body(Map.of("email", "test'); DROP TABLE users;--",
"password", "x"))
.post("/auth/login")
.then()
.statusCode(anyOf(is(400), is(401)));
// and verify users table still exists by other means
}
@Test
public void noStackTraceLeakOnException() {
Response r = given().contentType(JSON)
.body("not json")
.post("/orders");
assertThat(r.asString())
.doesNotContain("Exception", "Caused by:", "at com.example");
}
@Test
public void rateLimitTriggersAt100rpm() {
for (int i = 0; i < 100; i++) {
given().auth().oauth2(token).get("/api/data").then().statusCode(200);
}
given().auth().oauth2(token).get("/api/data").then().statusCode(429);
}
16. THE AXIOS PATTERN IN MORRIE (TypeScript)¶
This is your modern API testing pattern — the BaseApi from your Morrie Playwright framework.
16.1 Your BaseApi.ts (real code, annotated)¶
import axios, { AxiosInstance, AxiosError } from 'axios';
export class BaseApi {
protected client: AxiosInstance;
private static lastRequestTime = 0;
private static REQUEST_DELAY = 700; // ms between calls (safe for OTP)
constructor(baseUrl: string) {
if (!baseUrl) throw new Error('BaseApi: baseUrl is missing');
this.client = axios.create({
baseURL: baseUrl,
headers: {
...(process.env.ACCESS_TOKEN
? { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` }
: {}),
},
});
// 🔵 REQUEST INTERCEPTOR — global rate limiter
this.client.interceptors.request.use(async (config) => {
const now = Date.now();
const wait = Math.max(0, BaseApi.REQUEST_DELAY - (now - BaseApi.lastRequestTime));
if (wait > 0) {
await new Promise(r => setTimeout(r, wait));
}
BaseApi.lastRequestTime = Date.now();
return config;
});
// 🔵 RESPONSE INTERCEPTOR — 429 retry with Retry-After
this.client.interceptors.response.use(
(res) => res,
async (error: AxiosError) => {
const config: any = error.config;
if (!config) throw error;
if (error.response?.status === 429) {
config.__retryCount = config.__retryCount || 0;
if (config.__retryCount >= 3) throw error;
config.__retryCount++;
const retryAfter = Number(error.response.headers['retry-after']) || 3;
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.client(config);
}
throw error;
}
);
}
}
16.2 Subclass — AgentsApi.ts¶
export class AgentsApi extends BaseApi {
createAgent(name: string, prompt: string) {
return this.client.post(API_ENDPOINTS.AGENTS.BASE, { name, prompt });
}
getAgents() {
return this.client.get(API_ENDPOINTS.AGENTS.BASE);
}
getAgentById(id: number | string) {
return this.client.get(API_ENDPOINTS.AGENTS.BY_ID(id));
}
updateAgent(id: number | string, updates: { name?: string; prompt?: string }) {
return this.client.patch(API_ENDPOINTS.AGENTS.BY_ID(id), updates);
}
deleteAgent(id: number | string) {
return this.client.delete(API_ENDPOINTS.AGENTS.BY_ID(id));
}
}
Spoken walkthrough — "Walk me through BaseApi"¶
"BaseApi is the Axios wrapper that every API class extends — AuthApi, AgentsApi, UserApi, ChatsApi. Three responsibilities. First, auto-inject the Authorization header from the ACCESS_TOKEN env var that global-setup populates after OTP login. Second, a request interceptor with a static
lastRequestTimeand 700ms throttle — every outgoing request waits if the previous one was less than 700ms ago. This stops us tripping the OTP endpoint's rate limit when global-setup runs concurrently with the API test fixtures. Third, a response interceptor handles 429 — up to 3 retries, honoringRetry-Afterheader, otherwise defaulting to 3 seconds. ThelastRequestTimeis static deliberately so it's shared across every API class instance — true global pacing, not per-class."
Honest tech-debt callout¶
"One thing I'd refactor:
lastRequestTimebeing static means all API clients share one global lock. That works for our scale, but for high-concurrency load-style parallel tests, I'd switch to a per-host token-bucket. Right now all clients queue behind one another even when hitting different hosts."
16.3 Why this beats Playwright's built-in request¶
- Composable — same
AgentsApiclass is usable from Playwright tests, pure Node scripts, CLI tools - Battle-tested interceptors — Axios's interceptor model is years older and richer
- One client config in one place — base URL, default headers, timeout, retry logic all live in BaseApi
17. REST ASSURED vs AXIOS vs REQUESTS vs HTTPX — pick by stack¶
| Library | Language | Best at | Watch out for |
|---|---|---|---|
| REST Assured | Java | BDD fluent assertions, schema validation | Static RestAssured.baseURI mutation pattern |
| Axios | JS/TS | Interceptors, modern Promise API | Default-throw-on-non-2xx behavior |
| requests | Python | Simple, readable, vast ecosystem | Sync only |
| httpx | Python | Async support, HTTP/2 | Newer; some plugins still 'requests'-only |
| OkHttp | Java | Low-level control | Verbose for test assertions |
| WebClient | Java (Spring) | Reactive | Heavy if you don't need reactive |
17.1 Python requests equivalent of your OrdersAPI¶
import requests
class OrdersAPI:
def __init__(self, base_url: str, api_key: str):
self.session = requests.Session()
self.session.headers.update({
'Content-Type': 'application/json',
'apiKey': api_key,
})
self.base_url = base_url
def post_order(self, order_data: dict) -> str:
response = self.session.post(
f'{self.base_url}/orderManager/postRequest',
json=order_data
)
response.raise_for_status()
return response.json()['orderDetails']['dealId']
17.2 Python httpx for async¶
import httpx
import asyncio
async def post_orders_concurrently(orders):
async with httpx.AsyncClient() as client:
responses = await asyncio.gather(*[
client.post('/orders', json=order) for order in orders
])
return responses
18. API + UI COMBINED TESTS (your e2e-lifecycle pattern)¶
Your e2e-lifecycle.spec.ts from Morrie is the gold-standard pattern.
18.1 The pattern¶
test('Full lifecycle: Create (API) -> Verify (UI) -> Update (UI) -> Verify (API) -> Delete (UI)',
async ({ agentsApi, agentPage, page }) => {
// 1. Create via API (fast)
const createRes = await agentsApi.createAgent(agentName, agentPrompt);
expect(createRes.status).toBe(201);
agentId = createRes.data._id;
// 2. Verify in UI (real user experience)
await agentPage.goto();
await page.reload();
await expect(page.getByText(agentName).first()).toBeVisible();
// 3. Update via UI
await agentPage.editAgent(agentName, updatedName, agentPrompt);
await expect(page.getByText(updatedName).first()).toBeVisible();
// 4. Verify update via API (backend ground truth)
const getRes = await agentsApi.getAgentById(agentId);
expect(getRes.data.name).toBe(updatedName);
// 5. Delete via UI
await agentPage.deleteAgent(updatedName);
// 6. Verify deletion via API
try {
await agentsApi.getAgentById(agentId);
} catch (error: any) {
expect(error.response?.status).toBe(404);
}
});
18.2 Why this is the gold standard¶
- Setup via API (fast) — no UI flow needed for prerequisites
- Verify via UI (real user) — confirms users see what backend says
- Modify via UI (the actual user action being tested)
- Verify via API (backend ground truth) — catches the "UI says success but backend rejected silently" bug
- Cleanup via UI — also tests deletion flow
- Confirm cleanup via API — confirms hard delete, not soft-delete-with-200
Spoken answer — "How do you decide between API tests and UI tests?"¶
"API tests for setup, business logic, and verification of backend state. UI tests for the user-flow being explicitly under test. The Morrie e2e-lifecycle test is the model — we create the agent via API because it's fast, drive the edit through the UI because that's the user-facing thing we're testing, then re-verify via API to make sure the UI's success message corresponds to actual DB state. The pattern catches bugs UI tests alone miss — like a frontend that shows 'saved' but the backend returned a soft failure."
19. TECH DEBT IN B2BPROJECTTEST OrdersAPI — HONEST AUDIT¶
| Tech debt | Why it's a problem | What I'd do |
|---|---|---|
RestAssured.baseURI = baseUrl; static mutation |
Two parallel API tests overwrite each other's base URI; flaky parallel runs | Use given().baseUri(baseUrl).when()... — instance-scoped |
Assert.assertEquals(statuscode, 200) inside helper |
Helper is unusable for negative tests | Helpers return Response; tests assert |
RequestSpecification rebuilt in every method |
No DRY — base URI, headers repeated | Use RequestSpecBuilder once; pass via .spec(...) |
com.google.gson.JsonObject + org.json.simple.JSONObject mixed |
Two JSON libraries; conversion overhead, confusion | Pick one (Jackson preferred); use POJOs |
Hardcoded API key: "517f73c431514a562da1af6851h8q6851" |
Secret in source | Read from env var or vault |
getOrdersOnStatus parses response by string-splitting: orderStatus.split(",") |
Brittle; breaks on commas in data | Use JsonPath |
| No filter for logging | Every test prints raw via System.out.println |
Use RequestLoggingFilter + ResponseLoggingFilter with log().ifValidationFails() |
| No schema validation | A backend rename of dealId to id would pass the test until later assertions fail confusingly |
Add JSON schema files |
| POJOs not used | Stringly-typed JSON — typos in field names fail at runtime, not compile | Define OrderRequest / OrderResponse POJOs |
Commented-out @Test annotations in OrdersAPITest |
Tests effectively disabled but look active | Move to @Test(enabled = false) with a reason, or delete |
| No retry for transient 5xx | Network flakiness fails the test instead of retrying | Add filter that retries 502/503/504 with backoff |
The spoken answer¶
"Several. The big one is
RestAssured.baseURI = uri— that's a static mutation, so two parallel API tests can overwrite each other's base URI. The right shape is instance-scopedgiven().baseUri(uri).when().... Second,Assert.assertEquals(statusCode, 200)inside the helper makes the helper useless for negative tests — I can't reusepostOrderto test what happens with bad input, because the helper throws before my test asserts. Helpers should return responses; tests should assert. Third, two JSON libraries —com.google.gsonandorg.json.simple— used interchangeably; standardize on Jackson with POJOs for compile-time field safety. Fourth, the API key is hardcoded — should be from an env var or vault. Fifth, no schema validation — a backend field rename likedealId → idwould silently corrupt the test. None of these are urgent on the live suite, but they're the next-refactor list."
20. CONTRACT TESTING — PACT BASICS¶
When multiple teams consume the same API, contract testing prevents breaking changes from going unnoticed.
20.1 The idea¶
- Consumer writes a test using a mock API that returns a specific shape
- The mock recording becomes the contract (a JSON file)
- Provider runs the contract against their real API; fails if shape changed
20.2 Pact example¶
// Consumer side — defines what they expect
@Pact(consumer = "ordersClient", provider = "ordersApi")
public RequestResponsePact orderPact(PactDslWithProvider builder) {
return builder
.given("an order exists")
.uponReceiving("a request for the order")
.path("/orders/42").method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.integerType("id", 42)
.stringType("status", "paid")
.numberType("amount", 1000))
.toPact();
}
// Provider side runs the pact JSON against its real API
@PactBroker(host = "pact-broker.company.com")
@Provider("ordersApi")
public class OrdersApiPactTest { ... }
20.3 When to use¶
- Microservice-to-microservice integrations
- Public APIs with many consumers
- When breaking-change avoidance is critical (banking, healthcare)
21. INTERVIEW Q&A — 40+ Q&A WITH FULL ANSWERS¶
21.1 Fundamentals¶
Q1. REST vs SOAP?¶
"REST is resource-oriented, mostly JSON, lightweight, scales horizontally. SOAP is XML, strict WSDL contracts, heavy. SOAP still wins for some banking and telecom systems where the explicit schema and transactional guarantees matter. For most modern APIs, REST wins on simplicity and tooling."
Q2. PUT vs PATCH vs POST?¶
"POST creates a new resource — not idempotent. PUT replaces an entire resource — idempotent in the strict sense. PATCH updates partial fields — usually idempotent for set-style patches, not for increment-style. In practice, check the API contract; some teams use PUT for partial updates."
Q3. What's idempotency and why does it matter for testing?¶
"An operation is idempotent if calling it N times has the same effect as calling it once. Matters for retry logic — safe to retry GET, PUT, DELETE on network failure; unsafe to retry POST without an idempotency key. Tests verify idempotency by calling the same endpoint twice and checking the resource state matches one call's effect."
Q4. Status code: when is 200 wrong?¶
"When the operation specifically created something — that should be 201. When DELETE succeeded but there's no body — that should be 204. When an async operation was accepted but not yet executed — 202. Returning 200 for everything successful is technically valid but loses semantic information that monitoring and CDN caching rely on."
Q5. Difference between 401 and 403?¶
[See section 2.3]
Q6. What's the difference between 422 and 400?¶
"400 is for malformed requests — not valid JSON, missing required field, syntactically broken. 422 is for syntactically correct requests that fail business validation — e.g., a date that's in the past for a 'must be future' field. Some APIs collapse both to 400; the difference matters for client error messaging."
21.2 What to test¶
Q7. How would you test a POST /users API?¶
[See section 4 for the full 9-dimension answer]
Q8. How do you test GET endpoints?¶
"Five dimensions. Happy path — known ID returns 200 with correct body. Not found — non-existent ID returns 404. Authorization — viewer accessing admin-only resource returns 403. Pagination —
?page=2&size=50returns the right slice;?size=10000is capped. Filtering and sorting — query params actually affect results. And schema validation on every response shape."
Q9. How would you test pagination?¶
"Three checks. First, the basic —
?page=1&size=10returns 10 items,?page=2returns the next 10. Second, edge cases —?page=0, negative size, oversized size (server should cap not crash). Third, consistency — total count is stable across pages, no duplicates, no missing items. For cursor-based pagination, verify next/prev cursor tokens round-trip correctly."
21.3 REST Assured patterns¶
Q10. Walk me through REST Assured's given-when-then pattern.¶
[See section 5.2]
Q11. What's a RequestSpecification?¶
"A reusable builder for request setup — base URI, common headers, auth, filters. You build once via
RequestSpecBuilderand pass to tests withgiven().spec(spec). Eliminates repetition: instead of every test setting base URI plus auth header plus content-type, those live in the spec. Same idea forResponseSpecification— common assertions like status 200 and content-type JSON."
Q12. Why use POJOs for request body?¶
[See section 6.2]
Q13. How do you validate response time?¶
"REST Assured's
.time(lessThan(2000L))in thethenblock. TheLmakes it a long. Useful for catching performance regressions early. Set realistic thresholds — usually slightly higher than the SLA so you only fail on real degradation, not noise."
Q14. How do you extract a value for use in subsequent requests?¶
"
.extract().path('token')extracts a JsonPath value. For complex extraction,.extract().response()returns the whole Response object — then use.jsonPath().getString(...),.jsonPath().getList(...), or.as(MyClass.class)."
Q15. Why is RestAssured.baseURI = uri dangerous?¶
"It's a static field mutation — assigning to the global RestAssured class. In parallel tests, thread A sets baseURI to URL A, thread B then sets it to URL B, thread A's next request goes to URL B. The right shape is instance-scoped
given().baseUri(uri).when()...— each request builder has its own state. This is the #1 tech debt in our OrdersAPI helper."
Q16. Why are assertions in API helpers bad?¶
"The helper becomes single-purpose. If
postOrderasserts status == 200 internally, I can't reuse it for negative tests where I expect 400 or 409 — the helper throws before my test gets a chance to assert. Helpers should return responses; tests should assert. That separation makes helpers reusable across positive and negative cases."
21.4 Authentication¶
Q17. How do you handle authentication in REST Assured?¶
"Token-based: extract token from
/loginresponse, pass via.auth().oauth2(token)or asAuthorization: Bearer ...header. For Basic Auth,.auth().basic(user, pass)or.auth().preemptive().basic(user, pass)to skip the 401 challenge. For API key, just a custom header. For OTP flow like Morrie's, do two POSTs — one to trigger, one to verify, extract token from the verify response."
Q18. What's a JWT? How would you test JWT-based auth?¶
"JSON Web Token — base64-encoded header + payload + signature. Tests should cover: valid token works, expired token returns 401, tampered token (mutated payload, original signature) returns 401, missing signature returns 401, wrong algorithm header returns 401 (defense against
alg=noneattack), token without required claims like role returns 403. The signature is the security; the payload is just data, attackers can read it but can't forge without the secret."
Q19. How do you handle CSRF tokens?¶
"If the API uses cookie-based session with CSRF tokens, the test must: first GET a page to receive the CSRF cookie + token, second include the token in subsequent POST as a header (
X-CSRF-Token) or hidden form field. REST Assured: use.cookie()to send the session cookie and.header('X-CSRF-Token', token)for the value."
21.5 Data, chaining, cleanup¶
Q20. How do you handle dynamic data like generated IDs?¶
"Extract from the POST response:
int id = given().body(...).post(...).extract().path('id'). Store in a local variable for use in subsequent calls. For test independence in parallel runs, generate unique identifiers — UUID, timestamp suffix on emails — so two parallel tests don't collide."
Q21. How do you chain dependent API calls?¶
[See section 10 — full example]
Q22. How do you clean up after API tests?¶
"Three approaches. Best: each test deletes what it creates in the same flow — guarantees cleanup even on partial failure. Second:
@AfterMethodcleanup using IDs captured during the test. Third: nightly cleanup script that deletes test data older than 24 hours. I prefer the first because failures in cleanup don't pollute subsequent runs."
Q23. How do you do data-driven testing?¶
[See section 11]
21.6 Schema, contracts¶
Q24. How do you validate JSON response structure?¶
"Two layers. Per-field —
.body('user.name', equalTo('Rohan'))for specific values. Whole-schema —matchesJsonSchemaInClasspath('schemas/user.json')for the structure plus types plus enums. Schema validation catches a backend rename or type change immediately, which per-field assertions might miss if the renamed field isn't asserted on."
Q25. What's contract testing? When would you use it?¶
[See section 20]
21.7 Mocking¶
Q26. What is WireMock and when would you use it?¶
[See section 13.2]
Q27. How do you test against a slow API?¶
"Two ways. Mock with WireMock and add
withFixedDelay(5000)— deterministic, used for timeout tests. Or use Selenium 4 CDP / Playwright'srouteto add latency at the transport layer. The mock approach is preferred for unit-ish tests; the network layer approach is for integration tests where you want the real backend behavior plus simulated latency."
21.8 Performance + security¶
Q28. What metrics matter most in API performance testing?¶
"Latency percentiles — P50, P95, P99 — not averages. Throughput in requests per second. Error rate, where anything above 1% invalidates throughput. Saturation — at what RPS does latency climb? And always correlate with server-side metrics — CPU, memory, DB connection pool, GC pauses. Without server metrics, response time tells you something is slow but not why."
Q29. How would you test for SQL injection in an API?¶
"Submit known SQL-injection payloads in every string field — single quote, OR 1=1 comment, UNION SELECT. Watch for: stack traces leaking in response, unexpected data returned, successful authentication without valid credentials, 500 errors that crash the connection. Automated coverage with SQLMap. Modern stacks with parameterized queries should reject all of these without issue."
Q30. How would you test for IDOR (Insecure Direct Object Reference)?¶
"For every endpoint that takes an ID — like
/orders/42— login as user A, capture an ID, then login as user B and try to access user A's ID. Expected: 403 or 404. If 200, that's an authorization bug. Automated approach: a test that creates resources for two test users, then iterates every resource ID through both tokens, asserting cross-access returns the right code."
21.9 Real-world patterns¶
Q31. How would you test webhooks?¶
"Set up a public callback URL — ngrok for local, a dedicated test endpoint for CI. Trigger the event in the system under test. Assert the webhook fires within X seconds with the expected payload. Verify retry behavior on failure — return a 500 and confirm the webhook retries. Verify idempotency — make sure repeated webhooks don't double-process. Tools like RequestBin or webhook.site work for manual; for CI, a Node.js Express server that records received calls."
Q32. How do you handle pagination + rate limits in API tests?¶
"For pagination, use a known page size and iterate explicitly — assert at each page that the expected items appear and total count is stable. For rate limits, design tests to respect them — use sequential calls with backoff in setup, run the rate-limit assertion test separately (firing 100 calls in 1 second and confirming 429). The Morrie BaseApi has a built-in 700ms throttle to avoid tripping limits during normal test runs."
Q33. Walk me through your Morrie BaseApi.¶
[See section 16.1 + the spoken walkthrough]
Q34. How would you build an API testing framework from scratch tomorrow?¶
"Layered. At the bottom, a typed HTTP client — Axios with interceptors for auth, retry, rate limiting (Morrie pattern), or REST Assured with RequestSpec + filters. One client class per resource — UsersApi, OrdersApi, AgentsApi — extending the base. POJOs / TypeScript interfaces for every request and response shape. Test data via JSON files for static fixtures, Faker for dynamic. Pytest or TestNG for the runner, with fixtures for auth, DB connections, test data isolation. JSON Schema validation on every response. Allure for reporting. CI integration with parallel workers and HTML report publishing. The big difference from B2BProjectTest — no static
RestAssured.baseURImutation, no assertions in helpers, no string-typed JSON."
Q35. How do you organize API tests for a microservices system?¶
"Per service, not per UI flow. Each microservice gets its own test folder mirroring the API surface. Tests at the service level use real DB and mock downstream services with WireMock. Cross-service tests are a separate suite — fewer, run nightly, exercise critical paths like checkout. Plus contract tests via Pact between every consumer-producer pair, so breaking changes fail at build time. The pyramid: lots of per-service tests, some contract tests, very few cross-service tests."
Q36. How do you test idempotency keys?¶
"Three tests. Same key, same payload → second call returns the first call's cached response without re-executing. Same key, different payload → 422 or 409 (server rejects mismatch). Missing key on idempotency-required endpoints → 400. The cached response should include the same status code, body, and Location header as the original."
Q37. What's the difference between status code 401 returning when you sent a token vs when you didn't?¶
"Some implementations return 401 with
WWW-Authenticate: Bearerfor both. Others distinguish: 401 witherror=invalid_tokenfor invalid tokens, 401 with no error for missing tokens. Best practice from OAuth 2.0 RFC: differentiate. Tests should assert the right variant per case."
Q38. How do you test file upload via API?¶
"REST Assured:
given().multiPart('file', new File('test.pdf')).post('/upload'). Verify the response includes a file ID or URL, then GET that URL to confirm the file was stored correctly. Tests: valid file types, file too large (expect 413), wrong content type (expect 415), malicious file (file with..in name shouldn't escape upload directory), zero-byte file."
Q39. How do you handle test environments?¶
"Properties file per env —
dev.properties,stage.properties,prod.properties— selected via-Denv=stageflag. Each holds base URLs, credentials, feature flags. Tests don't run against prod by default; smoke-tests with read-only operations can if explicitly enabled. Secrets come from vault or env vars, never the properties files."
Q40. What's the worst API bug you've found?¶
"On the Avysh B2B app, the
updateOrderDetailsendpoint had a race condition under concurrent calls — two sellers updating the same order would result in one update silently overwriting the other, with both calls returning 200. We caught it because a parallel test happened to hit the same order from two threads and the second response's body was different from what we just sent. Backend fix was adding anIf-Matchetag check; our test got upgraded to explicitly verify concurrent updates return 412 Precondition Failed for the loser."
QUICK CHEAT SHEET¶
Most-used REST Assured idioms¶
// Basic
given().get("/users").then().statusCode(200);
// With auth + body
given().auth().oauth2(token).contentType(JSON).body(user)
.post("/users").then().statusCode(201);
// Extract
String token = given().body(creds).post("/login")
.then().extract().path("access_token");
// Schema
.body(matchesJsonSchemaInClasspath("schemas/user.json"));
// Time
.time(lessThan(2000L));
// Log on failure
.then().log().ifValidationFails();
Five lines that signal seniority in interview¶
- "Helpers should return responses; tests should assert. Don't bake assertions into API helpers."
- "
RestAssured.baseURI = uriis a static mutation that breaks parallel runs — always instance-scoped." - "POJOs + Jackson over JSON strings — compile-time field safety beats runtime surprises."
- "Schema validation catches contract drift the moment it happens, not later when the test confusingly fails."
- "Setup via API, verify via UI, re-verify via API — that's how you catch the silent-success-but-not-really bug."
Owner: Rohan Dsouza | Grounded in: B2BProjectTest OrdersAPI/OrdersAPITest (Java + REST Assured) + Morrie BaseApi/AgentsApi (TypeScript + Axios) | Updated: 2026