E2E 测试数据管理实战——测试数据准备、清理、环境隔离完整方案
E2E 测试数据管理实战——测试数据准备、清理、环境隔离完整方案
适读人群:E2E 测试工程师 / 测试架构师 | 阅读时长:约 16 分钟 | 核心价值:解决 E2E 测试数据管理的核心难题,建立可靠的数据策略
测试数据把生产数据库搞炸了
不是我们团队的事,但这个案例我印象太深了——某互联网公司的测试工程师小周,在做压力测试时,不小心把测试脚本指向了生产数据库地址。
测试脚本跑了半小时,生成了几十万条垃圾数据,把生产数据库磁盘撑满,整个线上服务宕机了两小时。
那天之后,他们公司专门出了一条"红线规定":测试环境和生产环境的数据库必须物理隔离,测试脚本中不允许写死数据库连接串,必须通过环境变量注入。
这是极端案例。但更常见的测试数据问题是:测试数据互相干扰,今天的测试结果依赖昨天残留的数据,或者并行测试时数据冲突。
E2E 测试数据的四种来源
方式一:通过 API 接口创建数据(推荐)
这是最快、最干净的方式。绕过 UI,直接调用后端接口创建测试所需数据。
package com.example.utils;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
import com.fasterxml.jackson.databind.ObjectMapper;
public class TestDataAPI {
private static final String API_BASE_URL = System.getenv("TEST_API_URL");
private static final String ADMIN_TOKEN = System.getenv("TEST_ADMIN_TOKEN");
private static final HttpClient client = HttpClient.newHttpClient();
private static final ObjectMapper mapper = new ObjectMapper();
/**
* 创建测试用户
*/
public static TestUser createUser(String email, String password, String role) {
String body = String.format(
"{\"email\":\"%s\",\"password\":\"%s\",\"role\":\"%s\"}",
email, password, role
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_BASE_URL + "/admin/users"))
.header("Authorization", "Bearer " + ADMIN_TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
try {
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
return mapper.readValue(response.body(), TestUser.class);
} catch (Exception e) {
throw new RuntimeException("创建测试用户失败: " + e.getMessage(), e);
}
}
/**
* 创建测试商品
*/
public static TestProduct createProduct(ProductData data) {
// ... 类似实现
}
/**
* 删除测试用户
*/
public static void deleteUser(String userId) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_BASE_URL + "/admin/users/" + userId))
.header("Authorization", "Bearer " + ADMIN_TOKEN)
.DELETE()
.build();
try {
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
System.err.println("删除测试用户失败(可忽略): " + e.getMessage());
}
}
/**
* 批量清理某个前缀的测试用户
*/
public static void cleanupTestUsers(String emailPrefix) {
// 调用管理 API 清理
}
}使用示例:
@Test
void shouldCompleteOrderFlow() {
// 通过 API 创建测试数据
TestUser buyer = TestDataAPI.createUser(
"buyer_" + System.currentTimeMillis() + "@test.com",
"Test@123",
"buyer"
);
TestProduct product = TestDataAPI.createProduct(
ProductData.builder()
.name("测试商品_" + System.currentTimeMillis())
.price(99.00)
.stock(100)
.build()
);
try {
// 执行 E2E 测试
new LoginPage(page, BASE_URL)
.loginAs(buyer.getEmail(), "Test@123")
.nav().search(product.getName())
.clickFirstResult()
.addToCart()
.nav().openCart()
.proceedToCheckout()
.placeOrder()
.verifyOrderCreated();
} finally {
// 清理测试数据
TestDataAPI.deleteUser(buyer.getId());
TestDataAPI.deleteProduct(product.getId());
}
}方式二:通过 UI 操作创建数据
速度慢,但测试注册流程、创建数据流程时必须用:
@Test
void shouldRegisterAndLogin() {
String uniqueEmail = "test_" + UUID.randomUUID().toString().substring(0, 8) + "@test.com";
// 通过 UI 注册
new RegistrationPage(page, BASE_URL)
.navigate()
.fillEmail(uniqueEmail)
.fillPassword("Register@123")
.fillConfirmPassword("Register@123")
.clickRegister()
.verifyRegistrationSuccess();
// 验证可以登录
new LoginPage(page, BASE_URL)
.loginAs(uniqueEmail, "Register@123")
.verifyLoaded();
}方式三:数据库直写(只用于 setup,不测试数据写入逻辑)
比 API 更快,但耦合了数据库 schema,schema 变化时测试代码也要改:
// TestDBHelper.java
public class TestDBHelper {
private static final DataSource dataSource = createDataSource();
public static String insertTestUser(String email, String passwordHash, String role) {
String sql = "INSERT INTO users (email, password_hash, role, created_at) " +
"VALUES (?, ?, ?, NOW()) RETURNING id";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, email);
ps.setString(2, passwordHash);
ps.setString(3, role);
ResultSet rs = ps.executeQuery();
rs.next();
return rs.getString("id");
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public static void deleteUserByEmail(String email) {
String sql = "DELETE FROM users WHERE email = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, email);
ps.executeUpdate();
} catch (SQLException e) {
System.err.println("清理用户数据失败: " + e.getMessage());
}
}
}方式四:固定测试账号(最简单,但有风险)
对于不涉及数据修改的测试(只读操作),可以用固定的测试账号:
// 固定账号配置
public class TestAccounts {
// 只读账号——权限受限,即使被污染影响也小
public static final String VIEWER_EMAIL = "viewer@test.com";
public static final String VIEWER_PASSWORD = "Viewer@123";
// 管理员账号——用于需要管理权限的测试
public static final String ADMIN_EMAIL = "admin@test.com";
public static final String ADMIN_PASSWORD = "Admin@123";
}适用场景: 纯阅读类测试(查看商品列表、查看订单详情),不修改数据的操作。
风险: 如果测试意外修改了这些账号的数据,后续测试会受影响。
数据清理策略
策略一:每次测试后清理(最干净)
public class OrderTest extends BaseTest {
private String testUserId;
private String testProductId;
private String testOrderId;
@BeforeEach
void prepareData() {
// 创建本次测试专用数据
testUserId = TestDataAPI.createUser(
"buyer_" + System.currentTimeMillis() + "@test.com",
"Test@123",
"buyer"
).getId();
testProductId = TestDataAPI.createProduct(
new ProductData("测试耳机", 299.00, 100)
).getId();
}
@AfterEach
void cleanupData() {
// 无论测试成功还是失败,都清理数据
if (testOrderId != null) {
TestDataAPI.deleteOrder(testOrderId);
}
if (testProductId != null) {
TestDataAPI.deleteProduct(testProductId);
}
if (testUserId != null) {
TestDataAPI.deleteUser(testUserId);
}
}
@Test
void shouldPlaceOrder() {
// 测试逻辑...
testOrderId = "ORDER_CAPTURED_DURING_TEST";
}
}策略二:测试前清理(适合 CI 环境)
测试开始前清理上一轮遗留数据:
@BeforeAll
static void cleanupBeforeAllTests() {
// 清理所有以 "test_" 开头的测试数据(上一次运行的残留)
TestDataAPI.cleanupTestUsers("test_");
TestDataAPI.cleanupTestProducts("测试商品_");
System.out.println("测试前清理完成");
}策略三:事务回滚(只适用于直接操作数据库的场景)
@Test
@Transactional
@Rollback // Spring Boot Test 支持
void shouldUpdateUserProfile() {
// 所有数据库操作在测试结束后自动回滚
// 注意:E2E 测试通常跨进程,事务回滚不适用
}注意: 事务回滚只适用于单进程内的集成测试,E2E 测试通常涉及真实的 HTTP 请求,不能用事务回滚。
环境隔离方案
方案一:独立测试数据库
这是最推荐的方案:
# docker-compose.test.yml
services:
test-db:
image: postgres:15
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpassword
ports:
- "5433:5432" # 用不同端口,避免和开发数据库冲突
tmpfs:
- /var/lib/postgresql/data # 使用内存,每次重启数据清空
app:
environment:
DATABASE_URL: jdbc:postgresql://test-db:5432/testdb方案二:数据命名空间隔离
用前缀区分不同环境的数据:
public class TestDataHelper {
// 每个测试 session 有唯一前缀
private static final String SESSION_PREFIX =
"E2E_" + System.getenv().getOrDefault("CI_RUN_ID",
String.valueOf(System.currentTimeMillis())) + "_";
public static String generateTestEmail() {
return SESSION_PREFIX + UUID.randomUUID().toString().substring(0, 8) + "@test.com";
}
public static String generateTestProductName() {
return SESSION_PREFIX + "商品_" + System.currentTimeMillis();
}
// CI 运行结束后,可以通过前缀批量清理该次运行的所有测试数据
public static void cleanupSessionData() {
TestDataAPI.cleanupByPrefix(SESSION_PREFIX);
}
}方案三:测试账号池
对于有多个并行测试 Worker 的情况,使用账号池避免冲突:
public class UserPool {
private static final LinkedBlockingQueue<TestUser> availableUsers = new LinkedBlockingQueue<>();
static {
// 预创建 10 个测试用户放入池中
for (int i = 1; i <= 10; i++) {
availableUsers.offer(new TestUser(
"pool_user_" + i + "@test.com",
"PoolUser@123",
"buyer"
));
}
}
/**
* 借用一个用户(阻塞等待,直到有用户可用)
*/
public static TestUser borrow() {
try {
return availableUsers.poll(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new RuntimeException("等待测试用户超时", e);
}
}
/**
* 归还用户(测试结束后清理用户数据并归还)
*/
public static void returnUser(TestUser user) {
// 清理该用户的所有测试数据
TestDataAPI.cleanupUserData(user.getId());
availableUsers.offer(user);
}
}
// 在测试中使用
public class ParallelCheckoutTest extends BaseTest {
private TestUser user;
@BeforeEach
void borrowUser() {
user = UserPool.borrow();
}
@AfterEach
void returnUser() {
UserPool.returnUser(user);
}
@Test
void shouldPlaceOrder() {
new LoginPage(page, BASE_URL)
.loginAs(user.getEmail(), "PoolUser@123")
// ...
}
}踩坑实录
坑一:AfterEach 清理失败,数据一直积累
现象: @AfterEach 中的清理代码有时候因为 API 调用超时而失败,测试数据越来越多,最后拖慢了整个测试套件。
解法: 清理失败不应该让测试失败,且应该在 CI 结束时有一个全量清理机制:
@AfterEach
void cleanupData() {
try {
if (testUserId != null) {
TestDataAPI.deleteUser(testUserId);
}
} catch (Exception e) {
// 清理失败不影响测试结果,只记录日志
System.err.println("警告:测试数据清理失败 userId=" + testUserId + ": " + e.getMessage());
}
}在 CI 流水线末尾加全量清理:
# CI 结束时调用清理脚本
curl -X DELETE "$TEST_API_URL/admin/cleanup?prefix=E2E_$CI_RUN_ID" \
-H "Authorization: Bearer $TEST_ADMIN_TOKEN"坑二:测试账号被并发测试锁定
现象: 两个并行测试用同一个账号登录,一个测试修改了账号的购物车,导致另一个测试断言失败。
解法: 严格执行"每个测试用独立数据"的原则,或者使用上述的账号池方案。
坑三:时间戳不够唯一
现象: 用 System.currentTimeMillis() 生成唯一邮箱,但并发测试时出现重复(两个测试在同一毫秒内执行)。
解法: 用 UUID 替代时间戳:
// 不够唯一
String email = "test_" + System.currentTimeMillis() + "@test.com";
// 更可靠
String email = "test_" + UUID.randomUUID().toString().replace("-", "") + "@test.com";
// 或者组合:时间戳 + 随机
String email = "test_" + System.currentTimeMillis() + "_" +
UUID.randomUUID().toString().substring(0, 8) + "@test.com";测试数据管理的原则总结
- 数据唯一化:每个测试用带 UUID 或时间戳的唯一数据,避免并发冲突
- 数据隔离:测试环境数据库独立,不和开发、生产环境共用
- 数据可追溯:用统一前缀标记测试数据,方便批量清理
- 清理无副作用:清理失败不应导致测试失败,设置超时和重试
- 最小化数据:只创建测试真正需要的数据,不创建多余数据
