Back to blog

Kiểm Thử Nâng Cao trong Spring Boot: Contract & Performance Testing

javaspring-boottestingperformancebackend
Kiểm Thử Nâng Cao trong Spring Boot: Contract & Performance Testing

Giới Thiệu

Unit test, integration test và MockMvc test giúp bạn tự tin rằng code hoạt động — nhưng chúng không trả lời được hai câu hỏi quan trọng:

  1. Thay đổi API của tôi có làm hỏng các service khác phụ thuộc vào nó không? (Contract testing)
  2. Ứng dụng của tôi có hoạt động tốt dưới tải thực tế không? (Performance testing)

Trong hệ thống production — đặc biệt là microservices — những lỗ hổng này gây ra những sự cố đau đớn nhất. Bộ test pass hết chẳng có ý nghĩa gì nếu một service downstream bị hỏng vì bạn đổi tên một trường JSON, hoặc nếu API của bạn sụp đổ khi có 1.000 người dùng đồng thời.

Bạn Sẽ Học Được Gì

✅ Hiểu tại sao contract test và performance test quan trọng hơn cả unit/integration test
✅ Thiết lập Spring Cloud Contract cho consumer-driven contract testing
✅ Viết contract xác minh tương thích API giữa các service
✅ Tự động tạo client stub từ contract
✅ Chạy performance test với Gatling để mô phỏng tải thực tế
✅ Xác định bottleneck với phân tích thời gian phản hồi và throughput
✅ Sử dụng các pattern kiểm thử nâng cao: parameterized test, Awaitility, ArchUnit
✅ Tích hợp contract và performance test vào CI/CD pipeline

Điều Kiện Tiên Quyết


1. Tại Sao Cần Contract Testing?

Vấn Đề: Lỗi Tích Hợp

Hãy tưởng tượng hai service:

  • Order Service gọi User Service để lấy thông tin người dùng
  • User Service thay đổi response — đổi tên username thành userName
  • Tất cả test của User Service đều pass (nó nhất quán nội bộ)
  • Order Service lỗi trong production vì nó mong đợi username

Giải Pháp: Consumer-Driven Contract

Contract testing tạo một thỏa thuận giữa các service: consumer (Order Service) định nghĩa những gì nó mong đợi, và producer (User Service) xác minh nó có thể đáp ứng contract đó.

Nếu User Service đổi tên một trường, contract test fail trước khi deploy — không phải trong production.

Loại TestXác Minh Điều GìBắt Lỗi Gì
Unit TestCác method riêng lẻ hoạt độngLỗi logic
Integration TestCác component hoạt động cùng nhauLỗi kết nối
Contract TestAPI khớp với kỳ vọng của consumerThay đổi API gây lỗi
Performance TestHệ thống xử lý được tảiVấn đề khả năng mở rộng

2. Thiết Lập Spring Cloud Contract

Thêm Dependency (Phía Producer)

Producer (service cung cấp API) cần 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 cho các test được tạo tự động -->
                <baseClassForTests>
                    com.example.userservice.contracts.BaseContractTest
                </baseClassForTests>
                <testFramework>JUNIT5</testFramework>
            </configuration>
        </plugin>
    </plugins>
</build>

Thêm Dependency (Phía Consumer)

Consumer (service gọi API) sử dụng contract stub:

<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. Viết Contract Đầu Tiên

Định Nghĩa Contract

Contract được viết bằng Groovy DSL hoặc YAML. Đặt chúng trong src/test/resources/contracts/ ở phía producer.

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: "[email protected]",
            firstName: "John",
            lastName: "Doe",
            active: true
        )
        bodyMatchers {
            jsonPath('$.id', byType())
            jsonPath('$.username', byRegex('[a-z0-9]+'))
            jsonPath('$.email', byRegex('[\\w.]+@[\\w.]+'))
        }
    }
}

Thay Thế Bằng YAML

Nếu bạn thích YAML hơn 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: "[email protected]"
    firstName: "John"
    lastName: "Doe"
    active: true
  matchers:
    body:
      - path: $.id
        type: by_type
      - path: $.username
        type: by_regex
        value: "[a-z0-9]+"

Thêm Ví Dụ Contract

Contract tạo user:

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: "[email protected]",
            password: "securePass123",
            firstName: "Jane",
            lastName: "Doe"
        )
    }
 
    response {
        status CREATED()
        headers {
            contentType(applicationJson())
        }
        body(
            id: $(anyPositiveInt()),
            username: "janedoe",
            email: "[email protected]",
            firstName: "Jane",
            lastName: "Doe",
            active: true
        )
    }
}

Contract response lỗi:

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. Triển Khai Phía Producer (Verifier)

Base Test Class

