Post

Contract Testing for Spring Boot and Angular: Catch Frontend-Backend Drift Before Production

Contract Testing for Spring Boot and Angular: Catch Frontend-Backend Drift Before Production

Your Angular service reads courseName off every response. Last Tuesday the backend renamed that field to name. Both projects compile. Both test suites are green. Production shows a table full of blanks.

Nothing in either codebase was wrong on its own. The Spring controller returns a perfectly valid DTO. The Angular CoursesService maps a perfectly valid TypeScript interface. The bug lives in the space between them, in the agreement the two sides think they share and no test actually checks. The compiler cannot see across the network boundary. Your unit tests mock it away. Your TypeScript interface is a hopeful transcription of a JSON shape that the backend is free to change without telling anyone.

This is the one seam that every technique in my recent posts deliberately cannot reach. In the mutation testing post, PIT guards the behavior of your code. In the architecture testing post, ArchUnit guards its shape — but it does so by reading one codebase’s compiled bytecode. ArchUnit can prove your CourseController never returns a JPA entity. It has no idea what the Angular client on the other end of that endpoint actually expects, because that client is not in its classpath. Neither sensor can see the contract between two separately deployed processes.

Contract testing is the sensor for that seam. Instead of hoping the producer and the consumer stay in sync, you capture their agreement as an executable artifact — a contract — and verify both sides against it on every build. The frontend proves it asks for what it thinks it asks for. The backend proves it delivers exactly that. The moment someone renames courseName to name on one side, a test fails on the other, before either side deploys.

This is the same harness move I keep coming back to: take an assumption that currently lives only in someone’s head and convert it into a deterministic check the build refuses to skip. This post walks through what contract testing actually verifies, the honest tradeoffs between the two dominant tools on the JVM (Spring Cloud Contract and Pact), and a full-stack setup for an Angular consumer and a Spring Boot producer.

Want the code? Every example in this post targets the full-stack CRUD project I use across this blog and my videos: loiane/crud-angular-spring

In this post, we cover:

  • Why the boundary between frontend and backend is the blind spot no in-process test can cover
  • What contract testing actually verifies, and the vocabulary you need: consumer, producer, contract, provider state
  • Spring Cloud Contract vs Pact, and which one fits an Angular-to-Spring boundary (with an honest recommendation)
  • Writing a consumer contract on the Angular side
  • Verifying that contract on the Spring Boot producer, including provider states
  • Reading a broken contract: the courseName rename, caught deterministically
  • Sharing contracts between repos with a Pact Broker and the can-i-deploy gate
  • Why the same AI agent writing both sides of the contract is exactly the drift this catches, with sample prompts
  • Running contract verification in GitHub Actions
  • An adoption path that does not require a big-bang rewrite

Why the Boundary Is the Blind Spot

Every test you already run lives inside a single process. Your Spring @WebMvcTest boots the controller and mocks the service. Your Angular HttpTestingController intercepts the request and hands back a fixture you wrote. Both are useful, and both share the same fatal limitation for this problem: the thing on the other side of the wire is a stand-in you control, not the real counterpart.

That is the gap. A mock is only as accurate as your memory of what the other side does. The day the backend changes its response, your Angular fixture keeps returning the old shape, your test keeps passing, and your test suite now actively lies to you. It reports green precisely because it is asserting against a version of reality that no longer exists.

Consider the endpoint at the center of the CRUD app: GET /api/courses returns a paginated list of courses. The Spring producer serializes a CourseDTO. The Angular consumer deserializes it into a Course interface. Here is the entire agreement, and nothing enforces it:

1
2
3
4
5
6
7
// Producer: crud-spring — what Spring actually returns
public record CourseDTO(
    @JsonProperty("_id") Long id,
    String name,
    String category,
    List<LessonDTO> lessons
) {}
1
2
3
4
5
6
7
// Consumer: crud-angular — what Angular expects to receive
export interface Course {
  _id: number;
  name: string;
  category: string;
  lessons: Lesson[];
}

These two declarations are supposed to describe the same JSON. Note the _id: the record’s id field serializes under a different name because of a @JsonProperty annotation, which is exactly the kind of detail a hand-kept TypeScript interface silently gets wrong. Nothing in either build checks that they still agree. Rename name to title on the record, and the Java side compiles, the Java tests pass, and the Angular side has no idea anything happened until a user loads the page. The TypeScript interface is not a contract. It is a comment that happens to be written in TypeScript.

