不要在测试中使用 @Transactional 属性
不要在测试中使用 @Transactional 属性
假阴性是指当生产代码存在错误时,本应失败的测试却成功运行。
不要在测试中使用 @Transactional 属性
如何避免毁掉你的 Spring Boot 应用程序测试套件
照片:Braulio Cassule和 Jesuelson Dacosta
源代码
您可以在这里找到本文使用的示例代码。该示例代码包含 3 个分支,第一个分支使用了 @Transactional 注解,第二个分支没有使用,第三个分支则使用了适当的替代方案。
如果您需要运行此项目,则需要在您的计算机上安装PostgreSQL。然后配置以下环境变量:
-
数据库主机:localhost
-
数据库名称:避免事务
-
数据库用户名:postgres
-
数据库密码:你的密码
当然,您可以根据本地数据库配置提供不同的值。
我建议您创建两个数据库,一个用于生产环境,另一个用于测试环境。这样,您可以使用 cURL 或 Postman 等工具运行测试,而不会干扰手动生产环境测试。您需要在测试和测试之间更改DATABASE_NAME变量。
问题
当我们使用 Spring Boot 进行集成测试时(这些测试会测试代码的多个层,而且速度通常很慢),我们倾向于使用 @Transactional 注解来保证在测试执行后清理数据,因为我们想要保证测试套件的确定性。
@SpringBootTest
@Transactional
internal class CartItemsControllerTests {
...
@Test
fun getAllCartItems() {
val cart = Cart()
cart.addProduct(product, 3)
cartsRepository.save(cart)
mockMvc.perform(get("/carts/{id}/items", cart.id))
.andExpect(status().isOk)
.andExpect(jsonPath("$.[*].id").value(hasItem(cart.items[0].id.toString())))
.andExpect(jsonPath("$.[*].product.id").value(hasItem(product.id.toString())))
.andExpect(jsonPath("$.[*].product.name").value(hasItem(product.name)))
.andExpect(jsonPath("$.[*].quantity").value(3))
}
}
当然,您也可以使用 @Transactional 注解来注解您的测试类,而不是注解所有测试方法。
像这样创建测试的问题在于,它极易导致测试套件出现大量误报,从而破坏测试结果。误报是指,由于生产代码中的错误,本应失败的测试却成功运行。
大量的漏报会使你的测试套件失去作用,原因有二:
-
开发人员对测试套件的信心会降低,人们会开始害怕进行更改,因为更改可能会破坏某些东西。
-
很多 bug 会在开发人员不知情的情况下进入生产环境,除非他们开始进行手动测试,但这反而会适得其反。
假阴性是指当生产代码存在错误时,本应失败的测试却成功运行。
如果在with-transactional git 分支中运行所有测试,它们都会成功运行:
接下来,让我们尝试使用 cURL 或 Postman 等工具来执行应用程序并调用端点。
延迟加载不起作用
如果您尝试创建购物车并向其中添加商品,将会收到 500 HTTP 错误。您可以将预生成的商品 ID 为 *2bdb8e93–3832–47b6–8bbc-d6b5db277989* 的产品用于购物车商品:
控制台日志中抛出的异常是LazyInitializationException:
org.hibernate.**LazyInitializationException**: failed to lazily initialize a collection of role: com.example.avoidtransactional.domain.model.Cart._items, could not initialize proxy - no Session
当 Hibernate 尝试延迟加载Cart._items列表时抛出了此异常。延迟加载在数据库会话关闭时不起作用,而这种情况通常会在您使用存储库检索实体后立即发生。
val product = productsRepository.findByIdOrNull(cartItem.productId)
if (product == null) {
return ResponseEntity.notFound().build()
}
val cart = cartsRepository.findByIdOrNull(cartId)
if (cart == null) {
return ResponseEntity.notFound().build()
}
cart.addProduct(product, cartItem.quantity)
cartsRepository.save(cart) // with @Transactional there's no need of this line
问题出在第 11 行。Cart.addProduct ()方法内部操作的是购物车商品的惰性集合,这引发了异常。
如果从CartItemsController类中移除 @Transactional 注解,则其中的测试将失败,并出现相同的LazyInitializationException异常。
org.springframework.web.util.**NestedServletException**: Request processing failed; nested exception is org.hibernate.**LazyInitializationException**: failed to lazily initialize a collection of role: com.example.avoidtransactional.domain.model.Cart._items, could not initialize proxy - no Session
你可以将 Git 分支切换到without-transactional,我已从所有测试中移除了 @Transactional 注解。仍然能够成功运行的两个测试之所以通过,是因为它们没有预先获取集合。
移除 @Transactional 后,漏报的情况就会显现出来。
为什么之前所有的测试都没有失败?原来,@Transactional 注解将包含的代码包装在一个事务中——这样可以维护事务中包含的所有代码的数据库会话,这对于成功延迟加载集合是必需的。
在 JPA/Hibernate 中,处理集合的推荐方法是手动使用 eager 模式获取集合,同时保持集合的 lazy 状态,这样可以只获取所需内容,避免NM 性能问题。但为了简洁起见,我在without-transactional-replacement 分支中将获取模式更改为了 EAGER 。
实体会自动保存
在测试中使用 @Transactional 注解的另一个不易察觉的问题是,它会为被测代码启用自动提交功能。这意味着在 @Transactional 注解的代码中获取的实体会在测试结束时自动保存。
即使我们在第 16 行没有显式调用save(),下面的方法测试仍然应该通过。只需确保你处于with-transactional分支即可。
@RestController
@RequestMapping("carts/{cart_id}/items")
class CartItemsController(
private val cartsRepository: CartsRepository,
private val productsRepository: ProductsRepository) {
@PostMapping
fun addItemToCart(
@PathVariable("cart_id") cartId: UUID,
@RequestBody cartItem: CartItemInputModel
) : ResponseEntity<Any> {
...
cart.addProduct(product, cartItem.quantity)
//cartsRepository.save(cart) // with @Transactional there's no need of this line
val savedProductItem = cart.items.first { x -> x.product.id == cartItem.productId }
return ResponseEntity.status(HttpStatus.CREATED)
.body(mapToCartItemViewModel(savedProductItem))
}
}
但是,如果您发送请求将商品添加到购物车,则新商品不会保存到数据库中。
数据库难以用于排查测试失败问题
使用 @Transactional 的另一个问题是,您无法使用数据库来调查失败原因,因为数据实际上从未保存到数据库中,因为事务总是在测试方法执行结束时回滚。
因此,为了方便起见,我们可以不在事务中包装我们的测试,以便保存数据,而只在执行测试方法之前清理数据库。
建议的解决方案
我们不能只移除 @Transactional 注解而不做任何其他操作,因为我们需要保证所有测试彼此独立(参见 Martin Fowler 关于测试中非确定性的文章)。
如果将 git 分支更改为without-transactional-replacement,您会发现测试正在使用 JUnit 5 扩展进行注释,该扩展会在执行测试方法之前清理所有数据。
@SpringBootTest
@ExtendWith(PostgresDbCleanerExtension::class)
internal class CartsControllerTests {
@Autowired
lateinit var cartsRepository: CartsRepository
@Autowired
lateinit var webApplicationContext: WebApplicationContext
...
}
PostgresDbCleanerExtension扩展了BeforeEachCallback接口,该接口实现了beforeEach方法,该方法在执行每个测试方法之前调用,以清理数据库。
class PostgresDbCleanerExtension : BeforeEachCallback {
companion object {
private val LOGGER = LoggerFactory.getLogger(PostgresDbCleanerExtension::class.java)
private val TABLES_TO_IGNORE = listOf(
TableData("databasechangelog"),
TableData("databasechangeloglock")
)
}
@Throws(Exception::class)
override fun beforeEach(context: ExtensionContext) {
val dataSource = getDataSourceFromYamlProperties("application.yml")
cleanDatabase(dataSource)
}
...
private fun cleanDatabase(dataSource: DataSource) {
try {
dataSource.connection.use { connection ->
connection.autoCommit = false
val tablesToClean = loadTablesToClean(connection)
cleanTablesData(tablesToClean, connection)
connection.commit()
}
} catch (e: SQLException) {
LOGGER.error(String.format("Failed to clean database due to error: \"%s\"", e.message))
e.printStackTrace()
}
}
}
从application.yml加载数据库属性并将其绑定到javax.sql.DataSource之后,我们使用java.sql.DatabaseMetaData类来检索所有数据库表:
@Throws(SQLException::class)
private fun loadTablesToClean(connection: Connection): List<TableData> {
val databaseMetaData = connection.metaData
val resultSet = databaseMetaData.getTables(
connection.catalog, null, null, arrayOf("TABLE"))
val tablesToClean = mutableListOf<TableData>()
while (resultSet.next()) {
val table = TableData(
schema = resultSet.getString("TABLE_SCHEM"),
name = resultSet.getString("TABLE_NAME")
)
if (!TABLES_TO_IGNORE.contains(table)) {
tablesToClean.add(table)
}
}
return tablesToClean
}
使用 TRUNCATE 查询清除所有表,但TABLES_TO_IGNORE列表中的表除外,这些表与数据库迁移历史记录相关:
@Throws(SQLException::class)
private fun cleanTablesData(tablesNames: List<TableData>, connection: Connection) {
if (tablesNames.isEmpty()) {
return
}
val stringBuilder = StringBuilder("TRUNCATE ")
for (i in tablesNames.indices) {
if (i == 0) {
stringBuilder.append(tablesNames[i].fullyQualifiedTableName)
} else {
stringBuilder
.append(", ")
.append(tablesNames[i].fullyQualifiedTableName)
}
}
connection.prepareStatement(stringBuilder.toString())
.execute()
}
使用 TRUNCATE 命令速度稍慢,您也可以禁用外键约束检查,并使用 DELETE 命令删除所有数据,这样速度更快。
我们采用的 TRUNCATE 方法速度较慢,可能是因为它会立即回收磁盘空间并清除所有垃圾数据。
解决生产环境中的漏洞
您还会注意到,我们将延迟加载 fetch 替换为立即加载,以便解决与LazyInitializationException相关的生产错误。
既然我们已经去掉了 @Transactional 注解,并且发现了测试套件中的误报,那么让我们修复实际的错误,以便测试能够通过。
我已将有问题的Cart._items属性中的 fetch 类型更改为 FetchType.EAGER 。但您最好还是按照Vlad Mihalcea在这篇文章中描述的方式手动进行 eager fetch 操作。
@Entity
@Table(name = "carts")
class Cart : DomainEntity() {
@OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true)
private val _items = mutableListOf<ProductItem>()
...
}
如果在without-transactional-replacement分支中运行测试,所有测试都应该通过。
结论
本文已经阐述了为什么在测试套件中使用 @Transactional 不是一个好主意。
@Transactional 允许出现一些不希望出现的行为,导致我们的测试套件出现误报,这会影响开发人员进行更改的信心,并使测试套件变得无用。
在测试中使用 @Transactional 也会使故障排除变得困难,因为实际上并没有数据保存到数据库中。
始终建议手动清理数据,而不是依赖事务,并且最好在测试执行之前进行清理,因为您需要通过分析数据库来了解测试失败的原因。
感谢您阅读本文,请点赞并送出独角兽,帮助文章触达更多读者。
参考
-
https://vladmihalcea.com/the-best-way-to-handle-the-lazyinitializationexception/
-
https://www.marcobehler.com/guides/spring-transaction-management-transactional-in-depth


