Spring BootPerformance Testing
Spring Boot

Performance Testing

Performance testing measures how a microservice behaves under load — validating response times, throughput, resource utilisation, and breaking points. It catches regressions before production, validates SLAs, and reveals bottlenecks in the database, connection pools, or service dependencies. The main categories are load testing (expected traffic), stress testing (beyond capacity), spike testing (sudden bursts), and endurance testing (sustained load over time).

Performance Testing Categories

Each category of performance test answers a different question about the system. Running only one type gives an incomplete picture — a system that handles average load gracefully may collapse under a sudden spike or degrade slowly over hours due to a memory leak.
Java
// ── Performance testing categories: ─────────────────────────────────
//
// 1. LOAD TEST — "Does the system handle expected traffic?"
//    Simulate the normal peak load for a sustained period.
//    Goal: verify response times and error rates stay within SLA.
//    Example: 500 concurrent users for 30 minutes.
//    Pass: p99 latency < 500ms, error rate < 0.1%
//
// 2. STRESS TEST — "Where does the system break?"
//    Gradually increase load beyond expected capacity.
//    Goal: find the breaking point and observe failure behaviour.
//    Example: ramp from 100 to 5000 users over 1 hour.
//    Output: maximum throughput before errors spike,
//            recovery behaviour after load drops.
//
// 3. SPIKE TEST — "Can the system survive sudden bursts?"
//    Inject a sudden large load spike, then drop back to normal.
//    Goal: verify auto-scaling, circuit breakers, and queues absorb the burst.
//    Example: idle → 2000 users in 10 seconds → idle again.
//    Pass: error rate < 1% during spike, recovery < 60 seconds.
//
// 4. SOAK / ENDURANCE TEST — "Does the system degrade over time?"
//    Run moderate load for an extended period (hours / days).
//    Goal: detect memory leaks, connection pool exhaustion,
//          thread leaks, and slow disk fill.
//    Example: 200 users for 8 hours.
//    Monitor: heap usage trend, GC pause frequency,
//             DB connection pool size, error rate over time.
//
// 5. VOLUME TEST — "Does performance degrade with data volume?"
//    Run normal load against a database with months/years of data.
//    Goal: verify query plans and indexes hold up at scale.
//    Example: 10M rows in orders table, normal user load.
//    Pass: query times within 10% of baseline with 10k rows.
//
// ── Key performance metrics: ─────────────────────────────────────────
//
// Throughput:       requests per second (RPS) the system sustains
// Latency p50:      median response time (half of requests faster)
// Latency p95:      95% of requests complete within this time
// Latency p99:      99% of requests complete within this time
// Error rate:       % of requests returning 4xx/5xx
// Apdex score:      satisfaction index (01) based on target threshold
// CPU / Memory:     resource utilisation under load
// GC pause time:    JVM garbage collection impact on latency

Baseline Metrics with Spring Boot Actuator

Before running external load tests, establish baseline metrics using Spring Boot Actuator and Micrometer. Actuator exposes JVM, HTTP, database pool, and custom metrics that reveal bottlenecks without any external tooling. These metrics are the ground truth for comparing before-and-after performance changes.
yaml
<!-- pom.xml: -->
<!-- <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency> -->

// ── application.yml — expose all metrics endpoints: ──────────────────
// management:
//   endpoints:
//     web:
//       exposure:
//         include: health, info, metrics, prometheus, httptrace
//   metrics:
//     distribution:
//       percentiles-histogram:
//         http.server.requests: true      # enables p50/p95/p99 histograms
//       percentiles:
//         http.server.requests: 0.5, 0.95, 0.99
//       slo:
//         http.server.requests: 100ms, 250ms, 500ms, 1000ms

// ── Key Actuator metrics endpoints: ──────────────────────────────────
// GET /actuator/metrics
//   → lists all available metric names
//
// GET /actuator/metrics/http.server.requests
//   → HTTP request count, total time, max time
//   → add ?tag=uri:/api/users to filter by endpoint
//   → add ?tag=status:200 to filter by status code
//
// GET /actuator/metrics/jvm.memory.used
// GET /actuator/metrics/jvm.gc.pause
// GET /actuator/metrics/hikaricp.connections.active
// GET /actuator/metrics/hikaricp.connections.pending
// GET /actuator/prometheus
//   → Prometheus scrape endpoint (all metrics in Prometheus format)

