接口返回 409,响应里的 importedRows 也是 0,第一行数据却已经入库了。只断言返回值,这条导入测试仍然能通过。
下面用一个本地联系人导入实验复现这个差别:两行数据,第一行正常,第二行邮箱重复。分别用逐行提交和整批事务处理,输入、状态码和返回数量都相同,数据库结果如下。
| 实现 | HTTP | 返回的importedRows | 数据库总行数 | 本批第一行是否存在 |
|---|---|---|---|---|
| 逐行提交 | 409 | 0 | 2 | 存在 |
| 整批事务 | 409 | 0 | 1 | 不存在 |

两版开始前,数据库都只有一条旧记录。409 报告了冲突,但前面的写入有没有撤销,还得查库。
为什么要让第二行失败
数据库先放一条旧记录:used@example.com,姓名为"旧用户"。
本次文件里的两行依次是:
text
第2行:本次新用户 / new@example.com
第3行:试图改名 / used@example.com
这组输入特意把正常数据放在前面。第二行失败时,才有一条已经执行过的写入可供检查;如果第一行就报错,两版都可能没有新增记录,测不出事务边界的差别。
这里特意不做"库内邮箱预查",让错误真正发生在写入阶段。即使业务接口已有预查,也仍需要单独测试写入冲突;另一个请求可能在预查后提交同样的邮箱。
两段代码只挪了事务的位置
坏版本的关键片段:
java
for (var row : rows) {
tx.executeWithoutResult(status -> insert(row));
}
本实验没有外层事务。每次 executeWithoutResult 都开启并结束一次事务,第一行已经提交,第二行回滚自己的那次事务,撤销不了第一行。
修正版把循环放进去:
java
tx.executeWithoutResult(status -> {
for (var row : rows) insert(row);
});
这时两个 INSERT 属于同一个事务。第二行的运行时异常离开回调,事务回滚,外层再把异常转换成409。Spring Framework 6.2.10 的 TransactionTemplate 源码可以核对这段异常处理顺序。
两版都有 catch,也都返回409。排查时要沿异常往外看:事务包住的是一次写入还是整个循环,异常有没有在触发回滚之前被捕获。只看有没有 try-catch,判断不了回滚结果。
请求结束后,至少再查三件事
本例要求整批成功或整批失败。对应到这份数据,请求结束后要满足:
- 总行数仍然是1。
new@example.com不存在。used@example.com的姓名仍然是"旧用户"。
只查总行数也不够:新增一条、误删一条,数量照样不变。最后一条检查的是旧数据没有被意外修改。
测试类没有加 @Transactional。请求完成后,重新从数据源取得连接,再查询提交后的状态。
MockMvc 请求常在测试线程里处理,应用事务可能加入测试事务。如果测试结束时又自动回滚,就容易把测试负责清理数据和接口自身的回滚混在一起。这里去掉测试事务,让断言检查请求完成后的数据库状态。
两个文件复现这个差别
下面是完整的最小实验,使用 JDK 21、Spring Boot 3.5.5 管理的 Spring Framework 6.2.10,以及 H2 2.3.232。
为聚焦事务边界,HTTP 入口直接接收"Excel 已解析出的两行"JSON,不重复上传和 POI 解析。MockMvc 确实走 Spring MVC 的路由、参数转换和响应处理,但没有启动真实网络端口。
创建下面两个文件,在项目目录执行 mvn test。输出是:
text
atomic: HTTP=409, total=1, new=0
broken: HTTP=409, total=2, new=1
两项测试都通过,是因为坏版测试明确断言"应该复现残留",并不意味着坏实现满足业务要求。真正业务接口的验收应使用整批事务那组断言。
pom.xml
xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.5</version><relativePath/>
</parent>
<groupId>demo</groupId><artifactId>excel-import-lab</artifactId><version>1.0</version>
<properties><java.version>21</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
</project>
src/test/java/demo/BoundaryTest.java
java
package demo;
import java.util.*;
import org.junit.jupiter.api.*;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.support.*;
import org.springframework.web.bind.annotation.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class BoundaryTest {
DriverManagerDataSource ds;
JdbcTemplate jdbc;
MockMvc mvc;
public record Contact(String name, String email) {}
@RestController
static class Api {
final JdbcTemplate jdbc;
final TransactionTemplate tx;
Api(JdbcTemplate jdbc, DriverManagerDataSource ds) {
this.jdbc = jdbc;
tx = new TransactionTemplate(new DataSourceTransactionManager(ds));
}
@PostMapping("/imports/{mode}")
ResponseEntity<?> upload(@PathVariable String mode, @RequestBody List<Contact> rows) {
try {
if ("broken".equals(mode)) {
for (var row : rows) tx.executeWithoutResult(s -> insert(row));
} else {
tx.executeWithoutResult(s -> { for (var row : rows) insert(row); });
}
return ResponseEntity.ok(Map.of("importedRows", rows.size()));
} catch (DataIntegrityViolationException e) {
// 故意让两版响应相同,观察状态码能不能证明回滚。
return ResponseEntity.status(409).body(Map.of("importedRows", 0));
}
}
void insert(Contact row) {
jdbc.update("INSERT INTO contacts VALUES (?,?)", row.name(), row.email());
}
}
@BeforeEach void setup() {
ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE contacts(name VARCHAR(50) NOT NULL,email VARCHAR(254) NOT NULL UNIQUE)");
jdbc.update("INSERT INTO contacts VALUES ('旧用户','used@example.com')");
mvc = MockMvcBuilders.standaloneSetup(new Api(jdbc, ds)).build();
}
@AfterEach void close() { jdbc.execute("SHUTDOWN"); }
@Test void brokenLeavesOneNewRow() throws Exception { verify("broken", true); }
@Test void atomicLeavesNoNewRow() throws Exception { verify("atomic", false); }
void verify(String mode, boolean hasNew) throws Exception {
assertFalse(TransactionSynchronizationManager.isActualTransactionActive());
mvc.perform(post("/imports/" + mode).contentType("application/json").content("""
[{"name":"本次新用户","email":"new@example.com"},
{"name":"试图改名","email":"used@example.com"}]
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.importedRows").value(0));
// 请求已经完成,再借出连接,检查提交后的状态。
try (var connection = ds.getConnection(); var statement = connection.createStatement()) {
assertTrue(connection.getAutoCommit());
var rows = new LinkedHashMap<String,String>();
try (var rs = statement.executeQuery("SELECT email,name FROM contacts ORDER BY email")) {
while (rs.next()) rows.put(rs.getString(1), rs.getString(2));
}
assertEquals(hasNew ? 2 : 1, rows.size());
assertEquals(hasNew, rows.containsKey("new@example.com"));
assertEquals("旧用户", rows.get("used@example.com"));
System.out.println(mode + ": HTTP=409, total=" + rows.size() + ", new=" + (hasNew ? 1 : 0));
}
}
}
这个实验验证的是同一数据源里的两次写入。换成 MySQL、PostgreSQL,或者事务里还要调用外部接口,需要补对应环境的测试;H2 的结果不能替代它们。
给现有接口补测试,可以先用这组输入:第一行能写入,第二行才冲突。你们目前的断言会检查第一行是否还在,还是只检查状态码和返回数量?