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:
- Will my API changes break other services that depend on it? (Contract testing)
- 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
- Spring Boot testing fundamentals (Testing Guide)
- REST API development (REST API Best Practices)
- Basic understanding of microservice architecture
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
usernametouserName - 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 Type | What It Verifies | Catches |
|---|---|---|
| Unit Tests | Individual methods work | Logic bugs |
| Integration Tests | Components work together | Wiring bugs |
| Contract Tests | APIs match consumer expectations | Breaking API changes |
| Performance Tests | System handles load | Scalability 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:
- Reads your contract files from
src/test/resources/contracts/ - Generates test classes in
target/generated-test-sources/ - Runs those tests against your base test class
- 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=falseGenerated 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
| Tool | Language | Strengths | Best For |
|---|---|---|---|
| Gatling | Java/Scala | Code-based, great reports | API load testing |
| JMeter | GUI/XML | Visual test builder | Quick ad-hoc tests |
| k6 | JavaScript | Developer-friendly | Modern API testing |
| Locust | Python | Simple, distributed | Python 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.html7. 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:
| Metric | What It Means | Good Target |
|---|---|---|
| Response Time (p50) | Median response time | < 200ms |
| Response Time (p95) | 95th percentile | < 500ms |
| Response Time (p99) | 99th percentile | < 1000ms |
| Throughput | Requests per second | Depends on requirements |
| Error Rate | Percentage 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-serviceRunning 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 Type | When to Add | Run Frequency | Cost |
|---|---|---|---|
| Unit Tests | Every new class/method | Every commit | Low |
| Integration Tests | Database/API interactions | Every commit | Medium |
| Contract Tests | Service-to-service APIs | Every PR | Medium |
| Architecture Tests | Once per project | Every commit | Low |
| WireMock Tests | External API integrations | Every commit | Low |
| Performance Tests | Before major releases | Weekly/on-demand | High |
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
Related Spring Boot Posts
- Testing Guide with JUnit & Mockito — the foundation this post builds on
- REST API Best Practices — design the APIs you're testing
- Advanced JPA Optimization — optimize the queries your performance tests reveal
Foundation Posts
- What is REST API? Complete Guide — understand the API contracts you're testing
- HTTP Protocol Complete Guide — the protocol underlying all your API tests
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.