Back to blog

Advanced Testing in Spring Boot: Contract & Performance Testing

javaspring-boottestingperformancebackend
Advanced Testing in Spring Boot: Contract & Performance Testing

Introduction

Unit tests, integration tests, and MockMvc tests give you confidence that your code works — but they don't answer two critical questions:

  1. Will my API changes break other services that depend on it? (Contract testing)
  2. Will my application perform well under real-world load? (Performance testing)

In production systems — especially microservices — these gaps cause the most painful failures. A passing test suite means nothing if a downstream service breaks because you renamed a JSON field, or if your API collapses under 1,000 concurrent users.

What You'll Learn

✅ Understand why contract and performance tests matter beyond unit/integration tests
✅ Set up Spring Cloud Contract for consumer-driven contract testing
✅ Write contracts that verify API compatibility between services
✅ Generate client stubs automatically from contracts
✅ Run performance tests with Gatling to simulate real-world load
✅ Identify bottlenecks with response time analysis and throughput metrics
✅ Use advanced testing patterns: parameterized tests, Awaitility, ArchUnit
✅ Integrate contract and performance tests into CI/CD pipelines

Prerequisites


1. Why Contract Testing?

The Problem: Integration Breakage

Imagine two services:

  • Order Service calls User Service to get user details
  • User Service changes its response — renames username to userName
  • All User Service tests pass (it's internally consistent)
  • Order Service breaks in production because it expected username

The Solution: Consumer-Driven Contracts

Contract testing creates an agreement between services: the consumer (Order Service) defines what it expects, and the producer (User Service) verifies it can fulfill that contract.

If User Service renames a field, the contract test fails before deployment — not in production.

Testing TypeWhat It VerifiesCatches
Unit TestsIndividual methods workLogic bugs
Integration TestsComponents work togetherWiring bugs
Contract TestsAPIs match consumer expectationsBreaking API changes
Performance TestsSystem handles loadScalability issues

2. Setting Up Spring Cloud Contract

Adding Dependencies (Producer Side)

The producer (the service that provides the API) needs Spring Cloud Contract:

<!-- pom.xml -->
<properties>
    <spring-cloud.version>2024.0.0</spring-cloud.version>
</properties>
 
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
 
<dependencies>
    <!-- Spring Cloud Contract Verifier -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-contract-verifier</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
 
<build>
    <plugins>
        <!-- Spring Cloud Contract Maven Plugin -->
        <plugin>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-contract-maven-plugin</artifactId>
            <extensions>true</extensions>
            <configuration>
                <!-- Base class for generated tests -->
                <baseClassForTests>
                    com.example.userservice.contracts.BaseContractTest
                </baseClassForTests>
                <testFramework>JUNIT5</testFramework>
            </configuration>
        </plugin>
    </plugins>
</build>

Adding Dependencies (Consumer Side)

The consumer (the service that calls the API) uses contract stubs:

<dependencies>
    <!-- Spring Cloud Contract Stub Runner -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3. Writing Your First Contract

Contract Definition

Contracts are written in Groovy DSL or YAML. Place them in src/test/resources/contracts/ on the producer side.

src/test/resources/contracts/user/shouldReturnUserById.groovy:

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    description "Should return user by ID"
 
    request {
        method GET()
        url "/api/users/1"
        headers {
            contentType(applicationJson())
        }
    }
 
    response {
        status OK()
        headers {
            contentType(applicationJson())
        }
        body(
            id: 1,
            username: "johndoe",
            email: "john@example.com",
            firstName: "John",
            lastName: "Doe",
            active: true
        )
        bodyMatchers {
            jsonPath('$.id', byType())
            jsonPath('$.username', byRegex('[a-z0-9]+'))
            jsonPath('$.email', byRegex('[\\w.]+@[\\w.]+'))
        }
    }
}

YAML Alternative

If you prefer YAML over Groovy:

src/test/resources/contracts/user/shouldReturnUserById.yml:

description: "Should return user by ID"
request:
  method: GET
  url: /api/users/1
  headers:
    Content-Type: application/json
response:
  status: 200
  headers:
    Content-Type: application/json
  body:
    id: 1
    username: "johndoe"
    email: "john@example.com"
    firstName: "John"
    lastName: "Doe"
    active: true
  matchers:
    body:
      - path: $.id
        type: by_type
      - path: $.username
        type: by_regex
        value: "[a-z0-9]+"

