DEV Community

Kuruba Ramesh
Kuruba Ramesh

Posted on • Edited on

Contract Testing a Spring Boot Banking API with Specmatic: A Complete Implementation Guide

Introduction

I'm Kuruba Ramesh, a Full Stack Developer specializing in the MERN stack and Java Spring Boot. As part of the Specmatic Full Stack AI Engineering Internship Assessment, I completed a hands-on project focused on spec-first engineering and contract-driven API development.

The assessment asked me to:

  1. Complete the Specademy course on Spec-First Engineering
  2. Integrate Specmatic into a real-world Spring Boot application
  3. Configure automated contract testing and CI
  4. Document the implementation, the challenges, and what I learned

For this, I integrated Specmatic into my banking API project, ValueMeters, and automated provider contract testing with GitHub Actions.

Portfolio: krameshdev.vercel.app

This article walks through the full implementation — what contract testing actually is, how I wired Specmatic into a JWT-secured Spring Boot app, the schema resiliency failures that taught me the most, and how the CI pipeline is structured today.


What Is Contract Testing, and Why Does It Matter?

Before taking the Specademy course, I knew unit testing and integration testing well, but contract testing was new to me. One idea from the course stuck with me:

Contract testing is compiler safety for API calls.

In a monolithic application, the compiler catches a type mismatch the moment you try to build. You cannot ship code that calls a function with the wrong arguments — the build simply fails.

In a distributed system, services talk to each other over HTTP instead of function calls. If one service changes a field name, a data type, or a status code, there's no compiler to catch it. The mismatch only surfaces at runtime — often in production, and often at the worst possible time.

Specmatic closes that gap. It reads your OpenAPI specification and turns it into an executable contract. Using the Specmatic documentation as a reference, I set it up to automatically validate, on every test run:

  • Request and response schemas
  • HTTP status codes
  • Response headers and content types
  • Whether every documented endpoint is actually implemented

If the running application ever drifts from what the OpenAPI spec promises, the test fails immediately — the same way a compiler error stops a bad build before it ships.


Project Overview: ValueMeters Banking API

For this assessment, I used my banking API project, ValueMeters.

Technology Stack

Backend

  • Java 17
  • Spring Boot 2.7.18
  • Spring Security with JWT authentication
  • Spring Data JPA
  • MySQL
  • Specmatic 2.48.0

Frontend

  • React 18
  • Vite
  • Tailwind CSS

API Documentation

  • SpringDoc OpenAPI 3.0

API Surface

The application exposes 13 REST endpoints across five feature areas:

Endpoint Method Description
/auth/register POST Register a new user
/auth/login POST Log in and receive a JWT
/account/user/{userId} GET Get an account by user ID
/account/{accountNumber} GET Get an account by account number
/transaction/deposit/{accountId} POST Deposit money
/transaction/withdraw/{accountId} POST Withdraw money
/transaction/transfer/{fromAccountId} POST Transfer money between accounts
/transaction/history/{accountId} GET Get transaction history
/expense/add/{accountId} POST Add an expense
/expense/list/{accountId} GET List all expenses
/expense/summary/{accountId} GET Get an expense summary
/budget/set/{accountId} POST Set budget limits
/budget/get/{accountId} GET Get budget limits

Since the project already used SpringDoc OpenAPI, generating a spec was straightforward, which made ValueMeters a good candidate for Specmatic integration.


Setting Up Specmatic

Adding the Dependency

I added the Specmatic JUnit 5 support dependency (version 2.48.0) along with Spring Boot Actuator, which Specmatic uses to auto-discover every registered endpoint and enforce coverage governance.

Writing the OpenAPI Specification

I wrote an OpenAPI spec describing every endpoint's:

  • Request schema
  • Response schema
  • Path parameters
  • Status codes (200, 400, 404)
  • Error response shapes

This spec became the single source of truth that both the application and Specmatic are validated against.

Configuring specmatic.yaml

version: 3
systemUnderTest:
  service:
    definitions:
      - definition:
          source:
            filesystem:
              directory: .
          specs:
            - spec:
                path: openapi.json
                type: openapi
    runOptions:
      openapi:
        type: test
        baseUrl: "http://localhost:9000"
        actuatorUrl: "http://localhost:9000/actuator/mappings"
        filter: "PATH!='/api-docs,/swagger-ui'"
    data:
      examples:
        - directories:
            - examples

specmatic:
  settings:
    test:
      schemaResiliencyTests: all
  governance:
    successCriteria:
      maxMissedOperationsInSpec: 0
      minCoveragePercentage: 100
      enforce: true
