Building an Engineering‑Grade Full‑Link API Test Suite for Spring Boot: From Unit Tests to Production Load Testing
The article explains why many Spring Boot services still crash in production despite passing unit and integration tests, defines a full‑link testing concept that covers code correctness, component collaboration, contract stability, end‑to‑end business flow, and production‑scale performance, and provides a layered, risk‑driven testing architecture with concrete code examples, tooling, and CI/CD integration.
Why "tested" systems still fail in production
Typical incidents show that functional correctness alone is insufficient:
Business logic is correct but cross‑service coordination fails (e.g., a payment gateway returns HTTP 200 with an error code in the body, causing order‑payment mismatch).
Interface works but capacity model is wrong – thread‑pool or DB‑connection‑pool exhaustion leads to response‑time spikes.
Downstream service changes a field name ( price → salePrice) without contract tests, causing batch deserialization failures.
Test environment data size differs from production; a pagination query using offset+limit scans billions of rows and crashes the DB under load.
These cases demonstrate that testing must cover the entire request chain from code to production capacity.
What constitutes full‑link testing
A full‑link test validates the whole request path:
Gateway or load‑balancer entry
Controller validation and authentication
Service orchestration
Database, cache and message‑queue read/write
External HTTP/RPC calls
Asynchronous consumption, compensation tasks, state flow
Logging, metrics, tracing, alerts
Thread‑pool, connection‑pool, lock and GC behavior under high concurrency
The test must answer five questions:
Is the code logic itself correct?
Does component collaboration meet expectations?
Are service‑to‑service protocols stable?
Does the business close the loop under real deployment and call chain?
Is the system stable under near‑production load?
Testing architecture: risk‑layered approach
Each layer focuses on the risks it can catch most cheaply and quickly.
Unit tests – business logic, edge branches, exceptions (milliseconds).
Slice tests – MVC, repository, serialization, config binding (seconds).
Integration tests – DB/Redis/MQ/external dependency collaboration, transaction and cache consistency (seconds‑to‑minutes).
Contract tests – service‑to‑service contract stability, field drift, status‑code changes (seconds).
E2E tests – cross‑service business closure, call‑chain breaks, async flow missing (minutes).
Load & production validation – performance, capacity, stability, disaster recovery, thread‑pool saturation, cascade avalanche (minutes‑to‑hours).
Layer 1 – Unit tests
Unit tests verify core business rules quickly. Example service for order creation:
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
private final IdempotencyService idempotencyService;
private final InventoryGateway inventoryGateway;
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final Clock clock;
@Transactional
public OrderCreateResponse createOrder(CreateOrderCommand command) {
if (!idempotencyService.tryAcquire(command.idempotencyKey())) {
throw new BizException("DUPLICATE_REQUEST", "请勿重复提交");
}
boolean reserved = inventoryGateway.reserve(command.skuId(), command.quantity());
if (!reserved) {
throw new BizException("INSUFFICIENT_STOCK", "库存不足");
}
Order order = Order.create(command.userId(), command.skuId(), command.quantity(), command.unitPrice(), LocalDateTime.now(clock));
orderRepository.save(order);
OutboxEvent event = OutboxEvent.orderCreated(order.getOrderNo(), order.getUserId());
outboxRepository.save(event);
return new OrderCreateResponse(order.getOrderNo(), order.getStatus().name());
}
}Key practices:
Abstract unstable factors (time, random IDs) via Clock, IdGenerator, RandomProvider.
Avoid starting the Spring container; use MockitoExtension instead of @SpringBootTest.
Cover error paths: timeout retries, null handling, concurrent submissions, rollback failures, partial third‑party success.
Layer 2 – Slice tests
Slice tests sit between unit and integration tests. They validate MVC, JSON binding, repository mapping and config loading without a full context.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderApplicationService orderApplicationService;
@Test
void should_return_400_when_quantity_is_invalid() throws Exception {
String requestBody = """
{"skuId":"SKU-10001","quantity":0,"unitPrice":99.00,"idempotencyKey":"idem-001"}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody)
.header("X-User-Id", "1001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").exists());
}
@Test
void should_return_201_when_request_is_valid() throws Exception {
when(orderApplicationService.createOrder(any()))
.thenReturn(new OrderCreateResponse("ORD202606120001", "CREATED"));
String requestBody = """
{"skuId":"SKU-10001","quantity":2,"unitPrice":99.00,"idempotencyKey":"idem-001"}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody)
.header("X-User-Id", "1001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.orderNo").value("ORD202606120001"))
.andExpect(jsonPath("$.data.status").value("CREATED"));
}
}Repository slice example (MyBatis):
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(MybatisPlusConfig.class)
class OrderMapperTest {
@Autowired
private OrderMapper orderMapper;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void should_query_pending_orders_by_page() {
jdbcTemplate.update("""
insert into t_order(order_no, user_id, status, total_amount, created_at)
values ('ORD1', 1001, 'PENDING', 99.00, now()),
('ORD2', 1002, 'PENDING', 199.00, now()),
('ORD3', 1003, 'PAID', 299.00, now())
""");
List<OrderDO> results = orderMapper.selectPendingOrders(2);
assertThat(results).hasSize(2);
assertThat(results).allMatch(it -> "PENDING".equals(it.getStatus()));
}
}Layer 3 – Integration tests
Integration tests run with real dependencies via Testcontainers, avoiding in‑memory substitutes.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("app")
.withUsername("test")
.withPassword("test");
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2")
.withExposedPorts(6379);
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@DynamicPropertySource
static void registerKafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
}Sample integration test validates transaction, cache and MQ behavior:
class OrderFlowIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate testRestTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private ConsumerFactory<String, String> consumerFactory;
@Test
void should_create_order_and_publish_event() {
stringRedisTemplate.opsForValue().set("stock:SKU-10001", "10");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-User-Id", "1001");
String body = """
{"skuId":"SKU-10001","quantity":2,"unitPrice":99.00,"idempotencyKey":"idem-001"}
""";
ResponseEntity<String> response = testRestTemplate.postForEntity("/api/orders", new HttpEntity<>(body, headers), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Integer orderCount = jdbcTemplate.queryForObject("select count(1) from t_order where user_id = ?", Integer.class, 1001L);
assertThat(orderCount).isEqualTo(1);
assertThat(stringRedisTemplate.opsForValue().get("stock:SKU-10001")).isEqualTo("8");
try (Consumer<String, String> consumer = consumerFactory.createConsumer()) {
consumer.subscribe(List.of("order-created"));
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
assertThat(records.count()).isGreaterThan(0);
}
}
@Test
void should_rollback_order_when_inventory_update_fails() {
stringRedisTemplate.delete("stock:SKU-10001");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-User-Id", "1001");
String body = """
{"skuId":"SKU-10001","quantity":2,"unitPrice":99.00,"idempotencyKey":"idem-rollback"}
""";
ResponseEntity<String> response = testRestTemplate.postForEntity("/api/orders", new HttpEntity<>(body, headers), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
Integer orderCount = jdbcTemplate.queryForObject("select count(1) from t_order where user_id = ? and order_no is not null", Integer.class, 1001L);
assertThat(orderCount).isZero();
}
}Layer 4 – External dependency testing
WireMock simulates third‑party services and verifies timeout, rate‑limit and error handling.
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class PaymentGatewayIntegrationTest {
@Autowired
private PaymentGatewayClient paymentGatewayClient;
@Test
void should_parse_success_response() {
stubFor(post(urlEqualTo("/third-party/pay"))
.willReturn(okJson("""
{"code":"SUCCESS","transactionId":"TXN202606120001","message":"ok"}
""")));
PaymentResponse response = paymentGatewayClient.pay(new PaymentRequest("ORD202606120001", new BigDecimal("99.00")));
assertThat(response.code()).isEqualTo("SUCCESS");
assertThat(response.transactionId()).isEqualTo("TXN202606120001");
}
@Test
void should_retry_and_fail_when_gateway_times_out() {
stubFor(post(urlEqualTo("/third-party/pay"))
.willReturn(aResponse().withFixedDelay(4000).withStatus(200)));
assertThatThrownBy(() -> paymentGatewayClient.pay(new PaymentRequest("ORD202606120002", new BigDecimal("99.00"))))
.isInstanceOf(PaymentTimeoutException.class);
}
@Test
void should_trigger_degrade_when_gateway_returns_429() {
stubFor(post(urlEqualTo("/third-party/pay"))
.willReturn(aResponse()
.withStatus(429)
.withHeader("Content-Type", "application/json")
.withBody("""{\"code\":\"TOO_MANY_REQUESTS\",\"message\":\"rate limited\"}""")));
assertThatThrownBy(() -> paymentGatewayClient.pay(new PaymentRequest("ORD202606120003", new BigDecimal("99.00"))))
.isInstanceOf(PaymentDegradeException.class);
}
}Layer 5 – Contract testing
Spring Cloud Contract defines a producer contract; the consumer test is generated automatically.
package contracts.order;
import org.springframework.cloud.contract.spec.Contract;
Contract.make {
description "should return order detail"
request {
method GET()
url "/internal/orders/ORD202606120001"
}
response {
status OK()
headers { contentType(applicationJson()) }
body(
orderNo: "ORD202606120001",
status: "PAID",
totalAmount: 198.00,
items: [[skuId: "SKU-10001", quantity: 2]]
)
}
}Producer test verifies the contract; any incompatible change fails the build.
Layer 6 – End‑to‑end testing
Docker‑Compose (or Testcontainers) can spin up the full stack for realistic scenarios.
version: '3.9'
services:
mysql:
image: mysql:8.0.36
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app
ports: ["3308:3306"]
redis:
image: redis:7.2
ports: ["6380:6379"]
kafka:
image: confluentinc/cp-kafka:7.6.1
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
ports: ["9094:9092"]
app:
build: .
environment:
SPRING_PROFILES_ACTIVE: e2e
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/app
SPRING_DATA_REDIS_HOST: redis
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
depends_on: [mysql, redis, kafka]
ports: ["18080:8080"]E2E test using RestAssured validates the whole business flow:
class OrderE2ETest {
@Test
void should_complete_order_payment_fulfillment_flow() {
RestAssured.baseURI = "http://localhost:18080";
String orderNo = given()
.contentType("application/json")
.header("X-User-Id", "1001")
.body("""
{"skuId":"SKU-10001","quantity":2,"unitPrice":99.00,"idempotencyKey":"idem-e2e-001"}
""")
.when().post("/api/orders")
.then().statusCode(201)
.extract().jsonPath().getString("data.orderNo");
// simulate payment callback
given()
.contentType("application/json")
.body(String.format("""
{"orderNo":"%s","status":"SUCCESS","transactionId":"TXN-E2E-001"}
""", orderNo))
.when().post("/api/payments/callback")
.then().statusCode(200);
// verify final state
await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
given().get("/api/orders/{orderNo}", orderNo)
.then().statusCode(200)
.body("data.status", equalTo("PAID"))
.body("data.fulfillmentStatus", equalTo("READY")));
}
}Layer 7 – Load & capacity testing
A typical four‑step approach:
Baseline test – single‑instance throughput and resource usage.
Component bottleneck test – DB, Redis, MQ, thread‑pool limits.
Mixed‑scenario test – realistic read/write ratios, hot‑data, traffic spikes.
Failure injection – slow SQL, cache miss, downstream timeouts.
Gatling script example:
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class OrderCreateSimulation extends Simulation {
private val httpProtocol = http.baseUrl("http://localhost:8080")
.contentTypeHeader("application/json")
.header("X-User-Id", "1001")
private val createOrderScenario = scenario("create-order")
.exec(http("create-order")
.post("/api/orders")
.body(StringBody("""
{"skuId":"SKU-10001","quantity":1,"unitPrice":99.00,"idempotencyKey":"#{idemKey}"}
""")).asJson
.check(status.is(201)))
setUp(
createOrderScenario.inject(
rampUsersPerSec(10).to(200).during(5.minutes),
constantUsersPerSec(200).during(10.minutes)
)
).protocols(httpProtocol)
}During load tests monitor:
Application metrics – QPS, error rate, P95/P99.
JVM metrics – GC pauses, heap usage.
DB metrics – connection pool, slow queries.
Cache metrics – hit rate, hot keys.
MQ metrics – lag, retries.
Host metrics – CPU, memory, I/O.
Layer 8 – Traffic replay & gray release
Record real production traffic at the gateway, then replay it against a pre‑release environment:
replayer record --source gateway --output traffic.log
replayer replay --input traffic.log --target http://pre-release.example.com
replayer diff --baseline baseline.log --candidate candidate.logGray‑release validation uses SLO‑based guard checks; any breach triggers automated rollback:
releaseGuard:
duration: 10m
checks:
- name: error-rate
threshold: "< 0.5%"
- name: p95-latency
threshold: "< 300ms"
- name: db-slow-sql-count
threshold: "< 20"
- name: mq-consumer-lag
threshold: "< 1000"Test data, environment & isolation
Key isolation principles:
Local dev, CI and pre‑release environments must be independently startable.
CI should spin up dependencies automatically (Testcontainers).
Pre‑release topology should mirror production.
Separate accounts, configs, topics and tables for test vs. prod.
Never mix test traffic with real traffic.
Example test‑data factory for deterministic values:
public final class TestDataFactory {
private TestDataFactory() {}
public static String nextOrderNo() { return "ORD-TEST-" + System.nanoTime(); }
public static String nextIdempotencyKey() { return "IDEM-" + UUID.randomUUID(); }
}CI/CD integration
Testing stages in the pipeline:
Pre‑commit – unit tests, format checks (fast feedback).
PR check – slice, integration, contract tests (block obvious defects).
Nightly – E2E, traffic replay, long‑run load (find complex issues).
Release – regression load, gray validation, rollback checks (release safety).
GitHub Actions simplified example (unit + slice, integration, contract):
name: quality-pipeline
on:
pull_request:
push:
branches: [ main ]
jobs:
unit-and-slice:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- run: ./mvnw -q -Dtest='**/*Test' test
integration:
runs-on: ubuntu-latest
needs: unit-and-slice
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- run: ./mvnw -q -Dtest='**/*IntegrationTest' test
contract:
runs-on: ubuntu-latest
needs: integration
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- run: ./mvnw -q -DskipTests=false verifyQuality gates should include unit & integration pass rates, contract compatibility, critical E2E success, latency/error‑rate thresholds, and static security scans.
High‑concurrency focus
Key aspects to verify under load:
Idempotency – duplicate requests create only one order; duplicate MQ consumption is idempotent.
Rate limiting – gateway limits are enforced; thread‑pool degrades gracefully.
Cache hot‑key protection – no single key causes a hotspot; cache miss does not trigger DB snowball.
Connection‑pool and thread‑pool watermarks – HikariCP contention, Tomcat/Netty thread saturation, rapid resource release on downstream timeout.
Common pitfalls
Treating @SpringBootTest as a universal tool leads to slow, flaky tests.
Testing only happy paths misses timeout, retry, duplicate, async‑order and downstream half‑failure scenarios.
Sharing environment or data between tests creates nondeterministic failures.
Skipping stability verification leaves capacity and resilience unchecked.
Evolution roadmap
Three stages for teams of increasing complexity:
Stage 1 (small projects) – unit tests, @WebMvcTest, basic slice tests, a couple of integration tests.
Stage 2 (mid‑size) – add Testcontainers, WireMock, core E2E scenarios, baseline load test, CI quality gates.
Stage 3 (microservices & high‑concurrency) – contract testing, traffic replay, gray‑release SLO guards, long‑run capacity testing, automated rollback.
Conclusion
Unit tests lock down business‑rule bugs quickly.
Slice tests catch MVC, serialization and SQL‑mapping issues.
Integration tests verify DB, cache, MQ and transaction collaboration.
Contract tests prevent service‑interface drift.
E2E tests ensure critical business‑flow closure.
Load tests and traffic replay assess capacity and stability.
Gray‑release metrics and automated rollback protect the final deployment mile.
When a testing suite can answer “Is the logic correct? Is the collaboration reliable? Are the contracts stable? Is the capacity sufficient? Is the release controllable?” it truly earns the name of an engineering‑grade full‑link testing system.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