More Contract Examples

Create user contract:

src/test/resources/contracts/user/shouldCreateUser.groovy:

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    description "Should create a new user"
 
    request {
        method POST()
        url "/api/users"
        headers {
            contentType(applicationJson())
        }
        body(
            username: "janedoe",
            email: "jane@example.com",
            password: "securePass123",
            firstName: "Jane",
            lastName: "Doe"
        )
    }
 
    response {
        status CREATED()
        headers {
            contentType(applicationJson())
        }
        body(
            id: $(anyPositiveInt()),
            username: "janedoe",
            email: "jane@example.com",
            firstName: "Jane",
            lastName: "Doe",
            active: true
        )
    }
}

Error response contract:

src/test/resources/contracts/user/shouldReturn404ForMissingUser.groovy:

import org.springframework.cloud.contract.spec.Contract
 
Contract.make {
    description "Should return 404 when user not found"
 
    request {
        method GET()
        url "/api/users/999"
        headers {
            contentType(applicationJson())
        }
    }
 
    response {
        status NOT_FOUND()
        headers {
            contentType(applicationJson())
        }
        body(
            error: "Not Found",
            message: $(regex("User .* not found")),
            status: 404
        )
    }
}

4. Implementing the Producer (Verifier) Side

Base Test Class

Spring Cloud Contract generates test classes from your contracts. You provide a base class that sets up the test context.

package com.example.userservice.contracts;
 
import com.example.userservice.controller.UserController;
import com.example.userservice.dto.UserResponse;
import com.example.userservice.exception.ResourceNotFoundException;
import com.example.userservice.service.UserService;
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
 
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
 
@SpringBootTest
public abstract class BaseContractTest {
 
    @Autowired
    private UserController userController;
 
    @MockBean
    private UserService userService;
 
    @BeforeEach
    void setup() {
        RestAssuredMockMvc.standaloneSetup(userController);
 
        // Setup mock responses matching contract expectations
        UserResponse john = new UserResponse(
            1L, "johndoe", "john@example.com",
            "John", "Doe", true
        );
 
        when(userService.getUserById(1L)).thenReturn(john);
        when(userService.getUserById(999L))
            .thenThrow(new ResourceNotFoundException(
                "User with id 999 not found"));
 
        UserResponse jane = new UserResponse(
            2L, "janedoe", "jane@example.com",
            "Jane", "Doe", true
        );
 
        when(userService.createUser(any())).thenReturn(jane);
    }
}

Running Contract Verification

When you run mvn test, Spring Cloud Contract:

  1. Reads your contract files from src/test/resources/contracts/
  2. Generates test classes in target/generated-test-sources/
  3. Runs those tests against your base test class
  4. Also generates client stubs (a WireMock-based JAR) in target/stubs/
# Run contract verification
mvn clean test
 
# Install stubs to local Maven repository (for consumer testing)
mvn clean install -DskipTests=false