// ── Custom performance metric — track slow operations: ────────────────
@Service
@RequiredArgsConstructor
public class OrderService {

    private final MeterRegistry meterRegistry;
    private final OrderRepository orderRepository;

    public OrderResponse placeOrder(CreateOrderRequest request) {
        // Record execution time with a timer:
        return Timer.builder("order.placement.duration")
            .description("Time to place an order end-to-end")
            .tag("channel", request.getChannel())
            .register(meterRegistry)
            .record(() -> doPlaceOrder(request));
    }

    private OrderResponse doPlaceOrder(CreateOrderRequest request) {
        // Track concurrent order placements:
        meterRegistry.gauge("order.placement.active",
            Tags.of("service", "order"),
            this, obj -> activeOrders.get());

        // Count orders by outcome:
        try {
            OrderResponse response = processOrder(request);
            meterRegistry.counter("order.placement.total",
                "status", "success").increment();
            return response;
        } catch (Exception ex) {
            meterRegistry.counter("order.placement.total",
                "status", "failure",
                "reason", ex.getClass().getSimpleName()).increment();
            throw ex;
        }
    }
}

Load Testing with Gatling

Gatling is a Scala-based load testing tool with a fluent DSL and rich HTML reports. It integrates with Maven/Gradle so load tests run as part of the build pipeline. Scenarios are code — they can be parameterised, randomised, and version-controlled alongside the service they test.
scala
<!-- pom.xml — Gatling Maven plugin: -->
<!-- <dependency>
    <groupId>io.gatling.highcharts</groupId>
    <artifactId>gatling-charts-highcharts</artifactId>
    <version>3.11.3</version>
    <scope>test</scope>
</dependency>
<plugin>
    <groupId>io.gatling</groupId>
    <artifactId>gatling-maven-plugin</artifactId>
    <version>4.9.6</version>
</plugin> -->

// ── src/test/scala/simulations/UserApiSimulation.scala: ───────────────
class UserApiSimulation extends Simulation {

    val httpProtocol: HttpProtocolBuilder = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json")
        .header("Authorization", "Bearer " + getTestToken())

    // ── Scenario 1: Browse users (read-heavy): ─────────────────────
    val browseUsersScenario: ScenarioBuilder = scenario("Browse Users")
        .exec(
            http("GET all users")
                .get("/api/users?page=0&size=20")
                .check(status.is(200))
                .check(jsonPath("$.content").exists)
                .check(responseTimeInMillis.lte(500))   // SLA: 500ms
        )
        .pause(1, 3)   // think time: 1–3 seconds between requests
        .exec(
            http("GET user by id")
                .get("/api/users/#{userId}")
                .check(status.is(200))
        )

    // ── Scenario 2: Create orders (write-heavy): ────────────────────
    val createOrderScenario: ScenarioBuilder = scenario("Create Orders")
        .feed(csv("test-data/orders.csv").circular)   // CSV feed
        .exec(
            http("POST create order")
                .post("/api/orders")
                .body(StringBody(
                    """{"userId":#{userId},"productId":#{productId},
                        "quantity":#{quantity}}"""))
                .check(status.is(201))
                .check(jsonPath("$.id").saveAs("orderId"))
        )
        .pause(2)
        .exec(
            http("GET order status")
                .get("/api/orders/#{orderId}")
                .check(status.is(200))
                .check(jsonPath("$.status").is("CONFIRMED"))
        )

    // ── Load test: ramp to 200 users over 2 minutes, hold 5 minutes: ─
    setUp(
        browseUsersScenario.inject(
            rampUsers(150).during(2.minutes),
            constantUsersPerSec(50).during(5.minutes)
        ),
        createOrderScenario.inject(
            rampUsers(50).during(2.minutes),
            constantUsersPerSec(20).during(5.minutes)
        )
    ).protocols(httpProtocol)
     .assertions(
         global.responseTime.percentile(99).lte(500),   // p99 < 500ms
         global.successfulRequests.percent.gte(99.5),   // 99.5% success
         global.requestsPerSec.gte(100)                 // min 100 RPS
     )
}

Load Testing with k6

