Skip to content

API Testing + REST Assured — Complete Interview Guide (5+ Yrs Automation Engineer)

Comprehensive API testing reference grounded in your actual code: REST Assured in OrdersAPI.java and OrdersAPITest.java from your B2BProjectTest/ Avysh framework, plus the Axios-based BaseApi.ts/AgentsApi.ts/AuthApi.ts from 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

                  /\
                 /UI\
                /----\
               / API  \
              /--------\
             /  Unit    \
            /------------\

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/42 with body X always leaves user 42 == X regardless of call count.
  • Safe = doesn't change server state. GET doesn't change state, POST does.

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; errors array is what you check
query GetUser($id: ID!) {
  user(id: $id) {
    id
    email
    name
  }
}
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 syntaxgiven().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

  1. Sets base URI (statically — issue)
  2. Builds request with Content-Type: application/json
  3. Posts JSON body (using com.google.gson.JsonObject)
  4. Parses response with JsonPath, extracts orderDetails.dealId
  5. Asserts 200 in the helper (issue — see tech debt)
  6. Returns the order ID

Tech debt — what I'd refactor

"Two things. First, RestAssured.baseURI = baseUrl is a static field mutation — two parallel API tests can overwrite each other's base URI. The right shape is given().baseUri(baseUrl).when()... — instance-scoped. Second, Assert.assertEquals(statuscode, 200) inside the helper makes it useless for negative tests — I can't reuse postOrder to 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)

String body = "{\"name\":\"" + name + "\",\"email\":\"" + email + "\"}";
given().body(body)...
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 email to 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);
This is a middle ground — typed wrapper around JSON without full POJO type safety. It works but loses the compile-time check that real POJOs give.


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());
What's good: AssertJ SoftAssertions collect all field-level mismatches; .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

given().auth().oauth2(token)...
// or
given().header("Authorization", "Bearer " + token)...

8.3 Form auth

given().auth().form("user", "pass", new FormAuthConfig("/login", "username", "password"))...

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:

httpRequest.header("apiKey", ordersData.get("apiKey").getAsString());

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 (emailemailAddress)
  • A type change (age was 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' }),
    });
});
Different scope — Playwright route mocks intercept what the browser sees. WireMock mocks what the backend sees. Choose based on which layer you're testing.


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 lastRequestTime and 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, honoring Retry-After header, otherwise defaulting to 3 seconds. The lastRequestTime is 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: lastRequestTime being 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 AgentsApi class 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-scoped given().baseUri(uri).when().... Second, Assert.assertEquals(statusCode, 200) inside the helper makes the helper useless for negative tests — I can't reuse postOrder to 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.gson and org.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 like dealId → id would 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=50 returns the right slice; ?size=10000 is 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=10 returns 10 items, ?page=2 returns 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 RequestSpecBuilder and pass to tests with given().spec(spec). Eliminates repetition: instead of every test setting base URI plus auth header plus content-type, those live in the spec. Same idea for ResponseSpecification — 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 the then block. The L makes 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 postOrder asserts 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 /login response, pass via .auth().oauth2(token) or as Authorization: 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=none attack), 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: @AfterMethod cleanup 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's route to 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.baseURI mutation, 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: Bearer for both. Others distinguish: 401 with error=invalid_token for 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=stage flag. 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 updateOrderDetails endpoint 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 an If-Match etag 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

  1. "Helpers should return responses; tests should assert. Don't bake assertions into API helpers."
  2. "RestAssured.baseURI = uri is a static mutation that breaks parallel runs — always instance-scoped."
  3. "POJOs + Jackson over JSON strings — compile-time field safety beats runtime surprises."
  4. "Schema validation catches contract drift the moment it happens, not later when the test confusingly fails."
  5. "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