Generated test (auto-created, don't edit):

// target/generated-test-sources/.../ContractVerifierTest.java
public class UserTest extends BaseContractTest {
 
    @Test
    public void validate_shouldReturnUserById() throws Exception {
        // Given:
        MockMvcRequestSpecification request = given()
            .header("Content-Type", "application/json");
 
        // When:
        ResponseOptions response = given().spec(request)
            .get("/api/users/1");
 
        // Then:
        assertThat(response.statusCode()).isEqualTo(200);
        assertThat(response.header("Content-Type"))
            .contains("application/json");
 
        DocumentContext parsedJson = JsonPath.parse(
            response.getBody().asString());
        assertThatJson(parsedJson).field("['username']")
            .isEqualTo("johndoe");
        // ... more assertions generated from contract
    }
}

5. Implementing the Consumer (Stub Runner) Side

Using Stubs in Consumer Tests

The consumer (Order Service) tests against the generated stubs instead of calling the real User Service. This is fast, reliable, and doesn't require the producer to be running.

package com.example.orderservice;
 
import com.example.orderservice.client.UserClient;
import com.example.orderservice.dto.UserDto;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cloud.contract.stubrunner.spring
    .AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring
    .StubRunnerProperties;
 
import static org.assertj.core.api.Assertions.assertThat;
 
@SpringBootTest
@AutoConfigureStubRunner(
    ids = "com.example:user-service:+:stubs:8081",
    stubsMode = StubRunnerProperties.StubsMode.LOCAL
)
class UserClientContractTest {
 
    @Autowired
    private UserClient userClient;
 
    @Test
    void shouldGetUserById() {
        // The stub runner starts a WireMock server on port 8081
        // pre-loaded with stubs generated from contracts
 
        UserDto user = userClient.getUserById(1L);
 
        assertThat(user).isNotNull();
        assertThat(user.getUsername()).isEqualTo("johndoe");
        assertThat(user.getEmail()).isEqualTo("john@example.com");
        assertThat(user.isActive()).isTrue();
    }
 
    @Test
    void shouldHandle404ForMissingUser() {
        assertThatThrownBy(() -> userClient.getUserById(999L))
            .isInstanceOf(UserNotFoundException.class);
    }
}

The UserClient (RestClient-based)

@Component
public class UserClient {
 
    private final RestClient restClient;
 
    public UserClient(
            @Value("${user-service.url}") String baseUrl) {
        this.restClient = RestClient.builder()
            .baseUrl(baseUrl)
            .build();
    }
 
    public UserDto getUserById(Long id) {
        return restClient.get()
            .uri("/api/users/{id}", id)
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError,
                (request, response) -> {
                    if (response.getStatusCode() ==
                            HttpStatus.NOT_FOUND) {
                        throw new UserNotFoundException(
                            "User " + id + " not found");
                    }
                })
            .body(UserDto.class);
    }
}

Contract Testing Workflow

The key insight: If the producer changes its API in a way that breaks the contract, the producer's build fails — not the consumer's. This catches breaking changes at the source.


6. Performance Testing with Gatling

Why Gatling?

Gatling is a powerful load testing tool that:

  • Writes tests as code (Scala/Java DSL) — version-controlled and reviewable
  • Generates detailed HTML reports with response time percentiles
  • Handles thousands of concurrent users efficiently
  • Integrates with Maven/Gradle for CI/CD
ToolLanguageStrengthsBest For
GatlingJava/ScalaCode-based, great reportsAPI load testing
JMeterGUI/XMLVisual test builderQuick ad-hoc tests
k6JavaScriptDeveloper-friendlyModern API testing
LocustPythonSimple, distributedPython teams

Adding Gatling Dependencies

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>io.gatling.highcharts</groupId>
        <artifactId>gatling-charts-highcharts</artifactId>
        <version>3.11.5</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.gatling</groupId>
        <artifactId>gatling-app</artifactId>
        <version>3.11.5</version>
        <scope>test</scope>
    </dependency>
</dependencies>
 
<build>
    <plugins>
        <plugin>
            <groupId>io.gatling</groupId>
            <artifactId>gatling-maven-plugin</artifactId>
            <version>4.9.6</version>
        </plugin>
    </plugins>
</build>

Your First Gatling Simulation

src/test/java/simulations/UserApiSimulation.java:

package simulations;
 
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
 
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
 
public class UserApiSimulation extends Simulation {
 
    // HTTP Configuration
    HttpProtocolBuilder httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json");
 
    // Scenario: Browse users
    ScenarioBuilder browseUsers = scenario("Browse Users")
        .exec(
            http("Get All Users")
                .get("/api/users")
                .check(status().is(200))
                .check(jsonPath("$[*]").count().gte(1))
        )
        .pause(1, 3) // Think time: 1-3 seconds
        .exec(
            http("Get User By ID")
                .get("/api/users/1")
                .check(status().is(200))
                .check(jsonPath("$.username").exists())
        );
 
    // Load profile
    {
        setUp(
            browseUsers.injectOpen(
                // Ramp up to 50 users over 30 seconds
                rampUsers(50).during(30),
                // Stay at 50 users for 60 seconds
                constantUsersPerSec(50).during(60),
                // Ramp up to 200 users over 30 seconds
                rampUsers(200).during(30)
            )
        ).protocols(httpProtocol)
         .assertions(
             global().responseTime().max().lt(2000),
             global().successfulRequests().percent().gt(99.0)
         );
    }
}

Running Gatling Tests

# Start your Spring Boot application first
mvn spring-boot:run
 
