Chapter 9: API Test Automation Framework
Test classes are where the magic happens. But they should be boring. Really boring. If your test class is complex, your framework is not doing its job. A good test reads like plain English: create user, check status 201, verify name matches.
package com.company.api.tests;
import com.company.api.base.BaseTest;
import com.company.api.endpoints.UserEndpoint;
import com.company.api.pojo.request.CreateUserRequest;
import com.company.api.pojo.response.UserResponse;
import io.qameta.allure.Description;
import io.qameta.allure.Feature;
import io.qameta.allure.Severity;
import io.qameta.allure.SeverityLevel;
import io.restassured.response.Response;
import org.testng.Assert;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.hamcrest.Matchers.*;
@Feature("User Management")
public class UserTests extends BaseTest {
private UserEndpoint userEndpoint;
@BeforeClass
public void setupEndpoint() {
userEndpoint = new UserEndpoint(requestSpec);
}
@Test(description = "GET /users returns 200 and non-empty list")
@Severity(SeverityLevel.BLOCKER)
@Description("Verify that getting all users returns a successful response with data")
public void testGetAllUsers() {
userEndpoint.getAllUsers()
.then()
.statusCode(200)
.body("size()", greaterThan(0))
.body("[0].name", notNullValue());
}
@Test(description = "GET /users/{id} returns correct user")
@Severity(SeverityLevel.CRITICAL)
public void testGetUserById() {
Response response = userEndpoint.getUserById(1);
response.then()
.statusCode(200)
.body("id", equalTo(1))
.body("name", notNullValue())
.body("email", containsString("@"));
}
@Test(description = "POST /users creates a new user")
@Severity(SeverityLevel.BLOCKER)
public void testCreateUser() {
CreateUserRequest request = new CreateUserRequest(
"Anita Desai", "anita@example.com",
"9876543210", "SDET"
);
Response response = userEndpoint.createUser(request);
response.then()
.statusCode(201)
.body("name", equalTo("Anita Desai"))
.body("email", equalTo("anita@example.com"))
.body("id", notNullValue());
// Deserialize and do further assertions
UserResponse created = response.as(UserResponse.class);
Assert.assertTrue(created.getId() > 0, "ID should be positive");
Assert.assertNotNull(created.getCreatedAt(), "createdAt should be set");
}
@Test(description = "GET /users/{id} with invalid ID returns 404")
@Severity(SeverityLevel.NORMAL)
public void testGetNonExistentUser() {
userEndpoint.getUserById(99999)
.then()
.statusCode(404);
}
@Test(description = "DELETE /users/{id} removes the user")
@Severity(SeverityLevel.CRITICAL)
public void testDeleteUser() {
// Setup — create a user to delete
CreateUserRequest request = new CreateUserRequest(
"Temp User", "temp@example.com",
"9000000000", "Contractor"
);
UserResponse created = userEndpoint.createUserAndGet(request);
// Act — delete the user
userEndpoint.deleteUser(created.getId())
.then()
.statusCode(204);
// Verify — user should no longer exist
userEndpoint.getUserById(created.getId())
.then()
.statusCode(404);
}
}Look at how clean those tests are. No REST Assured setup. No base URL. No content type. No headers. Just endpoint calls and assertions. That is the framework doing its job.
package com.company.api.tests;
import com.company.api.base.BaseTest;
import com.company.api.endpoints.UserEndpoint;
import com.company.api.pojo.request.CreateUserRequest;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import static org.hamcrest.Matchers.equalTo;
public class UserDataDrivenTests extends BaseTest {
private UserEndpoint userEndpoint;
@BeforeClass
public void setupEndpoint() {
userEndpoint = new UserEndpoint(requestSpec);
}
@DataProvider(name = "validUsers")
public Object[][] validUserData() {
return new Object[][] {
{ "Rahul Sharma", "rahul@example.com", "9876543210", "QA Lead" },
{ "Priya Patel", "priya@example.com", "8765432109", "SDET" },
{ "Amit Kumar", "amit@example.com", "7654321098", "Dev Lead" }
};
}
@DataProvider(name = "invalidEmails")
public Object[][] invalidEmailData() {
return new Object[][] {
{ "" },
{ "notanemail" },
{ "missing@" },
{ "@nodomain.com" },
{ "spaces in@email.com" }
};
}
@Test(dataProvider = "validUsers",
description = "POST /users with valid data returns 201")
public void testCreateUserWithValidData(
String name, String email, String phone, String job) {
CreateUserRequest request =
new CreateUserRequest(name, email, phone, job);
userEndpoint.createUser(request)
.then()
.statusCode(201)
.body("name", equalTo(name))
.body("email", equalTo(email));
}
@Test(dataProvider = "invalidEmails",
description = "POST /users with invalid email returns 400")
public void testCreateUserWithInvalidEmail(String badEmail) {
CreateUserRequest request = new CreateUserRequest(
"Test User", badEmail, "9876543210", "Tester"
);
userEndpoint.createUser(request)
.then()
.statusCode(400);
}
}@Test(groups = {"smoke"}, description = "GET /users returns 200")
public void testGetAllUsers() { ... }
@Test(groups = {"smoke", "regression"}, description = "POST /users creates user")
public void testCreateUser() { ... }
@Test(groups = {"regression"}, description = "PUT /users/{id} updates user")
public void testUpdateUser() { ... }
@Test(groups = {"regression", "negative"}, description = "Invalid email returns 400")
public void testInvalidEmail() { ... }Extend BaseTest — get requestSpec for free
Create endpoint instance in @BeforeClass
Use @Test with description and groups
Add @Severity for Allure reporting (BLOCKER, CRITICAL, NORMAL, MINOR)
Use DataProvider for parameterized tests
Mix Hamcrest matchers (fluent) with TestNG Assert (complex logic)
Test both happy path and error scenarios
Use Hamcrest matchers (equalTo, notNullValue, containsString) for simple inline assertions. Use TestNG Assert for complex assertions that need conditional logic or custom messages. You can mix both in the same test.
Every test should be independent. testCreateUser should not depend on testDeleteUser running first. If a test needs data, create it in the test itself (or in @BeforeMethod). Never rely on test execution order.
Q: How do you handle data-driven testing in your API framework?
A: We use TestNG DataProvider. Each DataProvider is a method that returns a 2D Object array with test data. The test method receives one row at a time as parameters. For example, our validUsers DataProvider has 3 rows with name, email, phone, job — TestNG runs the test 3 times with each row. For larger datasets, we read from CSV or JSON files in the DataProvider method. We keep positive and negative DataProviders separate — validUsers for 201 tests, invalidEmails for 400 tests. This way, one test method covers multiple scenarios without code duplication.
Key Point: Test classes should be simple — extend BaseTest, create endpoints in @BeforeClass, use DataProvider for data-driven tests, and mix Hamcrest matchers with TestNG Assert. Each test must be independent.