接口测试、性能测试、数据库测试与 TDD/BDD
接口测试
接口测试验证 API 端点的行为是否符合预期,包括状态码、响应体、响应头、异常场景等。在 Spring Boot 生态中,最常用的接口测试手段是 @WebMvcTest + MockMvc 以及 REST Assured。
@WebMvcTest Controller 层测试
@WebMvcTest 是 Spring Boot 提供的切片测试注解,仅加载 Controller 层相关组件(Controller、@ControllerAdvice、Filter、Jackson 配置等),不加载 Service 和 Repository 层,因此测试速度快且隔离性好。需要配合 @MockBean 模拟下层依赖。
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void getUserById_shouldReturnUser_whenUserExists() throws Exception {
// 准备模拟数据
User user = new User(1L, "张三", "zhangsan@example.com");
when(userService.findById(1L)).thenReturn(user);
// 执行请求并验证
mockMvc.perform(get("/api/users/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("张三"))
.andExpect(jsonPath("$.email").value("zhangsan@example.com"));
}
@Test
void getUserById_shouldReturn404_whenUserNotFound() throws Exception {
when(userService.findById(99L)).thenThrow(new UserNotFoundException(99L));
mockMvc.perform(get("/api/users/{id}", 99L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").exists());
}
@Test
void createUser_shouldReturn201_whenInputValid() throws Exception {
CreateUserRequest request = new CreateUserRequest("李四", "lisi@example.com");
User created = new User(2L, "李四", "lisi@example.com");
when(userService.create(any(CreateUserRequest.class))).thenReturn(created);
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"李四\",\"email\":\"lisi@example.com\"}"))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.id").value(2));
}
}MockMvc 核心方法链
MockMvc 提供了一套流畅的 Fluent API,核心由三个阶段组成。
perform 阶段 — 构建并发送请求:
// GET 请求
mockMvc.perform(get("/api/users"));
// 带路径变量
mockMvc.perform(get("/api/users/{id}", 1L));
// 带查询参数
mockMvc.perform(get("/api/users")
.param("page", "1")
.param("size", "20")
.param("sort", "name,asc"));
// POST 请求带请求体
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"张三\"}"));
// 带请求头
mockMvc.perform(get("/api/users")
.header("Authorization", "Bearer token123")
.header("X-Request-Id", "req-001"));
// 文件上传
mockMvc.perform(multipart("/api/files")
.file("file", "file-content".getBytes())
.param("description", "测试文件"));
// Session 和请求属性
mockMvc.perform(get("/api/users")
.sessionAttr("currentUser", new UserSession(1L, "admin"))
.requestAttr("traceId", "trace-001"));andExpect 阶段 — 验证响应:
// 状态码验证
mockMvc.perform(...)
.andExpect(status().isOk()) // 200
.andExpect(status().isCreated()) // 201
.andExpect(status().isNoContent()) // 204
.andExpect(status().isBadRequest()) // 400
.andExpect(status().isUnauthorized()) // 401
.andExpect(status().isForbidden()) // 403
.andExpect(status().isNotFound()) // 404
.andExpect(status().isInternalServerError()); // 500
// 响应头验证
mockMvc.perform(...)
.andExpect(header().string("Content-Type", "application/json"))
.andExpect(header().string("X-RateLimit-Remaining", "99"))
.andExpect(header().exists("Location"));
// 响应体验证 — 内容匹配
mockMvc.perform(...)
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string(containsString("success")))
.andExpect(content().json("{\"status\":\"ok\"}"));
// 响应体验证 — jsonPath
mockMvc.perform(...)
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.items").isArray())
.andExpect(jsonPath("$.data.items.length()").value(3))
.andExpect(jsonPath("$.data.items[0].id").value(1))
.andExpect(jsonPath("$.data.items[?(@.name=='张三')]").exists())
.andExpect(jsonPath("$.data.totalElements").isNumber())
.andExpect(jsonPath("$.data.totalElements").value(100));andDo 阶段 — 打印或记录请求/响应信息,常用于调试:
mockMvc.perform(get("/api/users"))
.andDo(print()) // 打印到控制台
.andDo(log().handler(new ConsoleHandler())) // 使用日志
.andDo(MockMvcResultHandlers.print(System.out)); // 输出到指定流andReturn 阶段 — 获取完整的响应结果,用于进一步断言:
MvcResult result = mockMvc.perform(get("/api/users/1"))
.andReturn();
// 获取原始响应
MockHttpServletResponse response = result.getResponse();
int status = response.getStatus();
String body = response.getContentAsString();
String contentType = response.getContentType();
// 获取请求相关信息
MockHttpServletRequest request = result.getRequest();
String requestBody = request.getContentAsString();
// 获取异步结果
MvcResult asyncResult = mockMvc.perform(get("/api/users/async"))
.andExpect(request().asyncStarted())
.andDo(timeoutHandler())
.andReturn();
Object asyncResultObj = asyncResult.getAsyncResult();JSON 响应验证 — jsonPath
jsonPath 是 Spring Boot 测试中对 JSON 响应进行断言的利器,基于 Jayway JsonPath 实现。
// 基本值断言
jsonPath("$.name").value("张三")
jsonPath("$.age").value(25)
jsonPath("$.active").value(true)
// 嵌套对象
jsonPath("$.address.city").value("北京")
jsonPath("$.address.detail.street").value("朝阳街")
// 数组断言
jsonPath("$.tags").isArray()
jsonPath("$.tags.length()").value(3)
jsonPath("$.tags[0]").value("java")
jsonPath("$.tags[-1]").value("spring") // 最后一个元素
// 数组过滤
jsonPath("$.items[?(@.price < 100)]").exists()
jsonPath("$.items[?(@.category == '电子产品')].name")
.value(hasItems("手机", "电脑"))
// 通配符和路径
jsonPath("$..phoneNumbers").exists() // 深层扫描
jsonPath("$.*.id").value(hasItems(1, 2, 3)) // 所有子对象的 id
// 类型断言
jsonPath("$.count").isNumber()
jsonPath("$.count").value(greaterThan(0))
jsonPath("$.name").isString()
jsonPath("$.items").isArray()
// Hamcrest 匹配器组合
jsonPath("$.page.totalPages").value(greaterThanOrEqualTo(1))
jsonPath("$.page.size").value(allOf(greaterThan(0), lessThanOrEqualTo(100)))
jsonPath("$.data.items", hasSize(3))
jsonPath("$.data.items[*].name", containsInAnyOrder("张三", "李四", "王五"))REST Assured 接口测试
REST Assured 是 Java 领域中流行的 HTTP API 测试框架,支持 Given-When-Then 风格的 DSL,适合对完整 HTTP 服务进行端到端测试。
<!-- pom.xml 依赖 -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>json-path</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>xml-path</artifactId>
<scope>test</scope>
</dependency>import static io.restassured.RestAssured.*;
import static io.restassured.matcher.RestAssuredMatchers.*;
import static org.hamcrest.Matchers.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiTest {
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
baseURI = "http://localhost";
basePath = "/api";
port = this.port;
}
@Test
void getUserById_shouldReturnUser() {
given()
.pathParam("id", 1)
.header("Accept", "application/json")
.when()
.get("/users/{id}")
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("id", equalTo(1))
.body("name", equalTo("张三"))
.body("email", notNullValue());
}
@Test
void createUser_shouldReturn201() {
String requestBody = """
{
"name": "赵六",
"email": "zhaoliu@example.com",
"age": 28
}
""";
given()
.contentType(ContentType.JSON)
.body(requestBody)
.when()
.post("/users")
.then()
.statusCode(201)
.header("Location", containsString("/users/"))
.body("name", equalTo("赵六"))
.body("id", greaterThan(0));
}
@Test
void createUser_shouldReturn400_whenNameMissing() {
given()
.contentType(ContentType.JSON)
.body("{\"email\":\"test@example.com\"}")
.when()
.post("/users")
.then()
.statusCode(400)
.body("message", containsString("name"));
}
@Test
void listUsers_shouldReturnPagedResult() {
given()
.queryParam("page", 1)
.queryParam("size", 10)
.queryParam("sort", "name,asc")
.when()
.get("/users")
.then()
.statusCode(200)
.body("content", hasSize(greaterThan(0)))
.body("totalElements", greaterThan(0))
.body("pageable.pageNumber", equalTo(0)); // page 从 0 开始
}
@Test
void deleteUser_shouldReturn204() {
given()
.pathParam("id", 1)
.when()
.delete("/users/{id}")
.then()
.statusCode(204);
}
@Test
void getUserById_shouldReturn401_whenNoAuth() {
given()
.pathParam("id", 1)
// 不携带认证头
.when()
.get("/users/{id}")
.then()
.statusCode(401);
}
}REST Assured 还支持响应提取,用于后续步骤:
// 提取单值
long userId =
given()
.contentType(ContentType.JSON)
.body("{\"name\":\"测试用户\",\"email\":\"test@example.com\"}")
.when()
.post("/users")
.then()
.statusCode(201)
.extract()
.path("id");
// 提取整个响应
Response response =
given()
.pathParam("id", userId)
.when()
.get("/users/{id}")
.then()
.statusCode(200)
.extract()
.response();
String userName = response.path("name");
String userEmail = response.path("email");接口自动化测试 — Postman Collection 与 Newman CLI
Postman Collection 是接口测试的另一种形态,将测试用例组织为可导出的 JSON 集合,借助 Newman CLI 可集成到 CI/CD 流水线中。
Postman Collection 结构示例(导出后为 JSON):
{
"info": {
"name": "用户中心 API 测试",
"description": "用户模块接口自动化测试集合",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "创建用户",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('状态码为 201', function () {",
" pm.response.to.have.status(201);",
"});",
"pm.test('响应中包含用户 ID', function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.id).to.exist;",
"});",
"",
"// 提取用户 ID 供后续请求使用",
"var jsonData = pm.response.json();",
"pm.collectionVariables.set('userId', jsonData.id);"
]
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\"name\":\"{{$randomFullName}}\",\"email\":\"{{$randomEmail}}\"}"
},
"url": {
"raw": "{{baseUrl}}/api/users",
"host": ["{{baseUrl}}"],
"path": ["api", "users"]
}
}
},
{
"name": "查询用户详情",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('状态码为 200', function () {",
" pm.response.to.have.status(200);",
"});",
"pm.test('用户信息完整', function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.name).to.not.be.empty;",
" pm.expect(jsonData.email).to.match(/^[\\w.-]+@[\\w.-]+\\.\\w+$/);",
"});"
]
}
}
],
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/api/users/{{userId}}",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "{{userId}}"]
}
}
}
],
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8080"
}
]
}Newman CLI 集成到 CI/CD:
# 安装 Newman
npm install -g newman
# 运行测试集合
newman run user-api-collection.json \
--environment user-api-env.json \
--reporters cli,json,junit \
--reporter-junit-export reports/junit-report.xml \
--reporter-json-export reports/json-report.json \
--iteration-count 3 \
--delay-request 100
# 使用环境变量文件
newman run user-api-collection.json \
--env-var "baseUrl=http://staging.example.com" \
--env-var "authToken=xxx"
# 带数据驱动的 CSV 文件运行
newman run user-api-collection.json \
--iteration-data test-data.csv \
--iteration-count 5
# 在 Docker 中运行
docker run -v $(pwd):/etc/newman \
postman/newman:latest \
run /etc/newman/user-api-collection.json \
--env-var "baseUrl=http://host.docker.internal:8080"性能测试
性能测试用于评估系统在负载下的响应时间、吞吐量和资源消耗。JMeter 和 Gatling 是 Java 生态中最常用的两种工具。
JMeter 测试计划结构
JMeter 测试计划采用树形结构组织,核心元素如下:
Test Plan (测试计划)
├── User Defined Variables (用户自定义变量)
├── Thread Group (线程组)
│ ├── CSV Data Set Config (CSV 数据驱动)
│ ├── HTTP Request Defaults (HTTP 请求默认值)
│ ├── HTTP Cookie Manager (Cookie 管理器)
│ ├── JDBC Connection Configuration (JDBC 连接配置)
│ ├── Sampler (取样器)
│ │ ├── HTTP Request
│ │ ├── JDBC Request
│ │ └── Debug Sampler
│ ├── Config Element (配置元件)
│ │ └── HTTP Header Manager
│ ├── Pre Processors (前置处理器)
│ ├── Post Processors (后置处理器)
│ │ ├── JSON Extractor (JSON 提取器)
│ │ └── Regular Expression Extractor (正则提取器)
│ ├── Assertions (断言)
│ │ ├── Response Assertion
│ │ ├── JSON Assertion
│ │ └── Duration Assertion
│ └── Listeners (监听器)
│ ├── View Results Tree
│ ├── Summary Report
│ ├── Aggregate Report
│ ├── Graph Results
│ └── jp@gc - PerfMon Metrics Collector
└── WorkBench (工作台,不参与实际运行)线程组配置参数:
| 参数 | 说明 | 示例值 |
|---|---|---|
| Number of Threads (users) | 并发用户数 | 100 |
| Ramp-Up Period (seconds) | 达到最大并发所需时间 | 30 |
| Loop Count | 循环次数 | 10 |
| Same user on each iteration | 是否复用同一用户 | false |
| Duration (seconds) | 测试持续时间 | 300 |
JDBC Request
<!-- JMeter JDBC Request 配置示例(测试计划中配置) -->
<!-- 1. 先添加 JDBC Connection Configuration -->
<JDBCConnectionConfiguration guiclass="TestBeanGUI" testclass="JDBCConnectionConfiguration">
<stringProp name="dataSource">mysql-pool</stringProp>
<stringProp name="dbUrl">jdbc:mysql://localhost:3306/testdb?useSSL=false</stringProp>
<stringProp name="driver">com.mysql.cj.jdbc.Driver</stringProp>
<stringProp name="username">test_user</stringProp>
<stringProp name="password">encrypted_password</stringProp>
<stringProp name="poolMax">10</stringProp>
<stringProp name="timeout">10000</stringProp>
<stringProp name="transactionIsolation">TRANSACTION_READ_COMMITTED</stringProp>
</JDBCConnectionConfiguration>
<!-- 2. 添加 JDBC Request Sampler -->
<!-- SQL 查询类型:Select Statement -->
SELECT u.id, u.name, o.order_id, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.id = ?
AND o.create_time > ?
<!-- 参数值通过 Variable Name 传入:${userId} ${startDate} -->
<!-- SQL 更新类型:Update Statement -->
UPDATE orders SET status = 'SHIPPED' WHERE order_id = ?HTTP Request
<!-- HTTP Request Sampler 配置 -->
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy">
<stringProp name="protocol">https</stringProp>
<stringProp name="domain">api.example.com</stringProp>
<stringProp name="port">443</stringProp>
<stringProp name="method">GET</stringProp>
<stringProp name="path">/api/users/${userId}</stringProp>
<stringProp name="followRedirects">true</stringProp>
<stringProp name="autoRedirects">false</stringProp>
<stringProp name="useKeepAlive">true</stringProp>
<stringProp name="DO_MULTIPART_POST">false</stringProp>
<stringProp name="connectTimeout">5000</stringProp>
<stringProp name="responseTimeout">10000</stringProp>
<!-- 添加 HTTP Header Manager -->
<elementProp name="HeaderManager" elementType="HeaderManager">
<collectionProp name="headers">
<elementProp name="">
<stringProp name="name">Authorization</stringProp>
<stringProp name="value">Bearer ${authToken}</stringProp>
</elementProp>
<elementProp name="">
<stringProp name="name">Content-Type</stringProp>
<stringProp name="value">application/json</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>压测指标
| 指标 | 全称 | 说明 | 良好标准 |
|---|---|---|---|
| TPS | Transactions Per Second | 每秒事务数,衡量系统处理能力 | 根据业务需求定 |
| QPS | Queries Per Second | 每秒查询数,侧重读场景 | 根据业务需求定 |
| RT | Response Time | 响应时间,从发起到收到完整响应的时间 | < 200ms(推荐) |
| P99 | 99th Percentile | 99% 请求的响应时间阈值 | < 500ms |
| P95 | 95th Percentile | 95% 请求的响应时间阈值 | < 300ms |
| P50 | 50th Percentile / Median | 半数请求的响应时间阈值 | < 100ms |
| 错误率 | Error Rate | 失败请求占总请求的比例 | < 0.1%(高要求)< 1%(一般) |
JMeter 聚合报告样例解释:
| Label | #Samples | Average | Median | 90% Line | 95% Line | 99% Line | Min | Max | Error% | Throughput/sec |
|---|---|---|---|---|---|---|---|---|---|---|
| HTTP Request | 50000 | 152 | 98 | 312 | 450 | 890 | 12 | 2100 | 0.02% | 1562.3 |
| JDBC Request | 50000 | 45 | 32 | 78 | 110 | 230 | 8 | 980 | 0.00% | 3200.1 |
压测结果分析
响应时间分布分析:
响应时间分布(单位:ms)
区间 请求数 占比 累计占比
0-50 15000 30.00% 30.00%
50-100 20000 40.00% 70.00%
100-200 8000 16.00% 86.00%
200-500 5000 10.00% 96.00%
500-1000 1500 3.00% 99.00%
1000+ 500 1.00% 100.00%常见问题与排查方向:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| P99 远高于 P50 | 有慢查询或 GC 停顿 | 查看 GC 日志、慢 SQL 日志 |
| 吞吐量上不去 | 数据库连接池耗尽 | 检查连接池配置、活跃连接数 |
| 错误率突然升高 | 线程池拒绝、连接超时 | 查看线程池指标、超时配置 |
| 随并发增加 RT 线性增长 | 存在串行瓶颈(锁、同步) | 火焰图分析、数据库锁监控 |
| 资源利用率低但 QPS 低 | 存在远程调用阻塞 | 查看网络 IO、外部依赖响应 |
火焰图分析流程:
1. 使用 async-profiler 采集 CPU 样本
java -agentpath:/path/to/libasyncProfiler.so=start,event=cpu,file=profile.html -jar app.jar
2. 使用 jstack 获取线程堆栈
jstack <pid> > thread-dump.txt
3. 分析最热的调用栈(最宽的矩形区域即为热点方法)Spring Boot 性能测试
结合 Spring Boot Actuator 和 Micrometer 可以在性能测试过程中实时监控应用状态。
// 在压测脚本中集成 Micrometer 指标收集
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PerformanceTest {
@Autowired
private TestRestTemplate restTemplate;
private static final int CONCURRENT_THREADS = 50;
private static final int TOTAL_REQUESTS = 10000;
@Test
void benchmarkGetUserApi() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_THREADS);
CountDownLatch latch = new CountDownLatch(TOTAL_REQUESTS);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
ConcurrentLinkedQueue<Long> latencies = new ConcurrentLinkedQueue<>();
long startTime = System.currentTimeMillis();
for (int i = 0; i < TOTAL_REQUESTS; i++) {
executor.submit(() -> {
try {
long requestStart = System.nanoTime();
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/users/1", String.class);
long duration = System.nanoTime() - requestStart;
if (response.getStatusCode().is2xxSuccessful()) {
successCount.incrementAndGet();
latencies.add(duration);
} else {
failureCount.incrementAndGet();
}
} catch (Exception e) {
failureCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
long totalTime = System.currentTimeMillis() - startTime;
executor.shutdown();
// 计算指标
long totalTimeSec = totalTime / 1000;
double qps = (double) TOTAL_REQUESTS / totalTimeSec;
// 计算百分位
List<Long> sortedLatencies = latencies.stream()
.sorted()
.collect(Collectors.toList());
long p50 = sortedLatencies.get((int) (sortedLatencies.size() * 0.5));
long p95 = sortedLatencies.get((int) (sortedLatencies.size() * 0.95));
long p99 = sortedLatencies.get((int) (sortedLatencies.size() * 0.99));
System.out.printf("并发线程数: %d%n", CONCURRENT_THREADS);
System.out.printf("总请求数: %d%n", TOTAL_REQUESTS);
System.out.printf("成功数: %d, 失败数: %d%n", successCount.get(), failureCount.get());
System.out.printf("总耗时: %d ms%n", totalTime);
System.out.printf("QPS: %.2f%n", qps);
System.out.printf("P50: %d ns (%.2f ms)%n", p50, p50 / 1_000_000.0);
System.out.printf("P95: %d ns (%.2f ms)%n", p95, p95 / 1_000_000.0);
System.out.printf("P99: %d ns (%.2f ms)%n", p99, p99 / 1_000_000.0);
System.out.printf("错误率: %.2f%%%n", (failureCount.get() * 100.0 / TOTAL_REQUESTS));
// 断言性能门禁
assertThat(qps).isGreaterThan(500);
assertThat(p99 / 1_000_000.0).isLessThan(1000);
assertThat(failureCount.get() * 100.0 / TOTAL_REQUESTS).isLessThan(1);
}
}Gatling 性能测试
Gatling 基于 Scala/Java DSL 和 Akka 异步 IO 模型,能够以更少的资源模拟更高的并发量。
<!-- pom.xml 依赖 -->
<dependency>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts</artifactId>
<version>3.11.0</version>
<scope>test</scope>
</dependency>Maven 插件配置:
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>4.8.0</version>
<configuration>
<simulationClass>com.example.UserApiSimulation</simulationClass>
<jvmArgs>
<jvmArg>-Xms512m</jvmArg>
<jvmArg>-Xmx2g</jvmArg>
</jvmArgs>
</configuration>
</plugin>Gatling 场景定义(Java DSL):
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
public class UserApiSimulation extends Simulation {
// HTTP 协议配置
HttpProtocolBuilder httpProtocol = http
.baseUrl("http://localhost:8080")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
.userAgentHeader("Gatling-Performance-Test")
.check(status().is(200)); // 全局状态码检查
// 通过 CSV 文件注入用户数据
FeederBuilder<String> userFeeder = csv("users.csv").circular();
// 场景 1:查询用户信息
ScenarioBuilder userQueryScenario = scenario("用户查询场景")
.feed(userFeeder)
.exec(http("查询用户详情")
.get("/api/users/#{userId}")
.check(
jsonPath("$.id").isEL("#{userId}"),
jsonPath("$.name").notNull(),
jsonPath("$.email").ofString().isEL("#{email}")
)
)
.pause(1, 3); // 模拟用户思考时间
// 场景 2:创建用户
ScenarioBuilder userCreateScenario = scenario("用户创建场景")
.exec(session -> {
// 动态生成请求体
String body = String.format(
"{\"name\":\"test_%d\",\"email\":\"test_%d@example.com\"}",
System.nanoTime(),
System.nanoTime()
);
return session.set("requestBody", body);
})
.exec(http("创建用户")
.post("/api/users")
.body(StringBody("#{requestBody}"))
.check(
status().is(201),
jsonPath("$.id").saveAs("createdUserId")
)
)
.exec(session -> {
System.out.println("Created user ID: " + session.getString("createdUserId"));
return session;
});
// 场景 3:混合读写操作
ScenarioBuilder mixedScenario = scenario("混合读写场景")
.feed(userFeeder)
.exec(http("获取用户列表")
.get("/api/users?page=1&size=20")
.check(
jsonPath("$.totalElements").ofInt().gt(0),
jsonPath("$.content[*].id").findRandom().saveAs("randomUserId")
)
)
.pause(1)
.exec(http("查询用户详情")
.get("/api/users/#{randomUserId}")
.check(jsonPath("$.id").isEL("#{randomUserId}"))
);
}
// 注入模型与负载配置
{
setUp(
// 场景 1:恒速注入 — 每秒 100 请求,持续 10 分钟
userQueryScenario.injectOpen(
constantUsersPerSec(100).during(Duration.ofMinutes(10))
).protocols(httpProtocol),
// 场景 2:阶梯加压 — 从 0 线性增加到 500,保持 5 分钟
userCreateScenario.injectOpen(
rampUsersPerSec(0).to(500).during(Duration.ofMinutes(5)),
constantUsersPerSec(500).during(Duration.ofMinutes(5))
).protocols(httpProtocol),
// 场景 3:突发流量模拟
mixedScenario.injectOpen(
nothingFor(Duration.ofSeconds(30)), // 预热 30s
atOnceUsers(10), // 瞬间 10 用户
rampUsers(100).during(Duration.ofMinutes(1)),
constantUsersPerSec(50).during(Duration.ofMinutes(3)),
stressPeakUsers(1000).during(Duration.ofSeconds(20)), // 峰值 1000
rampUsersPerSec(50).to(0).during(Duration.ofMinutes(1)) // 缓慢退出
).protocols(httpProtocol)
)
// 全局断言
.assertions(
global().responseTime().mean().lt(200), // 平均响应时间 < 200ms
global().responseTime().percentile(95).lt(500), // P95 < 500ms
global().responseTime().percentile(99).lt(1000), // P99 < 1000ms
global().successfulRequests().percent().gt(99.9) // 成功率 > 99.9%
)
.maxDuration(Duration.ofMinutes(30)); // 最大运行时间
}
}Gatling 的 inject 方法说明:
| 方法 | 含义 | 使用场景 |
|---|---|---|
nothingFor(duration) | 静默等待一段时间 | 预热期 |
atOnceUsers(n) | 立即注入 n 个用户 | 突发压力测试 |
rampUsers(n).during(d) | 在 d 时间段内线性增加到 n 个用户 | 阶梯加压 |
constantUsersPerSec(n).during(d) | 每秒注入 n 个新用户 | 稳态压力测试 |
rampUsersPerSec(from).to(to).during(d) | 每秒注入数从 from 线性增加到 to | 流量爬坡 |
stressPeakUsers(n).during(d) | 在 d 时间段内维持 n 个并发用户峰值 | 峰值测试 |
constantConcurrentUsers(n).during(d) | 维持 n 个恒定并发用户 | 并发测试 |
rampConcurrentUsers(from).to(to).during(d) | 并发数从 from 增加到 to | 并发爬坡 |
Gatling 报告指标说明:
Gatling 会在 target/gatling 目录下生成 HTML 格式的压测报告,包含以下核心信息:
- Active Users Over Time — 并发用户数随时间变化曲线
- Response Time Percentiles Over Time — 各百分位响应时间变化趋势
- Requests Per Second — 每秒请求数
- Response Time Distribution — 响应时间分布直方图
- OK/KO Requests — 成功/失败请求统计
数据库测试
数据库测试验证持久层逻辑的正确性,包括 CRUD 操作、事务行为、SQL 查询效率等。
@DataJpaTest 持久层测试
@DataJpaTest 是 Spring Boot 提供的 JPA 切片测试注解,仅加载 JPA 相关的组件(EntityManager、Repository、DataSource 等),不加载 Service 和 Controller 层。
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@ActiveProfiles("test")
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
void findByEmail_shouldReturnUser_whenEmailExists() {
// 准备测试数据
User user = new User("张三", "zhangsan@example.com", "password123");
entityManager.persistAndFlush(user);
// 执行查询
Optional<User> result = userRepository.findByEmail("zhangsan@example.com");
// 验证结果
assertThat(result).isPresent();
assertThat(result.get().getName()).isEqualTo("张三");
assertThat(result.get().getEmail()).isEqualTo("zhangsan@example.com");
}
@Test
void findByEmail_shouldReturnEmpty_whenEmailNotExists() {
Optional<User> result = userRepository.findByEmail("not_exists@example.com");
assertThat(result).isEmpty();
}
@Test
void findByCreatedAtBetween_shouldReturnUsersInDateRange() {
// 准备多笔数据
User user1 = new User("用户A", "a@example.com", "pass");
user1.setCreatedAt(LocalDateTime.of(2025, 1, 1, 10, 0));
User user2 = new User("用户B", "b@example.com", "pass");
user2.setCreatedAt(LocalDateTime.of(2025, 2, 1, 10, 0));
User user3 = new User("用户C", "c@example.com", "pass");
user3.setCreatedAt(LocalDateTime.of(2025, 3, 1, 10, 0));
entityManager.persistAll(user1, user2, user3);
entityManager.flush();
LocalDateTime start = LocalDateTime.of(2025, 1, 15, 0, 0);
LocalDateTime end = LocalDateTime.of(2025, 2, 28, 23, 59);
List<User> result = userRepository.findByCreatedAtBetween(start, end);
assertThat(result).hasSize(1);
assertThat(result.get(0).getName()).isEqualTo("用户B");
}
@Test
void updateUserEmail_shouldModifyDatabase() {
User user = new User("李四", "lisi@example.com", "pass");
entityManager.persistAndFlush(user);
// 修改邮箱
int updatedCount = userRepository.updateEmail(user.getId(), "newemail@example.com");
assertThat(updatedCount).isEqualTo(1);
// 清除持久化上下文后重新查询,确保数据真正写入数据库
entityManager.clear();
User updated = entityManager.find(User.class, user.getId());
assertThat(updated.getEmail()).isEqualTo("newemail@example.com");
}
@Test
void deleteUser_shouldRemoveFromDatabase() {
User user = new User("王五", "wangwu@example.com", "pass");
entityManager.persist(user);
Long userId = user.getId();
userRepository.deleteById(userId);
entityManager.flush();
Optional<User> deleted = userRepository.findById(userId);
assertThat(deleted).isEmpty();
}
}数据工厂模式
在测试中使用硬编码数据源会导致维护困难。推荐使用 Builder 模式、Fixture Factory 或 EasyRandom 构造测试数据。
Builder 模式:
// 在测试包中创建 Builder
public class UserBuilder {
private Long id;
private String name = "默认用户";
private String email = "default@example.com";
private String password = "password123";
private UserStatus status = UserStatus.ACTIVE;
private Set<Role> roles = new HashSet<>();
public static UserBuilder aUser() {
return new UserBuilder();
}
public UserBuilder withId(Long id) {
this.id = id;
return this;
}
public UserBuilder withName(String name) {
this.name = name;
return this;
}
public UserBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserBuilder withStatus(UserStatus status) {
this.status = status;
return this;
}
public UserBuilder withRole(Role role) {
this.roles.add(role);
return this;
}
public User build() {
User user = new User(name, email, password);
user.setId(id);
user.setStatus(status);
user.setRoles(roles);
return user;
}
}
// 测试中使用
User user = UserBuilder.aUser()
.withName("测试管理员")
.withEmail("admin@example.com")
.withStatus(UserStatus.ACTIVE)
.withRole(Role.ADMIN)
.build();Fixture Factory:
<dependency>
<groupId>org.instancio</groupId>
<artifactId>instancio-junit</artifactId>
<version>4.0.0</version>
<scope>test</scope>
</dependency>// 使用 Instancio(现代 Fixture 库)生成测试对象
import org.instancio.Instancio;
import static org.instancio.Select.*;
class UserServiceTest {
@Test
void createUser_shouldSucceed() {
// 完全随机生成
User user = Instancio.create(User.class);
// 自定义特定字段
User customUser = Instancio.of(User.class)
.set(field(User::getEmail), "test@example.com")
.set(field(User::getStatus), UserStatus.ACTIVE)
.set(field(User::getAge), between(18, 60))
.ignore(field(User::getId))
.create();
// 生成集合
List<User> users = Instancio.ofList(User.class)
.size(10)
.set(field(User::getStatus), UserStatus.ACTIVE)
.create();
}
@Test
void generateOrderWithRelations() {
// 生成关联对象
Order order = Instancio.of(Order.class)
.set(field(Order::getOrderNo), "ORD-" + System.currentTimeMillis())
.supply(field(Order::getItems), () -> {
// 自定义子对象生成逻辑
return Instancio.ofList(OrderItem.class)
.size(3)
.set(field(OrderItem::getQuantity), between(1, 5))
.create();
})
.create();
}
}EasyRandom(旧项目常见):
<dependency>
<groupId>org.jeasy</groupId>
<artifactId>easy-random-core</artifactId>
<version>5.0.0</version>
<scope>test</scope>
</dependency>import org.jeasy.random.EasyRandom;
import org.jeasy.random.EasyRandomParameters;
class EasyRandomExample {
@Test
void generateTestData() {
EasyRandomParameters parameters = new EasyRandomParameters()
.seed(123L) // 固定种子,保证每次生成数据一致
.objectPoolSize(100)
.randomizationDepth(3)
.charset(StandardCharsets.UTF_8)
.stringLengthRange(5, 50)
.collectionSizeRange(1, 10)
.ignoreRandomizationErrors(true);
EasyRandom easyRandom = new EasyRandom(parameters);
// 生成单个对象
User user = easyRandom.nextObject(User.class);
// 生成对象流
List<User> users = easyRandom.objects(User.class, 5)
.collect(Collectors.toList());
}
}@Sql 脚本执行
@Sql 注解可以在测试方法执行前后执行指定的 SQL 脚本,适合集成测试中初始化复杂的数据环境。
@DataJpaTest
@Sql(scripts = "/sql/clean-up.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@Sql("/sql/orders-init.sql")
void findPendingOrders_shouldReturnCorrectData() {
List<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(orders).hasSize(3);
assertThat(orders).allMatch(o -> o.getStatus() == OrderStatus.PENDING);
}
@Test
@Sql(scripts = {
"/sql/users-init.sql",
"/sql/orders-init.sql",
"/sql/order-items-init.sql"
})
void complexQuery_shouldReturnAggregatedData() {
// 多脚本组合初始化
List<OrderSummary> summaries = orderRepository.findOrderSummariesByUserId(1L);
assertThat(summaries).isNotEmpty();
assertThat(summaries.get(0).getTotalAmount()).isPositive();
}
@Test
@Sql(executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD,
scripts = "/sql/user-with-orders.sql")
@Sql(executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD,
scripts = "/sql/clean-up.sql")
void transactionalBehavior_shouldRollbackOnFailure() {
// 验证事务回滚
assertThrows(DataIntegrityViolationException.class, () -> {
Order invalidOrder = new Order(null, 999L, null); // 违反约束
orderRepository.saveAndFlush(invalidOrder);
});
// 验证数据未写入
List<Order> orders = orderRepository.findAll();
assertThat(orders).hasSize(3); // 仍然是初始化时的 3 条
}
}SQL 脚本示例:
-- /sql/users-init.sql
INSERT INTO users (id, name, email, password, status, created_at)
VALUES
(1, '张三', 'zhangsan@example.com', '$2a$10$xxx', 'ACTIVE', NOW()),
(2, '李四', 'lisi@example.com', '$2a$10$xxx', 'ACTIVE', NOW()),
(3, '王五', 'wangwu@example.com', '$2a$10$xxx', 'INACTIVE', NOW());-- /sql/clean-up.sql
DELETE FROM order_items;
DELETE FROM orders;
DELETE FROM users;数据库状态重置策略
| 策略 | 实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 每次回滚(默认) | @Transaction(@DataJpaTest 自带) | 大部分单元/集成测试 | 零配置、速度快 | 无法测试真实提交行为 |
| DDL 重建 | spring.jpa.hibernate.ddl-auto=create-drop | 每个测试类不同架构 | 架构隔离 | 速度慢 |
| @Sql 清理脚本 | @Sql(scripts="/sql/clean.sql", phase=AFTER) | 需要验证提交后状态 | 灵活控制 | 需维护清理脚本 |
| 数据库快照恢复 | 使用 Testcontainers + 容器快照 | 大型集成测试 | 完全隔离 | 依赖 Docker |
| 事务模板模式 | 手动管理 TransactionTemplate | 测试事务边界 | 精确控制 | 代码略多 |
事务模板模式示例:
@SpringBootTest
class TransactionalTest {
@Autowired
private UserRepository userRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Test
void testTransactionCommit() {
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
txTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
// 在新事务中执行
User saved = txTemplate.execute(status -> {
User user = new User("测试事务", "tx@example.com", "pass");
User result = userRepository.save(user);
// 此处 flush 不会触发回滚(除非抛出异常)
return result;
});
// 验证数据确实写入数据库
assertThat(saved.getId()).isNotNull();
User found = userRepository.findById(saved.getId()).orElse(null);
assertThat(found).isNotNull();
}
@Test
void testTransactionRollback() {
TransactionTemplate txTemplate = new TransactionTemplate(transactionManager);
assertThrows(RuntimeException.class, () -> {
txTemplate.execute(status -> {
userRepository.save(new User("会回滚", "rollback@example.com", "pass"));
// status.setRollbackOnly(); // 标记回滚
throw new RuntimeException("强制回滚");
});
});
// 验证数据未写入
Optional<User> found = userRepository.findByEmail("rollback@example.com");
assertThat(found).isEmpty();
}
}H2 内存数据库替代方案
@DataJpaTest 默认使用 H2 内存数据库。但在生产环境中使用 PostgreSQL、MySQL 等数据库时,建议用 Testcontainers 替代 H2 以避免数据库方言差异导致的测试失真。
方案一:Testcontainers(推荐)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>// 方式 1:单测试类使用
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryWithTestcontainersTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void databaseShouldBePostgres() {
// 验证使用的是真实 PostgreSQL,而非 H2
DatabaseMetaData metaData = userRepository.getEntityManager()
.getEntityManagerFactory()
.getProperties()
.get("hibernate.dialect")
.toString();
assertThat(metaData).contains("PostgreSQL");
}
}// 方式 2:全局测试配置(所有测试类共享一个容器)
// 文件: src/test/java/com/example/TestcontainersConfiguration.java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
static {
postgres.start();
}
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return postgres;
}
}
// 在测试类中引用
@SpringBootTest
@ContextConfiguration(classes = TestcontainersConfiguration.class)
class GlobalTestcontainersTest {
// 共享同一个 PostgreSQL 实例
}方案二:MySQL 内存数据库(不推荐生产使用):
<dependency>
<groupId>com.wix</groupId>
<artifactId>wix-embedded-mysql</artifactId>
<version>4.6.2</version>
<scope>test</scope>
</dependency>@BeforeAll
static void startMySQL() {
MysqldConfig config = MysqldConfig.aMysqldConfig(Version.v8_0_11)
.withPort(3307)
.withUser("test", "test")
.build();
EmbeddedMysql mysqld = EmbeddedMysql.anEmbeddedMysql(config)
.addSchema("testdb", ScriptResolver.classPathScript("schema.sql"))
.start();
}TDD(测试驱动开发)
TDD(Test-Driven Development)的核心思想是在编写生产代码之前先编写测试,通过测试来驱动代码的设计和实现。