# In another terminal, run Gatling
mvn gatling:test
 
# Reports are generated at:
# target/gatling/userapi-simulation-*/index.html

7. Real-World Performance Test Scenarios

Scenario 1: User Registration Load Test

public class RegistrationLoadSimulation extends Simulation {
 
    HttpProtocolBuilder httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json");
 
    // Feeder: generate unique user data for each request
    Iterator<Map<String, Object>> userFeeder =
        Stream.generate(() -> {
            String uuid = UUID.randomUUID().toString()
                .substring(0, 8);
            return Map.<String, Object>of(
                "username", "user_" + uuid,
                "email", uuid + "@loadtest.com",
                "password", "LoadTest123!",
                "firstName", "Load",
                "lastName", "Tester"
            );
        }).iterator();
 
    ScenarioBuilder registerUsers = scenario("User Registration")
        .feed(userFeeder)
        .exec(
            http("Register User")
                .post("/api/auth/register")
                .body(StringBody("""
                    {
                        "username": "#{username}",
                        "email": "#{email}",
                        "password": "#{password}",
                        "firstName": "#{firstName}",
                        "lastName": "#{lastName}"
                    }
                    """))
                .check(status().is(201))
                .check(jsonPath("$.id").saveAs("userId"))
        )
        .pause(1)
        .exec(
            http("Login")
                .post("/api/auth/login")
                .body(StringBody("""
                    {
                        "username": "#{username}",
                        "password": "#{password}"
                    }
                    """))
                .check(status().is(200))
                .check(jsonPath("$.token").saveAs("authToken"))
        )
        .pause(1)
        .exec(
            http("Get Profile (Authenticated)")
                .get("/api/users/#{userId}")
                .header("Authorization", "Bearer #{authToken}")
                .check(status().is(200))
        );
 
    {
        setUp(
            registerUsers.injectOpen(
                // Start slow: 5 users/sec for 10s
                constantUsersPerSec(5).during(10),
                // Ramp to 20 users/sec over 30s
                rampUsersPerSec(5).to(20).during(30),
                // Sustain 20 users/sec for 60s
                constantUsersPerSec(20).during(60)
            )
        ).protocols(httpProtocol)
         .assertions(
             global().responseTime().percentile3().lt(1500),
             global().failedRequests().percent().lt(1.0),
             forAll().responseTime().mean().lt(500)
         );
    }
}

Scenario 2: Mixed Workload (Read-Heavy)

Most real applications have a read-heavy workload — 80% reads, 20% writes:

public class MixedWorkloadSimulation extends Simulation {
 
    HttpProtocolBuilder httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json");
 
    // Authenticate first, then reuse the token
    ChainBuilder authenticate = exec(
        http("Login")
            .post("/api/auth/login")
            .body(StringBody("""
                {
                    "username": "admin",
                    "password": "admin123"
                }
                """))
            .check(status().is(200))
            .check(jsonPath("$.token").saveAs("token"))
    );
 
    // Read operations (80% of traffic)
    ChainBuilder browseOperations = exec(
        http("List Users")
            .get("/api/users?page=0&size=20")
            .header("Authorization", "Bearer #{token}")
            .check(status().is(200))
    )
    .pause(1, 2)
    .exec(
        http("Search Users")
            .get("/api/users/search?name=john")
            .header("Authorization", "Bearer #{token}")
            .check(status().is(200))
    )
    .pause(1, 2)
    .exec(
        http("Get User Detail")
            .get("/api/users/1")
            .header("Authorization", "Bearer #{token}")
            .check(status().is(200))
    );
 
    // Write operations (20% of traffic)
    Iterator<Map<String, Object>> updateFeeder =
        Stream.generate(() -> Map.<String, Object>of(
            "firstName", "Updated_" +
                ThreadLocalRandom.current().nextInt(1000)
        )).iterator();
 
    ChainBuilder writeOperations = feed(updateFeeder)
        .exec(
            http("Update User")
                .put("/api/users/1")
                .header("Authorization", "Bearer #{token}")
                .body(StringBody("""
                    { "firstName": "#{firstName}" }
                    """))
                .check(status().is(200))
        );
 
    // Scenarios
    ScenarioBuilder readers = scenario("Readers")
        .exec(authenticate)
        .repeat(10).on(browseOperations);
 
