接口测试、性能测试、数据库测试与 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)的核心思想是在编写生产代码之前先编写测试,通过测试来驱动代码的设计和实现。
红-绿-重构循环
TDD 遵循一个三阶段的迭代循环:
┌─────────────────────────────────────────┐
│ │
│ [RED] [GREEN] [REFACTOR]│
│ 写一个失败测试 → 写最少代码 → 重构代码 │
│ 测试先红 使测试变绿 消除坏味道 │
│ │
└─────────────────────────────────────────┘
│ ▲
└──────────────┘
(循环迭代,直到功能完成)TDD 三步法(详细示例)
下面以一个计算订单折扣的功能为例,展示完整的 TDD 流程。
步骤一:写一个失败测试(RED)
class DiscountCalculatorTest {
private DiscountCalculator calculator = new DiscountCalculator();
@Test
void shouldGive10PercentDiscountForOrdersOver1000() {
// 准备:订单金额 1500
Order order = new Order(1500.0);
// 执行:计算折扣
double discount = calculator.calculateDiscount(order);
// 断言:满 1000 减 10%
assertThat(discount).isEqualTo(150.0);
}
}此时运行测试,编译失败(DiscountCalculator 尚不存在),进入 RED 状态。
步骤二:写最少代码使测试通过(GREEN)
// 写最少代码,刚好让测试通过即可
public class DiscountCalculator {
public double calculateDiscount(Order order) {
// 仅实现当前测试所需的逻辑
if (order.getAmount() > 1000) {
return order.getAmount() * 0.1;
}
return 0;
}
}运行测试,通过。进入 GREEN 状态。
步骤三:重构(REFACTOR)
// 在测试通过的前提下,重构生产代码或测试代码
public class DiscountCalculator {
private static final double DISCOUNT_THRESHOLD = 1000.0;
private static final double DISCOUNT_RATE = 0.1;
public double calculateDiscount(Order order) {
if (order.getAmount() > DISCOUNT_THRESHOLD) {
return order.getAmount() * DISCOUNT_RATE;
}
return 0;
}
}继续迭代 — 添加新需求:订单满 5000 打 8 折,且与满 1000 的折扣不叠加。
// RED — 先写新测试
@Test
void shouldGive20PercentDiscountForOrdersOver5000() {
Order order = new Order(6000.0);
double discount = calculator.calculateDiscount(order);
assertThat(discount).isEqualTo(1200.0); // 6000 * 0.2 = 1200
}
@Test
void shouldApplyHighestDiscountOnly() {
// 金额 3000,仅满足满 1000 条件,打 10%
Order order = new Order(3000.0);
double discount = calculator.calculateDiscount(order);
assertThat(discount).isEqualTo(300.0); // 3000 * 0.1 = 300,不是 3000*0.1+3000*0.2
}// GREEN — 修改生产代码
public class DiscountCalculator {
private static final double DISCOUNT_THRESHOLD_1 = 1000.0;
private static final double DISCOUNT_RATE_1 = 0.1;
private static final double DISCOUNT_THRESHOLD_2 = 5000.0;
private static final double DISCOUNT_RATE_2 = 0.2;
public double calculateDiscount(Order order) {
double amount = order.getAmount();
if (amount > DISCOUNT_THRESHOLD_2) {
return amount * DISCOUNT_RATE_2;
}
if (amount > DISCOUNT_THRESHOLD_1) {
return amount * DISCOUNT_RATE_1;
}
return 0;
}
}// REFACTOR — 提取分级折扣
public class DiscountCalculator {
private static final List<DiscountTier> TIERS = List.of(
new DiscountTier(5000.0, 0.2),
new DiscountTier(1000.0, 0.1)
);
public double calculateDiscount(Order order) {
return TIERS.stream()
.filter(tier -> order.getAmount() > tier.getThreshold())
.findFirst()
.map(tier -> order.getAmount() * tier.getRate())
.orElse(0.0);
}
}验收标准驱动测试
验收标准驱动的测试将业务需求转化为可执行的测试用例,每个用例对应一个场景。
@SpringBootTest
class OrderServiceAcceptanceTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ProductRepository productRepository;
private Product product;
@BeforeEach
void setUp() {
product = productRepository.save(new Product("Java 编程思想", 89.0, 100));
}
// 场景:用户购买商品,库存充足时创建订单成功
@Test
void AC1_createOrderSuccessfully_whenStockSufficient() {
// Given:用户已登录,商品库存充足
Long userId = 1L;
CreateOrderRequest request = new CreateOrderRequest(userId, List.of(
new OrderItemRequest(product.getId(), 2)
));
// When:用户提交订单
Order result = orderService.createOrder(request);
// Then:订单创建成功,库存扣减
assertThat(result.getId()).isNotNull();
assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(result.getTotalAmount()).isEqualByComparingTo(new BigDecimal("178.00"));
// 验证库存扣减
Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
assertThat(updatedProduct.getStock()).isEqualTo(98);
}
// 场景:库存不足时创建订单失败
@Test
void AC2_createOrderFailed_whenStockInsufficient() {
Long userId = 1L;
CreateOrderRequest request = new CreateOrderRequest(userId, List.of(
new OrderItemRequest(product.getId(), 200) // 库存只有 100
));
assertThrows(InsufficientStockException.class, () -> {
orderService.createOrder(request);
});
// 验证库存未扣减
Product unchanged = productRepository.findById(product.getId()).orElseThrow();
assertThat(unchanged.getStock()).isEqualTo(100);
}
// 场景:订单金额超过免运费门槛时免运费
@Test
void AC3_freeShipping_whenOrderAmountExceedsThreshold() {
Long userId = 1L;
// 订单金额 178 >= 99,免运费
CreateOrderRequest request = new CreateOrderRequest(userId, List.of(
new OrderItemRequest(product.getId(), 2)
));
Order result = orderService.createOrder(request);
assertThat(result.getShippingFee()).isEqualByComparingTo(BigDecimal.ZERO);
}
// 场景:订单金额未达到免运费门槛时收取运费
@Test
void AC4_chargeShipping_whenOrderAmountBelowThreshold() {
Long userId = 1L;
// 订单金额 89 < 99,收运费 10 元
CreateOrderRequest request = new CreateOrderRequest(userId, List.of(
new OrderItemRequest(product.getId(), 1)
));
Order result = orderService.createOrder(request);
assertThat(result.getShippingFee()).isEqualByComparingTo(new BigDecimal("10.00"));
}
}BDD(行为驱动开发)
BDD(Behavior-Driven Development)在 TDD 的基础上引入了自然语言描述的规范,强调用业务语言编写测试用例,促进团队沟通。
Cucumber 框架
Cucumber 使用 .feature 文件(Gherkin 语法)来描述业务行为,然后将场景绑定到 Java 代码(Glue Code)。
.feature 文件与 Gherkin 语法
# src/test/resources/features/order.feature
Feature: 订单管理
As a 用户
I want to 下单购买商品
So that 我能收到心仪的商品
Background:
Given 存在商品 "Java 编程思想", 价格 89.0 元, 库存 100
And 存在商品 "Spring 实战", 价格 79.0 元, 库存 50
And 用户 "张三" 已登录
@happy-path
Scenario: 成功创建订单
Given 用户 "张三" 在购物车中添加了 2 件 "Java 编程思想"
And 用户 "张三" 在购物车中添加了 1 件 "Spring 实战"
When 用户 "张三" 提交订单
Then 订单创建成功
And 订单总金额为 257.0 元
And 订单状态为 "待支付"
And "Java 编程思想" 的库存减少 2
And "Spring 实战" 的库存减少 1
@exception
Scenario: 库存不足时创建订单失败
Given 用户 "张三" 在购物车中添加了 200 件 "Java 编程思想"
When 用户 "张三" 提交订单
Then 订单创建失败
And 提示信息为 "库存不足"
And "Java 编程思想" 的库存仍为 100
Scenario Outline: 运费计算
Given 用户 "张三" 在购物车中添加了 <quantity> 件 "Java 编程思想"
When 用户 "张三" 提交订单
Then 运费为 <shipping_fee> 元
Examples:
| quantity | shipping_fee |
| 1 | 10 |
| 2 | 0 |
| 5 | 0 |Gherkin 关键字说明:
| 关键字 | 含义 | 说明 |
|---|---|---|
Feature | 功能 | 描述被测试的功能模块 |
Scenario | 场景 | 一个具体的测试用例 |
Scenario Outline | 场景模板 | 带参数化 Example 的场景 |
Given | 给定 | 前置条件,设置测试上下文 |
When | 当 | 触发行为,执行操作 |
Then | 那么 | 断言结果,验证输出 |
And | 并且 | 延续上一个关键字 |
But | 但是 | 反向延续 |
Background | 背景 | 每个 Scenario 都执行的公共步骤 |
Examples | 示例 | 参数化数据表 |
Step Definitions(Glue Code)
package com.example.bdd.steps;
import io.cucumber.java.zh_cn.*;
import io.cucumber.spring.CucumberContextConfiguration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.*;
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public class OrderSteps {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderRepository orderRepository;
private Map<String, Object> requestBody = new HashMap<>();
private ResponseEntity<String> response;
private Map<String, Product> productCache = new HashMap<>();
@Given("存在商品 {string}, 价格 {double} 元, 库存 {int}")
public void 存在商品(String name, double price, int stock) {
Product product = new Product(name, BigDecimal.valueOf(price), stock);
product = productRepository.save(product);
productCache.put(name, product);
}
@Given("用户 {string} 已登录")
public void 用户已登录(String username) {
// 设置认证上下文或 Token
requestBody.put("userId", username);
}
@Given("用户 {string} 在购物车中添加了 {int} 件 {string}")
public void 用户在购物车中添加了商品(String username, int quantity, String productName) {
Product product = productCache.get(productName);
assertThat(product).isNotNull();
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) requestBody.getOrDefault("items", new ArrayList<>());
items.add(Map.of(
"productId", product.getId(),
"quantity", quantity
));
requestBody.put("items", items);
}
@When("用户 {string} 提交订单")
public void 用户提交订单(String username) {
String url = "http://localhost:" + port + "/api/orders";
response = restTemplate.postForEntity(
url,
requestBody,
String.class
);
}
@Then("订单创建成功")
public void 订单创建成功() {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
@Then("订单总金额为 {double} 元")
public void 订单总金额为(double expectedAmount) {
// 从响应中解析 totalAmount 字段
String body = response.getBody();
assertThat(body).contains("totalAmount");
// 使用 JSONPath 解析
double actualAmount = JsonPath.parse(body).read("$.totalAmount", Double.class);
assertThat(actualAmount).isCloseTo(expectedAmount, within(0.01));
}
@Then("订单状态为 {string}")
public void 订单状态为(String status) {
String body = response.getBody();
String actualStatus = JsonPath.parse(body).read("$.status", String.class);
assertThat(actualStatus).isEqualTo(status);
}
@Then("{string} 的库存减少 {int}")
public void 库存减少(String productName, int quantity) {
Product product = productCache.get(productName);
Product updated = productRepository.findById(product.getId()).orElseThrow();
assertThat(updated.getStock()).isEqualTo(product.getStock() - quantity);
}
@Then("订单创建失败")
public void 订单创建失败() {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Then("提示信息为 {string}")
public void 提示信息为(String message) {
String body = response.getBody();
assertThat(body).contains(message);
}
@Then("{string} 的库存仍为 {int}")
public void 库存未变(String productName, int stock) {
Product product = productCache.get(productName);
Product unchanged = productRepository.findById(product.getId()).orElseThrow();
assertThat(unchanged.getStock()).isEqualTo(stock);
}
@Then("运费为 {double} 元")
public void 运费为(double shippingFee) {
String body = response.getBody();
double actualFee = JsonPath.parse(body).read("$.shippingFee", Double.class);
assertThat(actualFee).isCloseTo(shippingFee, within(0.01));
}
}Cucumber 运行配置
// 方式 1:JUnit Platform + Surefire(推荐,与 Maven 集成)
// src/test/java/com/example/bdd/CucumberRunnerTest.java
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.bdd.steps")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty, html:target/cucumber-report.html, json:target/cucumber.json")
@ConfigurationParameter(key = SNIPPET_TYPE_PROPERTY_NAME, value = "camelcase")
public class CucumberRunnerTest {
// 不需要任何代码
}<!-- pom.xml 依赖 -->
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.18.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<version>7.18.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<version>7.18.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency><!-- 在 surefire 配置中启用 Cucumber -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes>
<include>**/CucumberRunnerTest.java</include>
</includes>
</configuration>
</plugin>Spring Cloud Contract 契约测试
Spring Cloud Contract 是 BDD 在微服务架构中的实践,通过契约定义服务间接口,分别验证生产者(Provider)和消费者(Consumer)。
生产者端
// contracts/shouldReturnUserById.groovy
Contract.make {
description "根据 ID 查询用户"
request {
method GET()
url "/api/users/1"
headers {
accept(applicationJson())
}
}
response {
status OK()
headers {
contentType(applicationJson())
}
body([
id: 1,
name: "张三",
email: "zhangsan@example.com"
])
}
}
// contracts/shouldCreateUser.groovy
Contract.make {
description "创建用户"
request {
method POST()
url "/api/users"
headers {
contentType(applicationJson())
}
body([
name: "新用户",
email: "newuser@example.com"
])
}
response {
status CREATED()
headers {
contentType(applicationJson())
}
body([
id: 2,
name: "新用户",
email: "newuser@example.com"
])
}
}生产者端 Maven 配置:
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.1.0</version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>
com.example.contract.BaseContractTest
</baseClassForTests>
</configuration>
</plugin>生产者端测试基类:
public abstract class BaseContractTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@BeforeEach
void setUp() {
// 配置模拟行为,与契约中的请求/响应匹配
User user = new User(1L, "张三", "zhangsan@example.com");
when(userService.findById(1L)).thenReturn(user);
CreateUserRequest request = new CreateUserRequest("新用户", "newuser@example.com");
User created = new User(2L, "新用户", "newuser@example.com");
when(userService.create(any(CreateUserRequest.class))).thenReturn(created);
}
// 提供 MockMvc 实例供契约测试使用
@BeforeEach
void setupMockMvc() {
RestAssuredMockMvc.mockMvc(mockMvc);
}
}消费者端
消费者端通过下载生产者发布的契约 Jar 包,使用 @AutoConfigureStubRunner 启动本地存根(Stub)进行验证。
<!-- 消费者端依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>@SpringBootTest
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.LOCAL,
ids = "com.example:user-service:+:stubs:8080"
)
class UserServiceConsumerTest {
@Autowired
private UserServiceClient userServiceClient;
@Test
void shouldGetUserById() {
// 调用生产者提供的存根服务(端口 8080)
User user = userServiceClient.getUserById(1L);
// 验证响应符合契约约定
assertThat(user.getId()).isEqualTo(1);
assertThat(user.getName()).isEqualTo("张三");
assertThat(user.getEmail()).isEqualTo("zhangsan@example.com");
}
@Test
void shouldCreateUser() {
CreateUserRequest request = new CreateUserRequest("新用户", "newuser@example.com");
User created = userServiceClient.createUser(request);
assertThat(created.getId()).isNotNull();
assertThat(created.getName()).isEqualTo("新用户");
}
}TDD vs BDD vs 传统测试对比
对比表
| 对比维度 | TDD(测试驱动开发) | BDD(行为驱动开发) | 传统测试 |
|---|---|---|---|
| 关注点 | 代码设计与实现正确性 | 业务行为与价值交付 | 功能正确性验证 |
| 测试编写时机 | 先写测试,后写代码 | 先定义行为,后实现 | 先写代码,后补测试 |
| 语言风格 | 编程语言(Java/Kotlin) | 自然语言(Gherkin) | 编程语言 |
| 受众 | 开发者 | 开发者 + QA + 业务方 | 开发者 + QA |
| 粒度 | 方法级/类级(细粒度) | 场景级/功能级(中粒度) | 功能级/集成级(粗粒度) |
| 主要框架 | JUnit, TestNG | Cucumber, JBehave | JUnit, TestNG |
| 核心循环 | 红-绿-重构 | 定义场景 -> 编写 Step -> 实现 | 实现功能 -> 编写测试 |
| 用例描述 | 方法名 + 断言 | .feature 文件 + Step Definition | 方法名 + 断言 |
| 沟通成本 | 低(仅开发团队) | 中(需跨角色协作) | 低(技术团队内部) |
| 维护成本 | 中(重构时需同步测试) | 高(feature 文件 + 代码同步) | 高(后期补测易遗漏) |
| 学习曲线 | 中(需习惯先写测试) | 中高(需掌握 Gherkin 和工具链) | 低(自然习惯) |
| 文档价值 | 低(测试即文档性差) | 高(feature 文件可直接阅读) | 低 |
| 反馈速度 | 快(单元测试级别) | 中(集成测试级别) | 中(取决于测试类型) |
| 覆盖率倾向 | 代码覆盖率 | 行为覆盖率 | 功能覆盖率 |
各自适用场景
TDD 适用场景:
- 核心业务逻辑的算法和计算(如折扣计算、税率计算、评分算法)
- 工具类和基础设施代码(如日期工具、加密工具、数据转换)
- 需要高代码质量且错误成本高的模块
- 微服务中的独立领域模型和领域服务
- 代码重构场景,TDD 提供安全网
BDD 适用场景:
- 涉及多方协作的业务流程(如订单审批、退款流程)
- 需要与产品经理、QA 对齐理解的复杂业务场景
- 验收测试和端到端测试
- 多服务编排的集成场景
- 契约测试(通过 Spring Cloud Contract 实现服务间接口验证)
传统测试适用场景:
- 简单的 CRUD 接口测试
- 遗留系统的测试补充(不便于重构测试架构)
- 性能测试、压力测试(JMeter、Gatling)
- 数据库迁移验证
- 对已有功能进行快速回归验证
实践建议
在实际项目中,三者并非互斥关系,而是可以组合使用形成分层测试策略:
测试金字塔中的角色分配:
/\
/ \
/ BD \
/ D \
/ (少) \
/----------\
/ TDD \
/ 集成/服务测试 \
/ \
/------------------\
/ 传统测试 + TDD \
/ 大量单元测试 \
/______________________\- 底层(单元测试 70%): TDD 驱动,单体测试覆盖核心逻辑、工具类、领域模型
- 中层(服务测试 20%): 传统测试 + BDD,验证服务接口正确性、异常场景
- 顶层(端到端测试 10%): BDD 驱动,验证关键业务路径,确保多服务协作正确
这种分层策略既能通过 TDD 保证代码质量,又能通过 BDD 确保业务价值交付,再配合传统测试覆盖性能、安全等非功能性需求。