k6 is a modern developer-friendly load testing tool with a JavaScript API. It runs as a single binary, integrates with CI pipelines, and outputs results to InfluxDB/Grafana or the k6 Cloud. Thresholds defined in the script act as pass/fail criteria — a failed threshold exits with a non-zero code, failing the CI build.
javascript
// ── k6 load test script (user-api-load-test.js): ─────────────────────
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';

// Custom metrics:
const errorRate      = new Rate('error_rate');
const orderDuration  = new Trend('order_placement_ms');
const ordersCreated  = new Counter('orders_created');

// ── Test configuration: ───────────────────────────────────────────────
export const options = {
    stages: [
        { duration: '1m',  target: 50  },   // ramp up to 50 users
        { duration: '3m',  target: 200 },   // ramp up to 200 users
        { duration: '5m',  target: 200 },   // hold at 200 users
        { duration: '2m',  target: 0   },   // ramp down
    ],
    thresholds: {
        // SLA thresholds — failure exits CI with non-zero code:
        'http_req_duration':              ['p(99)<500'],  // p99 < 500ms
        'http_req_duration{name:createOrder}': ['p(95)<800'],
        'http_req_failed':                ['rate<0.01'],  // < 1% errors
        'error_rate':                     ['rate<0.01'],
        'order_placement_ms':             ['p(99)<1000'],
    },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
const TOKEN    = __ENV.AUTH_TOKEN;

const headers = {
    'Content-Type':  'application/json',
    'Authorization': `Bearer ${TOKEN}`,
};

// ── Main test function (called once per VU per iteration): ────────────
export default function () {

    group('User API', () => {

        // GET users list:
        const listRes = http.get(`${BASE_URL}/api/users?page=0&size=20`,
            { headers, tags: { name: 'listUsers' } });
        check(listRes, {
            'list status 200':     (r) => r.status === 200,
            'list has content':    (r) => r.json('content') !== null,
            'list time < 300ms':   (r) => r.timings.duration < 300,
        });
        errorRate.add(listRes.status !== 200);
        sleep(1);
    });

    group('Order API', () => {

        // POST create order:
        const start = Date.now();
        const orderRes = http.post(
            `${BASE_URL}/api/orders`,
            JSON.stringify({
                userId:    Math.floor(Math.random() * 1000) + 1,
                productId: Math.floor(Math.random() * 100)  + 1,
                quantity:  Math.floor(Math.random() * 5)    + 1,
            }),
            { headers, tags: { name: 'createOrder' } }
        );
        orderDuration.add(Date.now() - start);

        const orderOk = check(orderRes, {
            'order status 201': (r) => r.status === 201,
            'order has id':     (r) => r.json('id') !== undefined,
        });
        if (orderOk) ordersCreated.add(1);
        errorRate.add(orderRes.status !== 201);
        sleep(2);
    });
}

// ── Run: ──────────────────────────────────────────────────────────────
// k6 run --env BASE_URL=http://staging.api.com \
//         --env AUTH_TOKEN=eyJ... \
//         --out influxdb=http://influxdb:8086/k6 \
//         user-api-load-test.js

Database Performance Testing

Database queries are the most common performance bottleneck in microservices. Spring Boot provides tools to detect slow queries, N+1 problems, and missing indexes without external tooling. Combine query logging with Hibernate statistics and explain-plan analysis to identify and fix bottlenecks before load testing.
yaml
// ── application.yml — enable slow query detection: ───────────────────
// spring:
//   jpa:
//     properties:
//       hibernate:
//         generate_statistics: true        # log query counts and times
//         session:
//           events:
//             log:
//               LOG_QUERIES_SLOWER_THAN_MS: 100  # log queries > 100ms
//
// logging:
//   level:
//     org.hibernate.stat: DEBUG            # Hibernate statistics
//     org.hibernate.SQL: DEBUG             # actual SQL
//     org.hibernate.orm.jdbc.bind: TRACE   # bind parameter values

// ── Detect N+1 queries with Hibernate statistics: ────────────────────
@SpringBootTest
class NPlus1DetectionTest {

    @Autowired
    private OrderRepository orderRepository;

    @PersistenceContext
    private EntityManager entityManager;

    @Test
    void findAllOrders_doesNotTriggerNPlus1() {
        // Use Hibernate statistics to count queries:
        SessionFactory sf = entityManager.getEntityManagerFactory()
            .unwrap(SessionFactory.class);
        Statistics stats = sf.getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();

        // Act — this triggers N+1 if Order.user is LAZY without join fetch:
        List<Order> orders = orderRepository.findAllWithUser();

        long queryCount = stats.getQueryExecutionCount();

        // Assert: should be 1 query (JOIN FETCH), not N+1:
        assertThat(queryCount)
            .as("Expected 1 query (JOIN FETCH) but got N+1: %d queries",
                queryCount)
            .isEqualTo(1);
    }
}

// ── Fix N+1 with JOIN FETCH: ──────────────────────────────────────────
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    // BAD — triggers N+1 (one query per user for each order):
    List<Order> findAll();

    // GOOD — single query with JOIN FETCH:
    @Query("SELECT o FROM Order o JOIN FETCH o.user " +
           "WHERE o.status = :status")
    List<Order> findAllWithUser();

    // GOOD — EntityGraph alternative:
    @EntityGraph(attributePaths = {"user", "items"})
    @Query("SELECT o FROM Order o")
    List<Order> findAllWithUserAndItems();
}