Spring Cloud Contract tạo các class test từ contract của bạn. Bạn cung cấp một base class thiết lập 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);
 
        // Thiết lập mock response khớp với kỳ vọng của contract
        UserResponse john = new UserResponse(
            1L, "johndoe", "[email protected]",
            "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", "[email protected]",
            "Jane", "Doe", true
        );
 
        when(userService.createUser(any())).thenReturn(jane);
    }
}

Chạy Contract Verification

Khi bạn chạy mvn test, Spring Cloud Contract:

  1. Đọc các file contract từ src/test/resources/contracts/
  2. Tạo các class test trong target/generated-test-sources/
  3. Chạy các test đó dựa trên base test class của bạn
  4. Cũng tạo client stub (một JAR dựa trên WireMock) trong target/stubs/
# Chạy contract verification
mvn clean test
 
# Cài stub vào local Maven repository (cho consumer testing)
mvn clean install -DskipTests=false

Test được tạo tự động (không chỉnh sửa):

// 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");
        // ... thêm assertion được tạo từ contract
    }
}

5. Triển Khai Phía Consumer (Stub Runner)

Sử Dụng Stub Trong Consumer Test

Consumer (Order Service) test dựa trên stub được tạo thay vì gọi User Service thật. Điều này nhanh, đáng tin cậy và không cần producer đang chạy.

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() {
        // Stub runner khởi động WireMock server trên port 8081
        // được nạp sẵn stub tạo từ contract
 
        UserDto user = userClient.getUserById(1L);
 
        assertThat(user).isNotNull();
        assertThat(user.getUsername()).isEqualTo("johndoe");
        assertThat(user.getEmail()).isEqualTo("[email protected]");
        assertThat(user.isActive()).isTrue();
    }
 
    @Test
    void shouldHandle404ForMissingUser() {
        assertThatThrownBy(() -> userClient.getUserById(999L))
            .isInstanceOf(UserNotFoundException.class);
    }
}

UserClient (Dùng RestClient)

@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);
    }
}

Quy Trình Contract Testing

Insight quan trọng: Nếu producer thay đổi API theo cách phá vỡ contract, build của producer fail — không phải của consumer. Điều này bắt lỗi thay đổi gây hỏng ngay tại nguồn.


6. Performance Testing Với Gatling

Tại Sao Gatling?

Gatling là công cụ load testing mạnh mẽ:

  • Viết test dưới dạng code (Scala/Java DSL) — quản lý version và review được
  • Tạo báo cáo HTML chi tiết với phân vị thời gian phản hồi
  • Xử lý hàng ngàn người dùng đồng thời hiệu quả
  • Tích hợp với Maven/Gradle cho CI/CD
Công CụNgôn NgữThế MạnhPhù Hợp Cho
GatlingJava/ScalaDựa trên code, báo cáo đẹpAPI load testing
JMeterGUI/XMLGiao diện trực quanTest nhanh ad-hoc
k6JavaScriptThân thiện developerAPI testing hiện đại
LocustPythonĐơn giản, phân tánTeam Python

Thêm Gatling Dependency

<!-- 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>

Simulation Gatling Đầu Tiên

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 {
 
    // Cấu hình HTTP
    HttpProtocolBuilder httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json");
 
    // Kịch bản: Duyệt danh sách user
    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) // Thời gian suy nghĩ: 1-3 giây
        .exec(
            http("Get User By ID")
                .get("/api/users/1")
                .check(status().is(200))
                .check(jsonPath("$.username").exists())
        );
 
    // Hồ sơ tải
    {
        setUp(
            browseUsers.injectOpen(
                // Tăng dần lên 50 user trong 30 giây
                rampUsers(50).during(30),
                // Giữ ở 50 user trong 60 giây
                constantUsersPerSec(50).during(60),
                // Tăng lên 200 user trong 30 giây
                rampUsers(200).during(30)
            )
        ).protocols(httpProtocol)
         .assertions(
             global().responseTime().max().lt(2000),
             global().successfulRequests().percent().gt(99.0)
         );
    }
}

Chạy Gatling Test

# Khởi động ứng dụng Spring Boot trước
mvn spring-boot:run
 
# Trong terminal khác, chạy Gatling
mvn gatling:test
 
# Báo cáo được tạo tại:
# target/gatling/userapi-simulation-*/index.html

7. Kịch Bản Performance Test Thực Tế

Kịch Bản 1: Load Test Đăng Ký User

public class RegistrationLoadSimulation extends Simulation {
 
    HttpProtocolBuilder httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json");
 
