API Contract Testing in CI with REST Assured, JSON Schema, and GitHub Actions
Build a small Java test suite that fails the pull request the moment an API response stops matching its contract, and get a test report out of every run.
Most API regressions I’ve seen in my years as an SDET weren’t dramatic outages. They were quiet changes: a field renamed from userId to user_id, an integer that became a string, a nested object that was suddenly nullable. The endpoint still returned 200 OK, the happy-path test that checked one or two values still passed, and the breakage turned up later in a mobile app or a downstream service.
Schema-based contract tests catch this class of bug cheaply. You write down the shape you depend on as a JSON Schema, check every response against it, and run those checks on every push and pull request so nobody can merge a breaking change without seeing a red build.
In this tutorial we’ll build exactly that:
- A Maven project with REST Assured 6.2.0 and JUnit 6.1.3 (the Jupiter API you already know from JUnit 5)
- Reusable request and response specifications, so tests stay short and consistent
- JSON Schema validation with REST Assured’s
json-schema-validatormodule - A GitHub Actions workflow that runs the suite on every push and PR and uploads the reports as a build artifact
I built and ran this project with JDK 21 and Maven 3.9.9 before writing it up. All four tests pass, and I also broke a schema on purpose to make sure the failure messages are useful. The versions above were the latest on Maven Central in October 2026.
A quick word on “contract testing”
The term gets used for two different things. Consumer-driven contract testing (Pact is the usual example) has consumers publish their expectations, and the provider verifies them in its own pipeline. What we’re building here is the lighter-weight option: schema contract tests that run against a live API and assert the response structure your client relies on.
It’s not a replacement for consumer-driven contracts in a large microservice estate. But it’s quick to set up, it needs nothing from the API team, and it works against any HTTP API, including third-party ones. For many teams it’s the right first step.
The API under test
We’ll use JSONPlaceholder, a free fake REST API that needs no key or sign-up. I checked a few alternatives while preparing this article. Some popular demo APIs now ask for an API key even for basic calls, which is awkward for a tutorial you want to clone and run.
We’ll test four behaviors:
GET /users/1returns a user that matches our user schema.GET /posts?userId=1returns an array of posts, all for user 1, and every item matches the post schema.POST /postsreturns201 Createdand echoes the payload. (JSONPlaceholder fakes the write, so nothing is actually stored.)GET /users/9999returns404.
Prerequisites
- JDK 17 or newer. REST Assured 6.x and JUnit 6 both require Java 17 as the minimum. I used Java 21.
- Maven 3.9.x
- A GitHub repository to push to
Step 1: Project layout
Here’s everything we’ll create:
api-contract-tests/
├── pom.xml
├── .github/workflows/api-tests.yml
└── src/test/
├── java/com/example/api/
│ ├── ApiSpecs.java
│ └── UsersContractTest.java
└── resources/schemas/
├── user.json
└── posts.jsonThere’s no src/main. This is a test-only project, which is common for a standalone API test suite that lives in its own repository.
Step 2: The POM
We need three test dependencies: rest-assured, json-schema-validator (the same version as REST Assured), and junit-jupiter. The JUnit BOM keeps all JUnit artifacts on the same version. We also pin Surefire, which runs the tests, and the Surefire Report plugin, which turns the XML results into an HTML page.
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>api-contract-tests</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<rest-assured.version>6.2.0</rest-assured.version>
<junit.version>6.1.3</junit.version>
<surefire.version>3.6.0</surefire.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${rest-assured.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>${surefire.version}</version>
</plugin>
</plugins>
</build>
</project>(I left out the xsi:schemaLocation attributes to keep the snippet short. Your IDE will add them if you want them.)
If your organization is still on JUnit 5, you can pin junit.version to a 5.x release instead. The test code below only uses Jupiter APIs that exist in both versions.
Step 3: Write the contract as JSON Schema
This is the most important file in the project, because it’s where you decide what your client actually depends on. A useful rule: require what you consume, and type everything you require. If your app never reads website, leave it out of required so the provider can change it without breaking your build.
src/test/resources/schemas/user.json:
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "object",
"required": ["id", "name", "username", "email", "address", "company"],
"properties": {
"id": { "type": "integer", "minimum": 1 },
"name": { "type": "string", "minLength": 1 },
"username": { "type": "string", "minLength": 1 },
"email": { "type": "string", "format": "email" },
"address": {
"type": "object",
"required": ["street", "city", "zipcode", "geo"],
"properties": {
"street": { "type": "string" },
"city": { "type": "string" },
"zipcode": { "type": "string" },
"geo": {
"type": "object",
"required": ["lat", "lng"],
"properties": {
"lat": { "type": "string" },
"lng": { "type": "string" }
}
}
}
},
"company": {
"type": "object",
"required": ["name"],
"properties": { "name": { "type": "string" } }
}
}
}src/test/resources/schemas/posts.json describes an array response. Here I've been deliberately strict with "additionalProperties": false, so any new field on a post fails the test:
{
"$schema": "http://json-schema.org/draft-04/schema#",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["userId", "id", "title", "body"],
"additionalProperties": false,
"properties": {
"userId": { "type": "integer" },
"id": { "type": "integer" },
"title": { "type": "string" },
"body": { "type": "string" }
}
}
}Strictness is a team decision. Strict schemas catch more changes but also flag harmless additions. I usually make response objects open (the default) and save additionalProperties: false for payloads where an unexpected field would really be a problem.
Why draft-04? REST Assured’s json-schema-validator module builds on the com.github.java-json-tools validator (version 2.2.14 in this build), which implements JSON Schema draft 4. Declare draft-04 explicitly, and avoid newer keywords from drafts 2019-09 or 2020-12. Otherwise you can end up with a keyword that's quietly ignored instead of enforced.
Step 4: Request and response specifications
Without specs, every test repeats the base URI, the headers, and the same baseline assertions. REST Assured’s RequestSpecBuilder and ResponseSpecBuilder let you define those once.
Get Moshiur Rahman’s stories in your inbox
Join Medium for free to get updates from this writer.
src/test/java/com/example/api/ApiSpecs.java:
package com.example.api;
import io.restassured.builder.RequestSpecBuilder;
import io.restassured.builder.ResponseSpecBuilder;
import io.restassured.filter.log.LogDetail;
import io.restassured.http.ContentType;
import io.restassured.specification.RequestSpecification;
import io.restassured.specification.ResponseSpecification;
import static org.hamcrest.Matchers.lessThan;
public final class ApiSpecs {
private static final String BASE_URI =
System.getProperty("api.baseUri", "https://jsonplaceholder.typicode.com");
private ApiSpecs() { }
public static RequestSpecification requestSpec() {
return new RequestSpecBuilder()
.setBaseUri(BASE_URI)
.setAccept(ContentType.JSON)
.setContentType(ContentType.JSON)
.log(LogDetail.URI)
.build();
}
public static ResponseSpecification okJson() {
return new ResponseSpecBuilder()
.expectStatusCode(200)
.expectContentType(ContentType.JSON)
.expectResponseTime(lessThan(10_000L))
.build();
}
}A few things worth pointing out:
- The base URI comes from a system property. Running
mvn test -Dapi.baseUri=https://staging.example.compoints the same suite at a different environment. Surefire passes Maven's-Dproperties through to the test JVM, and I confirmed the override works. log(LogDetail.URI)prints each request URI. It's a small amount of noise that helps a lot when you're reading CI logs.- The response-time check is a guardrail, not a performance test. Ten seconds is generous on purpose. Use a real load tool like JMeter or k6 for performance work.
One REST Assured 6.2 detail to know: a spec built with RequestSpecBuilder.build() has to be attached to a request with given().spec(spec) or given(spec). If you call spec.get("/x") directly, you now get a clear IllegalStateException instead of a confusing NullPointerException. The code below uses the supported pattern.
Step 5: The tests
src/test/java/com/example/api/UsersContractTest.java:
package com.example.api;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static io.restassured.module.jsv.JsonSchemaValidator.matchesJsonSchemaInClasspath;
import static org.hamcrest.Matchers.*;
class UsersContractTest {
@BeforeAll
static void setUp() {
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
}
@Test
@DisplayName("GET /users/{id} matches the user contract")
void singleUserMatchesSchema() {
given()
.spec(ApiSpecs.requestSpec())
.pathParam("id", 1)
.when()
.get("/users/{id}")
.then()
.spec(ApiSpecs.okJson())
.body(matchesJsonSchemaInClasspath("schemas/user.json"))
.body("id", equalTo(1));
}
@Test
@DisplayName("GET /posts?userId=1 returns only that user's posts")
void postsFilteredByUserMatchSchema() {
given()
.spec(ApiSpecs.requestSpec())
.queryParam("userId", 1)
.when()
.get("/posts")
.then()
.spec(ApiSpecs.okJson())
.body(matchesJsonSchemaInClasspath("schemas/posts.json"))
.body("userId", everyItem(equalTo(1)));
}
@Test
@DisplayName("POST /posts echoes the payload and returns 201")
void createPostReturns201() {
String payload = """
{"title": "contract test", "body": "hello", "userId": 1}
""";
given()
.spec(ApiSpecs.requestSpec())
.body(payload)
.when()
.post("/posts")
.then()
.statusCode(201)
.body("id", notNullValue())
.body("title", equalTo("contract test"))
.body("userId", equalTo(1));
}
@Test
@DisplayName("GET /users/{id} returns 404 for an unknown user")
void unknownUserReturns404() {
given()
.spec(ApiSpecs.requestSpec())
.pathParam("id", 9999)
.when()
.get("/users/{id}")
.then()
.statusCode(404);
}
}Notice how the schema and the value assertions split the work. matchesJsonSchemaInClasspath(...) checks the shape: which fields exist and what types they have. The Hamcrest assertions check behavior: the filter really filters (everyItem(equalTo(1))), and the path parameter really selects the right user. A contract suite needs both. A response can match the schema perfectly and still return the wrong data.
enableLoggingOfRequestAndResponseIfValidationFails() is the best single line you can add to an API suite that runs in CI. Passing tests stay quiet, and a failing test prints the full request and response, so the CI log has what you need to diagnose the failure.
Step 6: Run it locally
mvn testYou should see something like this:
[INFO] Running com.example.api.UsersContractTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSNow check that the safety net actually works. Add a field the API doesn’t return, such as "phoneNumber", to the required array in user.json, and run the suite again:
[ERROR] UsersContractTest.singleUserMatchesSchema <<< FAILURE!
error: object has missing required properties (["phoneNumber"])
missing: ["phoneNumber"]
[ERROR] Tests run: 4, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILUREThat message is what a developer will see in their PR, and it tells them exactly which field broke the contract. Revert the change before moving on.
To build an HTML report from the results, run:
mvn surefire-report:report-onlyWith Surefire Report plugin 3.x, the report is written to target/reports/surefire.html. The raw JUnit XML stays in target/surefire-reports/.
Step 7: Run it on every push and pull request
Create .github/workflows/api-tests.yml:
name: API contract tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
api-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Run tests
run: mvn -B -ntp test
- name: Build HTML report
if: ${{ !cancelled() }}
run: mvn -B -ntp surefire-report:report-only
- name: Upload test reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: api-test-reports
path: |
target/surefire-reports/
target/reports/
retention-days: 14What each part does:
- Triggers.
pushtomaincovers merges,pull_requestcovers every PR, andworkflow_dispatchadds a "Run workflow" button for manual runs. permissions: contents: read. The job only needs to read the code, so give the token nothing more.actions/checkout@v7andactions/setup-java@v6. These were the current major versions when I wrote this.distribution: temurininstalls Eclipse Temurin, andcache: mavencaches~/.m2/repositorywith a key based on yourpom.xml, so later runs skip most dependency downloads.-B -ntp. Batch mode with no transfer progress keeps the CI log readable.if: ${{ !cancelled() }}is the line people most often forget. Without it, a failing test step skips everything after it, so you'd get no report on exactly the runs where you need one. With it, the report steps run whether the tests pass or fail, and only a cancelled run skips them. The job still goes red when tests fail, because themvn teststep's exit code decides the job status.actions/upload-artifact@v7attaches the XML and HTML reports to the run summary. Fourteen days of retention is plenty for PR triage.
Push the project, open a pull request, and you’ll see the check run. To make it a real gate, go to your repository’s branch protection (or ruleset) settings and mark API contract tests / api-tests as a required status check. After that, a PR that breaks the contract can’t be merged.
Making it production-grade
The skeleton above is intentionally small. These are the next steps I’d take on a real project:
- Keep schemas next to their owner, or generate them. If the provider publishes an OpenAPI spec, derive your schemas from it, or validate against the spec directly, rather than maintaining JSON files by hand that will drift.
- Handle authentication through secrets. Add
.auth().oauth2(token)or a header to the request spec, read the token from an environment variable, and supply it withenv: API_TOKEN: ${{ secrets.API_TOKEN }}in the workflow. Never commit credentials. - Split contract tests from functional tests. Tag them with JUnit’s
@Tag("contract")and run them withmvn test -Dgroups=contract. That gives you a fast gate for PRs and a fuller suite on a schedule. - Run a nightly job against staging. Add a
schedule:trigger with a cron expression, and pass-Dapi.baseUri=...to point the suite at a real environment. Schema drift in a dependency you don't control shows up there first. - Be careful with parallel execution. REST Assured 6.2 fixed several race conditions in its static configuration. Even so, I keep global state like
RestAssured.filters(...)to a minimum and put everything in specs, which are plain objects and safe to share.
Wrapping up
In under 200 lines of Java and JSON, you now have a suite that:
- Writes down what your client depends on as versioned JSON Schemas
- Checks both the shape and the behavior of every response
- Runs on every push and pull request, with readable failures and downloadable reports
What makes it pay off is the habit around it more than the tooling. Treat a schema change like an API change, review it in the PR, and keep the build red until the contract and the code agree again.
The whole project is seven files. Copy them into a new repository, point api.baseUri at your own service, swap in your schemas, and you'll have a working contract gate before lunch.