Contract testing closes this gap by making the agreement itself the shared artifact. Instead of two independent transcriptions that drift, there is one contract that both sides verify against. When they disagree, a build goes red instead of a user filing a bug.

What Contract Testing Actually Verifies

The vocabulary is small, and getting it straight up front makes everything that follows easier.

Term What it means
Consumer The side that calls the API. Here, the Angular app.
Producer / Provider The side that serves the API. Here, the Spring Boot backend.
Contract An executable description of a request the consumer makes and the response it expects
Provider state A named precondition the producer sets up so the response in the contract is reproducible (for example, “three courses exist”)
Verification Replaying the contract’s request against the real producer and asserting the real response matches

The critical idea is that a contract is not a schema document you write once and forget. It is generated from a real interaction and then replayed against the real producer. The consumer says “when I send GET /api/courses, I expect a 200 with a JSON array where each item has a numeric _id and a string name.” The producer test takes that exact expectation, sends the request to the actual running controller, and checks the actual response satisfies it.

Two properties fall out of this that a hand-written OpenAPI file can never give you:

  • The consumer only asserts what it uses. If the backend adds a new createdAt field, the contract does not break, because the Angular app never asked about it. Contract testing verifies compatibility, not identity. This is what lets the two sides evolve independently instead of lockstep.
  • The producer is verified against real behavior. The contract is replayed against the actual controller and serializer, so a mismatch between the record and the JSON — a renamed field, a type change, a null where a value was promised — fails the producer’s own build.

This is a fitness function in exactly the sense the harness engineering post described: an automated, objective check that a specific architectural characteristic — here, cross-service compatibility — still holds. Where ArchUnit is a sensor on the shape of one codebase, contract testing is a sensor on the shape of the agreement between two.

Spring Cloud Contract vs Pact

There are two serious tools for contract testing on the JVM, and they differ in a way that matters for this decision. Choosing well is mostly about one question: who owns the contract, and what language is the consumer written in?

Spring Cloud Contract is producer-driven. The producer author writes the contract in a Groovy or YAML DSL, and the plugin generates two things from it: verification tests that prove the producer honors the contract, and a WireMock stub the consumer runs against. It is beautifully integrated when both sides are on the JVM — the stub ships as a Maven artifact, and a Spring consumer pulls it in with the Stub Runner and gets a realistic fake backend for free.

Pact is consumer-driven. The consumer author writes a test in the consumer’s own language that describes the interactions it needs. Running that test produces a pact file (JSON) capturing every request/response pair. The producer then replays that pact file against the real service to verify it. Pact is polyglot by design: there is a first-class JavaScript/TypeScript library for the consumer and a JVM verifier for the producer.

Here is the honest comparison for the decision at hand:

Dimension Spring Cloud Contract Pact
Contract direction Producer-driven Consumer-driven
Who authors the contract Producer, in a Groovy/YAML DSL Consumer, in its own language
Consumer language fit JVM-first Polyglot (TypeScript, Java, Go, …)
How the consumer verifies Runs against a generated WireMock stub Runs against a Pact mock server
How the producer verifies Plugin-generated tests from the DSL Replays the consumer’s pact file
Contract sharing Stubs as Maven artifacts or Git Pact Broker, or committed pact files
Sweet spot JVM service ↔ JVM service Cross-language, especially SPA ↔ API

For a JVM-to-JVM boundary — two Spring Boot services talking to each other — I reach for Spring Cloud Contract without hesitation. It keeps everything in one ecosystem, and the Stub Runner integration is hard to beat.

For this project, the consumer is Angular. The contract is most meaningful when it is written by the side that actually depends on the shape, in the language that side is written in, so the TypeScript that consumes name is the same code that declares it needs name. That is exactly what consumer-driven Pact gives you, and it is why the rest of this post uses Pact: the Angular app writes the contract, and the Spring Boot backend proves it can keep it. If your architecture is all Spring services, mentally swap in Spring Cloud Contract and the harness role is identical — only the mechanics change.

Writing the Consumer Contract in Angular

The consumer test describes one interaction: the request the Angular CoursesService makes, and the response it needs back. Pact spins up a local mock server, your code calls it, and if the call succeeds, Pact writes a pact file describing the exchange.