    ScenarioBuilder writers = scenario("Writers")
        .exec(authenticate)
        .repeat(3).on(writeOperations);
 
    {
        setUp(
            // 80% readers, 20% writers
            readers.injectOpen(
                rampUsers(80).during(30),
                constantUsersPerSec(80).during(120)
            ),
            writers.injectOpen(
                rampUsers(20).during(30),
                constantUsersPerSec(20).during(120)
            )
        ).protocols(httpProtocol)
         .assertions(
             global().responseTime().percentile3().lt(2000),
             global().successfulRequests().percent().gt(99.0)
         );
    }
}

Understanding Gatling Reports

After running a simulation, Gatling generates an HTML report with:

MetricWhat It MeansGood Target
Response Time (p50)Median response time< 200ms
Response Time (p95)95th percentile< 500ms
Response Time (p99)99th percentile< 1000ms
ThroughputRequests per secondDepends on requirements
Error RatePercentage of failed requests< 1%

Tip: Focus on p95 and p99 — not average. Averages hide outliers. If your p50 is 100ms but p99 is 5s, some users are having a terrible experience.


8. Advanced Testing Patterns

Beyond contract and performance testing, these patterns make your test suite more robust.

Parameterized Tests

Test the same logic with multiple inputs — cleaner than writing separate tests:

@ParameterizedTest(name = "Email {0} should be {1}")
@CsvSource({
    "john@example.com, true",
    "jane@test.org, true",
    "invalid-email, false",
    "'', false",
    "missing@, false",
    "@nodomain.com, false",
    "spaces in@email.com, false"
})
void shouldValidateEmail(String email, boolean expected) {
    assertThat(validator.isValidEmail(email))
        .isEqualTo(expected);
}
 
@ParameterizedTest(name = "Username ''{0}'' should be {1}")
@CsvSource({
    "johndoe, true",
    "jane_doe, true",
    "ab, false",       // Too short (min 3 chars)
    "a, false",
    "'', false",
    "valid123, true",
    "has spaces, false",
    "special!chars, false"
})
void shouldValidateUsername(String username, boolean expected) {
    assertThat(validator.isValidUsername(username))
        .isEqualTo(expected);
}

Method Source for Complex Parameters

@ParameterizedTest
@MethodSource("provideUsersForValidation")
void shouldValidateUserRegistration(
        UserRegistrationDto dto, boolean expectedValid,
        String expectedError) {
 
    ValidationResult result = validator.validate(dto);
 
    assertThat(result.isValid()).isEqualTo(expectedValid);
    if (!expectedValid) {
        assertThat(result.getErrors())
            .contains(expectedError);
    }
}
 
private static Stream<Arguments> provideUsersForValidation() {
    return Stream.of(
        Arguments.of(
            new UserRegistrationDto("john", "john@test.com",
                "Pass123!"),
            true, null),
        Arguments.of(
            new UserRegistrationDto("", "john@test.com",
                "Pass123!"),
            false, "Username is required"),
        Arguments.of(
            new UserRegistrationDto("john", "invalid",
                "Pass123!"),
            false, "Invalid email format"),
        Arguments.of(
            new UserRegistrationDto("john", "john@test.com",
                "weak"),
            false, "Password too weak")
    );
}

Testing Async Operations with Awaitility

Async code needs special handling — never use Thread.sleep():

import org.awaitility.Awaitility;
 
@Test
void shouldProcessOrderAsynchronously() {
    // Given
    OrderRequest request = new OrderRequest(
        "PROD-001", 2, 29.99);
 
    // When - triggers async processing
    orderService.submitOrder(request);
 
    // Then - wait for async result
    Awaitility.await()
        .atMost(Duration.ofSeconds(10))
        .pollInterval(Duration.ofMillis(500))
        .untilAsserted(() -> {
            Order order = orderRepository
                .findByProductId("PROD-001")
                .orElseThrow();
 
            assertThat(order.getStatus())
                .isEqualTo(OrderStatus.PROCESSED);
            assertThat(order.getTotalPrice())
                .isEqualByComparingTo(
                    new BigDecimal("59.98"));
        });
}
 