// ── Connection pool monitoring: ───────────────────────────────────────
// application.yml:
// spring:
//   datasource:
//     hikari:
//       maximum-pool-size: 20
//       minimum-idle: 5
//       connection-timeout: 30000    # ms — fail fast if pool exhausted
//       idle-timeout: 600000
//       max-lifetime: 1800000
//       leak-detection-threshold: 5000  # warn if connection held > 5s
//
// Under load, monitor:
// GET /actuator/metrics/hikaricp.connections.active
// GET /actuator/metrics/hikaricp.connections.pending
// GET /actuator/metrics/hikaricp.connections.timeout
// If pending > 0 regularly → increase pool size or optimise queries

Performance Test in CI Pipeline

Performance tests should run in CI against a staging environment after every deployment. A failed threshold (p99 > SLA, error rate > limit) fails the build and blocks the release. Use separate Maven profiles to keep performance tests out of the normal unit test run.
yaml
// ── Maven profile for performance tests: ─────────────────────────────
// pom.xml:
// <profiles>
//   <profile>
//     <id>performance</id>
//     <build>
//       <plugins>
//         <plugin>
//           <groupId>io.gatling</groupId>
//           <artifactId>gatling-maven-plugin</artifactId>
//           <executions>
//             <execution>
//               <goals><goal>test</goal></goals>
//             </execution>
//           </executions>
//           <configuration>
//             <simulationClass>
//               simulations.UserApiSimulation
//             </simulationClass>
//             <failOnError>true</failOnError>  ← fail build on threshold breach
//           </configuration>
//         </plugin>
//       </plugins>
//     </build>
//   </profile>
// </profiles>

// ── GitHub Actions CI pipeline: ───────────────────────────────────────
// .github/workflows/performance.yml:
//
// name: Performance Tests
// on:
//   push:
//     branches: [main]
//
// jobs:
//   performance:
//     runs-on: ubuntu-latest
//     services:
//       postgres:
//         image: postgres:16-alpine
//         env:
//           POSTGRES_DB: perftest
//           POSTGRES_USER: test
//           POSTGRES_PASSWORD: test
//         ports: ["5432:5432"]
//
//     steps:
//       - uses: actions/checkout@v4
//
//       - name: Start application
//         run: |
//           mvn spring-boot:run -Dspring-boot.run.profiles=perf &
//           sleep 30   # wait for startup
//
//       - name: Run Gatling load tests
//         run: mvn gatling:test -Pperformance
//
//       - name: Run k6 spike test
//         run: |
//           k6 run --threshold-abort-on-fail //             --env BASE_URL=http://localhost:8080 //             --out json=results/k6-results.json //             src/test/k6/spike-test.js
//
//       - name: Upload Gatling report
//         uses: actions/upload-artifact@v4
//         with:
//           name: gatling-report
//           path: target/gatling/
//
//       - name: Comment PR with results
//         uses: actions/github-script@v7
//         with:
//           script: |
//             const fs = require('fs');
//             const results = JSON.parse(
//               fs.readFileSync('results/k6-results.json'));
//             github.rest.issues.createComment({
//               issue_number: context.issue.number,
//               body: formatResults(results)
//             });