One practical note before the code: Pact JS runs in Node, not in the browser, so these contract tests live alongside your Angular project but run as Node tests, separate from the Angular unit tests. In this project they run under Vitest through a dedicated vitest.pact.config.ts with environment: 'node', and tsconfig.spec.json excludes src/contract/** so ng test never picks them up. They are a distinct, fast suite whose only job is to produce the pact file. Add the dependency in the crud-angular project:

1
npm install --save-dev @pact-foundation/pact

Wire a test:pact script and the Vitest config so the suite runs on its own:

1
2
3
4
// crud-angular/package.json
"scripts": {
  "test:pact": "vitest run --config vitest.pact.config.ts"
}
1
2
3
4
5
6
7
8
9
10
11
12
// crud-angular/vitest.pact.config.ts
import { defineConfig } from 'vitest/config';

// Pact consumer tests run in Node (they spin up a native mock server) and are
// intentionally kept separate from the Angular/jsdom unit tests.
export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/contract/**/*.pact.spec.ts'],
  },
});

Then write a test that exercises the real HTTP shape your service depends on. The eachLike, integer, and string matchers are the key: they assert the type and structure of each field, not exact values, so the contract stays stable when the actual data changes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// crud-angular/src/contract/course.consumer.pact.spec.ts
import path from 'node:path';

import { PactV3, MatchersV3 } from '@pact-foundation/pact';

const { eachLike, integer, string } = MatchersV3;

