When a REST Assured expectation fails, what is thrown and how does JUnit 5 or TestNG see it?
answer
- one AssertionError, not a runner callback
- message opens with an expectation count
- nothing catches it; it just unwinds
- plain java.lang.AssertionError, no opentest4j fields
- a Groovy bridge unwraps the wrapper first
basics
~20 sREST Assured throws a plain java.lang.AssertionError whose message opens with a count of failed expectations and lists each mismatch. Nothing catches it, so it unwinds out of the test method and the runner that called that method records a failure.
solid answer
~40 sA failed expectation in `then()` ends in REST Assured's internal response specification, which collects the mismatches and throws one `java.lang.AssertionError`. Its message is a count — `1 expectation failed.` or `3 expectations failed.` — followed by a block per mismatch, such as `JSON path state doesn't match.` with expected and actual values beneath it. The library does nothing else: no `try`/`catch` around your test, no reporting, and its main sources reference neither JUnit nor TestNG. The error simply propagates out of the method the runner invoked, and the runner records whatever escaped. Because it is `java.lang.AssertionError` and not an opentest4j `AssertionFailedError`, there are no structured expected/actual fields for tooling to render — the message string is everything. Transport and object-mapping problems escape as their own exception types instead.
code
java · 16 linesimport static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
// no try/catch, no runner API - the AssertionError just leaves the method
given()
.baseUri("https://turbines.example")
.when()
.get("/api/turbines/{serial}/status", "WTG-114")
.then()
.statusCode(200)
.body("state", equalTo("SPINNING"));
// java.lang.AssertionError: 1 expectation failed.
// JSON path state doesn't match.
// Expected: SPINNING
// Actual: FEATHEREDgo deeper
Remember the shape of the failure: a java.lang.AssertionError whose message begins with a count of failed expectations. You do not add any assert call of your own around a then() chain.
Explain the mechanism end to end - the internal response specification collects mismatches, throws one AssertionError, and nothing in the library catches it on the way out to the runner.
Demonstrate triage. Separate an AssertionError, which means the assertions ran and disagreed, from an IllegalStateException or a transport exception, which means the classpath or environment is wrong.
Argue the tradeoff of runner independence: portability across JUnit and TestNG bought at the cost of structured expected/actual data, and what the team invests in logging or reporting to compensate.
## Where the AssertionError is born Every expectation you attach in `then()` — a status code, a status line, a header, a cookie, a content type, a response time, a body path — is recorded on REST Assured's internal response specification and then checked against the response. When the checks produce mismatches, that class does exactly one thing: ```groovy throw new AssertionError("$numberOfErrors expectation$s failed.\n$errorMessage$formattedOnFailMessage") ``` That is `java.lang.AssertionError`, the JDK's own type, constructed with a message string and nothing else. There is no REST Assured exception hierarchy for validation, no error code, no structured payload and no callback. ## What the message contains The message has a fixed shape, and reading it at a glance is a real skill on a red build: - It opens with a count and a pluralised noun: `1 expectation failed.` or `3 expectations failed.` - Under that comes one block per mismatch. A body-path mismatch reads `JSON path <path> doesn't match.` followed by `Expected:` and `Actual:` lines; status code, status line, content type and response time each have their own wording. - An `onFailMessage` you supplied is appended at the very end. A failing turbine assertion therefore reaches CI looking like this: ```text java.lang.AssertionError: 1 expectation failed. JSON path state doesn't match. Expected: SPINNING Actual: FEATHERED ``` ## How the runner finds out: it doesn't, you throw at it There is no integration. REST Assured never registers a listener, never calls a reporting API and never names a runner. Both JUnit 5 and TestNG invoke your test method reflectively and record whichever throwable escapes it; "throw and let it unwind" is the one contract every JVM runner honours, and it is the entire handoff. That is why the library needs no adapter for JUnit 4, JUnit 5, TestNG or anything else: it never learns which of them is running, and it never needs to. - The library has no `@Test`, no lifecycle hooks and no result model of its own. - Its main sources contain no reference to JUnit, TestNG or opentest4j. `junit-jupiter` appears in its build only at test scope, for testing the library itself. - Consequently the same `given()...then()` chain compiles and fails identically under either runner. ## The Groovy boundary, and why you never see it REST Assured's request implementation is written in Groovy, and every verb method — `get`, `post`, `put`, `delete`, `head`, `patch`, `options` — wraps its work in `GroovyAssertBridge.runWithUnwrap`. Groovy and reflective dispatch like to wrap the real cause in `GroovyRuntimeException`, `InvocationTargetException` or `UndeclaredThrowableException`; the bridge unwraps those containers recursively and rethrows the original throwable, using a sneaky throw so the method signature does not change. Without it your CI report would show a Groovy wrapper with the interesting failure buried two `Caused by:` levels down. With it, the `AssertionError` — or the real transport exception — is the top-level throwable the runner records. ## Not everything that fails is an AssertionError | What went wrong | What leaves the library | |---|---| | an expectation did not match | `java.lang.AssertionError` | | no object mapper on the classpath | `IllegalStateException` | | a named object mapper is absent | `IllegalArgumentException` | | the turbine service is unreachable, or TLS fails | the transport's own exception, unwrapped | That distinction is the practical half of the answer. A red build full of `AssertionError` is a build whose assertions ran and disagreed with the service; a red build full of `IllegalStateException` is a build whose classpath or environment is wrong, and no amount of staring at the turbine API will explain it. What a report calls each category afterwards is the runner's and the reporting tool's business, not the library's. ## What this design buys, and what it costs - **Portability.** JUnit 4, JUnit 5, TestNG, Spock or a bare `main` method all work, with no adapter module per runner and no version matrix to maintain. - **Predictability.** One failure produces one throwable, at one point in the chain, with a message you can grep for in a log. - **A real cost.** Because it is a bare `AssertionError` rather than an opentest4j `AssertionFailedError`, there are no machine-readable expected and actual fields for an IDE to render as a side-by-side diff. Everything a tool can show you is inside the message string, which is precisely why REST Assured invests so heavily in request and response logging.
- Does REST Assured behave differently under TestNG than under JUnit 5?No. The library never names either one: its main sources hold no JUnit or TestNG reference, and `junit-jupiter` appears only at test scope in its own build. It throws `java.lang.AssertionError` and stops. Both runners treat an escaping throwable as a failed test, so the same chain works unchanged; the annotations, lifecycle and reporting around it belong to the runner.
- What does REST Assured throw if the turbine service is unreachable?Not an `AssertionError`. The call fails inside the transport and the underlying exception comes out as itself, because `GroovyAssertBridge.runWithUnwrap` strips the Groovy and reflection wrappers so you see the real cause rather than an `InvocationTargetException`. Only a failed expectation produces an `AssertionError`, which is why the two categories are worth telling apart on a red build.
- Why does the message sometimes say "3 expectations failed" instead of one?Because the count is simply how many registered expectations were found to mismatch in that validation pass. A pass that checks several expectations at once — a prepared response specification, or the Kotlin `Then { }` block — can report all of them together, while a plain Java chain validates after each link and throws on the first.
The library behaves like a smoke detector with no wiring to the fire brigade: it makes the noise and stops there. Whoever called the test method - JUnit 5, TestNG, or a plain main - is what decides the noise means a failed test.
saying these in an interview costs you the question
- Thinks REST Assured reports the result to JUnit through some integration API
- Expects a runner-specific type such as AssertionFailedError or ComparisonFailure
- Believes you must wrap then() in an assert or fail() call for the test to fail
- Assumes a connection refused surfaces as an AssertionError like a matcher mismatch
- Thinks switching from JUnit 5 to TestNG changes what the library throws
- Claims REST Assured needs a JUnit dependency present in order to work