    // Feeder: tạo dữ liệu user duy nhất cho mỗi 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(
                // Bắt đầu chậm: 5 user/giây trong 10 giây
                constantUsersPerSec(5).during(10),
                // Tăng lên 20 user/giây trong 30 giây
                rampUsersPerSec(5).to(20).during(30),
                // Duy trì 20 user/giây trong 60 giây
                constantUsersPerSec(20).during(60)
            )
        ).protocols(httpProtocol)
         .assertions(
             global().responseTime().percentile3().lt(1500),
             global().failedRequests().percent().lt(1.0),
             forAll().responseTime().mean().lt(500)
         );
    }
}

Kịch Bản 2: Tải Hỗn Hợp (Đọc Nhiều)

Hầu hết ứng dụng thực tế có workload đọc nhiều — 80% đọc, 20% ghi:

public class MixedWorkloadSimulation extends Simulation {
 
    HttpProtocolBuilder httpProtocol = http
        .baseUrl("http://localhost:8080")
        .acceptHeader("application/json")
        .contentTypeHeader("application/json");
 
    // Xác thực trước, sau đó tái sử dụng 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"))
    );
 
    // Thao tác đọc (80% 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))
    );
 
    // Thao tác ghi (20% 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))
        );
 
    // Kịch bản
    ScenarioBuilder readers = scenario("Readers")
        .exec(authenticate)
        .repeat(10).on(browseOperations);
 
    ScenarioBuilder writers = scenario("Writers")
        .exec(authenticate)
        .repeat(3).on(writeOperations);
 
    {
        setUp(
            // 80% đọc, 20% ghi
            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)
         );
    }
}

Hiểu Báo Cáo Gatling

Sau khi chạy simulation, Gatling tạo báo cáo HTML với:

Chỉ SốÝ NghĩaMục Tiêu Tốt
Response Time (p50)Thời gian phản hồi trung vị< 200ms
Response Time (p95)Phân vị thứ 95< 500ms
Response Time (p99)Phân vị thứ 99< 1000ms
ThroughputSố request mỗi giâyTùy yêu cầu
Error RatePhần trăm request thất bại< 1%

Mẹo: Tập trung vào p95 và p99 — không phải trung bình. Trung bình che giấu các giá trị ngoại lệ. Nếu p50 của bạn là 100ms nhưng p99 là 5s, một số người dùng đang có trải nghiệm rất tệ.


8. Các Pattern Kiểm Thử Nâng Cao

Ngoài contract và performance testing, các pattern này giúp bộ test của bạn mạnh mẽ hơn.

Parameterized Test

Test cùng một logic với nhiều đầu vào — gọn hơn viết từng test riêng:

@ParameterizedTest(name = "Email {0} should be {1}")
@CsvSource({
    "[email protected], true",
    "[email protected], true",
    "invalid-email, false",
    "'', false",
    "missing@, false",
    "@nodomain.com, false",
    "spaces [email protected], 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",       // Quá ngắn (tối thiểu 3 ký tự)
    "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 Cho Tham Số Phức Tạp

@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", "[email protected]",
                "Pass123!"),
            true, null),
        Arguments.of(
            new UserRegistrationDto("", "[email protected]",
                "Pass123!"),
            false, "Username is required"),
        Arguments.of(
            new UserRegistrationDto("john", "invalid",
                "Pass123!"),
            false, "Invalid email format"),
        Arguments.of(
            new UserRegistrationDto("john", "[email protected]",
                "weak"),
            false, "Password too weak")
    );
}

Test Async Với Awaitility

Code async cần xử lý đặc biệt — không bao giờ dùng Thread.sleep():

import org.awaitility.Awaitility;
 
@Test
void shouldProcessOrderAsynchronously() {
    // Given
    OrderRequest request = new OrderRequest(
        "PROD-001", 2, 29.99);
 
    // When - kích hoạt xử lý async
    orderService.submitOrder(request);
 
    // Then - chờ kết quả async
    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", "[email protected]", "Pass123!"));
 
    // Then - xác minh notification async đã được gửi
    Awaitility.await()
        .atMost(Duration.ofSeconds(5))
        .untilAsserted(() ->
            verify(notificationService, times(1))
                .sendWelcomeEmail("[email protected]")
        );
}

Test Kiến Trúc Với ArchUnit

Thực thi quy tắc kiến trúc tự động — không cần code review thủ công:

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("Controller nên dùng service, " +
                     "không truy cập repository trực tiếp");
 
    @ArchTest
    static final ArchRule servicesShouldBeAnnotated =
        classes()
            .that().resideInAPackage("..service..")
            .and().haveSimpleNameEndingWith("Service")
            .should().beAnnotatedWith(
                org.springframework.stereotype.Service.class)
            .because("Các class service phải dùng @Service");
 
    @ArchTest
    static final ArchRule noFieldInjection =
        noFields()
            .should().beAnnotatedWith(
                org.springframework.beans.factory.annotation
                    .Autowired.class)
            .because("Dùng constructor injection, " +
                     "không dùng field injection");
}