@Test
void shouldSendNotificationAfterUserRegistration() {
    // When
    userService.register(new RegisterDto(
        "john", "john@test.com", "Pass123!"));
 
    // Then - verify async notification was sent
    Awaitility.await()
        .atMost(Duration.ofSeconds(5))
        .untilAsserted(() ->
            verify(notificationService, times(1))
                .sendWelcomeEmail("john@test.com")
        );
}

Architecture Tests with ArchUnit

Enforce architectural rules automatically — no manual code reviews needed:

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
 
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.*;
 
@AnalyzeClasses(
    packages = "com.example.userservice",
    importOptions = ImportOption.DoNotIncludeTests.class
)
class ArchitectureTest {
 
    @ArchTest
    static final ArchRule layeredArchitecture =
        layeredArchitecture()
            .consideringAllDependencies()
            .layer("Controller").definedBy("..controller..")
            .layer("Service").definedBy("..service..")
            .layer("Repository").definedBy("..repository..")
            .layer("DTO").definedBy("..dto..")
 
            .whereLayer("Controller")
                .mayNotBeAccessedByAnyLayer()
            .whereLayer("Service")
                .mayOnlyBeAccessedByLayers(
                    "Controller", "Service")
            .whereLayer("Repository")
                .mayOnlyBeAccessedByLayers("Service");
 
    @ArchTest
    static final ArchRule controllersShouldNotAccessRepositories =
        noClasses()
            .that().resideInAPackage("..controller..")
            .should().accessClassesThat()
                .resideInAPackage("..repository..")
            .because("Controllers should use services, " +
                     "not repositories directly");
 
    @ArchTest
    static final ArchRule servicesShouldBeAnnotated =
        classes()
            .that().resideInAPackage("..service..")
            .and().haveSimpleNameEndingWith("Service")
            .should().beAnnotatedWith(
                org.springframework.stereotype.Service.class)
            .because("Service classes must use @Service");
 
    @ArchTest
    static final ArchRule noFieldInjection =
        noFields()
            .should().beAnnotatedWith(
                org.springframework.beans.factory.annotation
                    .Autowired.class)
            .because("Use constructor injection, " +
                     "not field injection");
}

Testing with WireMock for External APIs

When your service calls external APIs, use WireMock to simulate them:

import com.github.tomakehurst.wiremock.junit5
    .WireMockExtension;
 
import static com.github.tomakehurst.wiremock.client
    .WireMock.*;
 
@SpringBootTest
@ExtendWith(WireMockExtension.class)
class PaymentServiceTest {
 
    @RegisterExtension
    static WireMockExtension wireMock =
        WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort())
            .build();
 
    @DynamicPropertySource
    static void configureProperties(
            DynamicPropertyRegistry registry) {
        registry.add("payment-gateway.url",
            wireMock::baseUrl);
    }
 
    @Autowired
    private PaymentService paymentService;
 
    @Test
    void shouldProcessPaymentSuccessfully() {
        // Stub the external payment API
        wireMock.stubFor(
            post(urlEqualTo("/v1/charges"))
                .withRequestBody(matchingJsonPath(
                    "$.amount", equalTo("2999")))
                .willReturn(aResponse()
                    .withStatus(200)
                    .withHeader("Content-Type",
                        "application/json")
                    .withBody("""
                        {
                            "id": "ch_test_123",
                            "status": "succeeded",
                            "amount": 2999
                        }
                        """))
        );
 
        // Test
        PaymentResult result = paymentService.charge(
            new PaymentRequest("tok_visa", 2999));
 
        assertThat(result.isSuccessful()).isTrue();
        assertThat(result.getChargeId())
            .isEqualTo("ch_test_123");
 
        // Verify the external API was called correctly
        wireMock.verify(
            postRequestedFor(urlEqualTo("/v1/charges"))
                .withRequestBody(matchingJsonPath(
                    "$.currency", equalTo("usd")))
        );
    }
 
    @Test
    void shouldHandlePaymentGatewayTimeout() {
        wireMock.stubFor(
            post(urlEqualTo("/v1/charges"))
                .willReturn(aResponse()
                    .withFixedDelay(5000) // 5 second delay
                    .withStatus(200))
        );
 
        assertThatThrownBy(() ->
            paymentService.charge(
                new PaymentRequest("tok_visa", 2999))
        ).isInstanceOf(PaymentTimeoutException.class);
    }
 
    @Test
    void shouldRetryOnGatewayError() {
        // First call: 503 error
        wireMock.stubFor(
            post(urlEqualTo("/v1/charges"))
                .inScenario("retry")
                .whenScenarioStateIs("Started")
                .willReturn(aResponse().withStatus(503))
                .willSetStateTo("retrying")
        );
 
        // Second call: success
        wireMock.stubFor(
            post(urlEqualTo("/v1/charges"))
                .inScenario("retry")
                .whenScenarioStateIs("retrying")
                .willReturn(aResponse()
                    .withStatus(200)
                    .withBody("""
                        {
                            "id": "ch_retry_456",
                            "status": "succeeded",
                            "amount": 2999
                        }
                        """))
        );
 
        PaymentResult result = paymentService.charge(
            new PaymentRequest("tok_visa", 2999));
 
        assertThat(result.isSuccessful()).isTrue();
 
        // Verify it was called twice (original + 1 retry)
        wireMock.verify(2,
            postRequestedFor(urlEqualTo("/v1/charges")));
    }
}