Enter fullscreen mode Exit fullscreen mode

Three settings do most of the work here:

  • actuatorUrl — Specmatic queries Spring Actuator to discover every registered endpoint, so nothing gets silently skipped from coverage.
  • examples directory — externalized, deterministic test data instead of relying purely on generated values.
  • schemaResiliencyTests: all together with governance — this is what actually generates the negative and boundary-mutation scenarios, and then enforces that 100% of operations are covered with zero missed operations. Resiliency isn't a separate testing phase bolted on afterward — it's the same Specmatic run, driven by this one setting.

The Biggest Challenge: JWT Authentication

ValueMeters uses JWT authentication — every endpoint expects a valid token in the Authorization header. When Specmatic fired its test requests, Spring Security rejected them before they ever reached a controller, well before Specmatic could evaluate the contract at all.

Solution: A Separate Test Security Profile

Rather than weakening production security, I used Spring profiles to swap in a test-only configuration.

@Profile("!test")
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    // Production JWT configuration — untouched
}
Enter fullscreen mode Exit fullscreen mode
@Configuration
@Profile("test")
public class TestSecurityConfig {
    @Bean
    public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}
Enter fullscreen mode Exit fullscreen mode

This keeps production security completely untouched while giving Specmatic full access to exercise the API during the test profile only.


Writing the Contract Test

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@ActiveProfiles("test")
@Sql(scripts = "/data.sql", executionPhase = ExecutionPhase.BEFORE_TEST_METHOD)
public class BankingContractTest extends SpecmaticJUnitSupport {
}
Enter fullscreen mode Exit fullscreen mode

Two details here cost me real debugging time, and are worth calling out explicitly:

Use DEFINED_PORT, not RANDOM_PORT. With RANDOM_PORT, Spring Boot starts on a random port, but Specmatic connects to the port declared in the OpenAPI spec. Since those never match, every single test fails with "Connection refused" — and the error gives no hint that the port is the actual problem.

Reseed the database before every test method with @Sql. Provider tests need predictable state. Without a consistent reseed, resiliency's randomized requests can land on data that was mutated by a previous test, producing flaky, order-dependent failures.


The Most Valuable Debugging Session: Schema Resiliency Failures

The most useful thing I learned from this whole assessment came from debugging why Specmatic's schema resiliency run was failing tests that had nothing wrong with them.

The Problem

My first positive test passed. But the resiliency run then generated a login request with a random, schema-valid email like ahgtg@vismx.com instead of the seeded test@example.com. The API correctly rejected it with 400 Invalid credentials — but Specmatic's example expected 200, so it flagged the mismatch as a failure.

Root Cause

Schema resiliency doesn't just replay your examples — it actively mutates fields based on the constraints declared in the schema. My email field had "format": "email" in the OpenAPI schema, so Specmatic generated random-but-valid email addresses on each resiliency pass. My seeded H2 test database only had one real user, so anything else correctly failed login — which is exactly what a real client hitting the real API would experience, but not what the fixed example expected.

The Fix

Before:

"email": {
  "type": "string",
  "format": "email",
  "example": "test@example.com"
}
Enter fullscreen mode Exit fullscreen mode

After:

"email": {
  "type": "string",
  "example": "test@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Removing "format": "email" stopped Specmatic from generating random email-shaped values during resiliency runs, so it fell back to the example value instead — which matched the seeded test data.

Importantly, this did not weaken validation anywhere that matters: Spring's @Email and @Valid annotations still enforce email format at runtime, in production. The schema change only affects how Specmatic generates test data, not how the application validates real requests.

The Register Endpoint Needed the Same Treatment

The register endpoint also needed its own externalized example file (examples/auth_register_success.json). Without one, Specmatic logged a warning that it was ignoring the inline spec example and falling back to fully random schema-based data — which hit the same problem as the email field.


Expanding to Full API Coverage

With the JWT and resiliency issues resolved, I expanded the OpenAPI spec to cover all 13 endpoints — account, transaction, expense, and budget — not just auth.

For each endpoint, I:

  1. Tightened request schemas, removing loosely-typed optional fields that caused unpredictable resiliency mutations
  2. Documented the real response codes (200, 400, 404) instead of only the happy path
  3. Added externalized positive and negative example files under examples/
  4. Fixed service-layer exceptions to map to the correct HTTP status — AccountNotFoundException → 404, InsufficientBalanceException → 400

Final result: 96/96 tests passing, 100% API coverage across all 13 endpoints.


GitHub Actions CI Integration

Once the tests were solid locally, I automated them with GitHub Actions so every push gets contract-tested automatically, with no manual step required.

How the Pipeline Is Structured

Contract tests and schema resiliency are not two separate test suites — resiliency is just schemaResiliencyTests: all inside the same Specmatic run described above. So the CI pipeline runs everything as a single job:

name: Specmatic CI
on:
  push:
  pull_request:
jobs:
  contract-tests:
    name: Contract Tests (with schema resiliency)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: "17"
          distribution: "temurin"
      - name: Make mvnw executable
        run: chmod +x mvnw
      - name: Run Contract Tests
        run: ./mvnw test -Dtest=BankingContractTest -Dspring.profiles.active=test
        continue-on-error: true
      - name: Upload Contract Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: contract-report
          path: build/reports/specmatic/
Enter fullscreen mode Exit fullscreen mode

Every run of this job:

  • Boots the app in the test profile with TestSecurityConfig active
  • Runs the full contract test class, which exercises named examples and the schema resiliency mutations together
  • Enforces governance — zero missed operations, 100% coverage
  • Generates a Specmatic HTML report and uploads it as a GitHub Actions artifact, attached directly to that workflow run

I deliberately upload the report as a workflow artifact rather than publishing it to GitHub Pages. An artifact is tied to the exact commit and run that produced it, so the report you're looking at always matches the code you're looking at — there's no separate, manually-published copy that can quietly go stale while the code moves on.

Issues I Fixed to Get CI Green

mvnw not executable on Linux. Windows creates mvnw without the executable bit set. GitHub Actions runs on Ubuntu, so CI failed with exit code 126 until I added chmod +x mvnw as an explicit step.

Wrong Specmatic version. Version 0.28.0 isn't published on Maven Central — CI failed with a dependency resolution error. I checked Maven Central directly and pinned to 2.48.0.

RANDOM_PORT vs DEFINED_PORT. Same issue as locally, but CI made it impossible to ignore — Specmatic couldn't connect at all until I switched to DEFINED_PORT.

Two CI jobs doing the same work. Early on, I had contract-tests and resiliency-tests as two separate GitHub Actions jobs, both running the exact same command. Since resiliency is just a setting inside one Specmatic run, not a distinct test type, this duplicated the same execution and produced two nearly-identical reports for no benefit. Consolidating to the single job above cut CI time roughly in half and matches what's actually true about how Specmatic runs.

CI is green on every push: github.com/KRameshr/valuemeters-specmatic/actions


Key Learnings

1. Schema constraints drive resiliency test data, not just documentation. format: email tells Specmatic to generate random valid-looking emails during resiliency. Removing the format constraint pins generation back to your example. Schema constraints and runtime validation serve different purposes — don't confuse the two.

2. Every endpoint needs its own positive example. Without an externalized example, Specmatic falls back to fully random schema-based data, which may not match your seeded test state.

3. Exception types drive contract accuracy. RuntimeException → 500, AccountNotFoundException → 404, IllegalArgumentException → 400. Getting these right at the service layer is what makes the contract accurate, not just the code.

4. Governance turns coverage into a gate, not a metric. Enforcing minCoveragePercentage: 100 and maxMissedOperationsInSpec: 0 makes it structurally impossible to quietly ship an untested endpoint.

5. Provider testing needs deterministic state. Reseeding with @Sql before every test method removed an entire category of flaky, order-dependent failures.

6. Resiliency testing and contract testing are the same thing, not two pipelines. This was the biggest process lesson from this assessment. Schema resiliency is a setting on your contract test run, not a separate suite that needs its own job, its own report, or its own place in your CI. Treating it as separate — as I initially did with two CI jobs — just duplicates work and reporting for no additional signal.


What's Next

  • Consumer-driven contract testing
  • Deeper Specmatic provider examples for edge cases
  • Using Specmatic as a mock server for the React frontend during local development
  • Expanding negative-scenario coverage further

Conclusion

Before this project, I thought of OpenAPI mainly as documentation — something Swagger UI renders nicely, not something that actively enforces behavior. Working through this assessment changed that. An OpenAPI spec, run through Specmatic, becomes an executable contract that continuously checks the real application against what was promised, on every single push.

I integrated Specmatic 2.48.0 into a JWT-secured Spring Boot application, reached 96/96 tests passing with 100% API coverage across 13 endpoints, wired schema resiliency and governance into a single CI job, and learned — the hard way, through a failing test — that every schema constraint is an instruction to Specmatic's test generator, not just a documentation detail.

That last point is the one I keep coming back to: contract testing is compiler safety for API calls, and Specmatic is what makes that safety net actually executable instead of aspirational.


Resources

Top comments (0)