const provider = new PactV3({
  consumer: 'crud-angular',
  provider: 'crud-spring',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('CoursesService contract', () => {
  it('GET /api/courses returns a page of courses', () => {
    provider
      .given('three courses exist')
      .uponReceiving('a request for the first page of courses')
      .withRequest({
        method: 'GET',
        path: '/api/courses',
        query: { page: '0', pageSize: '10' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          totalElements: integer(3),
          totalPages: integer(1),
          courses: eachLike({
            _id: integer(1),
            name: string('Angular'),
            category: string('Front-end'),
          }),
        },
      });

    return provider.executeTest(async (mockServer) => {
      // In the Angular app this call goes through CoursesService.list();
      // here we hit the Pact mock server directly to record the interaction.
      const response = await fetch(
        `${mockServer.url}/api/courses?page=0&pageSize=10`
      );
      expect(response.status).toBe(200);

      const body = await response.json();
      expect(body.courses[0]._id).toBeDefined();
      expect(body.courses[0].name).toBeDefined();
      expect(body.courses[0].category).toBeDefined();
    });
  });
});

Read what this test actually pins down. It says the Angular app sends GET /api/courses with page and pageSize query parameters, and it needs back a 200 with totalElements and totalPages numbers and a courses array where every element has an integer _id, a string name, and a string category. It says nothing about fields it does not use. That restraint is the whole point: the contract is the minimum the consumer depends on, so the producer stays free to add anything without breaking it.

Running this test produces crud-angular/pacts/crud-angular-crud-spring.json. That file is the contract. It is generated, not hand-written, which means it can never drift from what the consumer code actually does — if you change the request, you change the pact.

Verifying the Contract on the Spring Producer

Now the other half. The producer takes the pact file the consumer generated and replays every interaction in it against the real controller, asserting the real response matches. If it does, the two sides are compatible. If it does not, the producer’s own build goes red.

Add the Pact JVM provider dependency to crud-spring:

1
2
3
4
5
6
<dependency>
  <groupId>au.com.dius.pact.provider</groupId>
  <artifactId>junit5spring</artifactId>
  <version>4.6.17</version>
  <scope>test</scope>
</dependency>

Then write a verification test. It points Pact at the pact file, boots the application on a random port, and replays every interaction over real HTTP. The @State method is where provider states earn their keep: the consumer’s contract said given('three courses exist'), so this is where the producer makes that precondition true before the request runs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// crud-spring/src/test/java/com/loiane/contract/CourseContractVerificationTest.java
package com.loiane.contract;

import static org.mockito.Mockito.when;

import java.util.List;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import com.loiane.course.CourseService;
import com.loiane.course.dto.CourseDTO;
import com.loiane.course.dto.CoursePageDTO;

import au.com.dius.pact.provider.junit5.HttpTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactFolder;

@Provider("crud-spring")
@PactFolder("../crud-angular/pacts")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CourseContractVerificationTest {

    @LocalServerPort
    private int port;

    @MockitoBean
    private CourseService courseService;

    @BeforeEach
    void setTarget(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", port));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @State("three courses exist")
    void threeCoursesExist() {
        when(courseService.findAll(0, 10)).thenReturn(
            new CoursePageDTO(List.of(
                new CourseDTO(1L, "Angular", "Front-end", List.of()),
                new CourseDTO(2L, "Spring Boot", "Back-end", List.of()),
                new CourseDTO(3L, "Java", "Back-end", List.of())
            ), 3, 1)
        );
    }
}

Why Not @WebMvcTest and MockMvcTestTarget

Most Pact JVM tutorials — and an earlier draft of this post — wire the verification with @WebMvcTest and Pact’s MockMvcTestTarget, which replays interactions against the MockMvc layer without ever opening a socket. That does not work on Spring Boot 4. Pact JVM 4.6.17 is compiled against Spring Framework 6, and MockMvcTestTarget calls MockHttpServletRequestBuilder.headers(...) with a signature that no longer exists in Spring Framework 7, so verification blows up with a NoSuchMethodError. On top of that, @WebMvcTest itself moved to a new package in Boot 4. Booting the real application with @SpringBootTest(webEnvironment = RANDOM_PORT) and pointing Pact at it with HttpTestTarget sidesteps the incompatibility entirely — and it verifies the true HTTP response, serialization and all, which is closer to what you actually want anyway. This is the contract-testing equivalent of the JUnit 5 and Surefire gotchas from the PIT and ArchUnit posts: the setup that every tutorial shows is the one that silently fails on a current stack.

The flow is worth tracing end to end, because it is where contract testing stops being abstract. The consumer said “when three courses exist, GET /api/courses gives me back a page of three.” The producer test sets up exactly that state, then Pact issues a real HTTP request to the booted server, through the real CourseController and the real Jackson serializer, and checks the actual JSON against the consumer’s expectations. The service is mocked so the data is deterministic, but the entire serialization path — field names, types, structure — is exercised for real. If the record’s id serializes as _id and the pact expects _id, it passes. If someone renames a field, it fails right here, on the producer’s build, naming the exact field.

Reading a Broken Contract

This is the payoff, so it is worth seeing precisely what happens when the two sides drift. Go back to the opening scenario: someone renames name to title on the producer’s CourseDTO, updates the Java code, and the Spring tests stay green because nothing in the Spring project knows the Angular app depends on name.

The next time the producer runs its contract verification, Pact replays the consumer’s pact — which still expects name — against the new response, and fails with a message like this:

1
2
3
4
5
6
7
Failures:

1) Verifying a pact between crud-angular and crud-spring
   [GET /api/courses] a request for the first page of courses
   has a matching body

   $.courses[0] -> Actual map is missing the following keys: name

That message is the entire value proposition. It does not say “something changed.” It says: the field the Angular app reads at courses[0].name is no longer in the response. Whoever renamed the field learns at the moment of the rename that a consumer depends on the old name, instead of a user discovering it in production three deploys later. The conversation that used to be a production incident becomes a red check on a pull request.

And notice which build caught it: the producer’s. The team that made the change is the team that sees the failure. That is the whole reason to run the producer verification in the backend’s own pipeline, not somewhere downstream.

Sharing Contracts Between Repos

In the CRUD project the frontend and backend live in one repository, so the producer can read the pact file straight from ../crud-angular/pacts with @PactFolder, as shown above. That is the simplest possible setup and perfectly fine to start with.

Real systems usually split the consumer and producer into separate repositories, and then a shared file path no longer works. This is what the Pact Broker solves. The broker is a service that stores pacts and verification results and answers the one question that actually matters at deploy time: is the version I am about to ship compatible with what is already running on the other side?

The consumer publishes its pact to the broker after generating it. The producer pulls the latest pact from the broker instead of a folder, by swapping the loader:

1
2
3
4
5
6
@Provider("crud-spring")
@PactBroker(url = "https://your-broker.example.com")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CourseContractVerificationTest {
    // identical body — only the pact source changed
}

The broker also unlocks the deployment gate that makes contract testing safe in a pipeline: can-i-deploy. Before a deploy, you ask the broker whether this exact version has a verified, compatible contract with every environment it needs to talk to:

1
2
3
4
pact-broker can-i-deploy \
  --pacticipant crud-angular \
  --version "$GIT_SHA" \
  --to-environment production

If the answer is no — the backend has not yet verified the frontend’s latest contract — the deploy stops. This is the same one-way gate philosophy as ArchUnit’s freeze: the pipeline refuses to move forward unless the sensor is satisfied. You do not need the broker on day one, but the moment your consumer and producer deploy independently, it is the piece that turns a pile of pact files into an actual safety net.

Why This Matters More When an AI Writes Both Sides

I have been building toward this across the whole specs-driven development series, and contract testing is where the frontend-backend seam finally gets a sensor.

When a human builds the backend endpoint in one sprint and the frontend team wires it up in the next, the contract is negotiated by conversation, a Slack thread, an OpenAPI file someone maintains by hand. It drifts, but slowly, and a human on each side usually notices. When an AI agent scaffolds the Spring controller and the Angular service in a single session, the agent is optimizing for one thing: making the feature work right now, in this conversation. It will happily rename a field on the DTO and forget to update the consumer that reads it — or, worse, update the consumer to read a field the DTO no longer sends and never notice, because the two edits happened minutes apart and neither one broke a test the agent could see.

This is the exact failure mode the ArchUnit post flagged and could not fully close. ArchUnit can enforce that the controller returns a DTO and never a JPA entity — but it cannot enforce that the DTO the backend returns is the DTO the Angular client expects, because the Angular client is not in its classpath. Contract testing is the sensor that spans both classpaths. It is the only thing in this whole series that verifies the agreement between the two artifacts the agent produced, rather than the internals of either one.

The framing is the same as always. A line in copilot-instructions.md that says “keep the Angular interfaces in sync with the Spring DTOs” is an inferential feedforward guide: it nudges the model, and the model can ignore it under pressure. A contract test is a computational feedback sensor: it fails the build when the two sides disagree, whether the disagreement was written by a person, a pair, or a prompt.

If you are driving this work with an AI agent, the harness pays off most when the agent generates the contract tests as part of the feature, not as an afterthought. Sample prompts I use:

1
2
3
4
5
6
7
8
9
10
11
12
13
We are adding a GET /api/courses endpoint to the Spring Boot backend
(crud-spring) and consuming it in the Angular app (crud-angular).

First, write the Pact consumer test in crud-angular that captures exactly
the fields the Angular CoursesService reads from the response — no more.
Use MatchersV3 type matchers (integer, string, eachLike), not literal
values. Generate the pact into crud-angular/pacts.

Then write the Pact JVM provider verification test in crud-spring that
loads that pact with @PactFolder and verifies it against a real server
booted with @SpringBootTest(RANDOM_PORT) and HttpTestTarget (MockMvcTestTarget
is binary-incompatible with Spring Boot 4). Add a @State method for every
provider state the consumer test declares.

And a follow-up prompt that turns the whole thing into a gate the agent must satisfy:

1
2
3
4
5
Treat the contract as the source of truth. If you change any field on
CourseDTO, update the Angular interface AND the consumer pact test in the
same change, then run the producer verification and show me it passes.
Do not consider the task done until `mvn -Dtest=CourseContractVerificationTest
test` is green.

That second prompt is the important one. It converts “please keep them in sync” from a hope into an acceptance criterion the agent has to prove, using the same test the CI pipeline will run.

Running Contract Verification in GitHub Actions

Because the consumer test is a Node test and the producer test is a JUnit test, both fit into pipelines you almost certainly already have. The consumer job runs the pact-generating test and publishes the pact; the producer job verifies it. In a single-repo setup like the CRUD project, the sequence is straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
contract-consumer:
  name: Contract - Generate Consumer Pact (Angular)
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v7
    - name: Setup Node.js
      uses: actions/setup-node@v6
      with:
        node-version: 22
        cache: 'npm'
        cache-dependency-path: crud-angular/package-lock.json
    - name: Install dependencies
      run: npm ci
      working-directory: crud-angular
    - name: Generate consumer pact
      run: npm run test:pact
      working-directory: crud-angular
    - name: Upload pact artifact
      uses: actions/upload-artifact@v7
      with:
        name: pacts
        path: crud-angular/pacts

contract-provider:
  name: Contract - Verify Provider Against Pact (Spring)
  runs-on: ubuntu-latest
  needs: contract-consumer
  steps:
    - uses: actions/checkout@v7
    - name: Set up JDK 25
      uses: actions/setup-java@v5
      with:
        java-version: '25'
        distribution: 'temurin'
        cache: maven
    - name: Download pact artifact
      uses: actions/download-artifact@v7
      with:
        name: pacts
        path: crud-angular/pacts
    - name: Verify pact
      run: mvn -B test -Dtest=CourseContractVerificationTest --file crud-spring/pom.xml

The provider job needs the consumer job, so verification always runs against the freshly generated pact. A broken contract fails the JUnit test, which fails the job, which turns the pull request red — the same gating mechanism as every other test in the build, no special infrastructure required. When you graduate to separate repositories, this becomes a publish-to-broker step on the consumer side and a pull-from-broker verification on the producer side, wired together with can-i-deploy before each deploy.

Keeping the Contracts Trustworthy

A contract suite decays the same way any test suite decays — through noise and neglect. A few practices keep it honest:

Assert only what the consumer uses. The strongest temptation is to make the contract mirror the full response. Resist it. Every field you pin that the Angular app does not actually read is a false coupling that will break on a harmless backend change and train the team to ignore failures. The contract is the consumer’s needs, not the producer’s output.

Use type matchers, not literal values. string('Angular') in the examples above matches any string, not the literal “Angular.” A contract full of exact values is really an end-to-end test wearing a contract’s clothes, and it will flake on every data change. Match structure and type; leave values to your other tests.

Make provider states cheap and explicit. Every given(...) on the consumer needs a matching @State on the producer. Keep those state methods small — a mock or a minimal fixture — and name them for the precondition, not the implementation. They are the seam where the producer makes the contract reproducible, and they should read like the sentence the consumer wrote.

Verify on the producer’s build, in the producer’s repo. The value of contract testing collapses if the team that changes the field is not the team that sees the failure. Run producer verification where the backend changes are made, so the feedback lands on the person who can act on it.

Exclude contract tests from mutation runs. If you also run PIT mutation testing, exclude the contract package (com.loiane.contract.*) from it. Like ArchUnit rules, contract verification replays an external pact rather than exercising your own logic, so it kills no mutants and only slows the mutation run down. Each sensor has its own job; do not make one grade the other.

Conclusion

An OpenAPI file tells you what the API was intended to be. Your TypeScript interfaces tell you what the frontend hopes it receives. Neither one is checked against the other, which is why the boundary between a Spring backend and an Angular frontend is the one place a green build can still ship a broken feature. Contract testing closes that gap by making the agreement itself executable, generated from real consumer code and replayed against the real producer, so drift becomes a red check instead of a support ticket.

A reasonable adoption path:

  1. Pick your busiest, most-depended-on endpoint. Write one Pact consumer test on the Angular side that captures only the fields it uses. Generate the pact.
  2. Add the Pact JVM provider dependency and one verification test on the Spring side. Load the pact with @PactFolder, add a @State for each provider state, and watch it pass.
  3. Break it on purpose. Rename a field on the DTO and confirm the producer verification goes red with a field-level diff. That failure is the entire value; see it once.
  4. Wire both tests into CI as separate jobs, with the producer job depending on the consumer job’s pact artifact.
  5. When the consumer and producer split into separate repositories, introduce a Pact Broker and gate deploys with can-i-deploy.
  6. Expand one endpoint at a time. Contract testing rewards incremental adoption — every endpoint you cover is one more boundary the build now watches.

Where PIT told you whether your tests would catch a behavioral bug, and ArchUnit told you whether your codebase still had the shape you designed, contract testing tells you whether two independently deployed systems still agree on how they talk. It is the third sensor on the same harness: one for what the code does, one for how the code is built, and now one for how your services keep their promises to each other. In a world where an agent can generate both sides of an API before lunch, that last sensor is the difference between “the feature works” and “the feature works together.”

References

Happy Coding!

This post is licensed under CC BY 4.0 by the author.
This site uses cookies. Please choose whether to accept analytics cookies. Privacy Policy