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.

Cloud Architecture
Cloud Architecture
Cloud Architecture
Building an Engineering‑Grade Full‑Link API Test Suite for Spring Boot: From Unit Tests to Production Load Testing

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 ( pricesalePrice) 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.log

Gray‑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 verify

Quality 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CI/CDperformance-testingSpring Bootintegration-testingapi-testingcontract-testingtestcontainers
Cloud Architecture
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.