Logging and Custom Filters

8 min read

The hardest test failures to debug are the ones that say "expected 200 but got 400" with no further information. The fix is mechanical: configure Rest Assured to log the request and response when an assertion fails, and the next failure tells you exactly what went on the wire. That's the floor. The ceiling is custom filters — small classes that intercept every request and response, attaching them to test reports, redacting secrets, mutating headers, or measuring performance. Both live in the same machinery, both are one-line changes once the framework is set up, and both pay for themselves the first time a CI failure is debuggable from the log alone.

The four logging modes

Rest Assured's .log() method offers four levels of detail per direction:

given()
    .log().all()         // method, URL, headers, params, body
    .log().headers()     // headers only
    .log().body()        // body only
    .log().method()      // method + URI only
    .log().ifValidationFails()  // log only if any assertion in this chain fails
.when()
    .get("/users/1")
.then()
    .log().all()
    .log().ifValidationFails()
    .statusCode(200);

.log().all() on green tests floods CI logs and adds nothing — most of the time you don't need the dump. .log().ifValidationFails() is the mode that earns its keep: silent on success, complete on failure. Set it once for the whole suite and forget about it.

The one-line global setting

In BaseApiTest.@BeforeSuite:

RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();

Done. Every test, every assertion, every failure now writes a complete request and response dump to System.out. CI logs go from useless to actionable.

If you also want logging on green tests during local debugging, gate it behind a system property:

if (Boolean.getBoolean("verbose")) {
    RestAssured.filters(new RequestLoggingFilter(), new ResponseLoggingFilter());
}

Run with mvn test -Dverbose=true when you need to see traffic for a passing test.

Anatomy of a logged failure

The output of .log().ifValidationFails() looks like:

Request method:  POST
Request URI:     https://api.staging.myapp.com/api/v1/users
Headers:         Authorization=Bearer eyJhbGciOiJIU... (truncated)
                 Content-Type=application/json; charset=UTF-8
                 Accept=application/json
Body:
{
    "name": "Alice",
    "email": "alice@test.com",
    "role": "admin"
}

HTTP/1.1 400 Bad Request
Content-Type: application/json
Body:
{
    "error": "validation_failed",
    "details": ["role must be one of: viewer, editor"]
}

That's a complete debug session before you've even left the CI page — the request is sent with role: admin, the API rejects it with a precise message. Without the log, the failure is just "expected 201 got 400."

Custom filters — what and why

A Filter is a class that wraps a request before it's sent and inspects the response before assertions run. The framework gives you a chance to:

  • Attach the request/response to a test report (Allure, ExtentReports).
  • Redact secrets before logging.
  • Add a per-request X-Request-Id for tracing.
  • Measure timing.
  • Retry on flaky responses (rare; usually a sign of a flaky API).

The interface:

import io.restassured.filter.Filter;
import io.restassured.filter.FilterContext;
import io.restassured.response.Response;
import io.restassured.specification.FilterableRequestSpecification;
import io.restassured.specification.FilterableResponseSpecification;
 
public class TraceIdFilter implements Filter {
 
    @Override
    public Response filter(FilterableRequestSpecification req,
                           FilterableResponseSpecification res,
                           FilterContext ctx) {
 
        // Before the request is sent
        String traceId = "test-" + UUID.randomUUID();
        req.replaceHeader("X-Request-Id", traceId);
 
        // Send the request and capture the response
        Response response = ctx.next(req, res);
 
        // After the response is received
        System.out.println("[trace] " + traceId + " → " + response.statusCode());
 
        return response;
    }
}

Three steps: do something to the request, call ctx.next(...) to actually send it, do something with the response, return it. The filter chain is bidirectional — every filter sees both directions.

Wiring filters globally

RestAssured.filters(
    new RequestLoggingFilter(),
    new ResponseLoggingFilter(),
    new TraceIdFilter()
);

Order matters: the filters run in the order you add them on the request side, and in reverse on the response side. A filter that times responses should be added first (so it brackets the entire chain).

For test-report integration with Allure, the io.qameta.allure:allure-rest-assured library ships a ready-made filter:

<dependency>
    <groupId>io.qameta.allure</groupId>
    <artifactId>allure-rest-assured</artifactId>
    <version>2.27.0</version>
</dependency>
RestAssured.filters(new AllureRestAssured());

After that, every Rest Assured request appears as an attachment on the corresponding Allure step. CI failures produce a report you can click through, request by request. This is often the single highest-impact filter you'll add.

Logging to a file instead of stdout

For CI, where stdout is already noisy, write logs to a per-suite file:

PrintStream logFile = new PrintStream(
    new FileOutputStream("target/api-log-" + System.currentTimeMillis() + ".txt"),
    true, StandardCharsets.UTF_8);
 
RestAssured.filters(
    new RequestLoggingFilter(logFile),
    new ResponseLoggingFilter(logFile)
);