9. CI/CD Integration

Running Contract Tests in CI

Add contract testing to your GitHub Actions pipeline:

# .github/workflows/contract-tests.yml
name: Contract Tests
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  producer-contract-tests:
    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: Run Contract Tests
        run: mvn clean test -pl user-service
 
      - name: Publish Stubs to Artifact Repository
        run: mvn deploy -pl user-service -DskipTests
        if: github.ref == 'refs/heads/main'
 
  consumer-contract-tests:
    runs-on: ubuntu-latest
    needs: producer-contract-tests
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
 
      - name: Run Consumer Contract Tests
        run: mvn clean test -pl order-service

Running Gatling in CI

# .github/workflows/performance-tests.yml
name: Performance Tests
 
on:
  workflow_dispatch: # Manual trigger
  schedule:
    - cron: '0 2 * * 1' # Every Monday at 2 AM
 
jobs:
  performance-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
 
      - name: Start Application
        run: |
          mvn spring-boot:run \
            -Dspring.datasource.url=jdbc:postgresql://localhost:5432/testdb \
            -Dspring.datasource.username=testuser \
            -Dspring.datasource.password=testpass &
          sleep 30 # Wait for app to start
 
      - name: Run Gatling Tests
        run: mvn gatling:test
 
      - name: Upload Gatling Report
        uses: actions/upload-artifact@v4
        with:
          name: gatling-report
          path: target/gatling/*/
        if: always()

10. Testing Strategy Decision Guide

When to Use Which Test Type

Test Coverage Recommendations

Test TypeWhen to AddRun FrequencyCost
Unit TestsEvery new class/methodEvery commitLow
Integration TestsDatabase/API interactionsEvery commitMedium
Contract TestsService-to-service APIsEvery PRMedium
Architecture TestsOnce per projectEvery commitLow
WireMock TestsExternal API integrationsEvery commitLow
Performance TestsBefore major releasesWeekly/on-demandHigh

Summary and Key Takeaways

Contract tests catch API breakage between services before production — they verify the producer meets consumer expectations
Spring Cloud Contract automates the workflow: write contracts, generate tests on the producer, generate stubs for the consumer
Consumer-driven contracts put the power with the API consumer — the producer must satisfy what consumers actually need
Gatling performance tests simulate real-world load with code-based simulations that integrate into CI/CD
Focus on p95/p99 percentiles — averages hide the worst-case user experience
Parameterized tests reduce duplication — test many inputs with a single test method
Awaitility replaces Thread.sleep() — wait for async conditions without fragile timing
ArchUnit enforces architecture rules as automated tests — no more manual code review for layer violations
WireMock stubs external APIs for reliable, fast tests without real dependencies
Run contract tests per PR, performance tests weekly — match frequency to cost and risk


What's Next?

Now that you can verify API contracts and measure performance, continue building production-ready applications:

Continue the Spring Boot Series

  • Performance Optimization & Profiling: Apply what your performance tests reveal — optimize the bottlenecks Gatling identifies
  • Docker & Kubernetes Deployment: Containerize your app and run performance tests against your production-like environment
  • Monitoring with Actuator & Prometheus: Connect runtime metrics to your Gatling baselines for continuous performance monitoring

Foundation Posts


Part of the Spring Boot Learning Roadmap series

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.