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:
- 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)
- Ứ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
- Kiến thức cơ bản về kiểm thử Spring Boot (Hướng Dẫn Testing)
- Phát triển REST API (REST API Best Practices)
- Hiểu biết cơ bản về kiến trúc microservice
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
usernamethànhuserName - 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 Test | Xác Minh Điều Gì | Bắt Lỗi Gì |
|---|---|---|
| Unit Test | Các method riêng lẻ hoạt động | Lỗi logic |
| Integration Test | Các component hoạt động cùng nhau | Lỗi kết nối |
| Contract Test | API khớp với kỳ vọng của consumer | Thay đổi API gây lỗi |
| Performance Test | Hệ thống xử lý được tải | Vấ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:
- Đọc các file contract từ
src/test/resources/contracts/ - Tạo các class test trong
target/generated-test-sources/ - Chạy các test đó dựa trên base test class của bạn
- 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=falseTest đượ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ạnh | Phù Hợp Cho |
|---|---|---|---|
| Gatling | Java/Scala | Dựa trên code, báo cáo đẹp | API load testing |
| JMeter | GUI/XML | Giao diện trực quan | Test nhanh ad-hoc |
| k6 | JavaScript | Thân thiện developer | API testing hiện đại |
| Locust | Python | Đơn giản, phân tán | Team 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.html7. 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ĩa | Mụ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 |
| Throughput | Số request mỗi giây | Tùy yêu cầu |
| Error Rate | Phầ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-serviceChạ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 Test | Khi Nào Thêm | Tần Suất Chạy | Chi Phí |
|---|---|---|---|
| Unit Test | Mỗi class/method mới | Mỗi commit | Thấp |
| Integration Test | Tương tác database/API | Mỗi commit | Trung bình |
| Contract Test | API service-to-service | Mỗi PR | Trung bình |
| Architecture Test | Một lần mỗi dự án | Mỗi commit | Thấp |
| WireMock Test | Tích hợp API bên ngoài | Mỗi commit | Thấp |
| Performance Test | Trước release lớn | Hàng tuần/theo yêu cầu | Cao |
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
- Hướng Dẫn Testing với JUnit & Mockito — nền tảng mà bài viết này xây dựng lên
- REST API Best Practices — thiết kế API bạn đang test
- Advanced JPA Optimization — tối ưu query mà performance test tiết lộ
Bài Viết Nền Tảng
- REST API Là Gì? Hướng Dẫn Đầy Đủ — hiểu API contract bạn đang test
- Hướng Dẫn Đầy Đủ HTTP Protocol — protocol nền tảng của tất cả API test
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.