The base class from the previous lesson centralises the one set of defaults shared by every test. But most suites need several configurations: an admin spec, a regular-user spec, a guest spec, plus reusable response patterns ("expect a 200 with JSON in under 3 seconds"). Rest Assured's RequestSpecBuilder and ResponseSpecBuilder produce reusable specs you can hand to any test via given().spec(...) or then().spec(...). This lesson is the four or five specs every framework grows, the patterns to organise them, and the rule for deciding when a spec earns its keep.
A request spec, end to end
The shape of a typical RequestSpecification:
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.filter.log.RequestLoggingFilter;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
RequestSpecification adminSpec = new RequestSpecBuilder()
.setBaseUri(Config.baseUri())
.setBasePath(Config.basePath())
.setContentType(ContentType.JSON)
.setAccept(ContentType.JSON)
.addHeader("Authorization", "Bearer " + adminToken)
.addHeader("X-Request-Id", UUID.randomUUID().toString())
.build();Every option you'd normally call inside given() has a setXxx or addXxx on the builder. Once built, the spec is immutable — pass it around freely.
Use it:
given()
.spec(adminSpec)
.when()
.get("/users")
.then()
.statusCode(200);given().spec(adminSpec) is the single line that replaces five or six per-test calls. The test is shorter; the intent (running as admin) is named.
Multiple specs for multiple roles
The most common reason to build several specs: tests run as different users.
public class Specs {
public static RequestSpecification admin;
public static RequestSpecification user;
public static RequestSpecification viewer;
public static RequestSpecification guest; // unauthenticated
static {
admin = forToken(TokenManager.tokenFor("admin@test.com"));
user = forToken(TokenManager.tokenFor("user@test.com"));
viewer = forToken(TokenManager.tokenFor("viewer@test.com"));
guest = baseBuilder().build();
}
private static RequestSpecification forToken(String token) {
return baseBuilder()
.addHeader("Authorization", "Bearer " + token)
.build();
}
private static RequestSpecBuilder baseBuilder() {
return new RequestSpecBuilder()
.setBaseUri(Config.baseUri())
.setBasePath(Config.basePath())
.setContentType(ContentType.JSON);
}
}Tests then read like English:
@Test public void adminCanListUsers() {
given().spec(Specs.admin)
.when().get("/users")
.then().statusCode(200);
}
@Test public void viewerCannotListUsers() {
given().spec(Specs.viewer)
.when().get("/users")
.then().statusCode(403);
}
@Test public void guestGetsUnauthorized() {
given().spec(Specs.guest)
.when().get("/users")
.then().statusCode(401);
}The role and the expected outcome are both named in the test method, the spec carries the auth, and the test body has no plumbing. This is the layout the authorisation-matrix tests from Chapter 4 graduate to.
Composable specs
Specs can layer. Build a "common" spec; layer auth on top:
RequestSpecification common = new RequestSpecBuilder()
.setBaseUri(Config.baseUri())
.setContentType(ContentType.JSON)
.addFilter(new RequestLoggingFilter())
.build();
// Per-test: extend common with auth
given()
.spec(common)
.auth().oauth2(token)
.when()
.get("/users");When two specs "almost" share configuration, factor the shared parts into a parent spec; the children only declare what differs. The same pattern as base classes — applied to specs.
Response specs — reusable assertion patterns
Most APIs return responses that share structural expectations: status 200, content type JSON, response time under N ms. ResponseSpecBuilder captures that shape:
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.specification.ResponseSpecification;
import static org.hamcrest.Matchers.lessThan;
public class ResponseSpecs {
public static final ResponseSpecification ok = new ResponseSpecBuilder()
.expectStatusCode(200)
.expectContentType(ContentType.JSON)
.expectResponseTime(lessThan(3000L))
.build();
public static final ResponseSpecification created = new ResponseSpecBuilder()
.expectStatusCode(201)
.expectContentType(ContentType.JSON)
.expectHeader("Location", notNullValue())
.build();
public static final ResponseSpecification noContent = new ResponseSpecBuilder()
.expectStatusCode(204)
.expectBody(emptyOrNullString())
.build();
public static final ResponseSpecification notFound = new ResponseSpecBuilder()
.expectStatusCode(404)
.expectContentType(ContentType.JSON)
.build();
public static final ResponseSpecification unauthorized = new ResponseSpecBuilder()
.expectStatusCode(401)
.build();
public static final ResponseSpecification forbidden = new ResponseSpecBuilder()
.expectStatusCode(403)
.build();
}Use them with then().spec(...):
@Test public void createUserReturnsCreated() {
given().spec(Specs.admin)
.body(new CreateUserRequest("Alice", "alice@test.com", "admin"))
.when()
.post("/users")
.then()
.spec(ResponseSpecs.created) // 201, JSON, Location header
.body("id", greaterThan(0)); // test-specific assertion still works
}The response spec covers the structural assertions every "create" expects. The test only adds the assertion specific to this test (id > 0). The pattern ratchets up the lower bound — every spec'd test asserts more, with less code.
When not to spec
Specs earn their keep when the same shape appears in three or more tests. For one-off configurations — a single test that pings an endpoint with a custom header — the spec ceremony adds noise. Keep one-off configurations inline; promote them to a spec the moment they recur.
A simple test for "should I spec this?": if writing the line given().spec(MySpec) would leave a reader confused about what it does, the spec is misnamed or too clever. Specs should be named by purpose, not by what they configure.
How specs slot into the framework
- – Specs.admin (auth + JSON)
- – Specs.user
- – Specs.viewer
- – Specs.guest (no auth)
- – ResponseSpecs.ok (200 + JSON + time)
- – ResponseSpecs.created (201 + Location)
- – ResponseSpecs.noContent (204 + empty body)
- – ResponseSpecs.notFound
- – ResponseSpecs.unauthorized / forbidden
- – given().spec(Specs.admin)
- – .then().spec(ResponseSpecs.ok)
- – + test-specific assertions
- static fields on a Specs class –
- initialised in static{} block or @BeforeSuite –
- shared across the entire suite –
The hub: a small handful of specs at the centre, every test method pulling exactly the ones it needs. The pattern centralises the structural checks (status code, content type, response time) and frees the test methods to assert on the unique, business-specific values.
A representative test class with specs
public class UserApiTest extends BaseApiTest {
@Test public void listUsersAsAdmin() {
given().spec(Specs.admin)
.when().get("/users")
.then()
.spec(ResponseSpecs.ok)
.body("size()", greaterThan(0));
}
@Test public void createUserAsAdmin() {
CreateUserRequest req = new CreateUserRequest("Alice", "alice@test.com", "admin");
given().spec(Specs.admin)
.body(req)
.when().post("/users")
.then()
.spec(ResponseSpecs.created)
.body("name", equalTo("Alice"));
}
@Test public void viewerCannotCreateUser() {
CreateUserRequest req = new CreateUserRequest("Alice", "alice@test.com", "admin");
given().spec(Specs.viewer)
.body(req)
.when().post("/users")
.then().spec(ResponseSpecs.forbidden);
}
}Three tests, three roles, three response shapes — and not a single line of redundant configuration. Each test's body is only the bits unique to that test. This is what "framework architecture" buys you in practice.
Specs and the global request specification
The base class set RestAssured.requestSpecification = ... for the suite-wide defaults. Per-call specs layer on top of the global one — they don't replace it. So content type set globally still applies even when you given().spec(adminSpec) (unless adminSpec explicitly overrides it). The mental model is cumulative, not substitutional.
This is why setting setContentType(JSON) on every per-role spec is fine — it harmlessly re-states what the global spec already says, but documents intent at the spec level. The cost is zero; the readability win is real.
⚠️ Common mistakes
- Building specs inside
@BeforeMethod. Cheap, but pointless — the spec doesn't change between methods, and the auth call inside it isn't free. Build once in@BeforeSuite(or astatic {}block) and reuse. - Specs with too much in them. A
Specs.adminthat also setspathParam("env", "prod")and addsAccept-Language: en-GBaccumulates concerns. Keep specs single-purpose ("admin auth + JSON"); per-test additions go in the per-testgiven()chain. - Forgetting that response specs run every matcher even on success. If
expectResponseTime(lessThan(3000L))is onResponseSpecs.ok, every "ok" test now has a soft perf check — fine on a fast network, flaky on a CI runner with cold caches. Pick response-time thresholds generously, or keep perf assertions in a separate spec used only by perf tests.
🎯 Practice task
Lift the previous lesson's tests onto specs and feel the line-count drop. 25–35 minutes.
- Create
Specs.javain your test source tree. Build at least two:Specs.admin(with bearer token) andSpecs.guest(no auth). Initialise in astatic {}block from values yourBaseApiTestprovides. - Refactor. Take three existing tests with hand-rolled
given().header("Authorization", ...).contentType(JSON)chains. Replace them withgiven().spec(Specs.admin). Run; confirm green. - Create
ResponseSpecs.javawith at least four entries:ok,created,notFound,unauthorized. Each should assert the status, content type, and any other structural invariant. - Apply. Use
then().spec(ResponseSpecs.ok)in three tests. Note that the test methods now only contain the value-specific assertions. - Compose specs. Build a
Specs.userJsonthat extendsSpecs.userwith a custom headerX-Test-Env: ci. Use it in one test. (Hint:new RequestSpecBuilder().addRequestSpecification(Specs.user).addHeader(...)) - Force a structural failure. Change one test's expected status to 401 while still using
ResponseSpecs.ok. The test fails — note that the failure message names the spec's status assertion. Restore. - Time check. Add
expectResponseTime(lessThan(3000L))toResponseSpecs.ok. Run on a fast network and a slow network. Decide whether the value should be in the spec or in a separateResponseSpecs.fastused selectively. - Stretch: measure the line count saved.
git diff --statagainst the pre-spec branch. Note both the deletion and the removal of duplication — the same idea now lives in one place instead of N.
Next lesson: logging, custom filters, and how to wire a single line of configuration that turns every test failure into a debuggable artifact.