Java 单元测试生成大量 Excel 测试数据实战指南
一、为什么需要生成测试数据
在开发批量导入功能时,需要验证以下场景:
| 测试场景 | 数据量 | 目的 |
|---|---|---|
| 正常流程 | 少量(100条) | 快速验证基本功能 |
| 性能测试 | 大量(8万~12万) | 验证异步插入性能、内存占用 |
| 边界测试 | 恰好上限/超出上限 | 验证上限校验逻辑 |
| 异常数据 | 含非法值 | 验证校验逻辑是否正确中断 |
| 空数据 | 只有表头 | 验证空数据提示 |
手动在 Excel 中填12万行不现实,用代码生成是唯一合理的方式。
二、技术选型
2.1 为什么用 JUnit 测试类而非 main 方法
| 方式 | 优点 | 缺点 |
|---|---|---|
| JUnit 测试方法 | IDE 一键运行、可选择性执行、不影响生产代码 | 无 |
| main 方法 | 简单 | 需要手动指定运行类,混在生产代码中 |
| 独立脚本 | 独立 | 需要单独配置依赖和运行环境 |
JUnit 测试类放在 src/test/java 下,不会被打包到生产 jar 中,也可以利用项目已有的所有依赖(如 POI)。
2.2 POI 的两种写入模式
| 模式 | 类 | 内存占用 | 适用场景 |
|---|---|---|---|
| DOM 模式 | XSSFWorkbook | 全部在内存 | 数据量 < 50万行 |
| 流式模式 | SXSSFWorkbook | 仅保留窗口行数 | 数据量 > 50万行 |
对于12万行数据,XSSFWorkbook 完全够用(内存大约占用 200到500MB),生成时间约 10~~30 秒。如果需要生成百万行以上,应使用 SXSSFWorkbook。
2.3 SXSSFWorkbook vs XSSFWorkbook
java
// XSSFWorkbook:所有行都在内存中
XSSFWorkbook workbook = new XSSFWorkbook(); // 适合 < 50万行
// SXSSFWorkbook:只保留最近 N 行在内存,其余刷到磁盘临时文件
SXSSFWorkbook workbook = new SXSSFWorkbook(1000); // 窗口大小1000行
// 超过1000行的部分自动写入临时文件
// 最终 write() 时合并输出
workbook.dispose(); // 清理临时文件
三、测试数据设计原则
3.1 数据多样性
不要所有行都用同一组值,应准备多组候选值随机选取:
- 避免数据库唯一约束导致的误判
- 模拟真实业务数据的分布
- 测试不同字符长度对性能的影响
3.2 边界值覆盖
| 字段 | 正常值 | 边界值 |
|---|---|---|
| 数量 | 1~999 | 0, -1, 999999, 1000000 |
| 文本 | 正常长度 | 空字符串, 超长字符串, 特殊字符 |
| 行数 | 正常量 | 0行, 1行, 上限行, 上限+1行 |
3.3 分层测试文件
为不同测试目的生成不同文件,而非一个文件覆盖所有场景:
test_small.xlsx → 快速冒烟测试(10秒内跑完)
test_normal.xlsx → 正常业务量测试
test_limit.xlsx → 上限边界测试
test_over_limit.xlsx → 超出上限测试
test_invalid.xlsx → 异常数据校验测试
test_empty.xlsx → 空数据测试
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
四、完整示例代码
以下是一个通用的"订单批量导入测试数据生成器":
4.1 项目依赖
项目中已有 POI 和 JUnit 依赖即可直接使用:
xml
<!-- 通常主项目已有 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>4.0.0</version>
</dependency>
<!-- test scope -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
4.2 测试数据生成器
java
package com.example.testdata;
import java.io.FileOutputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Random;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
/**
* 测试数据Excel生成器.
*
* <p>使用方式:在IDE中右键运行对应的测试方法,文件生成到指定路径.
* 生成的文件用于导入接口的手动测试或自动化测试.
*/
class ImportTestDataGenerator {
// ==================== 输出路径配置 ====================
/** 文件输出目录(按需修改). */
private static final String OUTPUT_DIR = "D:/testdata/";
// ==================== 候选数据池 ====================
private static final String[] ORDER_CODES = {
"ORD20260001", "ORD20260002", "ORD20260003", "ORD20260004", "ORD20260005",
"ORD20260006", "ORD20260007", "ORD20260008", "ORD20260009", "ORD20260010"
};
private static final String[] PRODUCT_CODES = {
"SKU-10001", "SKU-10002", "SKU-10003", "SKU-10004", "SKU-10005",
"SKU-20001", "SKU-20002", "SKU-20003", "SKU-20004", "SKU-20005"
};
private static final String[] PRODUCT_NAMES = {
"无线蓝牙耳机", "机械键盘104键", "27寸4K显示器", "静音鼠标",
"USB-C扩展坞", "笔记本支架", "桌面台灯", "降噪耳机",
"移动硬盘1TB", "网线6类3米"
};
private static final String[] WAREHOUSES = {
"WH-BJ-01", "WH-SH-01", "WH-GZ-01", "WH-SZ-01", "WH-CD-01"
};
private static final String[] WAREHOUSE_NAMES = {
"北京仓", "上海仓", "广州仓", "深圳仓", "成都仓"
};
private static final String[] CATEGORIES = {
"电子产品", "办公用品", "生活家居", "数码配件", "网络设备"
};
/** 表头定义. */
private static final String[] HEADERS = {
"订单编号", "商品编码", "商品名称", "分类", "仓库编码", "仓库名称", "数量", "单价"
};
// ==================== 测试方法 ====================
/**
* 生成少量数据(100条)用于快速验证.
* 适用场景:开发阶段快速确认导入流程是否通畅.
*/
@Test
void generateSmall() throws Exception {
generateExcel(100, OUTPUT_DIR + "import_small_100.xlsx", DataMode.NORMAL);
}
/**
* 生成中等数据量(1万条).
* 适用场景:验证分批插入逻辑.
*/
@Test
void generateMedium() throws Exception {
generateExcel(10000, OUTPUT_DIR + "import_medium_10000.xlsx", DataMode.NORMAL);
}
/**
* 生成大数据量(10万条).
* 适用场景:性能测试,验证异步插入耗时.
*/
@Test
void generateLarge() throws Exception {
generateExcel(100000, OUTPUT_DIR + "import_large_100000.xlsx", DataMode.NORMAL);
}
/**
* 生成上限数据量(12万条).
* 适用场景:边界测试.
*/
@Test
void generateLimit() throws Exception {
generateExcel(120000, OUTPUT_DIR + "import_limit_120000.xlsx", DataMode.NORMAL);
}
/**
* 生成超出上限(12万+1条).
* 预期:接口返回"单次导入上限120000条".
*/
@Test
void generateOverLimit() throws Exception {
generateExcel(120001, OUTPUT_DIR + "import_over_limit.xlsx", DataMode.NORMAL);
}
/**
* 生成含异常数据的文件.
* 预期:接口在遇到第一条异常数据时中断返回.
*/
@Test
void generateInvalid() throws Exception {
generateExcel(200, OUTPUT_DIR + "import_invalid.xlsx", DataMode.INVALID);
}
/**
* 生成空数据文件(只有表头和示例行,没有业务数据).
* 预期:接口返回"导入数据为空".
*/
@Test
void generateEmpty() throws Exception {
generateExcel(0, OUTPUT_DIR + "import_empty.xlsx", DataMode.NORMAL);
}
/**
* 生成含空行的文件(数据行中夹杂空行).
* 预期:空行被跳过,其余正常导入.
*/
@Test
void generateWithBlankRows() throws Exception {
generateExcel(100, OUTPUT_DIR + "import_with_blanks.xlsx", DataMode.WITH_BLANK_ROWS);
}
/**
* 生成含特殊字符的文件.
* 预期:特殊字符正常存储.
*/
@Test
void generateSpecialChars() throws Exception {
generateExcel(50, OUTPUT_DIR + "import_special_chars.xlsx", DataMode.SPECIAL_CHARS);
}
// ==================== 核心生成逻辑 ====================
private enum DataMode {
NORMAL, // 全部正常数据
INVALID, // 包含非法数据
WITH_BLANK_ROWS, // 包含空行
SPECIAL_CHARS // 包含特殊字符
}
private void generateExcel(int dataRows, String filePath, DataMode mode) throws Exception {
// 确保输出目录存在
java.io.File dir = new java.io.File(OUTPUT_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
Random random = new Random(42); // 固定种子,保证每次生成结果一致
long startTime = System.currentTimeMillis();
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Sheet sheet = workbook.createSheet("导入数据");
// ---- 创建表头(带样式) ----
CellStyle headerStyle = createHeaderStyle(workbook);
Row headerRow = sheet.createRow(0);
for (int i = 0; i < HEADERS.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(HEADERS[i]);
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, 20 * 256); // 列宽20字符
}
// ---- 创建示例行 ----
Row sampleRow = sheet.createRow(1);
sampleRow.createCell(0).setCellValue("ORD20260001");
sampleRow.createCell(1).setCellValue("SKU-10001");
sampleRow.createCell(2).setCellValue("无线蓝牙耳机");
sampleRow.createCell(3).setCellValue("电子产品");
sampleRow.createCell(4).setCellValue("WH-BJ-01");
sampleRow.createCell(5).setCellValue("北京仓");
sampleRow.createCell(6).setCellValue("10");
sampleRow.createCell(7).setCellValue("99.90");
// ---- 创建数据行 ----
for (int i = 0; i < dataRows; i++) {
int rowIndex = i + 2; // 从第3行开始
// 处理空行模式
if (mode == DataMode.WITH_BLANK_ROWS && i % 20 == 19) {
sheet.createRow(rowIndex); // 每20行插入一个空行
continue;
}
Row row = sheet.createRow(rowIndex);
int idx = random.nextInt(10);
int whIdx = random.nextInt(5);
row.createCell(0).setCellValue(ORDER_CODES[idx % 10]);
// 特殊字符模式
if (mode == DataMode.SPECIAL_CHARS) {
row.createCell(1).setCellValue("SKU-" + i);
row.createCell(2).setCellValue("商品【" + i + "】& <测试> \"引号\"");
} else {
row.createCell(1).setCellValue(PRODUCT_CODES[idx]);
row.createCell(2).setCellValue(PRODUCT_NAMES[idx]);
}
row.createCell(3).setCellValue(CATEGORIES[whIdx]);
row.createCell(4).setCellValue(WAREHOUSES[whIdx]);
row.createCell(5).setCellValue(WAREHOUSE_NAMES[whIdx]);
// 数量字段(关键校验字段)
if (mode == DataMode.INVALID) {
row.createCell(6).setCellValue(getInvalidQuantity(i, random));
} else {
row.createCell(6).setCellValue(random.nextInt(999) + 1);
}
// 单价
double price = Math.round((random.nextDouble() * 1000 + 1) * 100.0) / 100.0;
row.createCell(7).setCellValue(price);
}
// ---- 写入文件 ----
try (FileOutputStream fos = new FileOutputStream(filePath)) {
workbook.write(fos);
}
}
long cost = System.currentTimeMillis() - startTime;
java.io.File file = new java.io.File(filePath);
System.out.println("=== 文件生成完成 ===");
System.out.println("路径:" + filePath);
System.out.println("数据行数:" + dataRows);
System.out.println("文件大小:" + formatFileSize(file.length()));
System.out.println("生成耗时:" + cost + "ms");
System.out.println("==================");
}
/**
* 生成非法数量值.
* 在特定行插入非法数据,其余行正常.
*/
private int getInvalidQuantity(int rowIndex, Random random) {
return switch (rowIndex) {
case 48 -> 0; // 第50行(索引48+2):数量为0
case 78 -> -5; // 第80行:负数
case 98 -> 1000000; // 第100行:超过6位(100万)
default -> random.nextInt(999) + 1; // 正常值
};
}
/**
* 创建表头单元格样式.
*/
private CellStyle createHeaderStyle(XSSFWorkbook workbook) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
font.setFontHeightInPoints((short) 12);
font.setColor(IndexedColors.WHITE.getIndex());
style.setFont(font);
style.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
style.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
return style;
}
/**
* 格式化文件大小.
*/
private String formatFileSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
return String.format("%.1f MB", bytes / (1024.0 * 1024));
}
}
五、运行与输出
5.1 在 IDE 中运行
- IntelliJ IDEA:打开文件 → 点击方法名左侧的绿色三角 → Run
- 或右键类名 → Run 'ImportTestDataGenerator'(运行所有测试方法)
5.2 控制台输出示例
=== 文件生成完成 ===
路径:D:/testdata/import_large_100000.xlsx
数据行数:100000
文件大小:5.3 MB
生成耗时:12450ms
==================
5.3 用 Maven 命令运行
bash
# 运行指定测试方法
mvn test -Dtest=ImportTestDataGenerator#generateSmall
# 运行所有生成方法
mvn test -Dtest=ImportTestDataGenerator
六、设计技巧
6.1 固定随机种子
java
Random random = new Random(42); // 固定种子
好处:每次运行生成的数据完全一致,方便对比测试结果。如果想每次生成不同数据,去掉参数即可:new Random()。
6.2 数据池 + 随机选取
java
private static final String[] NAMES = {"商品A", "商品B", "商品C"};
// ...
row.createCell(1).setCellValue(NAMES[random.nextInt(NAMES.length)]);
比拼接"商品1"、"商品2"更贴近真实数据分布。
6.3 异常数据插入特定行
java
if (rowIndex == 48) return 0; // 已知第50行会出错
if (rowIndex == 78) return -5; // 已知第80行会出错
这样测试时可以精确验证错误提示中的行号是否正确。
6.4 枚举控制生成模式
java
private enum DataMode {
NORMAL, INVALID, WITH_BLANK_ROWS, SPECIAL_CHARS
}
一套核心生成逻辑,通过模式参数控制不同行为,避免代码重复。
6.5 输出目录自动创建
java
java.io.File dir = new java.io.File(OUTPUT_DIR);
if (!dir.exists()) dir.mkdirs();
避免因目录不存在导致 FileNotFoundException。
七、性能参考
| 数据量 | XSSFWorkbook 生成耗时 | 文件大小 |
|---|---|---|
| 100 条 | < 1s | ~10 KB |
| 1 万条 | 1~2s | ~500 KB |
| 5 万条 | 5~8s | ~2.5 MB |
| 10 万条 | 10~15s | ~5 MB |
| 12 万条 | 12~20s | ~6 MB |
| 50 万条 | 50~80s | ~25 MB |
| 100 万条 | 建议用 SXSSFWorkbook | ~50 MB |
八、进阶:自动化测试中的使用
生成的文件不仅可以手动用 Postman 测试,也可以在集成测试中直接使用:
java
@SpringBootTest
@AutoConfigureMockMvc
class ImportApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void testImportSmallFile() throws Exception {
// 先生成文件
byte[] fileBytes = generateInMemory(100);
MockMultipartFile file = new MockMultipartFile(
"file", "test.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
fileBytes
);
mockMvc.perform(multipart("/api/import/import")
.file(file)
.param("ownerId", "10001")
.param("ownerName", "测试用户"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
private byte[] generateInMemory(int rows) throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
// ... 生成逻辑(同上,但写到 ByteArrayOutputStream)
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
wb.write(bos);
return bos.toByteArray();
}
}
}
这样就可以实现全自动的导入流程测试,不依赖外部文件。