Test Với WireMock Cho API Bên Ngoài

Khi service của bạn gọi API bên ngoài, dùng WireMock để mô phỏng:

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 API thanh toán bên ngoài
        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");
 
        // Xác minh API bên ngoài được gọi đúng
        wireMock.verify(
            postRequestedFor(urlEqualTo("/v1/charges"))
                .withRequestBody(matchingJsonPath(
                    "$.currency", equalTo("usd")))
        );
    }
 
    @Test
    void shouldHandlePaymentGatewayTimeout() {
        wireMock.stubFor(
            post(urlEqualTo("/v1/charges"))
                .willReturn(aResponse()
                    .withFixedDelay(5000) // Delay 5 giây
                    .withStatus(200))
        );
 
        assertThatThrownBy(() ->
            paymentService.charge(
                new PaymentRequest("tok_visa", 2999))
        ).isInstanceOf(PaymentTimeoutException.class);
    }
 
    @Test
    void shouldRetryOnGatewayError() {
        // Lần gọi đầu: lỗi 503
        wireMock.stubFor(
            post(urlEqualTo("/v1/charges"))
                .inScenario("retry")
                .whenScenarioStateIs("Started")
                .willReturn(aResponse().withStatus(503))
                .willSetStateTo("retrying")
        );
 
        // Lần gọi thứ hai: thành công
        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();
 
        // Xác minh được gọi 2 lần (gốc + 1 retry)
        wireMock.verify(2,
            postRequestedFor(urlEqualTo("/v1/charges")));
    }
}

9. Tích Hợp CI/CD

Chạy Contract Test Trong CI

Thêm contract testing vào 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

Chạy Gatling Trong CI

# .github/workflows/performance-tests.yml
name: Performance Tests
 
on:
  workflow_dispatch: # Kích hoạt thủ công
  schedule:
    - cron: '0 2 * * 1' # Mỗi thứ Hai lúc 2 giờ sáng
 
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 # Chờ ứng dụng khởi động
 
      - 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. Hướng Dẫn Chọn Loại Test

Khi Nào Dùng Loại Test Nào

Khuyến Nghị Test Coverage

Loại TestKhi Nào ThêmTần Suất ChạyChi Phí
Unit TestMỗi class/method mớiMỗi commitThấp
Integration TestTương tác database/APIMỗi commitTrung bình
Contract TestAPI service-to-serviceMỗi PRTrung bình
Architecture TestMột lần mỗi dự ánMỗi commitThấp
WireMock TestTích hợp API bên ngoàiMỗi commitThấp
Performance TestTrước release lớnHàng tuần/theo yêu cầuCao

Tóm Tắt và Điểm Chính

✅ Contract test bắt lỗi API giữa các service trước production — chúng xác minh producer đáp ứng kỳ vọng của consumer
✅ Spring Cloud Contract tự động hóa quy trình: viết contract, tạo test trên producer, tạo stub cho consumer
✅ Consumer-driven contract trao quyền cho API consumer — producer phải đáp ứng những gì consumer thực sự cần
✅ Gatling performance test mô phỏng tải thực tế với simulation dạng code tích hợp vào CI/CD
✅ Tập trung vào phân vị p95/p99 — trung bình che giấu trải nghiệm xấu nhất của người dùng
✅ Parameterized test giảm trùng lặp — test nhiều đầu vào với một method test duy nhất
✅ Awaitility thay thế Thread.sleep() — chờ điều kiện async mà không cần timing mong manh
✅ ArchUnit thực thi quy tắc kiến trúc dưới dạng test tự động — không cần code review thủ công cho vi phạm layer
✅ WireMock stub API bên ngoài cho test đáng tin cậy, nhanh không cần dependency thật
✅ Chạy contract test mỗi PR, performance test hàng tuần — tần suất phù hợp với chi phí và rủi ro


Tiếp Theo

Giờ bạn đã có thể xác minh API contract và đo lường hiệu suất, hãy tiếp tục xây dựng ứng dụng sẵn sàng production:

Tiếp Tục Series Spring Boot

  • Performance Optimization & Profiling: Áp dụng những gì performance test tiết lộ — tối ưu các bottleneck mà Gatling phát hiện
  • Docker & Kubernetes Deployment: Container hóa ứng dụng và chạy performance test với môi trường giống production
  • Monitoring với Actuator & Prometheus: Kết nối runtime metric với baseline Gatling để theo dõi hiệu suất liên tục

Bài Viết Spring Boot Liên Quan

Bài Viết Nền Tảng


Thuộc series Lộ Trình Học Spring Boot

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.