The file is a complete trace of every request and response. Keep it as a CI artifact; debugging a failure later means downloading the log instead of hunting through stdout.

Redacting secrets in logs

Default logging includes the Authorization header verbatim. That's a bearer token in plaintext, in CI logs, indexed by every observability tool downstream. The fix:

import io.restassured.config.LogConfig;
 
RestAssured.config = RestAssured.config().logConfig(
    LogConfig.logConfig()
        .blacklistHeader("Authorization")
        .blacklistHeader("Cookie")
);

Logs replace blacklisted header values with [ BLACKLISTED ]. The token is never written. Add this to every framework — it's the only acceptable default for any test that hits a real API with a real token.

The filter chain

Step 1 of 6

Test calls given().get(...)

given() builds the request spec; the chain ends in a verb (get/post/...) which kicks off the filter pipeline.

The filter chain is the single seam that turns Rest Assured from "make a call" into "make an observable, redacted, traceable, reportable call." All five characteristics live in three or four lines of RestAssured.filters(...) configuration.

A timing filter

Useful as a soft perf signal that doesn't fail tests. Logs every call's elapsed time:

public class TimingFilter implements Filter {
    @Override
    public Response filter(FilterableRequestSpecification req,
                           FilterableResponseSpecification res,
                           FilterContext ctx) {
        long start = System.currentTimeMillis();
        Response response = ctx.next(req, res);
        long elapsed = System.currentTimeMillis() - start;
 
        if (elapsed > 1000) {
            System.out.println("[slow] " + req.getMethod() + " " + req.getURI()
                + " took " + elapsed + " ms");
        }
        return response;
    }
}

Add to the global chain. Calls slower than a second produce a one-line log entry. Over a long-running suite, the pattern of "what's slow" emerges — and it's often the first signal a backend regression is brewing.

When not to write a custom filter

Filters are powerful and easy to misuse. A few uses to resist:

  • Mutating responses to "fix" flaky tests. If the API returns a body the tests can't handle, the body is the bug. A filter that papers over it hides the regression.
  • Swallowing exceptions. A filter that catches and ignores errors makes tests pass when they shouldn't.
  • Implementing test logic. Anything that varies per test belongs on the test, not in a global filter.

The rule of thumb: filters are cross-cutting concerns (logging, tracing, reporting) — anything that applies uniformly to every request without test-specific judgment.

⚠️ Common mistakes

  • .log().all() everywhere. Tests pass; CI logs balloon to megabytes; the one failure you care about is buried under thousands of green-test dumps. Use .log().ifValidationFails() (or enableLoggingOfRequestAndResponseIfValidationFails() once globally) and forget about it.
  • Not blacklisting Authorization. Tokens in CI logs is a real security incident pattern. Configure LogConfig.logConfig().blacklistHeader("Authorization") in BaseApiTest and audit it once, not test by test.
  • Forgetting filters survive RestAssured.reset() is exactly what you want, but... filters added in @BeforeSuite and not reset will stick across test runs in some CI setups (where the JVM is reused). The RestAssured.reset() in @AfterSuite from the previous lesson is the cure — but don't add a "convenience" @BeforeMethod that re-adds the same filter without removing the old one, or you'll have logs printed N times.

🎯 Practice task

Wire logging and one custom filter into your framework. 25–35 minutes.

  1. In BaseApiTest.@BeforeSuite, add RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(). Run the existing suite — confirm logs are quiet.
  2. Force a failure. Change one assertion to a wrong value. Run the test, read the log. Confirm you see the full request and response. Restore.
  3. Blacklist auth. Add the LogConfig blacklist for Authorization and Cookie. Force a failure on a test that uses an auth spec. Confirm the dumped Authorization header reads [ BLACKLISTED ].
  4. Write a TraceIdFilter. Implement the filter from the lesson — it adds X-Request-Id: test-<UUID> to every request and prints the id + status to stdout. Wire it into RestAssured.filters(...) on the base class.
  5. Verify the trace. Run a test, watch the trace ID print. If your API echoes X-Request-Id in responses (many do), assert that the response header equals the request header.
  6. Write a TimingFilter. Same shape as the lesson example. Run the suite. Note any calls over a threshold.
  7. Allure (optional). Add allure-rest-assured to the pom and RestAssured.filters(new AllureRestAssured()). Run the suite with the Allure listener wired in (-Dallure.results.directory=target/allure-results). Generate the report — confirm every test has its requests attached.
  8. Stretch: write a RetryOnceOnTimeoutFilter that catches a timeout exception, sleeps 100 ms, retries the call once, then propagates the second failure. Use it cautiously — only against APIs you know are occasionally (not chronically) slow. Document why the filter exists in a comment so future-you doesn't add it everywhere.

Next lesson: utility classes that turn the patterns from this whole chapter into named, reusable test operations — API helpers, test data factories, and the project layout that makes them findable.

// tip to track lessons you complete and pick up where you left off across devices.